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/searchAmadeusndcFlightSearchService, FlightAmenityServiceinterfaces/controller/internals/AmadeusndcSearchController.kt:20-24
Booking/internals/AMADEUSNDC/bookingsAmadeusndcBookingService, AmadeusndcCancelServiceAmadeusndcBookingController.kt:16-20
Ticketing/internals/AMADEUSNDC/ticketingAmadeusndcBookingService, AmadeusndcTicketingService, RedisTemplateAmadeusndcTicketingController.kt:26-31
FareRule/internals/AMADEUSNDC/fare-rulesAmadeusndcFareRuleService, AmadeusndcFlightSearchServiceAmadeusndcFareRuleController.kt:11-15
Queue/internals/AMADEUSNDC/queuesAmadeusndcQueueServiceAmadeusndcQueueController.kt:11-14

SOAPAction = 오퍼레이션 식별자

중앙 디스패처가 없는 대신, AmadeusndcClient가 만드는 각 SOAP 요청의 action 필드가 오퍼레이션을 결정한다. 매핑은 아래 표 참고(상세는 amadeusndc-protocol).

오퍼레이션SOAPAction비고
검색FMPTBQ_23_1_1A클래식 1A (NDC 아님)
가격확정(pricing)Travel_OfferPrice_1.3NDC
예약생성(book)Travel_OrderCreate_1.7NDC
결제/발권(savePayment)Travel_OrderPay_1.7NDC
조회(retrieve)Travel_OrderRetrieve_1.7NDC
취소(cancel)Travel_OrderCancel_1.0NDC
분리(divide)/재발행(reissue)Travel_OrderChange_1.9NDC
취소료계산·재발행검색·재발행가격Travel_OrderReshop_1.6NDC (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
  • 응답 매핑: FareMasterPricerTravelBoardSearchReplysegmentFlightRef.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/searchdetail()은 캐시된 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}/confirmconfirm()bookingService.retrieveAndCancelIfNeed(pnr)결항/스케줄변경 시 자동 취소 트리거
GET /bookings/{pnr}/repricingrepricing()bookingService.retrieveAndCancelIfNeed(pnr)confirm과 동일 호출, View만 RepricingView
GET /bookings/{pnr}/check-pnrcheckPnr()(없음)무조건 true 반환(헬스/존재성 stub)
  • retrieve()parentSupplierIdentificationKey가 있으면 retrieveBySupplierIdentificationKey로 부모 예약을 추가 조회해 병합한다(AmadeusndcBookingService.kt:171-182).
  • retrieveAndCancelIfNeed()validateScheduleStatus=false로 조회 후 validateSchedulesCancellation()을 호출, 결항(UNTK 상태가 있으면 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/readyready()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.ktwithBlocking을 쓴다.

결제수단 제약 — only card OR only cash

ndcx 발권은 카드와 현금을 섞을 수 없다. 컨트롤러에서 cardInfo != null && Σcash > 0이면 즉시 INVALID_PARAMETER로 막는다(AmadeusndcTicketingController.kt:51-56). 카드 결제는 GpsClient.verifyCard()(GPS_Approval, SOAP)로 선승인VerifiedCardInfo를 OrderPay 본문에 싣는다.

발권 실패의 보상 트랜잭션

issue catch 블록은 (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=truevalidateSchedulesCancellation()(결항 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-304
  • shouldRetrieveRetryable(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-pitfallsresilience-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() (총액 불일치) :75
  • AmadeusndcBookingService.retrieveAndCancelIfNeed() (결항) :191
  • AmadeusndcTicketingService.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 — 결과를 기다리지 않는다

cancelAsyncCoroutineScope(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-71Request.ofPricing :119-147)
  • 응답 매핑: OrderReshopRS.toFareItinerary(fareItinerary, applyCalculateCarrierFee)(infrastructure/response/orderreshopreply/OrderReshopRS.kt:110-143) — 항공사 수수료(carrierFee)를 합산한 FareItinerary 재구성.

재발행 repricing의 3중 방어 — 금액 감소·동일 스케줄 차단

reissueDetail()재가격 호출(pricingWithReissue) 전후로 세 겹의 검증을 한다:

  1. 금액 감소 차단: passengerFaresairPrice<0 || tax<0이면 REISSUE_NON_CHANGEABLE_FARE_SCHEDULE. “결제 금액이 감소하여 재발행 불가” 메시지를 탑승객별 금액과 함께 빌드해 던진다(:179-209).
  2. 유지 구간 식별: fareItinerary.ndcReference.remainSchedulesserviceIds 집합으로 “변경 대상 스케줄(schedulesToChange)“만 골라낸다(:213-225).
  3. 동일 스케줄 차단: 변경 대상이라면서 출/도착·시각·편명·캐빈이 기존과 같으면 NON_CHANGEABLE_SCHEDULES(:228-244).

이 검증을 통과해야만 pricingWithReissue로 실제 OrderReshop pricing을 호출한다. 신입은 “재발행 검색이 됐으니 발권만 하면 된다”고 착각하기 쉽지만, 상세(repricing) 단계가 사실상 게이트키퍼다.

FareLogix 항공사 분기

OrderReshop 본문에서 booking.isFareLogixCarrierReshopParameterAutoExchRequestInd=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-49polling은 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"
  • 서비스: AmadeusndcQueueService application/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 헤더의 PseudoCityCodeofficeId 대신 **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:57OD 조합별 검색 병렬화, 부분 성공 허용
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 + 폴링을 쓰는 이유를 코드 근거로 설명하라.

Q2. Travel_OrderReshop_1.6(OrderReshop)이 이 모듈에서 수행하는 서로 다른 3가지 역할과 각 진입점을 적어라.

Q3. 예약 생성( book) 시 OrderCreate로 PNR이 만들어졌는데도 곧바로 retrieveByPnr을 다시 호출하는 이유 2가지를 코드 근거로 들어라.


11. 교차 참조