Lufthansa — 오퍼레이션 흐름

module-lufthansa arch-supplier-module pattern-caller-callee api-ndc

한 줄 요약

Lufthansa의 5개 오퍼레이션(Search·Booking·Ticketing·FareRule·Ancillary)을 콜러→콜리 체인으로 끝까지 추적한 노트다. 모든 흐름은 Controller → application 서비스 → LufthansaClient → NDC(SOAP) 외부 API → 응답 매핑의 4단 구조를 따른다. 특히 재발행(Reissue)·환불계산(Reshop)·취소(Cancel) 같은 복합 플로우는 “조회 → 재가격 → 변경”의 다단계이며, 코루틴/Redis 폴링/보상 트랜잭션이 얽혀 있어 이 모듈의 핵심 학습 가치가 여기에 있다.

관련 노트: 개요 · SOAP) · 함정 모음 · 공통 오퍼레이션 · 요청 흐름 · 콜러-콜리 맵 · 코루틴


0. 이 노트를 읽는 법 — 공통 4단 구조

Lufthansa의 모든 오퍼레이션은 동일한 4계층을 통과한다. 먼저 이 골격을 머리에 넣고 각 오퍼레이션의 “차이”만 보면 빠르다.

flowchart TD
    T["Triple 예약 시스템"] -->|"HTTP 내부 API"| C["① Controller<br/>Lufthansa Op Controller<br/>@RestController"]
    C -->|"도메인 모델 변환<br/>Request DTO에서 support model"| S["② Service<br/>Lufthansa Op Service<br/>@Service 유스케이스 조립"]
    S -->|"비즈니스 규칙·캐시·검증·코루틴 분기"| CL["③ LufthansaClient<br/>@Component 외부 호출 단일 진입점"]
    CL -->|"RQ DTO 빌드, SOAP 봉투, execute"| N["④ 외부 NDC API<br/>EDIST V17.2 / SOAP transport"]
    N -->|"SOAP Body 벗김, RS DTO 역직렬화"| M["RS.checkError 후 RS.to___ 매핑<br/>support model 반환"]
  • ③ 래핑: soapRequestBodyConverter로 SOAP 봉투 생성 → execute()
  • ④ 응답: soapBodyDeserializerOf로 SOAP Body 벗김 → RS DTO 역직렬화

단일 진입점 LufthansaClient

11개 메서드(search/getFareRule/pricing/booking/retrieve/changeApis/cancel/savePayment/divide/refundCalculate/reissueSearch/reissue/searchExtraBaggages/searchSeats/bookAncillaries/retrieveForAncillary/purchaseAncillaries)가 모두 동일 패턴을 쓴다: endpoint.post(request).header(getHeaderMap(request)).requestBodyConvert(soapRequestBodyConverter(...)).execute<RS>(...).fold(success={...}, failure={...}). fold의 failure 분기는 거의 항상 failure.handleSoapFaultException(ErrorMessage.XXX)로 끝난다. 이 형판을 외우면 1000줄짜리 LufthansaClient.kt가 11번의 변주로 읽힌다. (infrastructure/LufthansaClient.kt)

Resilience4j 사용 위치는 단 한 곳

이 모듈 전체에서 @Retry는 없다. @CircuitBreaker는 LufthansaSearchController.search(interfaces/controller/internals/LufthansaSearchController.kt:28)에만 붙는다(name = "lufthansaSearch", fallbackMethod = "searchFallback"). 즉 검색만 서킷브레이커로 보호되고, 예약/발권/취소/부가서비스는 보호받지 않는다(쓰기성 연산이라 자동 폴백이 위험). 상태 전파는 큐가 아니라 서킷 OPEN 시 Datadog 스팬 태깅 + 예외 전파 + Slack 경보로 이뤄진다 → resilience-and-events.


1. Search — 항공편 검색

콜러→콜리 체인

단계클래스/메서드파일:line
① ControllerLufthansaSearchController.search (@PostMapping, @CircuitBreaker)interfaces/.../LufthansaSearchController.kt:28-55
② ServiceLufthansaFlightSearchService.searchapplication/LufthansaFlightSearchService.kt:29-104
③ ClientLufthansaClient.searchinfrastructure/LufthansaClient.kt:80-146
④ NDC APIAirShoppingRQ → AirShoppingRSinfrastructure/request/AirShoppingRQ.kt, .../response/AirShoppingRS.kt
매핑AirShoppingRS.toFareItineraries(...)infrastructure/response/AirShoppingRS.kt:39

흐름 디테일 (왜 이렇게 복잡한가)

search 서비스는 단순 1:1 호출이 아니다. 핵심은 캐시 우선 + 다구간 병렬 검색 + 점수화 정렬이다.

