이 노트는 11개 공급사 모듈을 “읽기”에서 “추적·디버깅·확장”으로 끌어올리는 연습문제 모음이다. exercises-architecture가 레이어/요청흐름 같은 공통 골격을 단련했다면, 이 노트는 공급사별 프로토콜 차이(GDS SOAP 세션 / NDC 폴링 / LCC SEED 암호화) 가 코드에 어떻게 드러나는지를 짚는다.
푸는 법: ① 시작점 파일을 직접 열어 추적한다. ② 답을 적는다. ③ [!answer]- 정답 보기를 펼쳐 파일경로·클래스/메서드·단계까지 맞췄는지 대조한다. “결론”이 아니라 “어느 파일 몇 번째 단계에서 그 일이 일어나는가” 를 맞히는 것이 핵심이다.
POST /search?supplier=X 같은 단일 진입점은 없다. Triple 예약 시스템이 /internals/{SUPPLIER}/... 경로로 공급사별 {Name}{Op}Controller 를 직접 호출한다. 그래서 공급사 추적은 항상 supplier/{name}/interfaces/controller/internals/{Name}{Op}Controller.kt 에서 시작한다. → system-architecture, caller-callee-map
문제 1 — [extend] 신규 공급사 “XYZ”를 추가하려면 어디를 손대야 하나
신규 LCC 공급사 XYZ(REST, Search/Booking/Ticketing/FareRule 지원)를 추가하라는 요청을 받았다. 코드/설정에서 반드시 건드려야 하는 위치를 빠짐없이 나열하라. (힌트: enum, 패키지 레이어, 컨트롤러 경로 규칙, 설정 프로퍼티, 서킷브레이커 인스턴스)
supplier/xyz/interfaces/controller/internals/XyzSearchController.kt 등
컨트롤러 경로 규칙 반드시 준수: @RequestMapping("/internals/XYZ/search") 형태. 대문자 공급사명 + 오퍼레이션. (예: AmadeusBookingController는 /internals/AMADEUS/bookings)
4
configuration/Properties.kt
@ConfigurationProperties(prefix = "supplier.xyz") 데이터 클래스 추가. tway 패턴(TwayProperties Properties.kt:157)을 보면 channels → funnels 구조와 getApiProperties(salesChannel, salesFunnel) 룩업 메서드를 갖는다
5
src/main/resources/application.yml
supplier.xyz.channels[].funnels[] 에 endpoint/agencyCode/password 등 채움
6
application.yml resilience4j
instances: 아래 xyzSearch: { baseConfig: search } 추가. 그래야 검색 컨트롤러에 @CircuitBreaker(name = "xyzSearch", ...) 달 수 있다 (application.yml:48~)
7
검색 컨트롤러
@CircuitBreaker(name = "xyzSearch", fallbackMethod = "searchFallback") + 빈 리스트 fallback. AmadeusSearchController.kt:24,73 참고
자주 빠뜨리는 두 가지 컨트롤러 경로 대소문자 — /internals/{SUPPLIER}/...의 SUPPLIER는 enum 이름 그대로 대문자. Triple 쪽이 이 규칙으로 호출하므로 어긋나면 404.
(2) resilience4j 인스턴스 누락 — yml에 xyzSearch를 등록하지 않고 @CircuitBreaker(name="xyzSearch")만 달면, default 설정으로 떨어지거나 의도한 임계치(failureRateThreshold 35%, slidingWindowSize 180s)가 적용되지 않는다.
POST /internals/AMADEUS/bookings 가 도착했다. AmadeusBookingService.book() 내부에서 TOPAS 세션이 어떤 상태 단계(Start/InSeries)를 거치며 어떤 GDS 액션을 부르는지 순서대로 나열하라. 특히 PNR 저장 시 경고(warning) 처리 로직이 왜 두 번 호출될 수 있는지 설명하라.
stateful {
start { amadeusClient.markSeat(...) } // 좌석 점유, 세션 개시
inSeries { amadeusClient.saveReservationInfo(...) } // 승객 정보 저장 → pnrPassengers
pricingService.pricing(...) // 운임 확정 (같은 세션 statefulBuilder 전달)
inSeries { amadeusClient.getPnrFares(...) } // PNR 운임 조회
inSeries { amadeusClient.savePnrWithShowWarnings(...) } // ER 저장 + 경고 수집
// warning 재처리 …
}
경고 두 번 호출 이유: 첫 savePnrWithShowWarnings 가 warnings를 반환하면, “CHECK MINIMUM CONNECTION TIME”/“CHECK ARRIVAL/DEPARTURE” 같은 치명 경고는 InternationalAdapterException(MINIMUM_CONNECTION_TIME) 으로 즉시 throw. 그 외 경고는 무시 가능한 것으로 보고 저장을 한 번 더 호출(inSeries { ...savePnrWithShowWarnings().first })해 PNR을 확정한다. (Amadeus는 첫 ER에서 경고만 띄우고 실제 확정은 재요청이 필요한 경우가 있기 때문)
그리고 excludeWarnings(예: “ERROR AT END OF TRANSACTION TIME”)에 없는 경고는 slackService.sendWarnings(Supplier.AMADEUS, pnr, ...) 로 운영팀에 통지된다 → Slack 경보.
stateful은 "한 세션을 같은 statefulBuilder로 이어가는" 약속 statefulBuilder = this 를 받아 세션을 공유한다. inSeries를 빼먹고 새 stateful/start로 호출하면 다른 PNR 트랜잭션이 되어 좌석 점유/저장이 끊긴다. GDS stateful의 가장 흔한 함정. → amadeus-pitfalls
문제 3 — [config/debug] Tway SEED 인증은 어떻게 동작하나 — 그리고 두 종류의 SEED를 구분하라
Tway 검색 호출이 401/인증 실패로 떨어진다. TwayClient.retrieveRoute() 의 .authenticate(...) 가 어떤 값을 어떻게 만드는지 추적하고, 이 코드베이스에 존재하는 “두 가지 SEED 메커니즘” 을 구분해 설명하라. (어느 것이 외부 jar이고 어느 것이 자체 구현인가)
즉 “Tway=SEED”라고 뭉뚱그리면 안 된다. Tway는 jar 기반 인증/카드 인코딩, 자체 SeedEncryptor는 KoreanAir 결제 고정폭 전문의 특정 필드 암호화에 쓰인다.
디버깅 체크리스트 (401 시): ① application.yml의 supplier.tway.channels[].funnels[].password 가 평문으로 맞는지(인코딩은 코드가 함) ② MDCHolder의 salesChannel/salesFunnel 이 yml의 channel/funnel과 일치하는지 — 불일치 시 NOT_SUPPORTED_SALES_CHANNEL/FUNNEL 예외 (Properties.kt) ③ TwaySEED.jar 가 클래스패스에 있는지(빌드 산출물).
문제 4 — [trace] Lufthansa NDC 재발행(Reissue)이 왜 “폴링” 응답인가
POST /internals/LUFTHANSA/ticketing/addition(재발행) 의 응답이 즉시 결과가 아니라 202 ACCEPTED + 키다. 왜 이런 비동기 폴링 구조이고, 클라이언트는 결과를 어떻게 받는지 단계별로 추적하라. NDC OrderReshop과 어떻게 연결되는가?
polling 은 Redis에 DeferredResult.pending() 을 먼저 쓰고, CoroutineScope(Dispatchers.IO).withLaunch { runCatching { init() }.onSuccess{complete}.onFailure{error} } 로 백그라운드 실행 후 즉시 key 반환.
클라이언트는 GET /internals/LUFTHANSA/ticketing/addition/{reissueKey} 폴링 → poller<ReissueResult<Booking,Passenger>>(key, redisTemplate):
PENDING → DeferredView.Pending
ERROR → 저장된 throwable 을 그대로 throw (예외가 백그라운드에서 Redis에 직렬화되어 보관됨)
COMPLETE → ReticketingView.of(...)
polling/poller 는 공급사 무관 공통 유틸
이 deferred 패턴은 Lufthansa 전용이 아니라 support/util/PollingUtils.kt 의 공통 메커니즘이다. 재발행처럼 오래 걸리는 작업의 표준 패턴이며, CacheSet.REISSUE 의 TTL이 폴링 만료를 결정한다. TTL이 지나면 poller 가 CacheKeyInvalidException(INVALID_CACHE_KEY) 를 던진다.
백그라운드 코루틴은 fire-and-forget — 예외가 HTTP로 즉시 전파되지 않는다 withLaunch 안에서 던진 예외는 AdapterCoroutineExceptionHandler 로 로깅/Sentry 되고, 결과는 Redis의 DeferredResult.error 로만 남는다. 그래서 디버깅 시 로그/Sentry + Redis의 deferred 값 두 곳을 봐야 한다. → async-coroutines, error-handling
문제 5 — [trace] Sabre Queue 처리: 조회와 제거가 왜 세션 토큰을 매번 열고 닫나
GET /internals/SABRE/queues(조회)와 PUT /internals/SABRE/queues(제거)의 내부 동작을 추적하라. 특히 remove() 가 repeat(queueCount) 루프로 PNR을 하나씩 넘기며(QR/I) 처리하는 이유와, 세션 토큰 생명주기를 설명하라.
모든 channel/funnel의 PCC를 모으되 LEGACY_PCC = ["3OGJ","7CZJ"] 는 제외.
PCC마다 sabreClient.getSessionToken(...) 으로 토큰 발급 → try { ... } finally { closeSessionToken(...) } 로 반드시 닫음(SOAP stateful 세션 누수 방지).
TARGET_ORIGIN_QUEUE_NUMBERS = ["5","6","7","20"] 를 pmap 으로 병렬 조회(withBlocking(Dispatchers.IO)): 각 큐의 getPnrCountInQueue → count>0이면 getPnrsInQueueAction(listOfRecordLocator=true) 로 PNR 목록을 받아 QueuePnrInfo(pnr, queueNumber, pccOid) 로 매핑.
실패 시 slackService.sendQueueFail(...) 후 emptyList() (한 PCC 실패가 전체를 죽이지 않음).
제거 remove(queueNumber, pcc, pnrs):
@Retryable(maxAttempts=3, backoff=Backoff(delay=5000)) — Spring Retry로 재시도.
큐는 커서 기반이다: 한 번에 한 PNR만 “현재 PNR”로 보이고, 다음 액션을 보내야 다음 PNR로 넘어간다. 그래서:
currentPnr = 첫 PNR
repeat(queueCount) {
nextPnr = getPnrsInQueueAction(actionType = currentPnr in pnrs ? QR(remove) : I(ignore/skip))
if (currentPnr in pnrs) removePnrs += currentPnr
currentPnr = nextPnr
}
→ QR(제거 대상) 또는 I(건너뛰기)를 보내면 서버가 다음 PNR을 응답하는 구조라 루프로 끝까지 훑는다.
컨트롤러(SabreQueueController.remove)는 요청을 (queueNumber, pccOid) 로 groupBy 후 그룹별 remove 호출, 전부 성공이면 true.
세션 토큰은 SOAP stateful의 핵심 — finally로 닫지 않으면 누수 세션 토큰/세션ID를 발급받아 작업 후 명시적으로 닫아야 한다. 코드가 모든 경로에서 finally { closeSessionToken(...) } 를 두는 이유다. 닫기를 빼먹으면 세션 풀 고갈 → 이후 호출이 인증 실패. → sabre-pitfalls
운영 중 Amadeus 검색이 “결과가 비거나(빈 리스트) 가끔 SEARCH_FAILED 예외”로 갈린다. AmadeusFlightSearchService.search() 의 pmap { ... }.onFailure { ... }.getOrEmpty() 가 부분 실패를 어떻게 다루는지, 그리고 컨트롤러의 서킷브레이커 fallback과 어떻게 맞물리는지 설명하라.
Iterable<T>.pmap { block } 은 각 항목을 withAsync 로 병렬 실행하고, 각 결과를 try { AsyncSuccess(...) } catch { AsyncFail(e) } 로 개별 포장한다 → awaitAll() → AsyncResults.of(results).
즉 한 항목이 실패해도 예외가 전파되지 않고 성공/실패가 분리 수집된다.
onFailure { exceptions, successes -> ... } 는 실패가 있을 때만 콜백.
getOrEmpty() = 성공분만 반환 / getOrThrow() = 실패 1개라도 있으면 첫 예외 throw.
서킷이 OPEN이면 메서드가 실행되지 않고 searchFallback(CallNotPermittedException) 이 ResponseEntity.ok(emptyList()) 반환 + Datadog span에 supplier.circuit-breaker=OPEN 태그.
따라서 “빈 리스트”는 두 경로가 있다: (1) pmap 부분성공인데 결과 0건, (2) 서킷 OPEN으로 호출 자체가 차단. 디버깅 시 둘을 구분하려면 Datadog의 circuit-breaker 태그/amadeusSearch 메트릭(failureRateThreshold 35%, slidingWindow 180s, application.yml)을 본다.
withBlocking 안의 디스패처와 MDC
withBlocking(Dispatchers.IO) { ... } 는 runBlocking + SupervisorJob + AdapterCoroutineExceptionHandler + MDCContext 조합이다(CoroutineExtensions.kt). MDCContext() 덕분에 코루틴 안에서도 로그 MDC(traceId 등)가 보존된다. 디스패처를 IO로 둔 건 GDS 호출이 블로킹 I/O이기 때문. → async-coroutines
JejuairCancelService.cancel(pnr) 은 @Retryable(... exceptionExpression = "@jejuairCancelService.shouldRetry(#root)") 로 조건부 재시도한다. 그런데 calculateCancelFee 호출이 부수효과(side effect)를 갖는다는 주석이 있다. 무한/중복 취소 위험을 어떻게 통제하는지, 그리고 어떤 예외는 재시도하면 안 되는지 추론하라.
validateCancellation(booking) — hasPaidAncillary 면 InternationalAdapterException(CANCEL_UNABLE).capture(), 체크인 승객 있으면 StatusInvalidException(CANCEL_UNABLE_BY_ALREADY_CHECK_IN).capture().
jejuairClient.calculateCancelFee(booking) — 주의: 발권 안 된 예약이면 이 호출이 곧 취소 처리(주석: “발권 되지 않은 예약의 경우 calculateCancelFee 호출시 예약 취소 처리됨”). 즉 수수료 계산 API가 부수효과로 취소를 일으킨다.
발권된 예약(!booking.nonTicketing)만 jejuairClient.cancel(booking, timeoutCallback) 추가 호출. 타임아웃 시 slackService.sendCancelFailTimeout(JEJUAIR, pnr).
재시도 통제:
@Retryable(maxAttempts=2, include=[InternationalAdapterException::class], exceptionExpression="@jejuairCancelService.shouldRetry(#root)") — InternationalAdapterException중에서도shouldRetry() 가 true인 것만 재시도(예: 일시적 통신 오류). maxAttempts=2로 제한.
validateCancellation 의 비즈니스 예외는 .capture()(Sentry 기록) 후 던져지지만, StatusInvalidException(이미 체크인) 같은 상태성 예외는 재시도 대상이 아님 — 재시도해도 결과가 바뀌지 않고 오히려 위험.
부수효과 있는 호출을 무지성 재시도하면 안 된다 calculateCancelFee 가 (미발권 예약에서) 취소를 일으키므로, 만약 이 단계 이후의 예외를 광범위하게 재시도하면 이미 취소된 예약을 또 취소하려다 상태 불일치/이중 환불 위험. 그래서 재시도 범위를 InternationalAdapterException + shouldRetry 로 좁게 제한한다. 신규 공급사 취소 로직 작성 시 "재시도해도 안전(idempotent)한 단계까지만 재시도"를 설계 원칙으로 삼아라. → jejuair-pitfalls, error-handling
문제 8 — [config/extend] LCC 부가 기능: Tway AgencyCredit과 Ancillary 딥링크
Tway는 다른 GDS에 없는 AgencyCredit(대리점 크레딧 잔액)과 Ancillary 딥링크를 노출한다. GET /internals/TWAY/agency-credit 의 경로/응답 구조를 추적하고, Ancillary 딥링크가 SEED 인코딩을 어디서 쓰는지 설명하라. 이런 “공급사 고유 오퍼레이션”을 추가할 때 공통 계약(common-operations)과 어떻게 공존하나?
@RestController @RequestMapping("/internals/TWAY/agency-credit")class TwayAgencyCreditController(private val twayAgencyCreditService: TwayAgencyCreditService) { @GetMapping fun getAgencyCredit(): AgencyCreditView = AgencyCreditView(amount = twayAgencyCreditService.getAgencyCredit())}
경로 규칙은 동일(/internals/TWAY/...)하되, 표준 6오퍼레이션이 아닌 공급사 고유 엔드포인트다. 응답 DTO는 공통 interfaces/response/AgencyCreditView(Tway/Jinair 공유) → interfaces-dtos.
인프라단 RetrieveAgencyCreditRQ/RS (Tway XML 전문)으로 SOAP-ish XML 호출.
Ancillary 딥링크 (TwayAncillaryDeepLink.kt):
딥링크 파라미터(예약 식별/토큰 등)를 합친 문자열을 .let { SeedUtil.encoding(it) } 로 SEED 인코딩(문제 3의 (A) jar 메커니즘) 후 pc = "$deepLinkPc?$it", mobile = "$deepLinkMobile?$it" URL로 만든다. → 사용자를 Tway 부가서비스 페이지로 안전하게 넘기는 토큰형 딥링크.
공통 계약과의 공존 원리:
공통 오퍼레이션(Search/Booking/…)은 interfaces/request|response 의 공유 DTO 계약을 따른다(common-operations). AgencyCredit/Ancillary처럼 일부 LCC만 가진 기능은 그 공급사 컨트롤러에만 추가 엔드포인트로 노출한다. 중앙 디스패처가 없으므로 “이 공급사만 이 경로를 가진다”가 자연스럽다 — Triple 쪽이 해당 공급사에만 그 경로를 호출하면 된다.
"공통이면 공유 DTO, 고유면 공급사 컨트롤러 전용 엔드포인트" 그 공급사 컨트롤러에 전용 @RequestMapping("/internals/{SUPPLIER}/{feature}") 를 추가하라. Jinair도 동일하게 JinairAgencyCreditController/JinairAncillaryController 를 별도로 둔다.
문제 9 — [trace] Sabre 코루틴 사용처: APIS 변경이 runBlocking 안에서 무엇을 순서대로 하나
Sabre는 비동기/코루틴이 집중된 모듈 중 하나다. SabrePassengerService.changeApis(pnr, validatingCarrier, passengers) 가 runBlocking { ... } 안에서 기존 SSR/OSI 삭제 → APIS 재생성 → EOT 를 어떻게 순서 보장하는지, 그리고 왜 세션을 finally로 닫는지 추적하라.
GDS는 “삭제→재생성→EOT”가 한 세션 안에서 순서대로 일어나야 한다. runBlocking 은 코루틴 컨텍스트를 제공하되 블로킹 SOAP 호출을 순차 보장(여기선 병렬화가 아니라 컨텍스트 통일 목적).
토큰은 stateful 세션이므로 모든 client 호출에 token 을 전달하고, 예외/정상 어느 경로든 finally 로 닫아 세션 누수 방지(문제 5와 동일 원리).
runBlocking vs withBlocking SabrePassengerService 는 kotlinx.coroutines.runBlocking 을 직접 쓴다. 반면 검색/큐는 공통 withBlocking(SupervisorJob+예외핸들러+MDCContext)을 쓴다. fan-out 병렬(pmap) 이 필요하면 withBlocking(Dispatchers.IO), 순차 SOAP 한 세션 이면 runBlocking 으로 충분 — 이 차이를 코드에서 읽어내는 게 포인트. → async-coroutines
장애 알림에 /internals/SINGAPOREAIR/ticketing 5xx 로그만 있다. 코드에서 이 요청을 처리하는 컨트롤러 → 서비스 → 클라이언트를 규칙만으로(파일을 일일이 grep하지 않고) 어디서 찾을지 즉시 말하라. 또 이 모듈이 NDC EDIST라는 사실에서 무엇을 예상해야 하나.
설정: configuration/Properties.kt 의 SingaporeairProperties(prefix supplier.singaporeair), application.yml 의 supplier.singaporeair.* 와 resilience singaporeSearch.
NDC EDIST 18.1 에서 예상할 것:
무상태(stateless) — GDS처럼 세션 토큰을 열고 닫는 stateful 흐름이 아니라 OrderCreate/OrderReshop 등 요청-응답형 NDC 메시지.
온보딩 첫 주에 외울 단 하나의 규칙 /internals/{SUPPLIER}/{op} → supplier/{supplier-소문자}/interfaces/controller/internals/{Supplier}{Op}Controller. 이 규칙 하나면 11개 모듈 어디든 5초 안에 진입점을 찾는다. 나머지 레이어(application/infrastructure)는 같은 패키지 아래 이름만 바뀐다. → caller-callee-map, onboarding-map
GalileoFareRuleService 가 여러 운임에 대해 FareRule을 순차 조회해 느리다. 이를 pmap 병렬로 바꿀 때 (1) 어떤 공통 유틸을 쓰고 (2) 부분 실패를 어떻게 다룰지, (3) Galileo의 stateful 세션 특성상 주의할 점을 설계하라.
(1) 유틸: Iterable<T>.pmap { block } + withBlocking(Dispatchers.IO) { ... }(CoroutineExtensions.kt). Galileo는 이미 GalileoFareRuleService/GalileoFlightSearchService/GalileoQueueService 에서 pmap 을 쓴다(검증됨) — 동일 패턴 채택.
(2) 부분 실패: FareRule 일부 실패가 전체를 막으면 안 되므로 pmap{...}.onFailure{ exceptions, successes -> /* 로깅/Slack */ }.getOrEmpty() 로 성공분만 반환하거나, “전부 실패 시에만 throw” 정책(문제 6의 Amadeus search 패턴)을 따른다. 반대로 “하나라도 실패하면 전체 실패” 의미가 맞다면 getOrThrow().
(3) stateful 주의:
세션은 병렬에 안전하지 않다 — pmap 안에서 같은 세션 토큰 공유 금지 pmap 으로 N개를 동시에 돌릴 때 하나의 SessionContext/토큰을 공유하면 시퀀스/상태가 꼬인다. 병렬 단위마다 (a) 독립 세션을 쓰거나 (b) FareRule처럼 stateless 조회로 분리 가능한 부분만 병렬화해야 한다. 좌석점유/저장 같은 상태변경 작업은 절대 같은 세션을 병렬 공유하면 안 된다(문제 2 Amadeus stateful, 문제 9 Sabre 세션 참고). 검색/룰조회처럼 읽기성이면서 세션이 분리되는 작업만 안전하게 병렬화하라.