Amadeus NDC — 오퍼레이션 흐름
module-amadeusndc arch-supplier pattern-ndc-order api-soap
한 줄 요약
Amadeus NDC 모듈은 중앙 디스패처 없이 공급사 전용 5개 REST 컨트롤러(
AmadeusndcSearchController,AmadeusndcBookingController,AmadeusndcTicketingController,AmadeusndcFareRuleController,AmadeusndcQueueController)가 Triple 예약 시스템의 내부 API(/internals/AMADEUSNDC/...)로 노출된다. 각 컨트롤러는application서비스(들)에 위임하고, 서비스는infrastructure클라이언트(AmadeusndcClient=SOAP,NdcArtClient=운임규정 REST,GpsClient=카드승인 SOAP)를 통해 외부 API를 호출한 뒤 응답을 도메인 모델(Booking,FareItinerary,OfferPriceInfo)로 매핑한다. 이 노트는 모든 오퍼레이션의 콜러→콜리 체인을 코드 기준으로 정밀 추적한다.
사전 학습
이 모듈의 전반 구조·프로토콜·하이브리드(검색·큐만 클래식 1A) 배경은 amadeusndc-overview·amadeusndc-protocol 참고. 공통 오퍼레이션 추상은 common-operations, 전체 호출 그래프는 caller-callee-map, 요청 진입 흐름은 request-flow 참고. 흔한 함정은 amadeusndc-pitfalls에 정리.
0. 컴포넌트 한눈에 보기
flowchart LR subgraph CTRL["interfaces/controller/internals"] SC["AmadeusndcSearchController"] BC["AmadeusndcBookingController"] TC["AmadeusndcTicketingController"] FC["AmadeusndcFareRuleController"] QC["AmadeusndcQueueController"] end subgraph APP["application"] FSS["AmadeusndcFlightSearchService"] BS["AmadeusndcBookingService"] TS["AmadeusndcTicketingService"] CS["AmadeusndcCancelService"] FRS["AmadeusndcFareRuleService"] QS["AmadeusndcQueueService"] end subgraph INFRA["infrastructure"] AC["AmadeusndcClient SOAP 1A+NDC<br/>search pricing book ...<br/>retrieveByPnr cancel ...<br/>reissueSearch pricingWith reissue"] ART["NdcArtClient REST 운임규정"] GPS["GpsClient SOAP 카드 승인"] end CTRL --> APP APP --> INFRA
| 컨트롤러 | 베이스 경로 | 주입 서비스 | 소스 |
|---|---|---|---|
| Search | /internals/AMADEUSNDC/search | AmadeusndcFlightSearchService, FlightAmenityService | interfaces/controller/internals/AmadeusndcSearchController.kt:20-24 |
| Booking | /internals/AMADEUSNDC/bookings | AmadeusndcBookingService, AmadeusndcCancelService | AmadeusndcBookingController.kt:16-20 |
| Ticketing | /internals/AMADEUSNDC/ticketing | AmadeusndcBookingService, AmadeusndcTicketingService, RedisTemplate | AmadeusndcTicketingController.kt:26-31 |
| FareRule | /internals/AMADEUSNDC/fare-rules | AmadeusndcFareRuleService, AmadeusndcFlightSearchService | AmadeusndcFareRuleController.kt:11-15 |
| Queue | /internals/AMADEUSNDC/queues | AmadeusndcQueueService | AmadeusndcQueueController.kt:11-14 |
SOAPAction = 오퍼레이션 식별자
중앙 디스패처가 없는 대신,
AmadeusndcClient가 만드는 각 SOAP 요청의action필드가 오퍼레이션을 결정한다. 매핑은 아래 표 참고(상세는 amadeusndc-protocol).
| 오퍼레이션 | SOAPAction | 비고 |
|---|---|---|
| 검색 | FMPTBQ_23_1_1A | 클래식 1A (NDC 아님) |
| 가격확정(pricing) | Travel_OfferPrice_1.3 | NDC |
| 예약생성(book) | Travel_OrderCreate_1.7 | NDC |
| 결제/발권(savePayment) | Travel_OrderPay_1.7 | NDC |
| 조회(retrieve) | Travel_OrderRetrieve_1.7 | NDC |
| 취소(cancel) | Travel_OrderCancel_1.0 | NDC |
| 분리(divide)/재발행(reissue) | Travel_OrderChange_1.9 | NDC |
| 취소료계산·재발행검색·재발행가격 | Travel_OrderReshop_1.6 | NDC (3개 용도 공유) |
| 큐 카운트/리스트/이동/삭제 | QCSDRQ_13_1_1A / QDQLRQ_11_1_1A / QUQMUQ_03_1_1A / QUQMDQ_03_1_1A | 클래식 1A |
1. Search — 항공편 검색
1-1. 콜러→콜리 체인
sequenceDiagram participant Ctrl as "AmadeusndcSearchController" participant Svc as "AmadeusndcFlightSearchService" participant Cli as "AmadeusndcClient" Note over Ctrl: "POST /internals/AMADEUSNDC/search<br/>@CircuitBreaker amadeusndcSearch" Ctrl->>Ctrl: "CacheKeyGenerator.generateSearchRequestKey<br/>requestKey 생성" Ctrl->>Svc: "search(...)" Svc->>Svc: "flightSearchKeyRepository.findKey(requestKey)" alt 캐시 HIT Svc-->>Ctrl: "저장된 결과 반환 (소진분 필터)" else 캐시 MISS Note over Svc: "withBlocking(Dispatchers.IO) 코루틴 진입점" Svc->>Svc: "makeOriginDestinations(...).cartesianProduct()" loop OD 조합별 병렬 호출 pmap Svc->>Cli: "search(...) SOAP FMPTBQ_23_1_1A" Cli-->>Svc: "FareItinerary 목록" end Note over Svc: "onFailure 성공이 하나도 없으면 SEARCH_FAILED" Svc->>Svc: "flatten.distinctBy(id), OD 출/도착 일치 필터" Svc->>Svc: "filterByNotNullFlightTimes()" Svc->>Svc: "filterByUnexposedFareItinerary() 소진 운임 제외" Svc->>Svc: "save(requestKey) useCache & 비어있지 않으면 Redis 저장" Svc-->>Ctrl: "결과 목록" end Ctrl->>Ctrl: "FareItineraryView.of(...) 응답 매핑"
- 컨트롤러:
AmadeusndcSearchController.search()AmadeusndcSearchController.kt:25-47 - 서비스:
AmadeusndcFlightSearchService.search()AmadeusndcFlightSearchService.kt:37-93 - 클라이언트:
AmadeusndcClient.search()AmadeusndcClient.kt:82-153 - 응답 매핑:
FareMasterPricerTravelBoardSearchReply→segmentFlightRef.toFareItinerary(...)→FareItinerary도메인 모델 (AmadeusndcClient.kt:129-147)
병렬 호출과 코루틴 —
pmap/withBlocking검색은 OD(출발-도착) 조합마다 별도 SOAP 호출을 하고, 이를
Iterable<T>.pmap(support/util/CoroutineExtensions.kt:36-50)으로 병렬 실행한다.pmap은 각 항목을withAsync로 감싸try/catch로 성공/실패를AsyncResults에 분리 수집한다. 그 후onFailure는 성공이 하나도 없을 때만SEARCH_FAILED를 던진다(AmadeusndcFlightSearchService.kt:70-74). 즉 일부 OD 실패는 묵인하고 부분 성공을 노출하는 설계다. 진입은withBlocking(Dispatchers.IO)(CoroutineExtensions.kt:13-18)로, 내부에SupervisorJob + AdapterCoroutineExceptionHandler + MDCContext가 결합된다. 코루틴 메커니즘 전반은 async-coroutines 참고.
검색의 "정상 무시" 에러코드
AmadeusndcClient.search()는 응답 에러코드 중"866","931","977","996","118","950","9212","9211"을 무해(결과 없음)로 간주하고 나머지만SEARCH_FAILED로 던진다(AmadeusndcClient.kt:118-128). 이 코드 집합은 “조건에 맞는 항공편 없음” 류로, 검색 실패와 빈 결과를 구분하는 핵심이다.
비노출(필터) 운임 — 신입이 자주 놓치는 부분
filterByUnexposedFareItinerary()(AmadeusndcFlightSearchService.kt:114-122)는unexposedFareItineraryRepository에 저장된 소진(품절) ID를 검색 결과에서 제거한다. 이 ID는 예약 단계에서SOLD_OUT이 발생하면 비동기로 적재된다([2-2] 참고). 또isNonAir()(버스/기차 등NonAirEquipment)와hasNonTicketableCarrier()(MH 코드셰어)도 클라이언트 내부에서 제외한다(AmadeusndcClient.kt:144,156-170).
1-2. 상세조회(detail) — pricing 없음
GET /internals/AMADEUSNDC/search → detail()은 캐시된 FareItinerary를 키로 꺼내(getFareItinerary) 좌석 수 검증(validate)하고, FlightAmenityService.findAmenityMap(...)으로 어메니티를 붙여 FareItineraryDetailView로 반환한다(AmadeusndcSearchController.kt:49-62). 외부 호출이 없다(순수 캐시/어메니티 조합).
2. Booking — 예약 생성/조회/분리
2-1. 예약 생성(create) — pricing → book → retrieve 3단
sequenceDiagram participant Ctrl as "AmadeusndcBookingController" participant Svc as "AmadeusndcBookingService" participant Cli as "AmadeusndcClient" Note over Ctrl: "POST /internals/AMADEUSNDC/bookings" Ctrl->>Svc: "book(key, reservationUser, passengers)" Svc->>Svc: "flightSearchService.getFareItinerary(key) 캐시에서 운임 복원" alt 정상 try Svc->>Cli: "pricing(fareItinerary) 1 SOAP Travel_OfferPrice_1.3" Cli-->>Svc: "OfferPriceInfo" Svc->>Cli: "book(user, pax, offerPriceInfo) 2 SOAP Travel_OrderCreate_1.7" Cli-->>Svc: "Booking(pnr)" else catch e Note over Svc: "SOLD_OUT 이면 removeKey + saveUnexposed 후 throw" end Svc->>Cli: "retrieveByPnr(pnr, pax, offerPriceInfo) 3 SOAP Travel_OrderRetrieve_1.7" Cli-->>Svc: "Booking" Svc->>Svc: "removeFlightSearchKey(requestKey) 비동기" Svc->>Svc: "validateMismatchedTotalPrice() 총액 불일치 시 cancelAsync + BOOKING_FAILED" Svc-->>Ctrl: "Booking" Ctrl->>Ctrl: "BookingView.of(booking)"
- 서비스:
AmadeusndcBookingService.book()AmadeusndcBookingService.kt:31-67 - 클라이언트:
pricing()AmadeusndcClient.kt:172-199,book():201-235,retrieveByPnr():284-304 - 응답 매핑: pricing→
OfferPriceRS.toPricingInfo(), book/retrieve→OrderViewRS.toBooking()(infrastructure/response/orderview/OrderViewRS.kt:46)
예약 직후 재조회는 필수 — "예약은 만들어졌지만 못 가져온 상태" 처리
book()은 OrderCreate로 PNR을 만든 직후 반드시retrieveByPnr()로 다시 조회한다(AmadeusndcBookingService.kt:58-66). 이유: (1) 항공사가 내려준 총액(totalPrice)과 탑승객 운임 합계가 다르면validateMismatchedTotalPrice()가cancelService.cancelAsync(...)로 비동기 취소를 걸고BOOKING_FAILED를 던진다(:69-86). (2)retrieveByPnr은@Retryable이 걸려 있어 일시적 조회 실패를 흡수한다([4-2] 참고).
예약 실패 시 소진 처리(품절 전파)
book()의catch에서isUnexposedFareItinerary(e)(=StatusInvalidException&&ErrorMessage.SOLD_OUT)면removeFlightSearchKey(requestKey)+saveUnexposedFareItinerary(fareItinerary)를 호출한다(AmadeusndcBookingService.kt:49-56,148-162). 둘 다CoroutineScope(Dispatchers.IO).withLaunch{}로 fire-and-forget 비동기다. 이게 검색의filterByUnexposedFareItinerary로 연결되는 “이벤트 전파”의 한 축이다(메시지큐가 아니라 Redis 적재 + 예외 전파). 상태 전파 모델은 resilience-and-events 참고.
2-2. 조회(retrieve / confirm / repricing)
| 엔드포인트 | 컨트롤러 메서드 | 서비스 호출 | 특징 |
|---|---|---|---|
GET /bookings/{pnr} | retrieve() | bookingService.retrieve(pnr, validateScheduleStatus=false) | 부모 예약 연결(분리 예약) 시 withParentBooking |
GET /bookings/{pnr}/confirm | confirm() | bookingService.retrieveAndCancelIfNeed(pnr) | 결항/스케줄변경 시 자동 취소 트리거 |
GET /bookings/{pnr}/repricing | repricing() | bookingService.retrieveAndCancelIfNeed(pnr) | confirm과 동일 호출, View만 RepricingView |
GET /bookings/{pnr}/check-pnr | checkPnr() | (없음) | 무조건 true 반환(헬스/존재성 stub) |
retrieve()는parentSupplierIdentificationKey가 있으면retrieveBySupplierIdentificationKey로 부모 예약을 추가 조회해 병합한다(AmadeusndcBookingService.kt:171-182).retrieveAndCancelIfNeed()는validateScheduleStatus=false로 조회 후validateSchedulesCancellation()을 호출, 결항(UN)·TK상태가 있으면cancelAsync(...)를 걸고 예외를 재던진다(:184-200).
repricing 컨트롤러는 "운임 재계산" 호출이 아니다
GET /bookings/{pnr}/repricing은 이름과 달리 외부 재가격 호출을 하지 않는다. 단순히retrieveAndCancelIfNeed로 현재 예약을 가져와RepricingView.of(passengers, validatingCarrier)로 변환할 뿐이다(AmadeusndcBookingController.kt:88-96). 실제 운임 재계산(reshop/pricing)은 재발행 흐름([6장])에서 일어난다. 이름 혼동 주의.
2-3. 분리(divide)
sequenceDiagram participant Ctrl as "AmadeusndcBookingController" participant Svc as "AmadeusndcBookingService" participant Cli as "AmadeusndcClient" Note over Ctrl: "POST /internals/AMADEUSNDC/bookings/{pnr}/divide" Ctrl->>Svc: "divide(pnr, suppKey, vc, passengers)" Svc->>Cli: "retrieveByPnr(pnr) 현재 탑승객 확보" Cli-->>Svc: "현재 탑승객" Svc->>Svc: "validate(요청 pax vs 조회 pax) 동일인 + 유아-성인 페어 검증" Svc->>Cli: "divide(...) SOAP Travel_OrderChange_1.9 (ofSplit)" Cli-->>Svc: "dividedPnr" Svc->>Cli: "retrieveByPnr(dividedPnr) 분리된 PNR 재조회" Cli-->>Svc: "Booking"
- 검증
validate()(AmadeusndcBookingService.kt:108-137)는 ① 요청 탑승객이 실제 PNR에 존재하는지, ②ADULT/INFANT페어가 분리 그룹에 함께 있는지(verifyInfantGuardian) 확인한다. 누락 시DIVIDE_FAILED. - 클라이언트
divide()AmadeusndcClient.kt:462-498→ 요청은OrderChangeRQ.ofDivide(...)(request/orderchange/OrderChangeRQ.kt:27-44).
3. Ticketing — 발권 (issue / ready)
3-1. 발권 ready
POST /internals/AMADEUSNDC/ticketing/ready → ready()는 bookingService.retrieveAndCancelIfNeed(pnr)로 예약을 조회(결항이면 자동취소)하고 TicketingReadyView로 스케줄/검증항공사를 반환한다. 외부 발권 호출은 없다(AmadeusndcTicketingController.kt:32-42).
3-2. 발권 issue — savePayment + 발권 폴링
sequenceDiagram participant Ctrl as "AmadeusndcTicketingController" participant Svc as "AmadeusndcTicketingService" participant Cli as "AmadeusndcClient" participant Gps as "GpsClient" Note over Ctrl: "POST /internals/AMADEUSNDC/ticketing" Ctrl->>Ctrl: "검증 cardInfo 있고 cash 합 0 초과면 INVALID_PARAMETER (only card OR only cash)" Ctrl->>Svc: "issue(pnr, suppKey, vc, prices, cardInfo)" alt 정상 try Svc->>Cli: "retrieveByPnr(pnr) 사전 조회" Cli-->>Svc: "Booking" Svc->>Gps: "verifyCard(pnr, cardInfo) 카드면 GPS 선승인" Gps-->>Svc: "verifiedCardInfo" Svc->>Cli: "savePayment(amount=card+cash 합, verifiedCardInfo) SOAP Travel_OrderPay_1.7 발권" Note over Cli: "timeout 시 slackService.sendTicketingTimeout(...)" Cli-->>Svc: "결과" loop withBlocking 발권 폴링 hasAllTickets 아니고 attempt 3 미만 delay 2000 Svc->>Cli: "retrieveByPnr" Cli-->>Svc: "Booking" end Note over Svc: "끝까지 hasAllTickets 아니면 TICKETING_FAILED_PASSENGER_HAS_NO_TICKET" else catch e Note over Svc: "NO_QUOTA 면 slackService.sendNoQuota(...)" Svc->>Svc: "amadeusndcCancelService.cancelAsync(...) 발권 실패 시 비동기 취소 후 throw" end Svc-->>Ctrl: "passengers" Ctrl->>Ctrl: "TicketingView(passengers.map TicketingPassengerView.of)"
- 컨트롤러:
AmadeusndcTicketingController.issue()AmadeusndcTicketingController.kt:44-75 - 서비스:
AmadeusndcTicketingService.issue()AmadeusndcTicketingService.kt:32-86 - 클라이언트:
savePayment()AmadeusndcClient.kt:237-277, 카드승인GpsClient.verifyCard()infrastructure/gps/GpsClient.kt:59-77
발권 후 "티켓 번호 폴링" — NDC의 비동기성 대응
savePayment(OrderPay) 응답에 티켓이 즉시 안 붙는 경우가 있어,withBlocking { ... delay(2000) ... }루프로 최대 3회(2초 간격)retrieveByPnr을 재조회해 전원 티켓 확보를 확인한다(AmadeusndcTicketingService.kt:57-67). 끝까지 없으면TICKETING_FAILED_PASSENGER_HAS_NO_TICKET. 이 폴링은support/util/CoroutineExtensions.kt의withBlocking을 쓴다.
결제수단 제약 — only card OR only cash
ndcx 발권은 카드와 현금을 섞을 수 없다. 컨트롤러에서
cardInfo != null && Σcash > 0이면 즉시INVALID_PARAMETER로 막는다(AmadeusndcTicketingController.kt:51-56). 카드 결제는GpsClient.verifyCard()(GPS_Approval, SOAP)로 선승인한VerifiedCardInfo를 OrderPay 본문에 싣는다.
발권 실패의 보상 트랜잭션
issuecatch 블록은 (1)NO_QUOTA(발권 한도 소진)면 Slack 경보(sendNoQuota), (2) 무조건cancelAsync(...)로 비동기 취소를 걸어 결제만 되고 발권 안 된 좌비 상태를 정리한 뒤 예외를 재던진다(AmadeusndcTicketingService.kt:71-85). Slack 경보는 이 시스템의 “이벤트 전파” 메커니즘 중 하나다(resilience-and-events).
4. 조회의 회복력 — @Retryable과 noRetry()
4-1. retrieve 공통 경로
retrieveByPnr / retrieveBySupplierIdentificationKey는 모두 private retrieve(request, ...)(AmadeusndcClient.kt:326-368)로 수렴한다. 이 경로는:
- 응답에
"ORDER ALREADY CANCELLED"메시지가 있으면ALREADY_CANCELED_PNR+.noRetry()로 던진다(:343-347). response.body.response?.validateInvoluntaryScheduleChange(...)로 비자발적 스케줄 변경을 검증(:355).toBooking(...)매핑 후validateScheduleStatus=true면validateSchedulesCancellation()(결항UN/TK차단,support/model/Booking.kt:30-37).
4-2. @Retryable (Spring Retry — Resilience4j 아님)
@Retryable(
maxAttempts = 3,
backoff = Backoff(delay = 2000),
exceptionExpression = "@amadeusndcClient.shouldRetrieveRetryable(#root)",
)
fun retrieveByPnr(...) // AmadeusndcClient.kt:279-304shouldRetrieveRetryable(e)(:370-372)는ApiException.retryable를 보고 재시도 여부를 결정. 기본값은true이므로ApiException이 아니거나retryable=true면 재시도된다.Booking.validateSchedulesCancellation()이 던지는 예외에는.noRetry()가 붙어 있어(Booking.kt:35) 결항 상태는 재시도하지 않는다.
이 모듈의 회복력 분포 — Resilience4j vs Spring Retry
- @CircuitBreaker(Resilience4j): 컨트롤러의
search에만 적용(AmadeusndcSearchController.kt:25, name=amadeusndcSearch,application.yml:51-52, fallback=searchFallback→ 빈 리스트 + Datadog span 태그). 예약/발권/취소/재발행에는 서킷브레이커가 없다.- @Retryable(Spring Retry):
AmadeusndcClient.retrieveByPnr(maxAttempts=3, 2s):279,NdcArtClient.getFareRules(maxAttempts=2)infrastructure/art/NdcArtClient.kt:31.@Retry/@Bulkhead(Resilience4j 애너테이션)는 이 모듈에 없다. 상세는 amadeusndc-pitfalls 및 resilience-and-events 참고.
5. Cancel — 취소 (동기/비동기, VOID/REFUND)
5-1. 취소 가능성 판정(cancelable) — OrderReshop으로 취소료 계산
sequenceDiagram participant Ctrl as "AmadeusndcBookingController" participant Svc as "AmadeusndcCancelService" participant Cli as "AmadeusndcClient" Note over Ctrl: "GET /bookings/{pnr}/cancelable (또는 /expected-cancel)<br/>cancelable() / expectedCancel()" Ctrl->>Svc: "cancelable(pnr)" Svc->>Cli: "retrieveByPnr(pnr)" Cli-->>Svc: "booking" alt 체크인 탑승객 있음 Note over Svc: "CANCEL_UNABLE_BY_ALREADY_CHECK_IN" else booking.unissued 미발권 Note over Svc: "CancelableTypeDetail(VOID) 미발권은 무료 보이드" else 발권건 Svc->>Cli: "getCancelableTypeDetail(booking) SOAP Travel_OrderReshop_1.6" alt useCase VOID Cli-->>Svc: "VOID (NonVoidableAirline 이면 CANCEL_UNABLE)" else useCase REFUND Cli-->>Svc: "탑승객별 환불 수수료 계산 calculateRefundFare 후 REFUND" else 그 외 Cli-->>Svc: "CALCULATE_CANCEL_FEE_FAILED" end end
- 서비스:
AmadeusndcCancelService.cancelable()application/AmadeusndcCancelService.kt:90-111 - 클라이언트:
getCancelableTypeDetail()AmadeusndcClient.kt:374-425— OrderReshop 응답의reshopResult.useCase로 분기. - REFUND 분기는
deleteOrderItem.calculateRefundFare(fare, currencyMap)(infrastructure/response/orderreshopreply/DeleteOrderItem.kt)로 탑승객별 환불액을 산출하고,refundFee <= airPrice조건을 모두 만족할 때만 환불 목록을 채운다(AmadeusndcClient.kt:400-415).
취소료 계산도 OrderReshop(
Travel_OrderReshop_1.6)이다NDC에서는 “취소 시 보이드/환불 여부와 수수료”를 별도 메시지가 아니라 OrderReshop으로 묻는다. 이 모듈에서
Travel_OrderReshop_1.6은 (a) 취소료 계산(OrderReshopRQ.of), (b) 재발행 검색(ofReissueSearch), (c) 재발행 가격확정(ofPricing) 세 용도를 공유한다(request/orderreshop/OrderReshopRQ.kt:27-71). 헷갈리기 쉬운 핵심 포인트.
5-2. 동기 취소(cancel)
sequenceDiagram participant Ctrl as "AmadeusndcBookingController" participant Svc as "AmadeusndcCancelService" participant Cli as "AmadeusndcClient" Note over Ctrl: "PUT /bookings/{pnr}/cancel" Ctrl->>Svc: "cancel(pnr, suppKey, vc)" Svc->>Svc: "cancelable(pnr) 위 판정 재실행 (결과 반환용)" Svc->>Cli: "cancel(suppKey, vc, timeoutCallback) SOAP Travel_OrderCancel_1.0" Note over Cli: "timeout 시 slackService.sendCancelFailTimeout(...)" Cli-->>Svc: "결과"
- 서비스:
cancel()AmadeusndcCancelService.kt:25-43, 클라이언트:cancel()AmadeusndcClient.kt:427-460.
5-3. 비동기 취소(cancelAsync) — 보상 트랜잭션의 핵심
cancelAsync(pnr, suppKey, vc)(AmadeusndcCancelService.kt:45-88)는 예약/발권 실패의 사후 정리 용도로 여러 곳에서 호출된다:
AmadeusndcBookingService.validateMismatchedTotalPrice()(총액 불일치):75AmadeusndcBookingService.retrieveAndCancelIfNeed()(결항):191AmadeusndcTicketingService.issue()catch (발권 실패):79
sequenceDiagram participant Svc as "AmadeusndcCancelService" participant Cli as "AmadeusndcClient" Note over Svc: "cancelAsync = CoroutineScope(Dispatchers.IO).withLaunch fire-and-forget" Svc->>Svc: "delay(5000) 5초 지연 후 시작" alt 정상 try Svc->>Cli: "retrieveByPnr(pnr)" Cli-->>Svc: "booking" opt 발권건 unissued 아님 Svc->>Cli: "getCancelableTypeDetail(...) VOID + NonVoidable 검사" Cli-->>Svc: "결과" end Svc->>Cli: "cancel(suppKey, vc, timeoutCallback)" Cli-->>Svc: "결과" else catch e Note over Svc: "slackService.sendCancelFail(...) 후 throw e" end
cancelAsync는 fire-and-forget — 결과를 기다리지 않는다
cancelAsync는CoroutineScope(Dispatchers.IO).withLaunch{}로 분리 실행되며 호출자에게 결과를 돌려주지 않는다.delay(5000)은 직전 SOAP 트랜잭션이 항공사 측에 반영될 시간을 벌기 위함으로 보인다. 실패 시slackService.sendCancelFail(...)만 남고 예외는 코루틴 내부에서 소멸(또는AdapterCoroutineExceptionHandler로 흡수)된다. 운영에서 “취소 안 됨”은 Slack 로그로만 추적 가능하다는 점이 함정(amadeusndc-pitfalls).
6. Reissue — 재발행 (가장 복잡한 플로우)
재발행은 검색 → 상세(repricing) → 발권 ready → reissue(폴링) 의 다단계이며, Search/Booking/Ticketing 컨트롤러에 걸쳐 분산되어 있다.
6-1. 재발행 검색 (Search 컨트롤러)
sequenceDiagram participant Ctrl as "AmadeusndcSearchController" participant Svc as "AmadeusndcFlightSearchService" participant Cli as "AmadeusndcClient" Note over Ctrl: "POST /internals/AMADEUSNDC/search/reissue" Ctrl->>Svc: "reissueSearch(pnr, OD, ...)" Svc->>Cli: "retrieveByPnr(pnr)" Cli-->>Svc: "booking" Svc->>Svc: "bookingRepository.saveBooking(pnr, booking)" Svc->>Cli: "reissueSearch(key, booking, OD, ...) SOAP OrderReshop ofReissueSearch" Note over Cli: "remainSchedules = 변경 대상 외 스케줄 그룹 (유지할 구간)<br/>response.toFareItineraries(remainSchedules=...)" Cli-->>Svc: "재발행 후보 운임 목록" opt useCache Svc->>Svc: "fareItineraryRepository.saveFareItineraries(...)" end
- 서비스:
reissueSearch()AmadeusndcFlightSearchService.kt:136-174 - 클라이언트:
reissueSearch()AmadeusndcClient.kt:500-549, 요청OrderReshopRQ.ofReissueSearch(...)(request/orderreshop/OrderReshopRQ.kt:42-59) remainSchedules는 OD에 포함되지 않은 기존 스케줄 그룹(=유지 구간)으로, OrderReshop 본문에retainServiceIds로 들어간다(OrderReshopRQ.kt:135-142).- 에러코드
41913은 “검색 가능한 스케줄 없음”으로 사용자 친화 메시지로 변환(AmadeusndcClient.kt:528-535).
6-2. 재발행 상세 = repricing (Search 컨트롤러)
sequenceDiagram participant Ctrl as "AmadeusndcSearchController" participant Svc as "AmadeusndcFlightSearchService" participant Cli as "AmadeusndcClient" Note over Ctrl: "GET /internals/AMADEUSNDC/search/reissue?pnr&key" Ctrl->>Svc: "reissueDetail(pnr, key)" Svc->>Svc: "getFareItinerary(key)" Note over Svc: "airPrice 음수 또는 tax 음수면 REISSUE_NON_CHANGEABLE_FARE_SCHEDULE (금액 감소 차단)" Svc->>Cli: "booking = findBooking(pnr) 없으면 retrieveByPnr(pnr)" Cli-->>Svc: "booking" Svc->>Svc: "remainServiceIds 계산 후 schedulesToChange 필터" Note over Svc: "변경 대상이 실제로 안 바뀌었으면 NON_CHANGEABLE_SCHEDULES" Svc->>Cli: "pricingWithReissue(booking, fareItinerary, applyCalculateCarrierFee=true) SOAP OrderReshop ofPricing 운임 재계산" Cli-->>Svc: "재계산된 FareItinerary" Svc-->>Ctrl: "결과" Ctrl->>Ctrl: "FareItineraryDetailView.of(itemKey=key) pricing 후 farebasis 변경돼도 기존 key 유지"
- 서비스:
reissueDetail()AmadeusndcFlightSearchService.kt:176-251 - 클라이언트:
pricingWithReissue()AmadeusndcClient.kt:551-579, 요청OrderReshopRQ.ofPricing(...)(OrderReshopRQ.kt:61-71→Request.ofPricing:119-147) - 응답 매핑:
OrderReshopRS.toFareItinerary(fareItinerary, applyCalculateCarrierFee)(infrastructure/response/orderreshopreply/OrderReshopRS.kt:110-143) — 항공사 수수료(carrierFee)를 합산한FareItinerary재구성.
재발행 repricing의 3중 방어 — 금액 감소·동일 스케줄 차단
reissueDetail()은 재가격 호출(pricingWithReissue) 전후로 세 겹의 검증을 한다:
- 금액 감소 차단:
passengerFares중airPrice<0 || tax<0이면REISSUE_NON_CHANGEABLE_FARE_SCHEDULE. “결제 금액이 감소하여 재발행 불가” 메시지를 탑승객별 금액과 함께 빌드해 던진다(:179-209).- 유지 구간 식별:
fareItinerary.ndcReference.remainSchedules의serviceIds집합으로 “변경 대상 스케줄(schedulesToChange)“만 골라낸다(:213-225).- 동일 스케줄 차단: 변경 대상이라면서 출/도착·시각·편명·캐빈이 기존과 같으면
NON_CHANGEABLE_SCHEDULES(:228-244).이 검증을 통과해야만
pricingWithReissue로 실제 OrderReshop pricing을 호출한다. 신입은 “재발행 검색이 됐으니 발권만 하면 된다”고 착각하기 쉽지만, 상세(repricing) 단계가 사실상 게이트키퍼다.
FareLogix 항공사 분기
OrderReshop 본문에서
booking.isFareLogixCarrier면ReshopParameter에AutoExchRequestInd=true를 세팅한다(OrderReshopRQ.kt:99-109,126). FareLogix 기반 항공사는 자동 교환(auto-exchange) 지시가 필요하기 때문이다. 항공사 특성 분기의 대표 예.
6-3. 재발행 발권 (Ticketing 컨트롤러) — Redis 폴링(202 Accepted)
재발행 발권만 비동기 폴링 패턴(HTTP 202 + 폴링 키)을 쓴다. 일반 발권(issue)은 동기인 것과 대조적이다.
sequenceDiagram participant Ctrl as "AmadeusndcTicketingController" participant Svc as "AmadeusndcTicketingService" participant Redis as "Redis polling" Note over Ctrl: "POST /internals/AMADEUSNDC/ticketing/addition 202 ACCEPTED<br/>reissue()" Ctrl->>Redis: "polling(key=REISSUE::AMADEUSNDC_{pnr}, ttl) 백그라운드 코루틴 등록" Note over Ctrl: "prepaidPrice 없고 cardInfo 없으면 INVALID_PARAMETER" Redis->>Svc: "reissue(pnr, detailKey, prepaidPrice, cardInfo) 비동기 실행" Ctrl-->>Ctrl: "DeferredKeyView(pollingKey) 폴링 키 즉시 반환" Note over Ctrl: "GET /internals/AMADEUSNDC/ticketing/addition/{reissueKey} 200 OK<br/>checkReissue()" Ctrl->>Redis: "poller(reissueKey) ReissueResult Booking, PassengerFare" alt PENDING Redis-->>Ctrl: "DeferredView.Pending" else ERROR Redis-->>Ctrl: "throw throwable" else COMPLETE Redis-->>Ctrl: "DeferredView.Complete(ReticketingView.of(...))" end
- 컨트롤러:
reissue()AmadeusndcTicketingController.kt:77-101,checkReissue():103-124 - 폴링 유틸:
polling()/poller()support/util/PollingUtils.kt:12-49—polling은 Redis에pending을 쓰고CoroutineScope(Dispatchers.IO).withLaunch{}로init()을 실행, 성공/실패를DeferredResult로 갱신.poller는 키로 결과를 읽고throwable이 있으면 던진다.
6-4. reissue 서비스 본문 — repricing 재검증 + OrderChange
sequenceDiagram participant Svc as "AmadeusndcTicketingService" participant Cli as "AmadeusndcClient" participant Gps as "GpsClient" Note over Svc: "reissue(pnr, detailKey, prepaidPrice, cardInfo) TicketingService.kt:90-125" Svc->>Cli: "originBooking = retrieveByPnr(pnr)" Cli-->>Svc: "originBooking" Svc->>Svc: "fareItinerary = getFareItinerary(detailKey)" Svc->>Cli: "pricingWithReissue(originBooking, fareItinerary, true) 재가격 재확인" Cli-->>Svc: "passengerFares" Svc->>Svc: "pricedTotal = 합((total + carrierFee) * travellerIds.size)" Note over Svc: "prepaidPrice 있고 cardInfo 없고 prepaidPrice 가 pricedTotal 과 다르면<br/>RETICKETING_FAILED_BY_MISMATCH_PRICE (선결제액 vs 재계산액 차단)" Svc->>Gps: "verifyCard(pnr, cardInfo) 카드면 선승인" Gps-->>Svc: "verifiedCardInfo" Svc->>Cli: "reissue(booking, fareItinerary, price=prepaidPrice 또는 pricedTotal, verifiedCardInfo) SOAP Travel_OrderChange_1.9" Cli-->>Svc: "newBooking" Svc->>Svc: "ReissueResult(newBooking, passengerFares, cardInfo)"
- 서비스:
AmadeusndcTicketingService.reissue()AmadeusndcTicketingService.kt:90-125 - 클라이언트:
reissue()AmadeusndcClient.kt:581-618, 요청OrderChangeRQ.ofReissue(...)(request/orderchange/OrderChangeRQ.kt:46-72) — 현금이면PaymentInfo.ofCash, 카드면PaymentInfo.ofCard. - 응답 매핑:
OrderViewRS.toReissueBooking(originBooking)(infrastructure/response/orderview/OrderViewRS.kt:95).
재발행 가격은 두 번 확인된다 — repricing의 멱등성
repricing(
pricingWithReissue)은 ① 상세조회(reissueDetail)와 ② 실제 발권(TicketingService.reissue) 두 곳에서 모두 호출된다. 발권 시점에 다시 호출해pricedTotal을 계산하고, 선결제액(prepaidPrice)과 다르면RETICKETING_FAILED_BY_MISMATCH_PRICE로 막는다(AmadeusndcTicketingService.kt:108-115). 환율/운임 변동으로 발권 직전 금액이 바뀌는 NDC 특성 때문에 “최종가 재확인” 게이트를 둔 것. 단, 카드 결제(cardInfo != null)일 때는 이 비교를 건너뛴다.
7. FareRule — 운임 규정
sequenceDiagram participant Ctrl as "AmadeusndcFareRuleController" participant Svc as "AmadeusndcFareRuleService" participant Art as "NdcArtClient" Note over Ctrl: "GET /internals/AMADEUSNDC/fare-rules?key&adult&child&infant<br/>getFareRules()" Ctrl->>Svc: "getFareRules(key, a, c, i)" Svc->>Svc: "fareRuleRepository.findFareRules(fareRuleKey) 캐시 HIT 우선" alt 캐시 MISS Svc->>Svc: "fareItineraryRepository.getFareItinerary(key)" Svc->>Art: "getFareRules(a, c, i, fareItinerary) REST POST (ART, SOAP 아님)" Art-->>Svc: "운임 규정" Svc->>Svc: "fareRuleRepository.saveFareRules(...) 캐시 적재" end Note over Ctrl: "GET /internals/AMADEUSNDC/fare-rules/structured?key<br/>getStructuredFareRules() 외부호출 없음" Ctrl->>Svc: "getFareItinerary(key)" Ctrl->>Ctrl: "StructuredFareRuleView.of(fareItinerary)"
- 서비스:
AmadeusndcFareRuleService.getFareRules()application/AmadeusndcFareRuleService.kt:16-34 - 클라이언트:
NdcArtClient.getFareRules()infrastructure/art/NdcArtClient.kt:31-93
FareRule만 JSON REST — ART(Amadeus Rules & Tools) 채널
검색·예약·발권·취소·재발행이 모두 SOAP인 것과 달리, 운임규정만
NdcArtClient가 JSON REST를 쓴다. 엔드포인트는{art.endpoint}/api/v1/art/getrule/{agentCode}/{officeId}이고x-api-key헤더로 인증한다(NdcArtClient.kt:44-51).@Retryable(maxAttempts=2)가 걸려 있다(:31). 응답은categoryCode유무에 따라 정렬 규칙이 달라지는데, 비어있으면적용구간/항공사 수수료/수하물/...한글 타이틀 우선순위로 정렬한다(:63-79).
8. Queue — 큐 (클래식 1A, 미발권/오프라인 PNR 관리)
sequenceDiagram participant Ctrl as "AmadeusndcQueueController" participant Svc as "AmadeusndcQueueService" participant Cli as "AmadeusndcClient" Note over Ctrl: "GET /internals/AMADEUSNDC/queues getQueues()" Ctrl->>Svc: "getQueuePnrs()" Svc->>Svc: "offlineOid = amadeusProperties.channels TRIPLE.funnels TRIPLE.offlineOid" Svc->>Cli: "getPnrCountsInQueue(queueNumber=7, offlineOid) SOAP QCSDRQ_13_1_1A" Cli-->>Svc: "queueCounts" loop queueCounts.flatMap max 250 Svc->>Cli: "getPnrsInQueue(...) SOAP QDQLRQ_11_1_1A" Cli-->>Svc: "pnrs" end Note over Svc: "예외 시 slackService.sendQueueFail(...) 후 emptyList (큐 실패는 빈 목록으로 흡수)" Note over Ctrl: "PUT /internals/AMADEUSNDC/queues remove()" Ctrl->>Ctrl: "(queueNumber, category, timeMode)로 그룹핑" Ctrl->>Svc: "remove(queueNumber, pnrs, itemNumber, timeMode)" Svc->>Cli: "removePnrsInQueue(...) SOAP QUQMDQ_03_1_1A"
- 서비스:
AmadeusndcQueueServiceapplication/AmadeusndcQueueService.kt:23-80 - 클라이언트:
getPnrCountsInQueue()AmadeusndcClient.kt:633-665,getPnrsInQueue():667-706,removePnrsInQueue():752-790,moveQueuePnrs():708-750(서비스 미사용).
큐는 NDC가 아니라 클래식 1A + 오프라인 OID
큐 오퍼레이션은 SOAP이지만 NDC가 아닌 클래식 Amadeus 큐 메시지(
QCSDRQ/QDQLRQ/QUQMUQ/QUQMDQ)다. 또한soapRequestBodyConverter(useOfflineOid=true)로 SOAP 헤더의PseudoCityCode에officeId대신 **offlineOid**를 싣는다(AmadeusndcClient.kt:843-849). 큐 카운트는isNdcItemNumber && pnrCount>0인 카테고리만 추리고(:657-659), 큐 리스트는 한 번에 최대 250건만 조회된다(AmadeusndcQueueService.kt:36-37). 큐 호출 실패는 예외를 던지지 않고 Slack 경보 후 빈 목록을 반환해 전체 큐 폴링이 한 PNR 때문에 죽지 않게 한다(:52-55,71-74).
9. 비동기·코루틴·회복력 — 모듈 전체 요약
| 메커니즘 | 위치 | 용도 |
|---|---|---|
withBlocking(Dispatchers.IO) | search(FlightSearchService.kt:54), 발권 폴링(TicketingService.kt:57) | 블로킹 컨텍스트에서 코루틴 실행 |
pmap (병렬 map) | FlightSearchService.kt:57 | OD 조합별 검색 병렬화, 부분 성공 허용 |
withLaunch (fire-and-forget) | cancelAsync(CancelService.kt:50), removeFlightSearchKey/saveUnexposed(BookingService.kt:153,159), polling(PollingUtils.kt:21) | 비동기 보상/적재 |
polling/poller (Redis 폴링) | 재발행 발권(TicketingController.kt:80,106) | 202 Accepted + 폴링 키 |
@CircuitBreaker (Resilience4j) | search만(SearchController.kt:25) | OPEN 시 빈 목록 fallback |
@Retryable (Spring Retry) | retrieveByPnr(AmadeusndcClient.kt:279), getFareRules(NdcArtClient.kt:31) | 일시 오류 재시도 |
.noRetry() / .capture() (예외 데코) | 결항 차단(Booking.kt:35), 검색/취소 실패 등 | 재시도 억제 / Datadog 캡처 |
| Slack 경보 | sendCancelFailTimeout/sendCancelFail/sendTicketingTimeout/sendNoQuota/sendQueueFail | 상태 전파(메시지큐 대체) |
코루틴 예외 처리 — AdapterCoroutineExceptionHandler
withBlocking/withLaunch/withAsync는 모두support/util/CoroutineExtensions.kt에 정의되며SupervisorJob + AdapterCoroutineExceptionHandler() + MDCContext()를 결합한다(:16,25,33). fire-and-forget(withLaunch) 경로의 미처리 예외는 이 핸들러로 흡수되므로, 비동기 취소/적재 실패는 호출 응답에 반영되지 않고 로그/Slack으로만 남는다. 코루틴 메커니즘 전반과 예외 핸들러 동작은 async-coroutines 참고.
10. 연습문제
Q1. 일반 발권(
/ticketing)은 동기인데 재발행 발권(/ticketing/addition)만 202 Accepted + 폴링을 쓰는 이유를 코드 근거로 설명하라.정답 보기
reissue()본문에서 ①retrieveByPnr②getFareItinerary③pricingWithReissue(OrderReshop pricing) ④ 금액 검증 ⑤gpsClient.verifyCard(카드) ⑥OrderChange발권까지 여러 외부 SOAP 호출이 직렬로 이어져 응답 시간이 길고 변동적이다(AmadeusndcTicketingService.kt:90-125). 그래서polling(...)으로 백그라운드 코루틴에 위임하고 즉시 폴링 키를 반환(TicketingController.kt:80-100), 클라이언트가GET /addition/{key}로poller를 통해 PENDING/COMPLETE/ERROR를 폴링한다(:103-124). 일반 발권은savePayment+ 최대 3회 재조회 폴링(withBlocking)으로 동기 응답이 가능한 수준이다.재발행 발권은
Q2.
Travel_OrderReshop_1.6(OrderReshop)이 이 모듈에서 수행하는 서로 다른 3가지 역할과 각 진입점을 적어라.정답 보기 취소료 계산:
OrderReshopRQ.of→getCancelableTypeDetail()(AmadeusndcClient.kt:374),useCase로 VOID/REFUND 판정. (2) 재발행 검색:OrderReshopRQ.ofReissueSearch→reissueSearch()(:500). (3) 재발행 가격확정(repricing):OrderReshopRQ.ofPricing→pricingWithReissue()(:551),reissueDetail과TicketingService.reissue두 곳에서 호출. 세 용도가 동일 SOAPAction을 공유한다(request/orderreshop/OrderReshopRQ.kt:27-71).(1)
Q3. 예약 생성(
book) 시 OrderCreate로 PNR이 만들어졌는데도 곧바로retrieveByPnr을 다시 호출하는 이유 2가지를 코드 근거로 들어라.정답 보기 총액 검증:
validateMismatchedTotalPrice()가totalPrice != Σ(passenger.fare.totalPrice)이면cancelAsync로 비동기 취소 후BOOKING_FAILED를 던진다(BookingService.kt:58-86). (2) 회복력:retrieveByPnr은@Retryable(maxAttempts=3, 2s)로 일시 조회 실패를 재시도해, OrderCreate 직후 항공사 측 반영 지연을 흡수한다(AmadeusndcClient.kt:279-304). 부가로 재조회된 booking이 최종BookingView의 신뢰 가능한 소스가 된다.(1)
11. 교차 참조
- amadeusndc-overview — 모듈 개요·하이브리드(1A+NDC) 배경
- amadeusndc-protocol — SOAP 메시지·헤더(UsernameToken/PasswordDigest)·세션 상세
- amadeusndc-pitfalls — cancelAsync fire-and-forget, repricing 게이트, 결제수단 제약 등 함정
- common-operations — 공급사 공통 오퍼레이션 추상 모델
- request-flow — 내부 API 요청 진입 흐름
- caller-callee-map — 전체 호출 그래프
- async-coroutines —
withBlocking/pmap/withLaunch/폴링 메커니즘 - resilience-and-events — Resilience4j/Spring Retry/Slack 기반 상태 전파