flowchart TD
    A["search requestKey"] --> K["flightSearchKeyRepository.findKey"]
    K -->|"캐시 HIT"| H["fareItineraryRepository.getFareItineraries<br/>즉시 반환"]
    K -->|"캐시 MISS"| OD["AirportUtils.makeOriginDestinations<br/>공항코드 정규화, 비면 emptyList"]
    OD --> WB["withBlocking Dispatchers.IO<br/>코루틴 블로킹 브리지"]
    WB --> CP["cartesianProduct 다구간 조합 전개"]
    CP --> PM["pmap, 구간 조합별 병렬 호출<br/>pmap은 async + awaitAll"]
    PM --> OF["onFailure, 전부 실패 시에만 throw SEARCH_FAILED"]
    OF --> FL["getOrEmpty 후 flatten"]
    FL --> D["distinctBy it.id"]
    D --> FILT["filter, 요청 출발/도착이 응답 첫/끝 세그먼트와 일치<br/>isSameCode 멀티시티 허용"]
    FILT --> SC["map withScore<br/>price min/avg/max, flightTime min/avg/max<br/>FareItineraryScoring"]
    SC --> SORT["sortedByDescending score<br/>take advancedOption.ratio.total 또는 30"]
    SORT --> SAVE["if useCache 이면 addKey + saveFareItineraries<br/>Redis 저장"]
    H --> POST["filterByUnexposedFareItinerary, 품절 등록 운임 제외"]
    SAVE --> POST
    POST --> AIR["filterIncludedAirline, 요청 항공사 필터"]
  • 코루틴 블로킹 브리지: support/util/CoroutineExtensions.kt:13

preferences.first()만 사용 — multiFare 없음

