Amadeus — 오퍼레이션 흐름

module-amadeus arch-application pattern-stateful-session api-internal

Amadeus 모듈의 6개 오퍼레이션(Search, Booking, Ticketing, FareRule, Queue, CashReceipt)을 코드 레벨에서 콜러→콜리 체인으로 추적한다. 각 오퍼레이션이 어떤 컨트롤러로 노출되고, 어떤 application 서비스를 거쳐, 어떤 infrastructure 클라이언트 메서드가 외부(TOPAS/ART/GPS) API를 호출하며, 응답이 어떤 모델로 매핑되는지를 메서드/파일경로(file:line)와 함께 정리한다.

먼저 읽고 오면 좋은 노트

Amadeus의 가장 큰 특징은 stateful PNR 세션이다. 본 노트의 모든 흐름은 stateful 세션 프로토콜을 전제로 한다. 세션이 어떻게 시작/유지/종료되는지 모르면 흐름이 이해되지 않으니 amadeus-overviewamadeus-protocol을 먼저 보고 오자. 공통 오퍼레이션 모양은 common-operations, 요청 진입 흐름은 request-flow 참고.


0. 큰 그림: 컨트롤러 → 서비스 → 클라이언트 매핑

Amadeus는 중앙 디스패처 없이 오퍼레이션별 @RestController가 Triple 예약 시스템의 내부 API(/internals/AMADEUS/*)로 직접 노출된다. 각 컨트롤러는 interfaces/controller/internals 아래에 있고, 비즈니스 로직은 application/Amadeus*Service에 위임하며, 외부 호출은 단일 AmadeusClient(SOAP) 또는 ArtClient(REST)/GpsClient(SOAP)가 담당한다.

오퍼레이션컨트롤러매핑 경로주요 application 서비스외부 클라이언트
SearchAmadeusSearchController/internals/AMADEUS/searchAmadeusFlightSearchServiceAmadeusClient.search (FareMasterPricer)
BookingAmadeusBookingController/internals/AMADEUS/bookingsAmadeusBookingService + AmadeusPricingService + AmadeusCancelService + AmadeusPassengerService + AmadeusRetrieveServiceAmadeusClient (다수)
TicketingAmadeusTicketingController/internals/AMADEUS/ticketingAmadeusTicketingService + AmadeusPricingServiceAmadeusClient + GpsClient(결제)
FareRuleAmadeusFareRuleController/internals/AMADEUS/fare-rulesAmadeusFareRuleServiceArtClient (REST/ART)
QueueAmadeusQueueController/internals/AMADEUS/queuesAmadeusQueueServiceAmadeusClient (Queue_*)
CashReceiptAmadeusCashReceiptController/internals/AMADEUS/cash-receiptsAmadeusCashReceiptServiceGpsClient (현금영수증)
(취소/환불)AmadeusBookingController (cancel/cancelable/expected-cancel)/internals/AMADEUS/bookings/{pnr}/...AmadeusCancelService + AmadeusRefundServiceAmadeusClient + GpsClient(결제취소)

서비스가 11개로 쪼개져 있다

컨트롤러는 6개지만 application 패키지에는 서비스가 11개 있다. Booking/Ticketing/Cancel 오퍼레이션은 여러 서비스가 협력한다. 콜러-콜리 전체 관계는 caller-callee-map 참고. AmadeusFlightSearchService, AmadeusBookingService, AmadeusTicketingService, AmadeusFareRuleService, AmadeusQueueService, AmadeusCashReceiptService, AmadeusPricingService, AmadeusRefundService, AmadeusCancelService, AmadeusPassengerService, AmadeusRetrieveService

모든 SOAP 호출이 공유하는 공통 골격

AmadeusClient의 모든 메서드는 동일한 빌더 체인을 따른다(AmadeusClient.kt). 신입은 이 패턴 하나만 익히면 나머지 30여 개 메서드가 똑같이 읽힌다.

amadeusApiProperties.endpoint
    .post(request)                                   // OkHttp 기반 ClientSupport
    .header(getHeaderMap(request))                   // Content-Type: text/xml, SOAPAction
    .requestBodyConvert(soapRequestBodyConverter(..)) // ★ 여기서 세션 헤더를 SOAP에 주입
    .execute<AmadeusResponse<XxxReply>>(soapBodyDeserializerOf(logger, objectMapper))
    .fold(
        success = { response ->
            statefulBuilder?.receiveSession(response.session) // ★ 응답에서 받은 세션을 다시 저장
            response.body.checkError { ... }                  // 오류코드 → 도메인 예외 변환
            response.body.toXxx()                             // *Reply → support/model 매핑
        },
        failure = { throw it.handleSoapFaultException(ErrorMessage.XXX) }
    )

세션 헤더 주입은 soapRequestBodyConverter (AmadeusClient.kt:1327-1417)에서 일어난다. isStart일 때만 Security(UsernameToken+PasswordDigest)와 AMA_SecurityHostedUser(PseudoCityCode=officeId)를 넣고, 세션이 있으면 Session 헤더에 TransactionStatusCode / SessionId / SequenceNumber / SecurityToken을 붙인다. 자세한 메커니즘은 amadeus-protocol.


1. Search — 항공편 검색

가장 단순한 오퍼레이션이자 유일하게 서킷브레이커가 붙은 오퍼레이션이다.

콜 체인

  • 진입: AmadeusSearchController.search (AmadeusSearchController.kt:24-53) — @CircuitBreaker(name = "amadeusSearch", fallbackMethod = "searchFallback")
  • 캐시키 생성: CacheKeyGenerator.generateSearchRequestKey(supplier = AMADEUS, request)
  • 서비스: AmadeusFlightSearchService.search (AmadeusFlightSearchService.kt:39-111)
  • 클라이언트: AmadeusClient.search (AmadeusClient.kt:114-188) → 외부 FareMasterPricer_TravelBoardSearch (1A)
  • 응답 매핑: FareMasterPricerTravelBoardSearchReplyFareItinerary(도메인) → FareItineraryView(컨트롤러)

흐름 다이어그램

flowchart TD
    T["Triple<br/>POST /internals/AMADEUS/search"] --> CTRL["AmadeusSearchController.search"]
    CTRL -->|"CircuitBreaker amadeusSearch OPEN"| FB["searchFallback<br/>200 OK 빈 리스트"]
    CTRL -->|"requestKey = generateSearchRequestKey"| SVC["AmadeusFlightSearchService.search"]
    SVC --> Q{"flightSearchKeyRepository.findKey"}
    Q -->|"캐시 HIT"| HIT["fareItineraryRepository.getFareItineraries<br/>외부호출 없이 반환"]
    Q -->|"캐시 MISS"| MISS["key = generateFareItineraryKey AMADEUS<br/>withBlocking Dispatchers.IO 코루틴 진입"]
    MISS --> PAR["makeOriginDestinations.cartesianProduct<br/>.pmap amadeusClient.search<br/>OD 조합별 병렬 검색"]
    PAR --> POST["flatten.distinctBy id.filter OD 일치"]
    POST --> CACHE["useCache 그리고 결과 not empty<br/>addKey + saveFareItineraries 캐시 적재"]
    CACHE --> CLIENT["AmadeusClient.search<br/>SOAP stateless 세션 없음 → TOPAS 1A"]
    CLIENT --> CHK["checkError 코드 866 931 977 996 118 950 는 결과 없음으로 무시"]
    CHK --> MAP["FareMasterPricerTravelBoardSearchReply → List FareItinerary"]

Search는 stateless다

검색은 PNR 세션을 만들지 않는다(statefulBuilder 인자 없음). 그래서 withBlocking(Dispatchers.IO) + pmap으로 OD(출도착) 조합을 병렬 호출할 수 있다. 반면 Booking/Ticketing/Cancel은 세션 순서가 중요해서 절대 병렬화하지 않는다. 비동기 유틸은 async-coroutines.

서킷브레이커 fallback은 "빈 리스트"

searchFallback(AmadeusSearchController.kt:72-80)은 CallNotPermittedException을 받아 Datadog span에 supplier.circuit-breaker=OPEN 태그를 찍고 빈 리스트를 200으로 반환한다. 즉 Amadeus 검색이 죽어도 다른 공급사 결과는 살아남는다(전체 검색 실패 방지). 이 “이벤트 전파 = Resilience4j 상태전이”가 본 시스템의 핵심 설계다. 설정은 application.yml:41-50(slidingWindow 180s, failureRateThreshold 35%, waitDurationInOpenState 120s). 자세히는 resilience-and-events, configuration-and-infra.

detail 엔드포인트(AmadeusSearchController.kt:55-70)는 캐시된 FareItinerary를 키로 다시 꺼내(getFareItinerary) FlightAmenityService.findAmenityMap로 어메니티를 붙여 FareItineraryDetailView로 반환한다. 외부 호출 없음.


2. Booking — 예약 생성 (가장 복잡한 stateful 플로우)

AmadeusBookingController는 8개 엔드포인트(create / changeApis / cancel / expectedCancel / cancelable / retrieve / checkPnr / confirm / repricing / divide)를 노출한다. 여기서는 핵심인 **create(book)**을 먼저 본다.

book 콜 체인

  • 진입: AmadeusBookingController.create (AmadeusBookingController.kt:25-38)
  • 서비스: AmadeusBookingService.book (AmadeusBookingService.kt:53-183)
  • 협력 서비스: AmadeusFlightSearchService.getFareItinerary, AmadeusPricingService.pricing, AmadeusRetrieveService.getPnrInfoAndCheckInfantSoldOut, AmadeusCancelService.pnrCancelAsync, SlackService, CalculateTimezoneService
  • 응답 매핑: Booking.of(pnrInfo, pnrFares)BookingView

흐름 다이어그램 (세션 한 줄로 묶임)

flowchart TD
    A["AmadeusBookingService.book"] --> B["fareItinerary = flightSearchService.getFareItinerary key<br/>캐시에서 검색결과 복원"]
    B --> S["stateful 세션 1건 시작"]
    subgraph TRY ["try 블록"]
        S1["start markSeat<br/>좌석 확보 AirSell_FromRecommendation"] --> S2["inSeries saveReservationInfo<br/>PNR 골격 생성 PNR_AddMultiElements → pnrPassengers"]
        S2 --> S3["pricingService.pricing<br/>운임계산+TST 생성 3-A 참조"]
        S3 --> S4["inSeries getPnrFares<br/>TST 운임 조회 → pnrFares"]
        S4 --> S5["inSeries savePnrWithShowWarnings<br/>EOT 저장 → savedPnr, warnings"]
        S5 --> W{"warnings 판정"}
        W -->|"CHECK MINIMUM CONNECTION TIME 또는 CHECK ARRIVAL DEPARTURE"| WERR["throw MINIMUM_CONNECTION_TIME"]
        W -->|"warnings not null"| WRE["한 번 더 savePnrWithShowWarnings 재확정"]
        WRE --> S6["excludeWarnings 제외분 → slackService.sendWarnings"]
        S6 --> S7["inSeries getPnrInfoAndCheckInfantSoldOut acceptable KK HK NN<br/>Retryable"]
        S7 --> S8["carrierTimeLimit null 이면 withBlocking delay 3000 getPnrInfo 재조회<br/>TL 지연 보정"]
        S8 --> S9{"schedule confirmed 또는 confirming"}
        S9 -->|"아니면"| SOLD["throw SOLD_OUT"]
        S9 -->|"맞으면"| S10["legs 비어있으면 slackService.sendEmptyLeg"]
        S10 --> S11["end signOut 세션 종료"]
        S11 --> S12["Booking.of pnrInfo, pnrFares → removeFlightSearchKey 비동기"]
    end
    S --> S1
    subgraph CATCH ["catch e 블록"]
        C1["session.transactionStatusCode == InSeries 이면 end signOut<br/>세션 누수 방지"] --> C2["isUnexposedFareItinerary e 이면 removeKey + saveUnexposed 비동기<br/>품절 운임 숨김"]
        C2 --> C3["pnr not null 이면 cancelService.pnrCancelAsync<br/>실패 시 만든 PNR 자동 취소 비동기"]
        C3 --> C4["throw e"]
    end
    WERR -.->|"예외"| C1
    SOLD -.->|"예외"| C1
    S12 -.->|"예외 발생 시"| C1

세션 누수 방지 패턴(전 오퍼레이션 공통)

모든 stateful 블록의 catchif (session?.transactionStatusCode == TransactionStatusCode.InSeries) { end { signOut() } }로 끝난다. 도중에 예외가 나면 End를 못 보내 TOPAS 세션이 살아있게 되는데, 이 코드가 강제로 Security_SignOut을 쏴서 세션을 닫는다. 이 한 줄을 빠뜨리면 세션 풀 고갈로 전체 장애가 난다. amadeus-pitfalls에서 강조.

예약 실패 시 보상 트랜잭션

Amadeus는 트랜잭션이 없으므로 실패 시 직접 롤백한다. book 실패 catch에서 pnr != null이면 cancelService.pnrCancelAsync(pnr)이미 생성된 PNR을 비동기로 취소한다(AmadeusBookingService.kt:179). 또 품절성 예외(SOLD_OUT/INFANT_SOLD_OUT/MINIMUM_CONNECTION_TIME)면 saveUnexposedFareItinerary로 해당 운임을 검색 노출에서 숨긴다(isUnexposedFareItinerary, :185-189). 이 “품절 숨김”은 AmadeusFlightSearchService.filterByUnexposedFareItinerary(:126-134)와 짝을 이룬다.

인판트 품절 폴링 — getPnrInfoAndCheckInfantSoldOut

AmadeusRetrieveService.getPnrInfoAndCheckInfantSoldOut(AmadeusRetrieveService.kt:28-55)는 @Retryable(maxAttempts = 5, backoff = Backoff(delay = 5000)). INF SSR 상태가 KK/HK/NN이 아니면 INFANT_SOLD_OUT을 던지되, 상태가 "HN"(보류)일 때만 .retry()로 마킹해 최대 5회 재조회한다. 재시도 판정 함수는 shouldGetPnrInfoAndCheckInfantSoldOutRetryable(:60-62)이 ApiException.retryable을 읽는 SpEL(exceptionExpression)로 연결된다. 이 “예외 마킹 → @Retryable이 읽음” 패턴은 error-handling 참고.

기타 Booking 엔드포인트

엔드포인트서비스 메서드핵심
GET /{pnr} retrieveAmadeusBookingService.retrieve (:203-274)getPnrInfo → 티켓 있으면 발권일 히스토리(getTicketIssuedDateMap) + e-Doc/EMD 분리 조회 → getPnrFares(end)
GET /{pnr}/check-pnrAmadeusBookingService.checkPnrAmadeusClient.checkPnr (:675-680)stateless 단발 retrieve, PNR 유효성만 확인
GET /{pnr}/confirmAmadeusBookingService.confirm (:280-322)KK/HK 아니면 NOT_OK_SCHEDULE; confirming/SSR KK 있으면 confirmPnr
GET /{pnr}/repricingAmadeusBookingService.repricing (:324-381)★ 아래 별도 설명
POST /{pnr}/divideAmadeusBookingService.divide (:383-408)splitPnrsaveSplitPnrsavePnrWithShowWarnings → 새 PNR로 retrieve
PUT /{pnr} changeApisAmadeusPassengerService.changeApis (AmadeusPassengerService.kt:16-62)API(여권)정보 변경: 기존 element 삭제 → savePassengerInfosavePnrWithRetrieve

3. 운임 재계산(Repricing) — 핵심 공통 서브플로우

"repricing"은 코드에 3곳에 존재한다. 헷갈리기 쉬우니 구분하자.

  1. 컨트롤러 엔드포인트 GET /{pnr}/repricingAmadeusBookingService.repricing (예약 단계 재요금)
  2. 발권 준비 시 내부 repricing AmadeusTicketingService.repricing (AmadeusTicketingService.kt:127-175, @Deprecated)
  3. 운임계산 엔진 AmadeusPricingService.pricing — 위 둘이 모두 호출하는 실제 가격 재계산 로직

3-A. AmadeusPricingService.pricing (모든 repricing의 심장)

AmadeusPricingService.pricing(AmadeusPricingService.kt:16-107)은 book / repricing / ticketing-ready가 공통으로 부르는 서브루틴이다. 이미 열린 세션(statefulBuilder)을 받아 inSeries로 이어붙인다.

flowchart TD
    P["pricingService.pricing validatingCarrier, passengerAirPrices"] --> P1["inSeries amadeusClient.pricing<br/>FarePricePNRWithBookingClass → List AcceptableFare"]
    P1 --> P2["inSeries amadeusClient.tstCreate acceptableFares<br/>Ticket_CreateTSTFromPricing → List TstCreate"]
    P2 --> P3["updatableTstList = createdTst 교차 acceptableFare 매칭<br/>underFare 존재 그리고 tourCode null 그리고 discount 0 초과 인 것만<br/>할인운임만 TST 갱신 대상"]
    P3 --> P4{"updatableTstList not empty"}
    P4 -->|"yes"| P5["forEach inSeries amadeusClient.tstUpdate<br/>Ticket_UpdateTST 할인 반영"]
    P5 --> P6["inSeries amadeusClient.saveTourCode 첫 tourCode<br/>PNR_AddMultiElements tourCode 저장"]
    P4 -->|"no"| P7
    P6 --> P7{"validatingCarrier in PR, MU"}
    P7 -->|"맞으면 항공사별 특수처리"| P8["inSeries getPnrInfo 기존 endorsement 조회"]
    P8 --> P9["previousEndorsements not empty 이면 inSeries removeElements"]
    P9 --> P10["inSeries saveEndorsement<br/>DOB 그리고 PR이면 RFND ONLY TO ISSUE AGT 주입"]

PR/MU 항공사 endorsement 하드코딩

pricing 말미(:60-106)에 validatingCarrier == "PR" || "MU"이면 endorsement에 승객 생년월일(DOB)을, PR이면 추가로 "RFND ONLY TO ISSUE AGT" 문구를 강제로 넣는다. 항공사 약관 누락 방지용 특수 분기다. 이런 캐리어 하드코딩은 amadeus-pitfalls에 다수 모여 있다.

3-B. AmadeusBookingService.repricing (예약 단계 재요금)

AmadeusBookingService.repricing(AmadeusBookingService.kt:324-381)의 핵심은 **“운임 생성일이 오늘이 아니면 다시 가격을 매긴다”**는 것.

flowchart TD
    R["repricing pnr"] --> ST["stateful"]
    ST --> R1["start getPnrInfo pnr"]
    R1 --> R2["inSeries getPnrFares pnr → booking 원본"]
    R2 --> R3{"pnrInfo.fareCreatedDate != today<br/>어제 이전 운임 = 재계산 필요"}
    R3 -->|"yes"| R4["tourCodeElements 있으면 inSeries removeElements tourCode<br/>기존 tourCode 제거"]
    R4 --> R5["pricingService.pricing 3-A 실행 재가격"]
    R5 --> R6["inSeries savePnrWithRetrieve → newPnrInfo"]
    R6 --> R7["inSeries getPnrFares → newPnrFares"]
    R7 --> R8["Booking.of new.also validateFareBasisChange 원본, 신규<br/>fareBasis 바뀌면 품절"]
    R3 -->|"no"| R9["booking 원본 그대로"]
    R8 --> RE["end signOut"]
    R9 --> RE
    ST -.->|"catch NON_RETRIEVABLE_SCHEDULE_STATUS"| RC["cancelService.pnrCancelAsync pnr"]

validateFareBasisChange — 운임 변동 = 품절 판정

validateFareBasisChange(AmadeusBookingService.kt:410-425, AmadeusTicketingService.kt:687-702에 동일 코드 중복)는 재계산 전후 fareBasis(fare.fareComponents)가 다르면 PNR을 비동기 취소하고 SOLD_OUT 예외를 던진다. 즉 “어제 본 가격으로 발권 불가 = 좌석/운임 소진”으로 간주한다. 동일 로직이 두 서비스에 복붙되어 있는 것은 리팩터링 부채. landmines 참고.


4. Ticketing — 발권 (결제 + 발권 + 보상취소)

AmadeusTicketingControllerready(발권준비)와 issue(실발권) 2개 엔드포인트만 노출한다. 그러나 내부는 모듈에서 가장 복잡하다(AmadeusTicketingService.kt 712줄).

4-A. ready — 발권 준비/검증 + (필요 시) 재요금

  • 진입: AmadeusTicketingController.ready (AmadeusTicketingController.kt:19-29)
  • 서비스: AmadeusTicketingService.ready (AmadeusTicketingService.kt:37-125)
flowchart TD
    RD["ready pnr, validatingCarrier"] --> ST["stateful"]
    ST --> D1["start getPnrInfo pnr"]
    D1 --> D2{"carrierTimeLimit 가 now+2분 미만"}
    D2 -->|"yes"| E1["throw TICKETING_FAILED<br/>carrier time limit is short"]
    D2 -->|"no"| D3{"AB 제외 carrierPnr 없음"}
    D3 -->|"yes"| E2["throw TICKETING_FAILED"]
    D3 -->|"no"| D4{"티켓 이미 있음"}
    D4 -->|"yes"| E3["throw TICKETING_FAILED<br/>ticket exists"]
    D4 -->|"no"| D5{"schedule KK 또는 HK"}
    D5 -->|"아니면"| E4["NON_RETRIEVABLE_SCHEDULE_STATUS<br/>catch에서 pnrCancelAsync"]
    D5 -->|"맞으면"| D6["confirming 또는 SSR-KK 이면 inSeries confirmPnr"]
    D6 --> D7["inSeries getPnrFares fareCreatedDate"]
    D7 --> D8["needRepricing = fareCreatedDate != today"]
    D8 --> D9["needRepricing 이면 repricing<br/>AmadeusTicketingService.repricing Deprecated 3-A 호출"]
    D9 --> D10["end signOut"]
    D10 --> D11["return Pair passengers.takeIf needRepricing, schedules<br/>재요금됐을 때만 승객 가격 반환"]

4-B. issue — 실발권 (결제 → 발권 → 실패 시 보상)

AmadeusTicketingService.issue(AmadeusTicketingService.kt:178-236)는 3단 구조다.

flowchart TD
    I["issue pnr, validatingCarrier, passengerPrices, cardInfo, keepPnr"] --> I1["1. payment = cardInfo.let payment<br/>카드결제 GpsClient 세션 밖"]
    I1 --> I2["2. try 블록"]
    subgraph TRY ["try"]
        T1["passengers = ticketing pnr, payment<br/>실발권 세션 안 4-C"] --> T2["CZ 그리고 비성인 있음 이면 saveAdultTicketNumber<br/>CZ 항공 특수처리"]
        T2 --> T3["List TicketingPassenger 매핑"]
    end
    I2 --> T1
    subgraph CATCH ["3. catch e — 보상은 비동기로"]
        K1["CoroutineScope Dispatchers.IO withLaunch"] --> K2["delay 5000 locked pnr 방지"]
        K2 --> K3["cancelService.voidRepeat pnr 발권된 티켓 void"]
        K3 --> K4["payment not null 이면 cancelService.paymentCancelAsync 결제 취소"]
        K4 --> K5["keepPnr 아니면 cancelService.pnrCancelAsync pnr PNR 취소"]
        K5 --> K6["throw e"]
    end
    T3 -.->|"예외 발생 시"| K1

결제 payment (:263-297)

  • gpsClient.verifyCard(pnr, cardInfo) → 카드코드 획득 (GPS SOAP)
  • validatingCarrier == "KE"amadeusClient.approveByKeyIn(TOPAS Command_Cryptic 경유), 그 외는 gpsClient.approve
  • SocketTimeout/SocketException/IOException이면 slackService.sendPaymentFail 후 rethrow (결제 불확실 알림)

4-C. ticketing 내부 (:299-501) — 발권의 본체

flowchart TD
    TK["ticketing pnr, validatingCarrier, passengerPrices, payment"] --> ST["stateful"]
    ST --> G1["start getPnrInfo pnr + 검증<br/>schedule confirmed, 티켓 없음, carrierPnr, TL"]
    G1 --> G2["additionalCommissions = getAdditionalCommissions 오버커미션 우선순위 계산"]
    G2 --> G3["removeElements = getRemoveElements<br/>fop commission tourCode 참조번호 수집"]
    G3 --> G4["removeElements not empty 이면 inSeries removeElements"]
    G4 --> G5["additionalCommissions not empty 이면 inSeries saveCommission"]
    G5 --> G6["inSeries savePaymentInfo FOP_CreateFormOfPayment 결제수단 등록"]
    G6 --> G7["inSeries savePnrWithRetrieve EOT FOP 저장 확정"]
    G7 --> G8{"발권 분기 — MU 그리고 비성인 있음"}
    G8 -->|"yes"| G9["issueWithEndorsements<br/>MU 특수 성인발권 → TKT번호 → endorsement → 소아 유아발권"]
    G8 -->|"no"| G10["passengerPrices.forEach inSeries amadeusClient.ticketing 승객별 지정발권"]
    G9 --> G11["inSeries getPnrInfo → 티켓 없으면 TICKETING_FAILED"]
    G10 --> G11
    G11 --> G12["inSeries getPnrTicketDocuments e-Doc 조회"]
    G12 --> G13["isShownFare 아니면 end getPnrFares 로 운임 보강"]
    G13 --> G14["ticketedPnrInfo.passengers.withTickets"]
    ST -.->|"catch NON_RETRIEVABLE_SCHEDULE_STATUS 또는 NOT_OK_SCHEDULE"| GC["cancelService.pnrCancelAsync pnr"]

발권 재시도는 infrastructure에 있다

승객별 발권 AmadeusClient.ticketing(AmadeusClient.kt:894-946)에 @Retryable(maxAttempts = 3, backoff = Backoff(delay = 2000), exceptionExpression = "@amadeusClient.shouldTicketingRetryable(#root)"). 응답에 "WARNING: BAGGAGE ALLOWANCE MISSING IN TST"가 오면 .retry()로 마킹해 재시도하고, 재시도 횟수(RetrySynchronizationManager)가 0보다 크면 slackService.sendBaggageMissingTicketing을 보낸다. "NO ALLOCATION FOR NN"/"NO QUOTA DEFINED"NO_QUOTA로, "Time Out"timeoutCallback()(Slack 알림) 후 예외. 재시도 가능 판정은 shouldTicketingRetryable(:951-953)이 ApiException.retryable을 읽는다.

MU 항공 발권 — issueWithEndorsements (:583-685)

중국남방(MU)은 소아/유아 발권 전에 endorsement에 성인 티켓번호를 연결해야 한다. 그래서 (1) 성인 먼저 발권 → (2) getPnrInfo로 발권된 티켓번호 조회 → (3) endorsement에 /TKT{성인티켓번호} 주입(saveEndorsement) → (4) 소아/유아 발권 순서로 진행한다. 인덱스 계산 pnrInfo.tickets[index++ / 3](:648) 같은 매직 로직이 있어 매우 취약. CZ(saveAdultTicketNumber, :238-261)도 유사. 캐리어 특수 케이스 총정리는 amadeus-pitfalls.


5. 취소 / 환불 — Void vs Refund 분기

취소는 AmadeusBookingController의 cancel/cancelable/expectedCancel 엔드포인트로 들어와 AmadeusCancelService가 받고, 환불 계산/실행은 AmadeusRefundService에 위임한다.

5-A. cancel 의사결정 트리 (AmadeusCancelService.cancel, AmadeusCancelService.kt:35-103)

flowchart TD
    CA["cancel pnr, validatingCarrier, payment, autoRefundable, waivers"] --> CA1["pnrInfo = getPnrInfo pnr"]
    CA1 --> CA2{"nonCancelableTicket 있음 또는 EMD 티켓 있음"}
    CA2 -->|"yes"| U1["CANCEL_UNABLE"]
    CA2 -->|"no"| CA3{"tickets.isEmpty 미발권 상태"}
    CA3 -->|"yes"| CA4{"출발임박 또는 노쇼"}
    CA4 -->|"yes"| U2["CANCEL_UNABLE"]
    CA4 -->|"no"| CA5["pnrCancelRepeat pnr → return VOID"]
    CA3 -->|"no"| CA6["ticketDocuments = getPnrTicketDocuments pnr, tickets"]
    CA6 --> CA7["voidable = isVoidable<br/>발권일 == 오늘 그리고 NonVoidable 아님 그리고 EMD 없음"]
    CA7 --> W{"분기 when"}
    W -->|"voidable"| WV["voidRepeat pnr<br/>payment.let paymentCancelAsync<br/>pnrCancelAsync pnr → VOID"]
    W -->|"waiverRefundable 또는 autoRefundable"| WR["refunds = refundService.refund pnr, waivers 5-B<br/>pnrCancelAsync pnr → REFUND"]
    W -->|"else"| U3["CANCEL_UNABLE"]

cancelable/expectedCancel(:123-218)은 실제 취소 없이 조회만 한다(refundService.refundCalculate). 차이: expectedCancel은 waiverRefund(무료환불) 전용, cancelable은 autoRefundable도 허용.

5-B. refund — 환불 실행 (AmadeusRefundService.refund, AmadeusRefundService.kt:90-165)

flowchart TD
    RF["refund pnr, waivers"] --> ST["stateful"]
    ST --> F1["start getPnrInfo validateSchedule + validateRefundableStatus"]
    F1 --> F2["inSeries getPnrTicketDocuments + validateRefundableTickets"]
    F2 --> F3["isWaiverRefund 이면 saveWaiverRefunds waivers, pnrInfo<br/>OSI SSR REMARK 타입별 주입+검증"]
    F3 --> F4["try ticketDocuments.map refund 티켓별<br/>티켓 단위 환불"]
    F4 -->|"catch"| FE["slackService.sendAllTicketRefundFail<br/>throw REFUND_FAILED"]
    F4 --> F5["also inSeries getPnrTicketDocuments 환불 후 재조회"]
    F5 --> F6["filter status != REFUND not empty 이면 REFUND_FAILED<br/>부분환불 감지"]
    F6 --> F7["end signOut"]

티켓 단위 refund(:235-312)는 initRefund(DocRefund_InitRefund) → 조건부 updateRefund(DocRefund_UpdateRefund) → processRefund(DocRefund_ProcessRefund) 3단계.

환불 금액 역산 로직 — 부분 사용/혼합결제

refund 내부의 shouldUpdateRefund(:227-233)와 금액 분배(:250-294)가 환불에서 가장 위험한 코드다.

  • waiver환불인데 수수료>0 이거나, FOP가 2개 이상이고 환불불가금액>0이면 updateRefund 호출.
  • 카드만/현금만일 때 noneRefundAmount(사용한 운임+세금+수수료)를 역산해서 카드 환불액을 우선 차감(:271-278).
  • overrideFopGroups로 CC/CA별 금액을 다시 써서 보낸다. 금액 계산이 틀리면 과/소환불이 발생하므로 수정 시 극도로 주의. landmines에 등재.

refundCalculate의 일별 캐시

AmadeusRefundService.refundCalculate(:32-88)는 initRefund를 호출해 예상 환불액을 계산하는데, withBlocking(Dispatchers.IO) + pmap으로 승객 타입별 병렬 조회하고 결과를 amadeusInitRefundRepository당일 자정까지 TTL로 캐시한다(:54-73). 같은 PNR을 여러 번 조회해도 외부 호출은 하루 1회. 단, 주석대로 “stateful을 쓰려면 DocRefund_IgnoreRefund 처리가 필요”해 여기선 의도적으로 stateless(단발) 호출이다.

5-C. Void 재시도 — voidRepeat (AmadeusCancelService.kt:262-309)

void는 최대 3회 재시도. NOT_FOUND_TICKET이면 즉시 중단, 마지막 시도/ALREADY_CANCELED_PNR/INVALID_VOID_TICKET이면 별도 stateful 블록으로 티켓 목록을 조회해 slackService.sendVoidFail(target/fail 티켓 목록 포함)을 보내고 예외를 던진다. pnrCancelRepeat(:237-260)는 최대 2회.

비동기 보상 작업 (코루틴 fire-and-forget)

  • pnrCancelAsync(:311-316): CoroutineScope(Dispatchers.IO).withLaunch { delay(5000); pnrCancelRepeat(pnr) } — locked PNR 방지 5초 지연
  • paymentCancelAsync(:427-449): GPS 결제취소, 실패 시 sendPaymentCancelFail + PAYMENT_CANCEL_FAILED

withLaunch fire-and-forget의 함정

보상 취소는 응답을 기다리지 않는 코루틴이다. 예외는 AdapterCoroutineExceptionHandler로 잡혀 Slack으로만 전파되고 호출자에겐 보이지 않는다. “취소 API가 200을 줬는데 실제 PNR이 안 닫혔다”는 인시던트는 여기서 비롯된다. async-coroutines, error-handling 참고.


6. FareRule — 운임 규정 (유일한 REST 경로)

다른 모든 오퍼레이션이 TOPAS SOAP인데 FareRule만 **ART REST(JSON)**를 쓴다.

콜 체인

  • 진입: AmadeusFareRuleController.getFareRules (AmadeusFareRuleController.kt:20-33) / getStructuredFareRules (:35-39)
  • 서비스: AmadeusFareRuleService.findFareRules (AmadeusFareRuleService.kt:17-34)
  • 클라이언트: ArtClient.findFareRules (ArtClient.kt:30-93) → ART /api/v1/art/getrule/{agentCode}/{officeId} (POST, x-api-key)
flowchart TD
    FR["getFareRules key, adult, child, infant"] --> FR1["fareRuleKey = generateFareRuleKey key, adult, child, infant"]
    FR1 --> FR2["saved = fareRuleRepository.findFareRules fareRuleKey"]
    FR2 --> FR3{"saved 비었음"}
    FR3 -->|"yes"| FR4["fareItinerary = fareItineraryRepository.getFareItinerary key<br/>검색결과 캐시 재사용"]
    FR4 --> FR5["artClient.findFareRules → REST JSON → ART<br/>also fareRuleRepository.saveFareRules 결과 캐시"]
    FR3 -->|"no"| FR6["saved 그대로"]
    FR5 --> FR7["List FareRule → FareRuleView"]
    FR6 --> FR7

ArtClient@Retryable(maxAttempts = 2)(ArtClient.kt:29). 응답의 ruleGroup을 카테고리코드로 필터링하고, 카테고리코드가 비면 한글 제목(적용구간/항공사 수수료/수하물/…) 우선순위로 정렬한다(:60-78). getStructuredFareRules는 외부 호출 없이 캐시된 FareItinerary에서 StructuredFareRuleView를 만든다.

FareRule은 세션이 없다

ART는 별도 인증(x-api-key)의 stateless REST라 PNR 세션과 무관하다. 그래서 ArtClientClientSupport만 상속하고 StatefulBuilder를 전혀 쓰지 않는다. 검색→FareRule은 같은 캐시(FareItinerary)를 공유한다는 점만 기억하자.


7. Queue — 큐 처리 (오프라인 OID 병렬)

대기열(Queue)에 쌓인 PNR을 조회/제거한다. 오프라인 OID 하나로 모든 큐를 다룬다.

콜 체인

  • 진입: AmadeusQueueController.getQueues (AmadeusQueueController.kt:16-21) / remove (:23-37)
  • 서비스: AmadeusQueueService.getQueuePnrs (AmadeusQueueService.kt:28-65) / remove (:74-93)
  • 클라이언트: AmadeusClient.getPnrCountsInQueue(Queue_CountQueueTotal) → getPnrsInQueue(Queue_List) → removePnrsInQueue(Queue_RemoveItem)
flowchart TD
    QG["getQueuePnrs"] --> Q1["offlineOid = amadeusProperties.channels TRIPLE.funnels TRIPLE.offlineOid"]
    Q1 --> Q2["withBlocking Dispatchers.IO"]
    Q2 --> Q3["TARGET_QUEUE_NUMBERS 1, 7 .pmap queueNumber 큐 번호별 병렬"]
    Q3 --> Q4["counts = getPnrCountsInQueue queueNumber, offlineOid.filterByCategory queueNumber"]
    Q4 --> Q5["counts.flatMap getPnrsInQueue queueNumber, itemNumber, timeMode, offlineOid 최대 250<br/>.map QueuePnrInfo"]
    Q5 --> Q6["getOrEmpty.flatten"]
    Q2 -.->|"catch"| QC["slackService.sendQueueFail → emptyList<br/>큐 실패는 빈 리스트 검색 fallback과 동일 철학"]

remove(컨트롤러 :23-37)는 요청을 (queueNumber, category, timeMode)로 그룹핑해 removePnrsInQueue를 호출하고 전부 성공해야 true. AmadeusClient.removePnrsInQueue(AmadeusClient.kt:1173-1212)는 @Retryable(maxAttempts = 3, backoff = Backoff(delay = 5000))이며, 큐 SOAP 호출들은 soapRequestBodyConverter(useOfflineOid = true)로 PseudoCityCode에 offlineOid를 넣는다(일반 officeId와 다름, :1402-1407).


8. CashReceipt — 현금영수증 (발권 직후, GPS 경유)

콜 체인

  • 진입: AmadeusCashReceiptController.issue (AmadeusCashReceiptController.kt:16-28) / cancel (:30-42)
  • 서비스: AmadeusCashReceiptService.issue (AmadeusCashReceiptService.kt:29-74) / cancel (:76-99)
  • 클라이언트: GpsClient.issueCashReceipt / cancelCashReceipt (GpsClient.kt:132-197)
flowchart TD
    CR["issue pnr, price, validatingCarrier, receiptType, identityNumber"] --> ST["stateful — TOPAS는 티켓 검증에만 사용"]
    ST --> CR1["start getPnrInfo pnr 티켓 없으면 CASH_RECEIPT_ISSUE_FAILED"]
    CR1 --> CR2["inSeries getPnrTicketDocuments + validateTicketPrice<br/>카드결제분 있으면 발행불가, 현금이 요청가 미만이면 불가"]
    CR2 --> CR3["end signOut"]
    CR3 --> CR4["gpsClient.issueCashReceipt ticketNumbers = pnrInfo.tickets.map ticketNumber<br/>GPS SOAP 세션 밖"]
    ST -.->|"onFailure"| CRF["signOut 누수방지 + handleIssuanceFailure 비동기 Slack"]

validateTicketPrice(:101-116): cardPrice 합이 0보다 크면 “카드 발권은 현금영수증 불가”, 현금합이 요청가보다 작으면 “초과 요청”으로 거부. cancel은 TOPAS 없이 gpsClient.cancelCashReceipt만 호출하고 실패 시 handleCancellationFailure(비동기 Slack) 후 rethrow.

CashReceipt의 세션 경계

영수증 발행 자체는 GPS(외부 결제망)에서 일어나고 TOPAS 세션은 발행 전 티켓 검증용으로만 열린다. 그래서 end { signOut() }을 먼저 하고 그 다음 gpsClient.issueCashReceipt를 부른다(세션 밖). GpsClient도 SOAP지만 Amadeus 세션과 별개의 GPS_Approval 서비스다.


비동기 / Resilience4j / Retry 위치 총정리

위치 (file:line)종류적용 대상의미
AmadeusSearchController.kt:24@CircuitBreaker(amadeusSearch)검색OPEN 시 빈 리스트 fallback. 모듈 유일의 서킷브레이커
AmadeusClient.kt:894@Retryable(3, 2s)승객별 발권 ticketingBAGGAGE MISSING 등 retry 마킹 시 재시도
AmadeusClient.kt:1173@Retryable(3, 5s)removePnrsInQueue큐 제거 재시도
AmadeusRetrieveService.kt:28@Retryable(5, 5s)인판트 품절 폴링INF 상태 HN이면 최대 5회
ArtClient.kt:29@Retryable(2)FareRuleART REST 재시도
AmadeusFlightSearchService.kt:58withBlocking + pmap검색OD 조합 병렬 (stateless)
AmadeusQueueService.kt:34withBlocking + pmap큐 조회큐번호 병렬
AmadeusRefundService.kt:54withBlocking + pmap환불계산승객타입별 initRefund 병렬
AmadeusBookingService.kt:134withBlocking + delay(3000)예약carrierTimeLimit null 보정
AmadeusBookingService.kt:192,198CoroutineScope(IO).withLaunch예약품절숨김/키삭제 fire-and-forget
AmadeusCancelService.kt:312,428CoroutineScope(IO).withLaunch취소pnrCancelAsync / paymentCancelAsync
AmadeusTicketingService.kt:217CoroutineScope(IO).withLaunch발권실패 보상(void/결제취소/PNR취소)
AmadeusCashReceiptService.kt:119,130CoroutineScope(IO).withLaunch영수증실패 Slack 알림

핵심 정리 (시니어로 가는 한 문장)

Amadeus의 모든 “쓰기” 오퍼레이션(Booking/Ticketing/Cancel/Refund/CashReceipt)은 하나의 stateful 세션 안에서 start→inSeries*→end 시퀀스로 묶이고, 실패하면 catch에서 세션을 signOut으로 강제 종료한 뒤 fire-and-forget 코루틴으로 보상취소한다. 운임 재계산(repricing)은 AmadeusPricingService.pricing이 단일 진실원이며, 발권/환불의 금액·캐리어 분기가 버그의 온상이다.

다음으로 볼 노트