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-overview → amadeus-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 서비스 | 외부 클라이언트 |
|---|---|---|---|---|
| Search | AmadeusSearchController | /internals/AMADEUS/search | AmadeusFlightSearchService | AmadeusClient.search (FareMasterPricer) |
| Booking | AmadeusBookingController | /internals/AMADEUS/bookings | AmadeusBookingService + AmadeusPricingService + AmadeusCancelService + AmadeusPassengerService + AmadeusRetrieveService | AmadeusClient (다수) |
| Ticketing | AmadeusTicketingController | /internals/AMADEUS/ticketing | AmadeusTicketingService + AmadeusPricingService | AmadeusClient + GpsClient(결제) |
| FareRule | AmadeusFareRuleController | /internals/AMADEUS/fare-rules | AmadeusFareRuleService | ArtClient (REST/ART) |
| Queue | AmadeusQueueController | /internals/AMADEUS/queues | AmadeusQueueService | AmadeusClient (Queue_*) |
| CashReceipt | AmadeusCashReceiptController | /internals/AMADEUS/cash-receipts | AmadeusCashReceiptService | GpsClient (현금영수증) |
| (취소/환불) | AmadeusBookingController (cancel/cancelable/expected-cancel) | /internals/AMADEUS/bookings/{pnr}/... | AmadeusCancelService + AmadeusRefundService | AmadeusClient + 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) - 응답 매핑:
FareMasterPricerTravelBoardSearchReply→FareItinerary(도메인) →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 블록의
catch는if (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} retrieve | AmadeusBookingService.retrieve (:203-274) | getPnrInfo → 티켓 있으면 발권일 히스토리(getTicketIssuedDateMap) + e-Doc/EMD 분리 조회 → getPnrFares(end) |
GET /{pnr}/check-pnr | AmadeusBookingService.checkPnr → AmadeusClient.checkPnr (:675-680) | stateless 단발 retrieve, PNR 유효성만 확인 |
GET /{pnr}/confirm | AmadeusBookingService.confirm (:280-322) | KK/HK 아니면 NOT_OK_SCHEDULE; confirming/SSR KK 있으면 confirmPnr |
GET /{pnr}/repricing | AmadeusBookingService.repricing (:324-381) | ★ 아래 별도 설명 |
POST /{pnr}/divide | AmadeusBookingService.divide (:383-408) | splitPnr → saveSplitPnr → savePnrWithShowWarnings → 새 PNR로 retrieve |
PUT /{pnr} changeApis | AmadeusPassengerService.changeApis (AmadeusPassengerService.kt:16-62) | API(여권)정보 변경: 기존 element 삭제 → savePassengerInfo → savePnrWithRetrieve |
3. 운임 재계산(Repricing) — 핵심 공통 서브플로우
"repricing"은 코드에 3곳에 존재한다. 헷갈리기 쉬우니 구분하자.
- 컨트롤러 엔드포인트
GET /{pnr}/repricing→AmadeusBookingService.repricing(예약 단계 재요금)- 발권 준비 시 내부 repricing
AmadeusTicketingService.repricing(AmadeusTicketingService.kt:127-175,@Deprecated)- 운임계산 엔진
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 — 발권 (결제 + 발권 + 보상취소)
AmadeusTicketingController는 ready(발권준비)와 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.approveSocketTimeout/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
withLaunchfire-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 세션과 무관하다. 그래서
ArtClient는ClientSupport만 상속하고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) | 승객별 발권 ticketing | BAGGAGE MISSING 등 retry 마킹 시 재시도 |
AmadeusClient.kt:1173 | @Retryable(3, 5s) | removePnrsInQueue | 큐 제거 재시도 |
AmadeusRetrieveService.kt:28 | @Retryable(5, 5s) | 인판트 품절 폴링 | INF 상태 HN이면 최대 5회 |
ArtClient.kt:29 | @Retryable(2) | FareRule | ART REST 재시도 |
AmadeusFlightSearchService.kt:58 | withBlocking + pmap | 검색 | OD 조합 병렬 (stateless) |
AmadeusQueueService.kt:34 | withBlocking + pmap | 큐 조회 | 큐번호 병렬 |
AmadeusRefundService.kt:54 | withBlocking + pmap | 환불계산 | 승객타입별 initRefund 병렬 |
AmadeusBookingService.kt:134 | withBlocking + delay(3000) | 예약 | carrierTimeLimit null 보정 |
AmadeusBookingService.kt:192,198 | CoroutineScope(IO).withLaunch | 예약 | 품절숨김/키삭제 fire-and-forget |
AmadeusCancelService.kt:312,428 | CoroutineScope(IO).withLaunch | 취소 | pnrCancelAsync / paymentCancelAsync |
AmadeusTicketingService.kt:217 | CoroutineScope(IO).withLaunch | 발권 | 실패 보상(void/결제취소/PNR취소) |
AmadeusCashReceiptService.kt:119,130 | CoroutineScope(IO).withLaunch | 영수증 | 실패 Slack 알림 |
핵심 정리 (시니어로 가는 한 문장)
Amadeus의 모든 “쓰기” 오퍼레이션(Booking/Ticketing/Cancel/Refund/CashReceipt)은 하나의 stateful 세션 안에서 start→inSeries*→end 시퀀스로 묶이고, 실패하면 catch에서 세션을 signOut으로 강제 종료한 뒤 fire-and-forget 코루틴으로 보상취소한다. 운임 재계산(repricing)은
AmadeusPricingService.pricing이 단일 진실원이며, 발권/환불의 금액·캐리어 분기가 버그의 온상이다.
다음으로 볼 노트
- Amadeus 개요·모듈 구조
- stateful 세션 SOAP 프로토콜 상세
- Amadeus 지뢰·캐리어 특수처리
- 공급사 공통 오퍼레이션 패턴
- 요청 진입 흐름 · 콜러-콜리 전체 지도
- 코루틴 · Resilience4j 이벤트 전파
- 기존 분석: amadeus-gds