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 역직렬화
단일 진입점
LufthansaClient11개 메서드(
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 |
|---|---|---|
| ① Controller | LufthansaSearchController.search (@PostMapping, @CircuitBreaker) | interfaces/.../LufthansaSearchController.kt:28-55 |
| ② Service | LufthansaFlightSearchService.search | application/LufthansaFlightSearchService.kt:29-104 |
| ③ Client | LufthansaClient.search | infrastructure/LufthansaClient.kt:80-146 |
| ④ NDC API | AirShoppingRQ → AirShoppingRS | infrastructure/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 |
|---|---|---|
| ① Controller | LufthansaBookingController.create (@PostMapping) | interfaces/.../LufthansaBookingController.kt:25-36 |
| ② Service | LufthansaBookingService.book | application/LufthansaBookingService.kt:39-62 |
| ②-a Service | LufthansaBookingService.pricing (선행 필수) | application/LufthansaBookingService.kt:64-71 |
| ③ Client (가격) | LufthansaClient.pricing → OfferPriceRQ/OfferPriceRS | infrastructure/LufthansaClient.kt:204-230 |
| ③ Client (예약) | LufthansaClient.booking → OrderCreateRQ/OrderViewRS | infrastructure/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 |
|---|---|---|
| ① Controller | LufthansaBookingController.divide | LufthansaBookingController.kt:131-147 |
| ② Service | LufthansaBookingService.divide (사전 검증 후 호출) | LufthansaBookingService.kt:73-95 |
| ③ Client | LufthansaClient.divide → OrderChangeRQ.ofDivide | LufthansaClient.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 |
|---|---|---|
| ① Controller | LufthansaTicketingController.issue (prepayment만 허용) | interfaces/.../LufthansaTicketingController.kt:45-66 |
| ② Service | LufthansaTicketingService.issue | application/LufthansaTicketingService.kt:37-62 |
| ③ Client | LufthansaClient.savePayment → OrderChangeRQ.ofPayment | infrastructure/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 |
| ② Service | LufthansaTicketingService.reissue | application/LufthansaTicketingService.kt:68-103 |
| ③ Client (조회) | LufthansaClient.retrieve | LufthansaClient.kt:263 |
| ③ Client (재발행) | LufthansaClient.reissue → OrderChangeRQ.ofReissue | LufthansaClient.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 |
|---|---|---|
| ① Controller | LufthansaFareRuleController.getFareRules (@GetMapping) | interfaces/.../LufthansaFareRuleController.kt:16-29 |
| ② Service | LufthansaFareRuleService.findFareRules | application/LufthansaFareRuleService.kt:25-54 |
| ③ Client | LufthansaClient.getFareRule → OfferPriceRQ/OfferPriceRS | infrastructure/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 |
|---|---|---|
| ① Controller | LufthansaBookingController.cancel (@PutMapping("/{pnr}/cancel")) | LufthansaBookingController.kt:68-90 |
| ② Service | LufthansaCancelService.cancel | application/LufthansaCancelService.kt:13-36 |
| ②-a Service | LufthansaCancelService.expectedCancel (선계산) | application/LufthansaCancelService.kt:38-44 |
| ③ Client (조회) | LufthansaClient.retrieve → OrderRetrieveRQ/OrderViewRS | LufthansaClient.kt:263 |
| ③ Client (재가격) | LufthansaClient.refundCalculate → OrderReshopRQ.ofRefundCalculate/OrderReshopRS | LufthansaClient.kt:458-538 |
| ③ Client (취소) | LufthansaClient.cancel → OrderCancelRQ/OrderCancelRS | LufthansaClient.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를 다르게 처리하려던 흔적이지만 현재는 분기 의미가 없다. NDCOrderCancelRQ가 자동환불(Auto-Refund)까지 처리하기 때문으로 보이며, 유지보수 시 주의 → lufthansa-pitfalls.
refundCalculate — 환불액 산식 (정밀)
LufthansaClient.refundCalculate(:458-538)는OrderReshopRS의reshopOffers에서 승객별deleteOfferItem을 찾아 다음을 계산해passenger.fare에 채운다:
voidable=dataList.disclosures또는reshopOffers에VOID항목 존재 여부refundFee=penaltyAmount(위약금)expectedRefundAmount=unUsedAirPrice + unUsedTaxusedTax=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/key | searchAvailAncillary(key, adult, child, infant) | pricing → searchSeats + searchExtraBaggages (병렬) |
GET /avail/pnr | searchAvailAncillary(sik, validatingCarrier) | retrieve → searchSeats + searchExtraBaggages (병렬) |
GET /baggage/key | searchExtraBaggages(key, ...) | pricing → searchExtraBaggages |
GET /baggage/pnr | searchExtraBaggages(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으로 병렬(AncillaryServicewithBlocking스코프 내, 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 | 매핑 함수 |
|---|---|---|---|---|
| Search | search | AirShoppingRQ | AirShoppingRS | toFareItineraries |
| Pricing(예약/규정/부가 선행) | pricing / getFareRule | OfferPriceRQ | OfferPriceRS | toPricingInfo / toFareRules |
| Booking | booking | OrderCreateRQ | OrderViewRS | toBooking |
| Retrieve | retrieve / retrieveForAncillary | OrderRetrieveRQ | OrderViewRS | toBooking / (원본 Response) |
| APIS 변경 | changeApis | OrderChangeRQ.ofApis | OrderViewRS | toBooking().passengers |
| Ticketing(발권) | savePayment | OrderChangeRQ.ofPayment | OrderViewRS | toBooking |
| Cancel | cancel | OrderCancelRQ | OrderCancelRS | (반환 0L) |
| 환불계산 | refundCalculate | OrderReshopRQ.ofRefundCalculate | OrderReshopRS | (수기 산식) |
| 재발행 검색 | reissueSearch | OrderReshopRQ.ofReissueSearch | OrderReshopRS | toReshopFareItineraries |
| 재발행 확정 | reissue | OrderChangeRQ.ofReissue | OrderViewRS | toBooking |
| Divide(미지원) | divide | OrderChangeRQ.ofDivide | OrderViewRS | toBooking |
| 좌석 검색 | searchSeats | SeatAvailabilityRQ | SeatAvailabilityRS | toSeatAvailabilityList |
| 수하물 검색 | searchExtraBaggages | ServiceListRQ | ServiceListRS | toExtraBaggages |
| 부가 주문 | bookAncillaries | OrderChangeRQ.ofBookAncillaries | OrderViewRS | (검증만) |
| 부가 결제 | purchaseAncillaries | OrderChangeRQ.ofPurchaseAncillaries | OrderViewRS | toBooking |
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을 부르면 어떤 문제가 생기나?정답 보기
NDC 예약(
OrderCreateRQ)은 검색 결과의 Offer를 그대로 못 쓰고 재가격(OfferPrice)으로 갱신된OfferPriceInfo(responseId/offerId/offerItems/owner) 를 요구한다.pricing없이는OrderCreateRQ.of(...)에 넣을 유효 데이터가 없다. 또 검색 시점과 예약 시점 사이 운임이 바뀌었으면pricing이PRICING_FAILED를 던져 잘못된 가격 예약을 막는다. (LufthansaBookingService.book:53,LufthansaClient.pricing:204)
Q2. 취소(
cancel) 한 번에 외부 NDC 호출이 몇 번 일어나며, 각각 무엇인가?정답 보기
최소 3번이다. ①
OrderRetrieveRQ(retrieve, 예약 조회) → ②OrderReshopRQ.ofRefundCalculate(refundCalculate, 환불/VOID 선계산) → ③OrderCancelRQ(cancel, 실제 취소). 환불액·VOID 여부는 ②에서 이미 산출되고, ③의 반환값은0L로 무력화돼 있어 실제 환불 정보는 ②에서 가져온다. (LufthansaCancelService.kt:13-44,LufthansaClient.cancel:338/refundCalculate:458)
Q3. 재발행(
reissue)이 다른 오퍼레이션과 달리202 ACCEPTED를 돌려주는 이유와, 클라이언트가 결과를 받는 방법은?정답 보기
재발행은 처리 시간이 길어 동기 응답하지 않는다.
LufthansaTicketingController.reissue는polling(key="REISSUE::LUFTHANSA_{pnr}") { ticketingService.reissue(...) }로 작업을 백그라운드 코루틴에 띄우고 Redis에PENDING을 박은 뒤 즉시 폴링 키를202 ACCEPTED로 반환한다. 클라이언트는GET /ticketing/addition/{reissueKey}(checkReissue)로poller(...)를 통해PENDING/COMPLETE/ERROR를 폴링한다. ERROR면 저장된throwable을 그대로 throw한다. (LufthansaTicketingController.kt:68-103,support/util/PollingUtils.kt)
Q4. FareRule 조회에 전용 NDC 메시지가 없는데, 그러면 무엇으로 운임 규정을 가져오나? SOLD_OUT은 어떻게 감지되나?
정답 보기
가격조회 메시지(
OfferPriceRQ)를 재사용한다.LufthansaClient.getFareRule은pricing과 동일한OfferPriceRQ.of(...)를 보내고OfferPriceRS.toFareRules()로 규정을 추출한다(NDC는 규정을 Offer 부속으로 내려줌). SOLD_OUT은 응답 메시지에"No fares found for booking class"가 포함될 때StatusInvalidException(SOLD_OUT)으로 감지되고, 서비스가 이를 잡아 검색키 삭제 + 미노출 운임 등록을 코루틴으로 수행한다. (LufthansaClient.getFareRule:156,LufthansaFareRuleService.kt:44-50)
더 보기
- 공급사 개요·중요도·핵심 파일표 → lufthansa-overview
- NDC/EDIST/SOAP 봉투·
OrderChangeRQ다목적 팩토리 디테일 → lufthansa-protocol - divide 미지원·cancel 반환 무력화·구매 미연결·재발행 차액 TODO 등 함정 → lufthansa-pitfalls
- 11개 모듈 공통 오퍼레이션 비교 → common-operations · 요청 한 바퀴 → request-flow
- 코루틴/폴링/보상 트랜잭션 공통 메커니즘 → async-coroutines
- 누가 누구를 부르는지 전체 지도 → caller-callee-map