search는 SearchPreferenceInfo.of(preferences.first())로 첫 번째 선호도 하나만 쓴다(LufthansaFlightSearchService.kt:57, 주석 // multiFare 옵션이 없기 때문에 첫번째 하나만 사용). GDS와 달리 운임 패밀리 멀티 검색을 안 한다.

비동기 — 코루틴은 검색 병렬화에 사용

withBlocking { ... pmap { ... } }는 동기 컨트롤러 스레드에서 코루틴 스코프를 열어 구간 조합을 병렬 호출한다. pmap은 각 호출을 withAsync로 띄우고 awaitAll한 뒤 성공/실패를 AsyncResults로 모은다(support/util/CoroutineExtensions.kt:36). 예외는 AdapterCoroutineExceptionHandler로 격리된다 → async-coroutines.

응답 매핑 시 에러 코드 분기

AirShoppingRS.checkError에서 NDC 코드 325(No inventory)·719(No fares)는 정상 빈 결과로 흡수, 그 외는 SEARCH_FAILED 예외(LufthansaClient.kt:110-125). 또 failure.isTimeout이면 예외 대신 emptyList()를 반환한다(:139). 매핑 후 NonAirEquipment(버스/기차 등 비항공 구간) 운임은 filterNot { it.isNonAir() }로 제거한다.

detail / reissueDetail

detail·reissueDetail은 외부 호출 없이 getFareItinerary(key)로 Redis 캐시에서 운임을 꺼내 FlightAmenityService.findAmenityMap(...)으로 어메니티만 덧붙여 FareItineraryDetailView로 반환한다(LufthansaSearchController.kt:57-108). detail은 validate(adult/child/infant)로 좌석수 검증을 추가로 수행한다.


2. Booking — 예약 생성

콜러→콜리 체인

단계클래스/메서드파일:line
① ControllerLufthansaBookingController.create (@PostMapping)interfaces/.../LufthansaBookingController.kt:25-36
② ServiceLufthansaBookingService.bookapplication/LufthansaBookingService.kt:39-62
②-a ServiceLufthansaBookingService.pricing (선행 필수)application/LufthansaBookingService.kt:64-71
③ Client (가격)LufthansaClient.pricing → OfferPriceRQ/OfferPriceRSinfrastructure/LufthansaClient.kt:204-230
③ Client (예약)LufthansaClient.booking → OrderCreateRQ/OrderViewRSinfrastructure/LufthansaClient.kt:232-261
매핑Response.toBooking(passengers)infrastructure/response/OrderViewRS.kt:50

흐름 디테일

flowchart TD
    A["create BookingRequest"] --> B["bookingService.book<br/>detailKey, ReservationUser.of, passengers"]
    B --> CNT["adult/child/infant = passengers.count by type"]
    CNT --> FI["fareItinerary = flightSearchService.getFareItinerary detailKey<br/>Redis 캐시 조회"]
    FI --> P["offerPriceInfo = pricing<br/>★ OfferPrice 선행 필수"]
    P --> P2["lufthansaClient.pricing<br/>OfferPriceRQ, OfferPriceRS.toPricingInfo"]
    P2 --> BK["lufthansaClient.booking<br/>reservationUser, passengers, offerPriceInfo"]
    BK --> BK2["OrderCreateRQ.of, OrderViewRS<br/>response.toBooking passengers"]
    BK2 --> RM["removeFlightSearchKey fareItinerary.requestKey<br/>예약 후 검색키 비동기 삭제"]

NDC 예약은 "가격 재확인(OfferPrice)" 없이는 불가

NDC에서 검색 결과(AirShoppingRS)의 Offer는 그대로 예약에 못 쓴다. 반드시 OfferPriceRQ로 해당 Offer를 다시 가격 조회해 유효한 OfferPriceInfo(responseId/offerId/offerItems/owner; support/model/OfferPriceInfo.kt)를 받아야 하고, 그 값이 OrderCreateRQ에 들어간다. book()이 항상 pricing()을 먼저 부르는 이유다. 가격 검증에서 운임이 사라졌으면 pricing은 PRICING_FAILED를 던진다(LufthansaClient.kt:222).

예약 직후 검색키 비동기 정리

removeFlightSearchKey는 CoroutineScope(Dispatchers.IO).withLaunch { flightSearchKeyRepository.removeKey(key) }로 fire-and-forget 실행된다(LufthansaBookingService.kt:97-101). 예약 응답을 지연시키지 않으려는 의도. 실패해도 본 흐름은 영향 없다(async-coroutines).

retrieve / confirm / repricing — 모두 retrieve로 수렴

retrieve(GET /{pnr}), confirm(GET /{pnr}/confirm), repricing(GET /{pnr}/repricing) 세 엔드포인트는 모두 bookingService.retrieve 하나로 귀결된다(OrderRetrieveRQ → OrderViewRS.toBooking). 차이는 응답 뷰뿐이다.

  • confirm은 retrieve와 동일 결과를 BookingView로 반환(예약 확정 확인 용도).
  • repricing은 retrieve 결과의 passengers·validatingCarrier를 RepricingView.of(...)로 변환한다 — 즉 Lufthansa의 “repricing” 엔드포인트는 별도 NDC 재가격 호출이 아니라 리트리브 결과의 운임을 그대로 재노출하는 것(LufthansaBookingController.kt:167-176). 진짜 재가격(reshop)은 취소·재발행 플로우에서 일어난다(§5, §3.2).

retrieve 빈 orderItems = 이미 취소된 PNR

LufthansaClient.retrieve는 orderViewRS.response?.order?.orderItems.isNullOrEmpty()이면 ALREADY_CANCELED_PNR 예외를 던진다(LufthansaClient.kt:286). 정상 응답(에러 없음)인데도 주문 항목이 비어 있으면 취소된 것으로 간주한다.

divide — 코드만 존재, 실제 미지원

단계클래스/메서드파일:line
① ControllerLufthansaBookingController.divideLufthansaBookingController.kt:131-147
② ServiceLufthansaBookingService.divide (사전 검증 후 호출)LufthansaBookingService.kt:73-95
③ ClientLufthansaClient.divide → OrderChangeRQ.ofDivideLufthansaClient.kt:425-456

divide는 호출하면 NDC가 거부한다

LufthansaClient.divide의 KDoc(LufthansaClient.kt:425-431): “LH 루프트한자는 divide API 기능을 지원하지 않습니다.” 호출 시 <Error Code="325"> ... functionality has not been enabled for this carrier가 응답된다. 서비스 레이어(LufthansaBookingService.divide)는 validate(...)로 요청 승객이 실제 예약에 존재하는지·성인/유아 쌍이 맞는지 사전 검증까지 정성껏 구현돼 있지만(:103-124), 정작 외부 API가 막혀 있어 전체가 죽은 경로다 → lufthansa-pitfalls.


3. Ticketing — 발권 / 재발행

티켓팅은 ready(준비) → issue(발권)의 동기 흐름과, reissue(재발행)의 비동기 Redis 폴링 흐름으로 나뉜다.

3.1 issue — 발권 + 실패 시 보상 자동취소

단계클래스/메서드파일:line
① ControllerLufthansaTicketingController.issue (prepayment만 허용)interfaces/.../LufthansaTicketingController.kt:45-66
② ServiceLufthansaTicketingService.issueapplication/LufthansaTicketingService.kt:37-62
③ ClientLufthansaClient.savePayment → OrderChangeRQ.ofPaymentinfrastructure/LufthansaClient.kt:394-423
매핑Response.toBooking()OrderViewRS.kt:50
flowchart TD
    A["issue TicketingRequest"] --> CHK{"request.prepayment 인가"}
    CHK -->|"아니오"| ERR["throw INVALID_PAYMENT_METHOD<br/>사전결제만 허용"]
    CHK -->|"예"| SVC["ticketingService.issue<br/>pnr, supplierIdentificationKey, validatingCarrier"]
    SVC --> TRY["try: lufthansaClient.savePayment<br/>OrderChangeRQ.ofPayment, CA 현금/캐시 결제<br/>timeoutCallback은 slackService.sendTicketingTimeout"]
    TRY -->|"성공"| OK["발권 완료"]
    TRY -->|"예외 catch"| CA["cancelAsync pnr<br/>★ 보상 트랜잭션, 코루틴 5초 delay 후 cancel"]
    CA --> RT["throw e 재전파"]
  • 타임아웃 시 timeoutCallback이 Slack 알림 발송

발권 실패 시 보상 트랜잭션 — cancelAsync

savePayment(결제·발권)가 예외를 던지면 catch에서 cancelAsync(...)를 호출한다(LufthansaTicketingService.kt:53-60). 이는 CoroutineScope(Dispatchers.IO).withLaunch { delay(5000); lufthansaClient.cancel(...) }로 5초 뒤 비동기 자동취소를 수행하고, 그 취소마저 실패하면 SlackService.sendCancelFail로 경보 후 재-throw한다(:105-123). 결제는 됐는데 후속이 깨진 “고아 발권”을 막는 보상 로직이며, 이 시스템의 상태 전파가 큐가 아니라 코루틴 + Slack으로 구현됨을 보여주는 대표 사례다 → async-coroutines, resilience-and-events.

savePayment 타임아웃 ≠ 발권 실패

LufthansaClient.savePayment의 failure 분기는 failure.isTimeout이면 먼저 timeoutCallback()(= Slack 타임아웃 알림)을 실행한 뒤 TICKETING_FAILED를 던진다(LufthansaClient.kt:417-420). 발권 요청이 타임아웃돼도 실제 항공권은 발권됐을 수 있어, 사람이 개입하도록 알림을 쏜다 → lufthansa-pitfalls.

ready

ready(POST /ticketing/ready)는 LufthansaTicketingService.ready가 lufthansaClient.retrieve(...)로 예약을 조회해 null to booking.schedules!!만 반환한다(:26-35). 승객 리스트는 null로 비우고 스케줄만 TicketingReadyView로 노출 — 발권 직전 일정 확인용.

3.2 reissue — 비동기 Redis 폴링 + 재가격(reshop)

재발행은 시간이 오래 걸려 즉시 응답하지 않고 폴링 키를 돌려준 뒤 백그라운드 처리한다. 이 모듈에서 가장 복잡한 비동기 패턴.

단계클래스/메서드파일:line
① Controller (시작)LufthansaTicketingController.reissue (POST /addition) → 202 ACCEPTED + 폴링키LufthansaTicketingController.kt:68-83
폴링 래퍼polling(key, ttl, redisTemplate) { ... }support/util/PollingUtils.kt:12
② ServiceLufthansaTicketingService.reissueapplication/LufthansaTicketingService.kt:68-103
③ Client (조회)LufthansaClient.retrieveLufthansaClient.kt:263
③ Client (재발행)LufthansaClient.reissue → OrderChangeRQ.ofReissueLufthansaClient.kt:589-622
① Controller (폴링)LufthansaTicketingController.checkReissue (GET /addition/{reissueKey})LufthansaTicketingController.kt:85-103
폴링 조회poller<ReissueResult<Booking,Passenger>>(key, redisTemplate)support/util/PollingUtils.kt:43
flowchart TD
    subgraph START["재발행 시작"]
        R["reissue ReticketingRequest"] --> POLL["polling key REISSUE LUFTHANSA pnr<br/>백그라운드 코루틴 실행"]
        POLL --> ACC["즉시 202 + DeferredKeyView pollingKey"]
    end
    subgraph BG["백그라운드 처리"]
        BS["ticketingService.reissue key, sik, vc"]
        BS --> F1["fareItinerary = getFareItinerary key<br/>재발행 검색으로 캐싱된 새 운임"]
        F1 --> F2["originalBooking = lufthansaClient.retrieve<br/>기존 예약 승객 식별자 확보"]
        F2 --> F3["reissueBooking = lufthansaClient.reissue<br/>OrderChangeRQ.ofReissue<br/>passengers는 originalBooking.passengers를 PassengerIdentification으로"]
        F3 --> F4["ReissueResult<br/>booking은 reissueBooking<br/>passengers는 발권상태 ISSUE 티켓만 골라 단일화"]
    end
    subgraph POLLR["재발행 결과 폴링"]
        CK["checkReissue reissueKey"] --> PR["poller ReissueResult reissueKey"]
        PR --> ST{"status 분기"}
        ST -->|"PENDING"| PEN["DeferredView.Pending"]
        ST -->|"ERROR"| ERR["throw throwable"]
        ST -->|"COMPLETE"| CMP["ReticketingView.of booking, passengers"]
    end
    POLL -.->|"백그라운드"| BS
    F4 -.->|"Redis 저장"| PR

polling/poller 메커니즘

polling(...)은 Redis에 ADAPTER-DEFERRED-RESULT::{key}로 PENDING을 먼저 박고, CoroutineScope(Dispatchers.IO).withLaunch { runCatching { init() }.onSuccess{COMPLETE}.onFailure{ERROR} }로 백그라운드 실행한 뒤 key를 즉시 반환한다(PollingUtils.kt:12-40). poller는 그 키를 읽어 DeferredResult<T>를 꺼내며, throwable != null이면 즉시 throw한다(:43-49). 재발행 키는 CacheSet.REISSUE.cacheName::LUFTHANSA_{pnr}로 만들어 PNR당 1개 작업을 보장한다.

ofReissue의 미해결 결제 처리 (TODO)

OrderChangeRQ.ofReissue는 Payment.ofCache()(금액 미지정 현금)만 넣는다. 코드 주석(OrderChangeRQ.kt:186-199)에 “LH 돌려줘야 하는 환불금액이 생길 경우 처리방법 수정 필요”, “스케줄 변경 차액 추가결제 필요 여부 트랙스페이스 문의 진행중”이라 적혀 있다 — 즉 재발행 차액 정산이 미완 상태다 → lufthansa-pitfalls.

재발행 검색(reissueSearch) — reissue의 전제

reissue가 쓰는 fareItinerary는 그 전에 재발행 검색(LufthansaSearchController.reissueSearch → LufthansaFlightSearchService.reissueSearch → LufthansaClient.reissueSearch)으로 미리 만들어 Redis에 캐싱된 것이다.

flowchart TD
    A["reissueSearch<br/>sik, validatingCarrier, originDestinationInfos, cabins"] --> RET["booking = lufthansaClient.retrieve sik, vc<br/>기존 예약 orderItems 확보"]
    RET --> RS["lufthansaClient.reissueSearch<br/>orderItems = booking.orderItems<br/>OrderReshopRQ.ofReissueSearch<br/>Add 새 구간 / Delete 기존 항목"]
    RS --> MAP["OrderReshopRS.toReshopFareItineraries"]
    MAP --> SAVE["saveFareItineraries key<br/>재발행 운임 캐싱"]

OrderReshopRQ.ofReissueSearch(infrastructure/request/OrderReshopRQ.kt:75-157)는 Reshop.OrderServicing에 Add(새 origin/destination + cabin 선호)와 Delete(기존 orderItem + 유지할 service 참조)를 동시에 담는다. 주석 // 루프트한자 TrueReshop 다중 좌석 요청 불가(:122)대로 cabin은 cabins.first() 하나만 보낸다. 응답 매핑 OrderReshopRS.toReshopFareItineraries(response/OrderReshopRS.kt:47)는 reshopOffers를 비행 참조로 그룹핑→최저가 offerItem 선택→ReshopDifferential(원가/신가/위약금/차액)을 계산해 FareItinerary로 만든다.


4. FareRule — 운임 규정

콜러→콜리 체인

단계클래스/메서드파일:line
① ControllerLufthansaFareRuleController.getFareRules (@GetMapping)interfaces/.../LufthansaFareRuleController.kt:16-29
② ServiceLufthansaFareRuleService.findFareRulesapplication/LufthansaFareRuleService.kt:25-54
③ ClientLufthansaClient.getFareRule → OfferPriceRQ/OfferPriceRSinfrastructure/LufthansaClient.kt:156-202
매핑OfferPriceRS.toFareRules() (내부 Offer.toFareRules)response/OfferPriceRS.kt:42, response/Offer.kt:248
flowchart TD
    A["getFareRules key, adult, child, infant"] --> FR["findFareRules"]
    FR --> GK["fareRuleKey = CacheKeyGenerator.generateFareRuleKey key, a, c, i"]
    GK --> CACHE{"fareRuleRepository.findFareRules 존재"}
    CACHE -->|"있으면"| HIT["그대로 반환"]
    CACHE -->|"없으면"| FI["fareItinerary = flightSearchService.getFareItinerary key"]
    FI --> TRY["try: lufthansaClient.getFareRule a, c, i, fareItinerary<br/>OfferPriceRQ, OfferPriceRS.toFareRules<br/>이후 fareRuleRepository.saveFareRules"]
    TRY -->|"catch StatusInvalidException SOLD_OUT"| SO["removeFlightSearchKey + saveUnexposedFareItinerary<br/>품절 처리 후 throw e"]

FareRule은 별도 NDC 메시지가 없다 — OfferPrice 재사용

Lufthansa에는 전용 FareRule RQ가 없어 가격조회(OfferPriceRQ) 응답에서 규정을 추출한다(LufthansaClient.getFareRule, pricing과 동일한 OfferPriceRQ.of(...) 사용). 그래서 getFareRule은 OfferPriceRS를 받아 toFareRules()로 매핑한다. NDC는 운임 규정을 Offer의 부속 정보로 함께 내려주기 때문.

SOLD_OUT 감지 → 미노출 운임 등록 (코루틴)

OfferPriceRS.checkError에서 메시지에 "No fares found for booking class"(NO_FARES_FOUND_MESSAGE, LufthansaClient.kt:48)가 포함되면 StatusInvalidException(SOLD_OUT)을 던진다(:179-186). 서비스는 이를 잡아 removeFlightSearchKey(검색키 삭제) + saveUnexposedFareItinerary(품절 운임 등록)를 각각 별도 코루틴으로 실행한다(LufthansaFareRuleService.kt:56-66). 등록된 ID는 이후 검색의 filterByUnexposedFareItinerary에서 걸러진다(§1). 운임 규정 조회가 사실상 “최종 재고 검증” 지점이 된다.

getStructuredFareRules(/structured)는 외부 호출 없이 캐시의 FareItinerary를 StructuredFareRuleView로 변환만 한다(LufthansaFareRuleController.kt:31-35).


5. Cancel — 취소 / 환불계산 / VOID·REFUND 판정

취소는 단일 호출이 아니라 조회 → 재가격(reshop) 선계산 → 취소의 3단계다. 컨트롤러는 LufthansaBookingController에 흡수돼 있다(별도 취소 컨트롤러 없음).

단계클래스/메서드파일:line
① ControllerLufthansaBookingController.cancel (@PutMapping("/{pnr}/cancel"))LufthansaBookingController.kt:68-90
② ServiceLufthansaCancelService.cancelapplication/LufthansaCancelService.kt:13-36
②-a ServiceLufthansaCancelService.expectedCancel (선계산)application/LufthansaCancelService.kt:38-44
③ Client (조회)LufthansaClient.retrieve → OrderRetrieveRQ/OrderViewRSLufthansaClient.kt:263
③ Client (재가격)LufthansaClient.refundCalculate → OrderReshopRQ.ofRefundCalculate/OrderReshopRSLufthansaClient.kt:458-538
③ Client (취소)LufthansaClient.cancel → OrderCancelRQ/OrderCancelRSLufthansaClient.kt:338-392
flowchart TD
    A["cancel pnr, sik, validatingCarrier"] --> EC["expectedCancel sik, vc"]
    EC --> RET["booking = lufthansaClient.retrieve sik, vc<br/>OrderRetrieveRQ"]
    RET --> RC["lufthansaClient.refundCalculate booking<br/>OrderReshopRQ.ofRefundCalculate<br/>반환 Pair voidable Boolean, refunds List Passenger"]
    RC --> COND{"voidable 또는 모든 승객 ticket 없음"}
    COND -->|"참"| C1["lufthansaClient.cancel<br/>OrderCancelRQ"]
    COND -->|"거짓 else"| C2["lufthansaClient.cancel<br/>동일 호출"]

cancel의 if/else는 사실상 동일 — 의도된 분기 미완

LufthansaCancelService.cancel의 if (voidable || ...) { cancel(...) } else { cancel(...) }는 양쪽 모두 같은 lufthansaClient.cancel을 호출한다(LufthansaCancelService.kt:22-34). VOID와 REFUND를 다르게 처리하려던 흔적이지만 현재는 분기 의미가 없다. NDC OrderCancelRQ가 자동환불(Auto-Refund)까지 처리하기 때문으로 보이며, 유지보수 시 주의 → lufthansa-pitfalls.

refundCalculate — 환불액 산식 (정밀)

LufthansaClient.refundCalculate(:458-538)는 OrderReshopRS의 reshopOffers에서 승객별 deleteOfferItem을 찾아 다음을 계산해 passenger.fare에 채운다:

  • voidable = dataList.disclosures 또는 reshopOffers에 VOID 항목 존재 여부
  • refundFee = penaltyAmount(위약금)
  • expectedRefundAmount = unUsedAirPrice + unUsedTax
  • usedTax = originTax - unUsedTax, usedAirPrice = originAirPrice - unUsedAirPrice - penalty

여기서 unUsedAirPrice는 reshopDue.byAirline.total - unUsedTax로 구한다. NDC 환불은 “원가 - 미사용분 - 위약금”의 차감 모델임을 코드로 확인할 수 있다.

cancel 반환값은 항상 0L (무력화됨)

LufthansaClient.cancel은 취소 수수료를 계산하던 로직이 통째로 주석 처리되고 0L을 반환한다(:370-379). 주석: “orderCancelRS.response.changeFees 는 내려오지 않는다 … 사용하는 곳이 없어 0 을 리턴”. 환불액 정보는 취소 응답이 아니라 **선행 refundCalculate**에서 이미 얻는다. 또 메시지에 "Checked In status not valid for Auto-Refund"가 있으면 CANCEL_UNABLE_BY_ALREADY_CHECK_IN(체크인 후 취소 불가)을, 타임아웃이면 slackService.sendCancelFailTimeout(...) 후 예외를 던진다(:357-389).

expectedCancel / cancelable — 사전 조회 전용

  • expectedCancel(GET /{pnr}/expected-cancel): retrieve + refundCalculate 결과를 ExpectedCancelView로(취소 전 환불 미리보기).
  • cancelable(GET /{pnr}/cancelable): 동일 선계산 후 voidable이면 CancelActionType.VOID(환불 null), 아니면 REFUND(환불 리스트)로 CancelableTypeDetail을 만든다(LufthansaCancelService.kt:46-61, support/model/CancelableTypeDetail.kt).

세 엔드포인트(cancel/expectedCancel/cancelable) 모두 환불 필터가 동일: refundFee>0 || usedTax>0 || usedAirPrice>0인 승객만 노출(LufthansaBookingController.kt:81-110).


6. Ancillary — 부가서비스(좌석/추가수하물)

6.1 가용성 검색 (avail / baggage)

컨트롤러(LufthansaAncillaryController)는 두 가지 진입(key / pnr) 을 제공한다. key 기반은 예약 전(검색 결과 기준), pnr 기반은 예약 후(리트리브 기준).

엔드포인트서비스 메서드외부 호출
GET /avail/keysearchAvailAncillary(key, adult, child, infant)pricing → searchSeats + searchExtraBaggages (병렬)
GET /avail/pnrsearchAvailAncillary(sik, validatingCarrier)retrieve → searchSeats + searchExtraBaggages (병렬)
GET /baggage/keysearchExtraBaggages(key, ...)pricing → searchExtraBaggages
GET /baggage/pnrsearchExtraBaggages(sik, vc)retrieve → searchExtraBaggages
flowchart TD
    A["searchAvailAncillary key, a, c, i"] --> WB["withBlocking 스코프"]
    WB --> FI["fareItinerary = searchService.getFareItinerary key"]
    FI --> OP["offerPriceInfo = bookingService.pricing a, c, i, fareItinerary<br/>OfferPrice 선행"]
    OP --> PM["AncillaryType.availOf LUFTHANSA .pmap by type<br/>좌석/수하물 병렬 탐색"]
    PM -->|"SEAT"| SEAT["lufthansaClient.searchSeats offerPriceInfo<br/>isNotEmpty 이면 SEAT"]
    PM -->|"EXTRA_BAGGAGE"| BAG["lufthansaClient.searchExtraBaggages offerPriceInfo<br/>isNotEmpty 이면 EXTRA_BAGGAGE"]
    PM -->|"else"| NULL["null"]
    SEAT --> RES["getOrEmpty 후 filterNotNull"]
    BAG --> RES
    NULL --> RES
  • 위치: LufthansaAncillaryService.kt:27-59

  • 좌석 검색: LufthansaClient.searchSeats → SeatAvailabilityRQ → SeatAvailabilityRS.toSeatAvailabilityList() (LufthansaClient.kt:685-747).

  • 수하물 검색: LufthansaClient.searchExtraBaggages → ServiceListRQ → ServiceListRS.toExtraBaggages() (LufthansaClient.kt:624-683).

  • 두 호출은 pmap으로 병렬(AncillaryService withBlocking 스코프 내, async-coroutines).

가용성 검색도 OfferPrice 선행 (key 기반)

key 기반은 PNR이 없으므로 좌석/수하물을 조회하려면 먼저 pricing(...)으로 OfferPriceInfo를 만들어야 한다. NDC ServiceList/SeatAvailability RQ가 유효한 Offer 참조를 요구하기 때문.

6.2 부가서비스 구매 (purchaseAncillaries) — 컨트롤러 미노출

LufthansaAncillaryService.purchaseAncillaries(:149-196)는 완성된 “예약 → 좌석/수하물 주문 → 재조회(상태확인) → 결제” 흐름을 가진다:

flowchart TD
    A["purchaseAncillaries sik, validatingCarrier, baggages, seats"] --> FIX["validatingCarrier LX를 LXA로 보정"]
    FIX --> RET["booking = bookingService.retrieve sik, LXA보정"]
    RET --> COMB["passengersWithAncillaries = 승객별 seat/baggage 결합"]
    COMB --> BOOK["lufthansaClient.bookAncillaries<br/>OrderChangeRQ.ofBookAncillaries, 좌석/수하물 Offer"]
    BOOK --> RFA["retrieveForAncillary sik<br/>재조회 status HN 이면 ANCILLARY_BOOKING_PENDING<br/>orderItems는 id가 Po로 시작 안하는 것만"]
    RFA --> PUR["lufthansaClient.purchaseAncillaries sik, orderItemIds<br/>OrderChangeRQ.ofPurchaseAncillaries, CA 0.00 결제"]
  • 위치: LufthansaAncillaryService.kt:156

purchaseAncillaries / searchSeats(pnr) 는 컨트롤러에 연결돼 있지 않다

LufthansaAncillaryController(interfaces/.../LufthansaAncillaryController.kt)에는 좌석 검색 전용 엔드포인트도, 구매 엔드포인트도 없다. searchAvail(avail)과 searchExtraBaggages(baggage) 4개만 노출된다. 즉 purchaseAncillaries·searchSeats(sik, vc)는 서비스에 구현됐지만 이 모듈 컨트롤러로는 호출 불가한 미연결 코드다(공통/다른 진입점에서 쓰일 수 있으나 이 모듈 컨트롤러 기준으론 dead path). 신입이 “구매가 되는 줄” 오해하기 쉬움 → lufthansa-pitfalls.

retrieveForAncillary의 "HN" 상태 = 대기

부가서비스 주문 후 재조회 시 orderItem.services.first().status == "HN"이면 ANCILLARY_BOOKING_PENDING 예외를 던진다(LufthansaClient.kt:821-822). 항공사 측 처리가 끝나지 않았다는 뜻으로, 결제(purchaseAncillaries)로 진행하지 않는다.


7. 한눈에 보는 콜리(외부 호출) ↔ NDC 메시지 매핑

오퍼레이션LufthansaClient 메서드RQ (action)RS매핑 함수
SearchsearchAirShoppingRQAirShoppingRStoFareItineraries
Pricing(예약/규정/부가 선행)pricing / getFareRuleOfferPriceRQOfferPriceRStoPricingInfo / toFareRules
BookingbookingOrderCreateRQOrderViewRStoBooking
Retrieveretrieve / retrieveForAncillaryOrderRetrieveRQOrderViewRStoBooking / (원본 Response)
APIS 변경changeApisOrderChangeRQ.ofApisOrderViewRStoBooking().passengers
Ticketing(발권)savePaymentOrderChangeRQ.ofPaymentOrderViewRStoBooking
CancelcancelOrderCancelRQOrderCancelRS(반환 0L)
환불계산refundCalculateOrderReshopRQ.ofRefundCalculateOrderReshopRS(수기 산식)
재발행 검색reissueSearchOrderReshopRQ.ofReissueSearchOrderReshopRStoReshopFareItineraries
재발행 확정reissueOrderChangeRQ.ofReissueOrderViewRStoBooking
Divide(미지원)divideOrderChangeRQ.ofDivideOrderViewRStoBooking
좌석 검색searchSeatsSeatAvailabilityRQSeatAvailabilityRStoSeatAvailabilityList
수하물 검색searchExtraBaggagesServiceListRQServiceListRStoExtraBaggages
부가 주문bookAncillariesOrderChangeRQ.ofBookAncillariesOrderViewRS(검증만)
부가 결제purchaseAncillariesOrderChangeRQ.ofPurchaseAncillariesOrderViewRStoBooking

OrderChangeRQ는 만능 변경 메시지

APIS변경·발권결제·분할·재발행·부가주문·부가결제가 모두 OrderChangeRQ의 6개 팩토리(ofApis/ofPayment/ofDivide/ofReissue/ofBookAncillaries/ofPurchaseAncillaries)로 생성된다(infrastructure/request/OrderChangeRQ.kt:40-291). 마찬가지로 OrderReshopRQ는 환불계산·재발행검색 2용도를 공유한다. NDC가 “주문 변경”을 단일 메시지에 파라미터로 표현하는 설계라서 그렇다. 디버깅 시 어떤 팩토리로 만들어진 OrderChangeRQ인지부터 확인하라 → lufthansa-protocol.


8. 연습 문제

Q1. 예약 생성( book)에서 pricing을 먼저 호출하지 않고 곧장 booking을 부르면 어떤 문제가 생기나?

Q2. 취소( cancel) 한 번에 외부 NDC 호출이 몇 번 일어나며, 각각 무엇인가?

Q3. 재발행( reissue)이 다른 오퍼레이션과 달리 202 ACCEPTED를 돌려주는 이유와, 클라이언트가 결과를 받는 방법은?

Q4. FareRule 조회에 전용 NDC 메시지가 없는데, 그러면 무엇으로 운임 규정을 가져오나? SOLD_OUT은 어떻게 감지되나?


더 보기