요청 처리 흐름 (End-to-End 콜러/콜리)
arch-request-flow pattern-layered api-internals
이 노트의 목표
이 어댑터에는 중앙 디스패처가 없다. Triple 예약 시스템이 공급사별 URL(
/internals/{SUPPLIER}/...)을 직접 호출하면, 해당 공급사의{Name}{Op}Controller가 진입점이 된다. 이 노트는 대표 요청 2건(amadeus 검색, amadeus 예약 생성)을 골라 HTTP 진입부터 외부 공급사 API 호출, 응답 매핑, 반환까지 클래스/메서드(가능하면 file:line) 단위로 추적한다. 다른 공급사도 레이어 구조는 동일하므로 이 두 흐름이 “표준 템플릿”이다. 관련: system-architecture, caller-callee-map, common-operations, error-handling, async-coroutines, amadeus-operations
0. 공통 진입 파이프라인 (모든 요청)
컨트롤러 메서드가 실행되기 전후에 서블릿 필터 체인이 항상 끼어든다. 등록은 WebMvcConfig(configuration/WebMvcConfiguration.kt:50)에서 @Order로 순서가 고정된다.
| 순서 | 필터 | 파일:line | 역할 | 주의점 |
|---|---|---|---|---|
@Order(1) | ContentCachingWrapperFilter | support/filter/ContentCachingWrapperFilter.kt:12 | 요청/응답 body를 ContentCaching...Wrapper로 감싸 여러 번 읽기 가능하게 함 | 이게 없으면 로깅 필터가 body를 소비해 컨트롤러가 빈 body를 받음 |
@Order(2) | MDCFilter | support/filter/MDCFilter.kt:10 | 요청 헤더 → MDC 컨텍스트 적재. finally에서 반드시 clear() | 스레드풀 재사용 시 MDC 누수 방지를 위해 finally 필수 |
@Order(3) | AdapterLoggingFilter | support/filter/AdapterLoggingFilter.kt:15 | afterRequest에서 POST/PUT body를 구조화 로그로 기록, Datadog span에 supplier.name 태그 부착 | beforeRequest는 일부러 로그를 남기지 않음(주석 27행). /health,/swagger 등은 shouldLog로 제외 |
MDCFilter는 실제 헤더 파싱을 MDCHolder.putAll(request, env)(support/web/MDCHolder.kt:45)에 위임한다. MDCHolder는 sealed class로, 각 헤더가 object 하위클래스로 정의된다.
// support/web/MDCHolder.kt (발췌)
object SalesFunnel : MDCHolder(Constants.TRIPLE_SALES_FUNNEL_HEADER) { ... }
object Pnr : MDCHolder(Constants.AIR_PNR_HEADER) { ... }
object DatadogTraceId : MDCHolder(CorrelationIdentifier.getTraceIdKey()) {
fun valueOf(): String = CorrelationIdentifier.getTraceId()
}이 MDC 값이 왜 중요한가
SlackService의 모든 경보 메시지(application/SlackService.kt)와RestExceptionHandler.sentryLog()(support/exception/RestExceptionHandler.kt:82)가MDCHolder.SalesChannel.get(),MDCHolder.DatadogTraceId.getOrNull()등을 읽어 채널/경로/TraceId를 붙인다. 즉 이벤트 전파의 컨텍스트는 메시지큐가 아니라 MDC + Datadog span으로 흐른다. → resilience-and-events
flowchart TD REQ["HTTP 요청"] subgraph CHAIN["Servlet Filter Chain"] F1["(1) ContentCachingWrapperFilter<br/>body 재읽기 래핑"] F2["(2) MDCFilter<br/>MDCHolder.putAll headers<br/>try/finally"] F3["(3) AdapterLoggingFilter<br/>afterRequest POST/PUT body 로그<br/>Datadog span supplier.name 태그"] F1 --> F2 --> F3 end CTRL["Name+Op Controller"] HANDLER["RestExceptionHandler<br/>@ControllerAdvice<br/>support/exception/RestExceptionHandler.kt"] REQ --> CHAIN CHAIN -->|"DispatcherServlet → @RestController"| CTRL CTRL -->|"예외 발생 시"| HANDLER
1. 검색 흐름 — POST /internals/AMADEUS/search
1.1 콜러/콜리 단계표
| # | 레이어 | 클래스#메서드 (file:line) | 핵심 동작 |
|---|---|---|---|
| 1 | interfaces | AmadeusSearchController#search (.../internals/AmadeusSearchController.kt:26) | @CircuitBreaker(name="amadeusSearch", fallbackMethod="searchFallback") 적용. CacheKeyGenerator.generateSearchRequestKey(...)로 requestKey 생성 후 서비스 호출 |
| 2 | application | AmadeusFlightSearchService#search (application/AmadeusFlightSearchService.kt:39) | 캐시키 조회 → 캐시 미스면 withBlocking(Dispatchers.IO) 안에서 코루틴 fan-out |
| 3 | support/util | withBlocking + Iterable.pmap (support/util/CoroutineExtensions.kt:13, 36) | OD 조합(cartesianProduct)을 병렬로 각각 클라이언트 호출 |
| 4 | infrastructure | AmadeusClient#search (infrastructure/AmadeusClient.kt:114) | FareMasterPricerTravelBoardSearch.of(...)로 SOAP 요청 빌드 → OkHttp 전송 |
| 5 | support/web | OkHttpRequestBuilder#execute (support/web/ClientSupport.kt:146) | OkHttp 실제 호출, LoggingAndCompressionInterceptor로 RQ/RS 로깅·gzip 해제 |
| 6 | 외부 | TOPAS/Amadeus SOAP Fare_MasterPricerTravelBoardSearch | GDS 1A 검색 |
| 7 | infrastructure | AmadeusClient#search의 .fold(success=...) | 응답 FareMasterPricerTravelBoardSearchReply → segmentFlightRef.toFareItinerary(...) 매핑, NonAir/비발권 캐리어 제외 |
| 8 | application | AmadeusFlightSearchService#search 후반부 | 결과 distinct/OD 필터링 → useCache면 Redis 저장 → soldOut/airline 필터 |
| 9 | interfaces | AmadeusSearchController#search 반환부 | FareItineraryView.of(...)로 View 매핑 → ResponseEntity.ok(...) |
1.2 디테일: 코루틴 fan-out (3단계)
AmadeusFlightSearchService.kt:53~108의 핵심은 OD(출발-도착) 조합을 병렬 호출하고 부분 실패를 허용하는 점이다.
// application/AmadeusFlightSearchService.kt (발췌)
withBlocking(Dispatchers.IO) {
AirportUtils.makeOriginDestinations(originDestinationLocationInfos)
.cartesianProduct()
.pmap { originDestRequests -> // ← 코루틴 병렬 (CoroutineExtensions.kt:36)
amadeusClient.search(requestKey = requestKey, key = key, ...)
}.onFailure { exceptions, successes -> // CoroutineExtensions.kt:52
if (successes.isEmpty()) { // 전부 실패해야만 예외 throw
throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, exceptions.first())
}
}.getOrEmpty() // 성공분만 회수 (CoroutineExtensions.kt:62)
}.flatten().distinctBy { it.id } ...pmap은 각 호출을 try/catch로 감싸 AsyncSuccess/AsyncFail로 분류(CoroutineExtensions.kt:36~50, support/model/AsyncMapResult.kt)한다. 따라서 일부 OD 조합이 실패해도 나머지 성공분은 결과에 포함된다(getOrEmpty()). 부분 성공이 0건일 때만 SEARCH_FAILED를 던진다. → async-coroutines
withBlocking은 runBlocking이다
withBlocking(CoroutineExtensions.kt:13)은 내부적으로runBlocking(... + SupervisorJob() + AdapterCoroutineExceptionHandler() + MDCContext())이다. 즉 호출 스레드(톰캣 워커)를 블로킹한다.MDCContext()를 명시적으로 넘기기 때문에 코루틴 안에서도 MDC(TraceId 등)가 유지된다. 이게 빠지면 자식 코루틴 로그에서 TraceId가 사라진다.
1.3 디테일: 응답 매핑 (7단계)
AmadeusClient#search의 .fold(success = ...)(AmadeusClient.kt:148~187)에서:
fareMasterPricerTravelBoardSearchReply.checkError { code, message -> ... }로 에러코드 검사."866","931","977","996","118","950"는 “결과 없음” 류라 무시, 그 외엔InternationalAdapterException(SEARCH_FAILED).capture()throw.recommendations.flatMap { ... segmentFlightRef.toFareItinerary(...) }로 도메인 모델FareItinerary리스트 생성..filterNot { it.isNonAir() || it.hasNonTicketableCarrier() }로 버스/기차 구간(NonAirEquipment) 및 MH 코드셰어 비발권 건 제거.
1.4 검색 시퀀스 다이어그램
sequenceDiagram participant T as "Triple 예약" participant C as "SearchController" participant S as "FlightSearchService" participant A as "AmadeusClient" participant O as "ClientSupport/OkHttp" participant G as "TOPAS 1A" T->>C: "POST /internals/AMADEUS/search" Note over C: "@CircuitBreaker amadeusSearch<br/>generateSearchRequestKey" C->>S: "search(...)" S->>S: "findKey(requestKey)?" Note over S: "Redis 캐시 HIT 시 바로 반환" Note over S: "withBlocking(IO)<br/>OD.cartesianProduct()" loop "pmap 코루틴 병렬 (OD 조합별)" S->>A: "search(od)" Note over A: "FareMasterPricer....of(...) 빌드" A->>O: "endpoint.post / execute()" O->>G: "SOAP(gzip) 전송" G-->>O: "SOAP Reply Fare_MasterPricerTBS" Note over A: "soapBodyDeserializerOf → AmadeusResponse<br/>.fold(success): checkError → toFareItinerary" A-->>S: "FareItinerary 결과" end Note over S: "getOrEmpty()<br/>distinct/OD필터/캐시저장<br/>filterByUnexposed..." S-->>C: "List<FareItinerary>" Note over C: "FareItineraryView.of()" C-->>T: "200 List<View>" alt "CB OPEN 시" C->>C: "searchFallback() / span tag CB=OPEN" C-->>T: "200 emptyList()" end
검색은 무상태(stateless)
검색은
SecuritySignIn/세션 없이 단발 SOAP 호출이다.soapRequestBodyConverter(AmadeusClient.kt:1327)에서session == null이면 매 요청에UsernameToken(WS-Security) 헤더를 붙여 인증한다. 검색 전용 OkHttp 클라이언트(searchClient, timeout 25s)를 쓴다. 예약은 정반대(상태 보유)다 → 2장.
2. 예약 생성 흐름 — POST /internals/AMADEUS/bookings
검색과 달리 예약은 stateful PNR 세션을 연다. Amadeus SOAP는 Start → InSeries → ... → End로 세션 상태를 한 트랜잭션에 묶는다. 이 시스템은 이걸 stateful { start{...}; inSeries{...}; end{...} } DSL로 추상화했다(supplier/amadeus/support/util/StatefulBuilder.kt).
2.1 콜러/콜리 단계표
| # | 레이어 | 클래스#메서드 (file:line) | 핵심 동작 |
|---|---|---|---|
| 1 | interfaces | AmadeusBookingController#create (.../internals/AmadeusBookingController.kt:26) | request.passengers.map { Passenger.of(it) }, ReservationUser.of(request) 변환 후 서비스 호출 |
| 2 | application | AmadeusBookingService#book (application/AmadeusBookingService.kt:53) | flightSearchService.getFareItinerary(key)로 캐시된 검색결과 복원 → stateful { ... } 시작 |
| 3 | application | AmadeusBookingService.book의 stateful 블록 | start{markSeat} → inSeries{saveReservationInfo} → pricingService.pricing → inSeries{getPnrFares} → inSeries{savePnrWithShowWarnings} → inSeries{getPnrInfoAndCheckInfantSoldOut} → end{signOut} |
| 4 | infrastructure | AmadeusClient#markSeat / saveReservationInfo / getPnrFares / savePnrWithShowWarnings / getPnrInfo / signOut | 각 단계가 별도 SOAP 호출. withSession(statefulBuilder?.session)로 세션 전파, 응답에서 receiveSession(response.session) 회수 |
| 5 | support/web | OkHttpRequestBuilder#execute (ClientSupport.kt:146) | 1장과 동일한 OkHttp 경로(단 defaultClient, timeout 60s) |
| 6 | 외부 | TOPAS SOAP: Air_SellFromRecommendation, PNR_AddMultiElements, Fare_PricePNRWithBookingClass, Ticket_DisplayTST, PNR_Retrieve, Security_SignOut | 한 세션 안의 다중 오퍼레이션 |
| 7 | application | Booking.of(pnrInfo = ..., pnrFares = ...) (AmadeusBookingService.kt:168) | 도메인 모델 → Booking 조립. 성공 시 removeFlightSearchKey(requestKey)(코루틴) |
| 8 | interfaces | AmadeusBookingController#create 반환부 | BookingView.of(...) → ResponseEntity.ok(...) |
2.2 디테일: stateful 세션 DSL
// supplier/amadeus/support/util/StatefulBuilder.kt
fun <T> stateful(block: StatefulBuilder.() -> T): T = StatefulBuilder().block()
fun <T> StatefulBuilder.start(block: StatefulBuilder.() -> T): T =
withSession(TransactionStatusCode.Start).block()
fun <T> StatefulBuilder.inSeries(block: StatefulBuilder.() -> T): T =
withSession(TransactionStatusCode.InSeries).block()
fun <T> StatefulBuilder.end(block: StatefulBuilder.() -> T): T =
withSession(TransactionStatusCode.End).block()withSession(StatefulBuilder.kt:10)이 호출될 때:
Start→ 새Session(transactionStatusCode = Start)(아직sessionId없음)InSeries/End→ 직전 세션을copy(transactionStatusCode, sequenceNumber = sequence + 1)
각 AmadeusClient 메서드는 request.withSession(statefulBuilder?.session)로 현재 세션을 SOAP 헤더에 직렬화하고, 응답에서 statefulBuilder?.receiveSession(response.session)(예: AmadeusClient.kt:261)로 TOPAS가 발급한 sessionId/sequenceNumber/securityToken을 받아 갱신한다. 실제 헤더 생성은 soapRequestBodyConverter(AmadeusClient.kt:1327~1417)에서:
isStart일 때만UsernameToken(WS-Security)과AMA_SecurityHostedUser를 붙임session != null이면<awsse:Session TransactionStatusCode=...>헤더에SessionId/SequenceNumber/SecurityToken삽입
세션 누수 = TOPAS 동시세션 고갈
book의catch(e: Exception)(AmadeusBookingService.kt:171)는session?.transactionStatusCode == TransactionStatusCode.InSeries이면 반드시end{signOut}을 호출해 세션을 닫는다. 이걸 빠뜨리면 GDS의 동시 세션 한도가 막혀 전체 예약이 마비된다. 정상 경로에서도end{ amadeusClient.signOut(...) }(:166)로 닫는다. 예약/취소/리프라이싱 등 모든 stateful 메서드가 동일 패턴을 갖는다. → amadeus-pitfalls, error-handling
2.3 디테일: 예약 중 분기되는 이벤트(Slack/취소)
book 본문에서 메시지큐 없이 다음 “이벤트”들이 발생한다(→ resilience-and-events):
| 조건 (file:line) | 처리 |
|---|---|
EOT warning에 CHECK MINIMUM CONNECTION TIME/CHECK ARRIVAL/DEPARTURE 포함 (:101) | InternationalAdapterException(MINIMUM_CONNECTION_TIME) throw → catch에서 unexposed 처리 |
인식 못한 warning 존재 (:116) | slackService.sendWarnings(...) 운영 채널 알림 |
schedule에 leg 누락 (:155) | slackService.sendEmptyLeg(...) |
예외가 soldOut/infant-soldOut/MCT (isUnexposedFareItinerary, :185) | removeFlightSearchKey + saveUnexposedFareItinerary(코루틴) → 재검색 시 노출 제외 |
예약 도중 예외 + pnr 이미 생성됨 (:179) | cancelService.pnrCancelAsync(pnr)로 비동기 PNR 취소 |
removeFlightSearchKey/saveUnexposedFareItinerary는 CoroutineScope(Dispatchers.IO).withLaunch { ... }(:192, :198)로 fire-and-forget. withLaunch는 SupervisorJob + AdapterCoroutineExceptionHandler + MDCContext를 붙이므로(CoroutineExtensions.kt:20), 이 백그라운드 작업에서 터진 예외는 HTTP 응답에 영향 주지 않고 AdapterCoroutineExceptionHandler(support/exception/AdapterCoroutineExceptionHandler.kt)가 잡아 Sentry로 보낸다.
2.4 예약 시퀀스 다이어그램
sequenceDiagram participant T as "Triple" participant C as "BookingController" participant S as "BookingService stateful" participant A as "AmadeusClient" participant G as "TOPAS 1A 세션" T->>C: "POST /internals/AMADEUS/bookings" Note over C: "Passenger.of()<br/>ReservationUser.of()" C->>S: "book(key, user, pax)" Note over S: "getFareItinerary(key) ← Redis 캐시 복원<br/>stateful 블록 시작" S->>G: "start{ markSeat } → Air_SellFromRecommendation" G-->>S: "Session(Start) / sessionId 발급" S->>G: "inSeries{ saveReservationInfo } → PNR_AddMultiElements (좌석/승객)" G-->>S: "Session(InSeries)" S->>G: "pricing(...) → Fare_PricePNRWithBookingClass" S->>G: "inSeries{ getPnrFares } → Ticket_DisplayTST" S->>G: "inSeries{ savePnrWithShowWarnings } → PNR_AddMultiElements" G-->>S: "(pnr, warnings) EOT" alt "MCT warning" S->>S: "throw MINIMUM_CONNECTION_TIME" else "unknown warning" S->>S: "slackService.sendWarnings" end S->>G: "inSeries{ getPnrInfoAndCheckInfantSoldOut }" Note over S: "empty leg? → slackService.sendEmptyLeg" S->>G: "end{ signOut } → Security_SignOut" Note over S: "Booking.of(pnrInfo, pnrFares)<br/>removeFlightSearchKey (코루틴)" S-->>C: "Booking" Note over C: "BookingView.of()" C-->>T: "200 View" Note over S: "catch(e): InSeries면 end{signOut},<br/>pnr이면 cancelService.pnrCancelAsync(pnr), throw e" S-->>C: "예외 전파 → RestExceptionHandler" C-->>T: "5xx (handler)"
3. 에러 진입점 — RestExceptionHandler
컨트롤러/서비스/클라이언트 어디서든 예외가 톰캣 워커 스레드까지 전파되면(코루틴은 runBlocking이 다시 던지므로 동일 스레드), @ControllerAdvice인 RestExceptionHandler(support/exception/RestExceptionHandler.kt:15)가 받는다.
// support/exception/RestExceptionHandler.kt (발췌)
@ExceptionHandler(InternationalAdapterException::class)
fun handle(e: InternationalAdapterException, request: WebRequest): ResponseEntity<Any>? {
e.findRootCause().log().sentryLog()
return handleExceptionInternal(e, RestExceptionView.of(exception = e), ..., HttpStatus.INTERNAL_SERVER_ERROR, request)
}| 예외 타입 | HTTP 상태 | 의미 |
|---|---|---|
MethodArgumentInvalidException | 400 BAD_REQUEST | 요청/공급사 입력 검증 실패 |
CacheKeyInvalidException | 410 GONE | 검색 캐시키 만료/소실 — 재검색 유도 |
StatusInvalidException | 500 | 스케줄 상태 비정상(soldOut 등) |
InternationalAdapterException / 그 외 Exception | 500 | 공급사/내부 오류 |
공통 처리: findRootCause()(:108)로 cause 체인 끝까지 내려가 로깅 → sentryLog()(:82)에서 MDCHolder.contextMap()를 Sentry scope에 주입(TraceId/SpanId는 tag, 나머지는 context). SentryAlertHandler.capturable == false면 Sentry 보고를 건너뛴다. → error-handling
검색의 CB fallback은 예외 핸들러를 타지 않는다
검색에서
CallNotPermittedException(서킷 OPEN)이 나면RestExceptionHandler가 아니라AmadeusSearchController#searchFallback(AmadeusSearchController.kt:73)이 가로채ResponseEntity.ok(emptyList())로 200 빈 결과를 반환하고 Datadog span에supplier.circuit-breaker=OPEN태그를 단다. Triple 입장에서 “이 공급사는 지금 결과 0건”으로 보여 다른 공급사 결과로 화면이 채워진다. → resilience-and-events
4. 두 흐름 비교 요약
| 항목 | 검색 (search) | 예약 (book) |
|---|---|---|
| 진입 | AmadeusSearchController#search | AmadeusBookingController#create |
| 상태 | 무상태(단발 SOAP, searchClient 25s) | stateful 세션(defaultClient 60s) |
| 동시성 | OD 조합 pmap 병렬 fan-out | start→inSeries→end 순차 |
| 부분 실패 | 일부 OD 실패 허용(getOrEmpty) | 한 단계 실패 시 전체 롤백 + PNR 취소 |
| 회복탄력성 | @CircuitBreaker(amadeusSearch) + fallback 200 빈배열 | 명시 CB 없음. catch에서 signOut + 비동기 취소 + Slack |
| 캐시 | requestKey→fareItineraryKey Redis 저장 | 캐시 복원(getFareItinerary) + 성공 시 키 제거 |
| 응답 매핑 | FareItineraryView.of | BookingView.of ← Booking.of(pnrInfo, pnrFares) |
신입 확인 문제
Q1. 검색에서 OD 조합 5개 중 2개가 SOAP 타임아웃이면 클라이언트는 무엇을 받는가?
정답 보기
FareItinerary만 받는다.pmap이 실패를AsyncFail로 흡수하고onFailure는successes.isEmpty()일 때만 throw하므로(CoroutineExtensions.kt:52,AmadeusFlightSearchService.kt:75), 부분 성공은 정상 200 응답이 된다.성공한 3개 조합의
Q2. 예약
inSeries{saveReservationInfo}도중 예외가 나면 세션과 PNR은 어떻게 되는가?정답 보기
catch(AmadeusBookingService.kt:171)에서session.transactionStatusCode == InSeries이므로end{ signOut }으로 세션을 닫고, 이 시점엔pnr이 아직null이라 PNR 취소는 일어나지 않으며, 예외를 재throw하여RestExceptionHandler가 500을 반환한다. PNR이 이미 생성된 뒤 실패라면cancelService.pnrCancelAsync(pnr)로 비동기 취소된다.Q3.
amadeusSearch서킷이 OPEN이면 사용자는 에러를 보는가?정답 보기
searchFallback이 200 + 빈 리스트를 반환하므로 해당 공급사만 결과 0건으로 보이고 다른 공급사 결과로 채워진다.아니다.
관련 노트
- system-architecture — 레이어·패키지 전체 지도
- caller-callee-map — 11개 공급사 컨트롤러→서비스→클라이언트 매핑 표
- common-operations — Search/Booking/Ticketing 등 오퍼레이션 공통 패턴
- error-handling — 예외 계층,
findRootCause, Sentry/Slack 분기 - async-coroutines —
withBlocking/pmap/withLaunch상세 - resilience-and-events — Resilience4j 서킷브레이커·이벤트 전파
- amadeus-operations — Amadeus 오퍼레이션별 SOAP 메시지 상세
- amadeus-gds — 기존 Amadeus GDS API 분석