요청 처리 흐름 (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)ContentCachingWrapperFiltersupport/filter/ContentCachingWrapperFilter.kt:12요청/응답 body를 ContentCaching...Wrapper로 감싸 여러 번 읽기 가능하게 함이게 없으면 로깅 필터가 body를 소비해 컨트롤러가 빈 body를 받음
@Order(2)MDCFiltersupport/filter/MDCFilter.kt:10요청 헤더 → MDC 컨텍스트 적재. finally에서 반드시 clear()스레드풀 재사용 시 MDC 누수 방지를 위해 finally 필수
@Order(3)AdapterLoggingFiltersupport/filter/AdapterLoggingFilter.kt:15afterRequest에서 POST/PUT body를 구조화 로그로 기록, Datadog span에 supplier.name 태그 부착beforeRequest는 일부러 로그를 남기지 않음(주석 27행). /health,/swagger 등은 shouldLog로 제외

MDCFilter는 실제 헤더 파싱을 MDCHolder.putAll(request, env)(support/web/MDCHolder.kt:45)에 위임한다. MDCHoldersealed 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)핵심 동작
1interfacesAmadeusSearchController#search (.../internals/AmadeusSearchController.kt:26)@CircuitBreaker(name="amadeusSearch", fallbackMethod="searchFallback") 적용. CacheKeyGenerator.generateSearchRequestKey(...)로 requestKey 생성 후 서비스 호출
2applicationAmadeusFlightSearchService#search (application/AmadeusFlightSearchService.kt:39)캐시키 조회 → 캐시 미스면 withBlocking(Dispatchers.IO) 안에서 코루틴 fan-out
3support/utilwithBlocking + Iterable.pmap (support/util/CoroutineExtensions.kt:13, 36)OD 조합(cartesianProduct)을 병렬로 각각 클라이언트 호출
4infrastructureAmadeusClient#search (infrastructure/AmadeusClient.kt:114)FareMasterPricerTravelBoardSearch.of(...)로 SOAP 요청 빌드 → OkHttp 전송
5support/webOkHttpRequestBuilder#execute (support/web/ClientSupport.kt:146)OkHttp 실제 호출, LoggingAndCompressionInterceptor로 RQ/RS 로깅·gzip 해제
6외부TOPAS/Amadeus SOAP Fare_MasterPricerTravelBoardSearchGDS 1A 검색
7infrastructureAmadeusClient#search.fold(success=...)응답 FareMasterPricerTravelBoardSearchReplysegmentFlightRef.toFareItinerary(...) 매핑, NonAir/비발권 캐리어 제외
8applicationAmadeusFlightSearchService#search 후반부결과 distinct/OD 필터링 → useCache면 Redis 저장 → soldOut/airline 필터
9interfacesAmadeusSearchController#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)에서:

  1. fareMasterPricerTravelBoardSearchReply.checkError { code, message -> ... }로 에러코드 검사. "866","931","977","996","118","950"는 “결과 없음” 류라 무시, 그 외엔 InternationalAdapterException(SEARCH_FAILED).capture() throw.
  2. recommendations.flatMap { ... segmentFlightRef.toFareItinerary(...) }로 도메인 모델 FareItinerary 리스트 생성.
  3. .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)핵심 동작
1interfacesAmadeusBookingController#create (.../internals/AmadeusBookingController.kt:26)request.passengers.map { Passenger.of(it) }, ReservationUser.of(request) 변환 후 서비스 호출
2applicationAmadeusBookingService#book (application/AmadeusBookingService.kt:53)flightSearchService.getFareItinerary(key)로 캐시된 검색결과 복원 → stateful { ... } 시작
3applicationAmadeusBookingService.bookstateful 블록start{markSeat}inSeries{saveReservationInfo}pricingService.pricinginSeries{getPnrFares}inSeries{savePnrWithShowWarnings}inSeries{getPnrInfoAndCheckInfantSoldOut}end{signOut}
4infrastructureAmadeusClient#markSeat / saveReservationInfo / getPnrFares / savePnrWithShowWarnings / getPnrInfo / signOut각 단계가 별도 SOAP 호출. withSession(statefulBuilder?.session)로 세션 전파, 응답에서 receiveSession(response.session) 회수
5support/webOkHttpRequestBuilder#execute (ClientSupport.kt:146)1장과 동일한 OkHttp 경로(단 defaultClient, timeout 60s)
6외부TOPAS SOAP: Air_SellFromRecommendation, PNR_AddMultiElements, Fare_PricePNRWithBookingClass, Ticket_DisplayTST, PNR_Retrieve, Security_SignOut한 세션 안의 다중 오퍼레이션
7applicationBooking.of(pnrInfo = ..., pnrFares = ...) (AmadeusBookingService.kt:168)도메인 모델 → Booking 조립. 성공 시 removeFlightSearchKey(requestKey)(코루틴)
8interfacesAmadeusBookingController#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 동시세션 고갈

bookcatch(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/saveUnexposedFareItineraryCoroutineScope(Dispatchers.IO).withLaunch { ... }(:192, :198)로 fire-and-forget. withLaunchSupervisorJob + 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이 다시 던지므로 동일 스레드), @ControllerAdviceRestExceptionHandler(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 상태의미
MethodArgumentInvalidException400 BAD_REQUEST요청/공급사 입력 검증 실패
CacheKeyInvalidException410 GONE검색 캐시키 만료/소실 — 재검색 유도
StatusInvalidException500스케줄 상태 비정상(soldOut 등)
InternationalAdapterException / 그 외 Exception500공급사/내부 오류

공통 처리: 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#searchAmadeusBookingController#create
상태무상태(단발 SOAP, searchClient 25s)stateful 세션(defaultClient 60s)
동시성OD 조합 pmap 병렬 fan-outstart→inSeries→end 순차
부분 실패일부 OD 실패 허용(getOrEmpty)한 단계 실패 시 전체 롤백 + PNR 취소
회복탄력성@CircuitBreaker(amadeusSearch) + fallback 200 빈배열명시 CB 없음. catch에서 signOut + 비동기 취소 + Slack
캐시requestKey→fareItineraryKey Redis 저장캐시 복원(getFareItinerary) + 성공 시 키 제거
응답 매핑FareItineraryView.ofBookingView.ofBooking.of(pnrInfo, pnrFares)

신입 확인 문제

Q1. 검색에서 OD 조합 5개 중 2개가 SOAP 타임아웃이면 클라이언트는 무엇을 받는가?

Q2. 예약 inSeries{saveReservationInfo} 도중 예외가 나면 세션과 PNR은 어떻게 되는가?

Q3. amadeusSearch 서킷이 OPEN이면 사용자는 에러를 보는가?


관련 노트