Korean Air — 오퍼레이션 흐름

module-koreanair api-ndc pattern-caller-callee pattern-async

한 줄 요약

Korean Air 모듈은 4개의 컨트롤러(Search/Booking/FareRule/Ticketing)가 7개의 application 서비스(FlightSearch/Booking/FareRule/Ticketing/Cancel/Passenger/Payment)와 1개의 NDC 클라이언트(KoreanairClient) + 1개의 결제 클라이언트(KoreanairPaymentClient)를 조합해 동작한다. 모든 항공 API 호출은 **단일 진입점 KoreanairClient**를 거치며, 그 클라이언트의 11개 메서드가 6종의 NDC 메시지(AirShopping/OfferPrice/OrderCreate/OrderRetrieve/OrderChange/OrderReshop)로 매핑된다. 가장 복잡한 플로우는 발권(issue)(결제→발권→실패 시 보상취소), 재발행(reissue)(비동기 폴링 + repricing 검증), 취소(cancel)(미발권 VOID vs 발권 REFUND 분기)다.

관련 노트: 개요 · 전문 구조 · 주의점 · 공통 오퍼레이션 · 요청 흐름 · 콜러-콜리 맵 · 코루틴


0. 전체 레이어 맵 — 누가 누구를 부르는가

중앙 디스패처가 없다

Triple 예약 시스템은 POST /internals/KOREANAIR/... 형태의 공급사 전용 내부 REST를 직접 호출한다. 분기 라우터가 없으므로, 신입은 “어떤 URL이 어떤 컨트롤러 메서드로 가는가”를 컨트롤러의 @RequestMapping/@PostMapping 경로로 직접 읽어야 한다. 자세한 라우팅 규칙은 request-flow 참고.

flowchart TD
  Triple["Triple 예약 시스템 (내부 호출자)"]
  Triple -->|"HTTP"| SearchC["KoreanairSearchController"]
  Triple -->|"HTTP"| BookingC["KoreanairBookingController"]
  Triple -->|"HTTP"| FareRuleC["KoreanairFareRuleController"]
  Triple -->|"HTTP"| TicketingC["KoreanairTicketingController"]

  SearchC --> FS1["FlightSearchService"]
  BookingC --> BookS["BookingService"]
  BookingC --> PassS["PassengerService"]
  BookingC --> CancS["CancelService"]
  FareRuleC --> FrS["FareRuleService"]
  FareRuleC --> FS2["FlightSearchService"]
  TicketingC --> TkS["TicketingService"]
  TkS --> CancS
  TkS --> PayS["PaymentService"]

  FS1 --> KC
  BookS --> KC
  PassS --> KC
  CancS --> KC
  FrS --> KC
  FS2 --> KC

  subgraph KC ["KoreanairClient (NDC/SOAP) — 11개 메서드"]
    KCmethods["search / pricing / book / retrieve / issue<br/>reissueSearch / reissue / refundCalculate<br/>issuedCancel / unissuedCancel / changeContactInfo / splitPnr"]
  end

  PayS --> KPC["KoreanairPaymentClient<br/>(NicePay TCP+SEED)"]

  KC -->|"SOAP(NDC) over HTTP"| KE["대한항공 NDC 엔드포인트 (KE)"]
  KPC -->|"raw TCP"| NicePay["NicePay 결제 게이트웨이"]
컨트롤러파일주입받는 서비스
KoreanairSearchControllerinterfaces/controller/internals/KoreanairSearchController.ktKoreanairFlightSearchService
KoreanairBookingControllerinterfaces/controller/internals/KoreanairBookingController.ktKoreanairBookingService, KoreanairPassengerService, KoreanairCancelService
KoreanairFareRuleControllerinterfaces/controller/internals/KoreanairFareRuleController.ktKoreanairFareRuleService, KoreanairFlightSearchService
KoreanairTicketingControllerinterfaces/controller/internals/KoreanairTicketingController.ktKoreanairTicketingService, RedisTemplate<String, Any>

KoreanairClient는 11개 public 메서드를 가진 단일 진입점

infrastructure/KoreanairClient.kt의 메서드를 NDC 메시지 기준으로 정리하면 다음과 같다. 모든 메서드가 동일한 SOAP 봉투 빌더(soapRequestBodyConverter, KoreanairClient.kt:549)와 동일한 SOAP 디시리얼라이저(soapBodyDeserializerOf, KoreanairClient.kt:56)를 공유한다.

KoreanairClient 메서드NDC 메시지(RQ→RS)사용처 서비스
search (:95)AirShoppingRQAirShoppingRSFlightSearch
pricing (:162)OfferPriceRQOfferPriceRSBooking, FareRule
book (:202)OrderCreateRQOrderViewRSBooking
retrieve (:239)OrderRetrieveRQOrderViewRSBooking, Ticketing, Cancel, Passenger, FlightSearch
issue (:274)OrderChangeRQ.ofIssueOrderViewRSTicketing
unissuedCancel (:304)OrderChangeRQ.ofUnIssuedCancelOrderViewRSCancel
issuedCancel (:324)OrderChangeRQ.ofIssuedCancelOrderViewRSCancel
refundCalculate (:353)OrderReshopRQ.ofRefundCalculateOrderReshopRSCancel
changeContactInfo (:387)OrderChangeRQ.ofContactInfoOrderViewRSPassenger
splitPnr (:420)OrderChangeRQ.ofSplitOrderViewRSBooking
reissueSearch (:467)OrderReshopRQ.ofReissueSearchOrderReshopRSFlightSearch
reissue (:522)OrderChangeRQ.ofReissueOrderViewRSTicketing

OrderChange가 다재다능

발권/취소/연락처변경/분리(divide)/재발행이 모두 동일한 IATA_OrderChangeRQ 메시지(action 값 동일, OrderChangeRQ.kt:34)를 쓰며, 차이는 OrderChangeRQ.of* 팩토리(ofIssue/ofIssuedCancel/ofUnIssuedCancel/ofContactInfo/ofReissue/ofSplit, OrderChangeRQ.kt:37~132)로 만드는 본문 내용뿐이다. 클라이언트는 이들을 공통 헬퍼 orderChange()(KoreanairClient.kt:449)로 전송한다. 전문 차이는 koreanair-protocol 참고.


1. Search — 항공편 검색

1-1. 콜러→콜리 체인

flowchart TD
  EP["POST /internals/KOREANAIR/search"] --> Ctrl["KoreanairSearchController.search (:31)<br/>@CircuitBreaker koreanairSearch<br/>generateSearchRequestKey(KOREANAIR, request)"]
  Ctrl --> Svc["KoreanairFlightSearchService.search (:43)<br/>1) findKey(requestKey)"]
  Svc -->|"캐시 HIT"| Hit["getFareItineraries(key) 즉시 반환"]
  Svc -->|"MISS"| Coro["withBlocking(Dispatchers.IO) 코루틴 진입<br/>makeOriginDestinations(...).cartesianProduct()<br/>pmap OD조합별 병렬 호출<br/>onFailure 전부 실패 시 SEARCH_FAILED throw<br/>flatten.distinctBy.filter.withScore.sortedByDescending<br/>take(ratio.total 또는 30)<br/>3) 캐시 저장(useCache 그리고 비어있지 않음)"]
  Coro --> Client["KoreanairClient.search (:95)<br/>AirShoppingRQ.of → SOAP 전송 → AirShoppingRS<br/>checkError 325/719 무시, 그 외 SEARCH_FAILED<br/>airShoppingRS.toFareItineraries(...)"]
  Client --> Resp["응답: List FareItineraryView<br/>(FareItineraryView.of, 컨트롤러 :45)"]

Search만 서킷브레이커로 보호된다

모듈 전체에서 Resilience4j 애너테이션은 딱 하나KoreanairSearchController.search@CircuitBreaker(name = "koreanairSearch", fallbackMethod = "searchFallback")(KoreanairSearchController.kt:28)뿐이다. @Retry/@Bulkhead/@RateLimiter는 이 모듈에 없다.

  • 설정: application.ymlresilience4j.circuitbreaker.instances.koreanairSearchbaseConfig: search를 상속(application.yml:67) → 슬라이딩윈도 180초/TIME_BASED, 최소호출 30, OPEN 대기 120초, 실패율 35%.
  • OPEN 시 fallback: searchFallback(CallNotPermittedException)(KoreanairSearchController.kt:122)이 빈 리스트를 반환하고 Datadog span에 supplier.circuit-breaker=OPEN 태그를 찍는다. 즉 검색 폭주 시 예외 대신 “결과 없음”으로 graceful degrade. 상세는 resilience-and-events.

pmap은 부분 성공을 허용한다

cartesianProduct()로 만든 OD(출발-도착) 조합을 pmap(코루틴 병렬 map, CoroutineExtensions.kt:36)으로 동시에 호출한 뒤 .onFailure { exceptions, successes -> if (successes.isEmpty()) throw ... }(KoreanairFlightSearchService.kt:73)로 처리한다. 하나라도 성공하면 예외를 던지지 않고 성공분만 모은다. 멀티-시티/복수 공항(SEL→{ICN,GMP}) 검색에서 일부 조합만 결과가 와도 응답이 나가는 이유. 코루틴 실패 흡수 동작은 async-coroutines 참고.

경유지 국내공항 필터 — withoutDomesticAirportInViaRoute

KoreanairFlightSearchService.kt:124. 출발지가 국내공항(domesticAirports 16개 하드코딩 셋, :36)이면, 경유 구간(최초 출발/최종 도착을 제외한 모든 leg)에 국내공항이 끼면 그 여정을 버린다. 국내선 경유로 위장된 국제선 운임을 걸러내는 비즈니스 룰.

1-2. detail / structured fare rule

  • GET /internals/KOREANAIR/searchdetail(SearchDetailRequest)(:50): 캐시에서 getFareItinerary(key)validate(adult,child,infant)로 좌석 수 검증.
  • GET /internals/KOREANAIR/fare-rules/structuredKoreanairFareRuleController.getStructuredFareRules(:31): 별도 API 호출 없이 캐시된 FareItinerary 하나를 StructuredFareRuleView.of로 변환.

2. FareRule — 운임 규정 조회

2-1. 콜러→콜리 체인

flowchart TD
  EP["GET /internals/KOREANAIR/fare-rules?key,adult,child,infant"] --> Ctrl["KoreanairFareRuleController.getFareRules (:18)"]
  Ctrl --> Svc["KoreanairFareRuleService.findFareRules (:17)<br/>fareRuleKey = generateFareRuleKey(key,adult,child,infant)<br/>savedFareRules = findFareRules(fareRuleKey)"]
  Svc -->|"HIT"| Hit["그대로 반환"]
  Svc -->|"MISS"| Miss["getFareItinerary(key) 캐시된 여정<br/>airportMap = 여정 leg 공항 IATA → cityClient.getAirportByIata<br/>koreanairClient.pricing(adult,child,infant, fareItinerary, airportMap)<br/>→ OfferPriceRS.toPricingInfo(...).fareRules<br/>saveFareRules(fareRuleKey, ...)"]
  Hit --> Resp["List FareRuleView (FareRuleView.of)"]
  Miss --> Resp

FareRule은 별도 API가 없다 — Pricing의 부산물

Korean Air는 운임 규정을 독립 메시지로 주지 않는다. findFareRulesOfferPriceRQ(가격 재산출) 응답에 딸려오는 fareRules를 추출(KoreanairFareRuleService.kt:42~52)한다. 즉 FareRule 호출은 내부적으로 Pricing 호출과 완전히 동일한 외부 API(KoreanairClient.pricing, :162)다. 그래서 예약 시 Pricing과 FareRule 조회가 별개로 일어나면 같은 OfferPrice를 두 번 부르게 된다. 캐시(fareRuleRepository)가 그걸 막아준다.


3. Booking — 예약 생성 및 변경

3-1. 예약 생성 (book)

flowchart TD
  EP["POST /internals/KOREANAIR/bookings"] --> Ctrl["KoreanairBookingController.create (:24)<br/>ReservationUser.of(request), passengers.map Passenger.of"]
  Ctrl --> Svc["KoreanairBookingService.book (:39)<br/>1) adult/child/infant 카운트 (PassengerTypeCode 기준)<br/>2) fareItinerary = getFareItinerary(detailKey) 캐시 필수"]
  Svc --> Reprice["3) offerPriceInfo = pricing(adult,child,infant, fareItinerary)<br/>예약 직전 재가격<br/>koreanairClient.pricing(...) → OfferPriceRS → OfferPriceInfo"]
  Reprice --> Book["4) koreanairClient.book(reservationUser, passengers, offerPriceInfo)<br/>OrderCreateRQ.of(...) → OrderViewRS.toBooking()"]
  Book --> Rm["5) removeFlightSearchKey(fareItinerary.requestKey)<br/>비동기(launch) 캐시키 제거"]
  Rm --> Resp["BookingView.of(Booking)"]

예약 직전 재가격(repricing) — pricing 호출이 끼어있다

book()은 검색 결과(FareItinerary)를 그대로 예약하지 않고, OrderCreate 직전에 반드시 OfferPrice(pricing)를 한 번 더 호출(KoreanairBookingService.kt:52)해 최신 OfferPriceInfo를 받아 그걸로 예약한다. NDC의 Offer가 시간이 지나면 만료/가격변동하기 때문이다. 이 단계에서 가격이 바뀌거나 Offer가 죽으면 PRICING_FAILED로 예약 자체가 막힌다. repricing 일반론은 common-operations 참고.

book 성공 후 검색키를 비동기로 지운다

removeFlightSearchKey(KoreanairBookingService.kt:96)는 CoroutineScope(Dispatchers.IO).withLaunch { flightSearchKeyRepository.removeKey(key) }. fire-and-forget이라 키 삭제 실패가 예약 응답을 막지 않는다. withLaunchSupervisorJob+AdapterCoroutineExceptionHandler가 붙은 안전 launch( async-coroutines ).

3-2. APIS(여권/연락처) 변경 — changeApis

flowchart TD
  EP["PUT /internals/KOREANAIR/bookings/{pnr}"] --> Ctrl["KoreanairBookingController.changeApis (:38)"]
  Ctrl --> Svc["KoreanairPassengerService.changeApis (:11)<br/>booking = koreanairClient.retrieve(key) 현재 예약 조회<br/>changePassengers = requestPassengers.filter needChange(요청,원본)<br/>needChange: passport/stayInfo/mobile/email 중 하나라도 다르면 true (:40)"]
  Svc -->|"변경대상 있음"| Chg["changePassengers.map koreanairClient.changeContactInfo(...)<br/>승객별 개별 순차 호출"]
  Svc -->|"변경대상 없음"| NoChg["요청 그대로 반환 (외부 호출 없음)"]
  Chg --> Resp["BookingChangeView(passengers = PassengerApisChangeView.of)"]
  NoChg --> Resp

변경 대상별로 OrderChange를 N번 부른다

changeApis는 바뀐 승객 한 명마다 koreanairClient.changeContactInfo순차적으로 따로 호출(KoreanairPassengerService.kt:26)한다. 승객 3명이 동시에 여권을 바꾸면 OrderChange 3회. 중간에 한 호출이 실패하면 앞선 변경은 이미 반영된 채 예외가 나가므로 부분 적용 상태가 될 수 있다(보상 로직 없음). koreanair-pitfalls에 정리.

3-3. PNR 분리 — splitPnr (divide)

flowchart TD
  EP["POST /internals/KOREANAIR/bookings/{pnr}/divide"] --> Ctrl["KoreanairBookingController.divide (:110)<br/>passengers.map PassengerIdentification.of"]
  Ctrl --> Svc["KoreanairBookingService.splitPnr (:82)<br/>originBooking = koreanairClient.retrieve(orderId)"]
  Svc --> Val["validate(요청승객, 원본승객) 복잡한 사전검증"]
  Val --> Split["koreanairClient.splitPnr(originBooking,<br/>splitPassengerId = 요청승객 중 INFANT 아닌 첫 승객의 identificationKey)<br/>OrderChangeRQ.ofSplit → OrderViewRS.toBooking()"]
  Split --> Ret["koreanairClient.retrieve(분리된 새 supplierIdentificationKey)<br/>.copy(pnrCreatedAt = lastModifiedAt ?: pnrCreatedAt)"]
  Ret --> Resp["BookingView.of(Booking)"]

divide의 validate() — 유아-성인 페어링 규칙

KoreanairBookingService.validate(KoreanairBookingService.kt:102)가 던지는 DIVIDE_FAILED 케이스:

  • 분리 승객이 2명이면 반드시 (성인 1 + 유아 1) 조합이어야 한다(:103). 그 외 2명 조합은 거부.
  • 분리 승객이 2명 초과면 무조건 거부(:112).
  • 요청 승객이 원본 예약에 존재하지 않으면 거부(:120).
  • 성인을 분리하는데 그 성인에 묶인 유아(infantIdentificationKey)가 분리 목록에 없으면 거부, 반대로 유아만 떼려 해도 짝 성인이 없으면 거부(:130~).

핵심: 유아는 부모 성인을 자동으로 따라간다. 그래서 splitPassengerId로 INFANT가 아닌 승객을 넘긴다 — 코드 주석: “유아는 부모 탑승객 자동으로 따라감(유아 승객 RQ 생성시 오류 발생)“(KoreanairBookingService.kt:88).

3-4. 조회 — retrieve / confirm

GET /{pnr}(retrieve, :99)와 GET /{pnr}/confirm(confirm, :89)은 둘 다 bookingService.retrieve(orderId = supplierIdentificationKey)로 동일하게 KoreanairClient.retrieve(:239)를 호출한다.

retrieve는 취소된 PNR을 막는다

KoreanairClient.retrieve(:258)는 응답의 orders가 비었거나 statusCode == "CLOSED"ALREADY_CANCELED_PNR을 던진다. retrieve가 거의 모든 후속 오퍼레이션(발권/취소/변경)의 첫 단계이므로, 이 가드가 죽은 PNR에 대한 작업을 입구에서 차단한다.


4. Ticketing(Issue) — 발권

4-1. 콜러→콜리 체인 (가장 보상 로직이 많은 플로우)

flowchart TD
  EP["POST /internals/KOREANAIR/ticketing"] --> Ctrl["KoreanairTicketingController.issue (:29)<br/>cardInfo = paymentInfo?.let PaymentInfo.ofKeyInCard"]
  Ctrl --> Svc["KoreanairTicketingService.issue (:32) try 블록 시작"]
  Svc --> S1["① booking = koreanairClient.retrieve(key)<br/>스케줄 null이거나 CONFIRMED 아니면 → NOT_OK_SCHEDULE (StatusInvalidException)<br/>carrierPnr 비면 → TICKETING_FAILED"]
  S1 --> S2["② payment = if(!prepayment 그리고 cardPrice합 0 초과)<br/>paymentService.approve(cardPrice, cardInfo!!, pnr)<br/>카드 승인 먼저"]
  S2 --> S3["③ booking = koreanairClient.issue(booking, passengerPrices, payment,<br/>timeoutCallback = slackService.sendTicketingTimeout)<br/>OrderChangeRQ.ofIssue → OrderViewRS.toBooking()"]
  S3 -->|"성공"| Ret["return Pair(booking, payment)"]
  Ret --> Resp["TicketingView(passengers = TicketingPassengerView.of)"]
  S1 -.->|"예외 e"| Catch
  S2 -.->|"예외 e"| Catch
  S3 -.->|"예외 e"| Catch["catch(e) 보상 트랜잭션<br/>payment?.run paymentCancelAsync(payment, pnr) — 5초 후 결제 취소(launch)<br/>cancelAsync(key, validatingCarrier) — 10초 후 예약 취소(launch)<br/>throw e"]

발권은 "선결제 → 발권 → 실패 시 보상취소" 사가(SAGA) 패턴이다

결제(NicePay 카드 승인)가 발권보다 먼저 일어난다(KoreanairTicketingService.kt:63). 발권이 실패하면 catch 블록(:89)에서:

  1. paymentCancelAsync(payment, pnr)5초 지연 후 결제 취소(:125). 취소 실패 시 slackService.sendPaymentCancelFail로 슬랙 경보, 그 후 throw.
  2. cancelAsync(key, validatingCarrier)10초 지연 후 예약 취소(:144) → cancelService.cancel(...).

둘 다 CoroutineScope(Dispatchers.IO).withLaunch { delay(...) } 비동기 fire-and-forget이고, 원래 예외 e는 그대로 호출자에게 던져진다. 즉 사용자에겐 즉시 실패를 알리되, 결제·예약 정리는 백그라운드로 진행한다. 결제 실패/지연은 메시지큐가 아니라 Slack 경보로 전파( resilience-and-events ). delay 시간차(5초 vs 10초)에 주목 — 결제 취소가 예약 취소보다 먼저 실행되도록 의도된 순서.

prepayment 분기

prepayment == true면 카드 승인(approve)을 건너뛴다(takeIf { !prepayment }, KoreanairTicketingService.kt:64). 선결제(예: 예치금/별도 결제) 케이스에서는 NicePay를 타지 않고 바로 koreanairClient.issue만 호출한다.

4-2. 결제 — KoreanairPaymentClient (NicePay, raw TCP + SEED)

flowchart TD
  Svc["KoreanairPaymentService.approve (:15)"] --> Client["KoreanairPaymentClient.approve (:33)<br/>NicePayApproveRequest.of(...)"]
  Client --> Send["send REQ,RES (:83)<br/>Socket(soTimeout=5000).connect(host,port)<br/>serializeToLiteralTextByByte(req, seedKey, iv, charset EUC-KR) — SEED 암호화<br/>write → read(버퍼 1024, CR 만나면 종료)<br/>deserializeOfLiteralTextByByte RES (EUC-KR)"]
  Send --> Chk["response.checkError PAYMENT_ETC throw"]
  Send -.->|"SocketTimeoutException"| Timeout["timeoutCallback(txNo, msg)<br/>→ slackService.sendPaymentFail"]

결제는 HTTP가 아니라 raw TCP 소켓이다

KoreanairPaymentClient(infrastructure/KoreanairPaymentClient.kt)는 KoreanairClient(SOAP/HTTP)와 완전히 별개다. java.net.Socket으로 NicePay에 직접 붙고, EUC-KR 인코딩 + SEED 암호화(serializeToLiteralTextByByte, seedKey/iv 사용)로 전문을 주고받는다. 타임아웃은 5초(:31). 전문 구조는 koreanair-protocol 참고.


5. Reissue(재발행) — 비동기 폴링 + repricing 검증

Korean Air에서 가장 복잡한 플로우

재발행은 ① 재발행용 검색(reissueSearch) ② 재가격 검증(reissueDetail의 음수 운임 차단) ③ 비동기 발행(reissue + 폴링) 세 단계로 나뉜다.

5-1. 재발행 검색 — reissueSearch

flowchart TD
  EP["POST /internals/KOREANAIR/search/reissue"] --> Ctrl["KoreanairSearchController.reissueSearch (:60)<br/>originDestinations = 요청 OD → OriginDestination(AIRPORT 타입, 첫 공항만 사용)"]
  Ctrl --> Svc["KoreanairFlightSearchService.reissueSearch (:141)<br/>booking = koreanairClient.retrieve(supplierIdentificationKey) 원 예약"]
  Svc --> Call["koreanairClient.reissueSearch(key, booking, cabins, OD, ...)<br/>OrderReshopRQ.ofReissueSearch → OrderReshopRS<br/>checkError REISSUE_SEARCH_FAILED + 공급사 메시지 합성<br/>OrderReshopResponse.toFareItineraries(seatCount = 유아제외 승객수)"]
  Call --> Filter["filter !fareItinerary.isSameSchedule(booking) — 기존과 동일 스케줄 제거<br/>캐시 저장(useCache)"]
  Filter --> Resp["List FareItineraryView"]

5-2. 재발행 상세/가격 검증 — reissueDetail (★ 음수 운임 차단)

flowchart TD
  EP["GET /internals/KOREANAIR/search/reissue?key"] --> Ctrl["KoreanairSearchController.reissueDetail (:83)<br/>fareItinerary = flightSearchService.getFareItinerary(key)"]
  Ctrl --> Guard{"passengerFares.any airPrice 0 미만 또는 tax 0 미만?<br/>repricing 가드"}
  Guard -->|"예"| Throw["throw REISSUE_NON_CHANGEABLE_FARE_SCHEDULE<br/>메시지: 결제 금액이 감소하여 재발행이 불가합니다 (승객별 금액 동봉)"]
  Guard -->|"아니오"| Resp["FareItineraryDetailView.of"]

재발행 repricing 가드 — 결제 금액 감소는 거부

reissueDetail(KoreanairSearchController.kt:83)은 재발행 후보 여정의 승객 운임에 **음수(airPrice<0 또는 tax<0)**가 있으면 REISSUE_NON_CHANGEABLE_FARE_SCHEDULE을 던진다. 이는 “변경 후 결제 금액이 줄어드는(=환불이 발생하는) 재발행”을 어댑터 차원에서 막는 것 — 그런 케이스는 “항공사 사이트에서 직접 진행”하라는 안내 메시지를 사용자에게 보낸다(:100~). repricing/재발행 일반론은 common-operations 참고.

5-3. 재발행 발행 — reissue (비동기 Deferred 폴링)

flowchart TD
  EP1["POST /internals/KOREANAIR/ticketing/addition<br/>HTTP 202 ACCEPTED"] --> Ctrl1["KoreanairTicketingController.reissue (:54)<br/>pollingKey = polling( — PollingUtils.polling<br/>key = REISSUE KOREANAIR_{pnr}, ttl = CacheSet.REISSUE.ttl, redisTemplate)<br/>블록: ticketingService.reissue(detailKey, supplierIdentificationKey) 백그라운드 실행"]
  Ctrl1 --> Resp1["DeferredKeyView(pollingKey) — 키만 즉시 반환"]

  EP2["GET /internals/KOREANAIR/ticketing/addition/{reissueKey}"] --> Ctrl2["KoreanairTicketingController.checkReissue (:69)<br/>poller ReissueResult Booking,Passenger (reissueKey, redisTemplate)"]
  Ctrl2 --> St{"when(status)"}
  St -->|"PENDING"| P["DeferredView.Pending"]
  St -->|"ERROR"| E["throw"]
  St -->|"COMPLETE"| C["ReticketingView"]
sequenceDiagram
  participant Caller as "호출자"
  participant Ctrl as "Controller"
  participant Redis as "Redis (Deferred)"
  participant Coro as "코루틴 (launch)"
  Caller->>Ctrl: "POST .../addition"
  Ctrl->>Redis: "polling(): set PENDING"
  Ctrl->>Coro: "launch reissue()"
  Ctrl-->>Caller: "202 + DeferredKey"
  Coro->>Coro: "ticketingService.reissue(...)"
  Coro->>Redis: "set COMPLETE/ERROR"
  Caller->>Ctrl: "GET .../addition/{k}"
  Ctrl->>Redis: "poller(): get"
  Ctrl-->>Caller: "Pending 또는 Complete"
flowchart TD
  Svc["KoreanairTicketingService.reissue (:99)<br/>fareItinerary = fareItineraryRepository.getFareItinerary(key)<br/>booking = koreanairClient.retrieve(supplierIdentificationKey)"] --> Reissue["reissuedBooking = koreanairClient.reissue(booking, fareItinerary, payment=null) — TODO 카드결제<br/>OrderChangeRQ.ofReissue → OrderViewRS.toBooking() (실패 시 TICKETING_FAILED)"]
  Reissue --> Wait["withBlocking delay(5000); koreanairClient.retrieve(reissued.key) — 수하물 누락 회피"]
  Wait --> Resp["return ReissueResult(booking = reissuedBooking, passengers = reissuedBooking.passengers)"]

재발행은 결제 미구현 + 5초 강제 대기 두 가지 함정

  1. 결제 미구현: koreanairClient.reissue(... payment = null /* TODO: 카드 결제 기능 추가 필요 */)(KoreanairTicketingService.kt:110). 현재 재발행은 추가 결제 없이 진행되는 경로만 구현되어 있다. (그래서 5-2의 음수운임 차단이 더 중요해진다 — 결제 정산을 어댑터가 못 한다.)
  2. 5초 강제 대기: 재발행 직후 withBlocking { delay(5000); retrieve(...) }(:114). 주석: “재발행 이후 수하물 정보 누락 이슈로 5초 대기 후 retrieve”. 단, withBlocking의 결과로는 reissuedBooking을 그대로 쓰고 retrieve 결과는 버린다(:116~120) — delay만이 목적인 미묘한 코드. 두 함정 모두 koreanair-pitfalls에 등록.

왜 동기가 아니라 폴링인가

재발행은 retrieve→reissue→5초 대기→retrieve로 시간이 길고, 클라이언트 타임아웃을 넘길 수 있다. 그래서 polling(PollingUtils.kt:12)이 Redis에 ADAPTER-DEFERRED-RESULT::... 키로 PENDING을 박고 코루틴(withLaunch)으로 실제 작업을 돌린 뒤, 호출자는 poller(PollingUtils.kt:43)로 폴링해 COMPLETE/ERROR를 받는다. 동일 Deferred 폴링 패턴은 async-coroutines에서 설명.


6. Cancel — 취소 (VOID vs REFUND 분기)

컨트롤러 위치 주의

취소는 Ticketing이 아니라 KoreanairBookingController(:52~87)에 있다. KoreanairCancelService는 BookingController와 TicketingService 양쪽에서 쓰인다.

6-1. 취소 가능 여부/예상 비용 — cancelable / expectedCancel

flowchart TD
  EP["GET /{pnr}/cancelable 또는 /{pnr}/expected-cancel"] --> Svc["KoreanairCancelService.cancelable (:53)<br/>booking = koreanairClient.retrieve(key)"]
  Svc --> Calc["detail = koreanairClient.refundCalculate(booking)<br/>OrderReshopRQ.ofRefundCalculate → OrderReshopRS.toCancelableTypeDetail(booking)"]
  Calc --> Cache{"offerTimeLimit - 1분 > now?"}
  Cache -->|"예"| Save["koreanairCancelableTypeDetailRepository.save(key=supKey+pnr, detail, ttl=diff)"]
  Cache -->|"아니오"| Resp
  Save --> Resp["CancelableTypeDetailView.of 또는 ExpectedCancelView.of"]

refundCalculate가 환불액을 어떻게 계산하나 — toCancelableTypeDetail

OrderReshopRS.toCancelableTypeDetail(OrderReshopRS.kt:41):

  1. deleteOrderItemspriceDifferential.differentialTypeCode가 전부 VOIDCancelActionType.VOID, 전부 REFUNDREFUND, 섞이면 CALCULATE_CANCEL_FEE_FAILED(:44~55).
  2. REFUND이면 승객별로 환불 상세 산출(:61~95):
    • usedTax = 원본Tax − 미사용Tax
    • unUsedAirPrice = differencePrice.dueByAirlineAmount − 미사용Tax
    • refundFee(위약금) = penaltyIds 합산(dataList.penaltyMap 조회)
    • expectedRefundAmount = 미사용항공운임 + 미사용Tax
    • usedAirPrice = 원본항공운임 − 미사용항공운임 − 위약금
  3. offerIdofferTimeLimit(Asia/Seoul로 변환) 추출 — 이 offerId가 실제 취소(issuedCancel)에 필수.

offer 만료 전까지 환불계산 결과를 캐싱한다

cancelableofferTimeLimit - 1분이 미래일 때만 결과를 Redis에 저장(KoreanairCancelService.kt:56)한다. 키는 supplierIdentificationKey + pnr, TTL은 만료까지 남은 시간. 이렇게 캐싱해 두면 실제 cancel 호출 시 refundCalculate를 다시 안 부르고 캐시된 offerId를 재사용한다(다음 6-2 참고). 캐시 저장소: KoreanairCancelableTypeDetailRepository(CacheSet.KOREANAIR_CANCELABLE_TYPE_DETAIL).

6-2. 실제 취소 — cancel

flowchart TD
  EP["PUT /{pnr}/cancel"] --> Ctrl["KoreanairBookingController.cancel (:52)"]
  Ctrl --> Svc["KoreanairCancelService.cancel (:19)<br/>booking = koreanairClient.retrieve(key)"]
  Svc --> Br{"booking.unissued?"}
  Br -->|"미발권"| Void["koreanairClient.unissuedCancel(orderId, validatingCarrier)<br/>OrderChangeRQ.ofUnIssuedCancel → CANCEL_FAILED 체크<br/>return CancelableTypeDetail(action = VOID)"]
  Br -->|"발권완료"| Refund["detail = repository.find(supKey+pnr) 캐시된 환불계산 재사용<br/>?: koreanairClient.refundCalculate(booking) 없으면 다시 계산<br/>koreanairClient.issuedCancel(orderId, validatingCarrier,<br/>offerId = detail.offerId!!,<br/>timeoutCallback = slackService.sendCancelFailTimeout)<br/>OrderChangeRQ.ofIssuedCancel → CANCEL_FAILED 체크<br/>return detail"]
  Void --> Resp["CancelView.of"]
  Refund --> Resp

미발권/발권 분기 — VOID vs REFUND

KoreanairCancelService.cancel(KoreanairCancelService.kt:19)의 핵심 분기는 booking.unissued다.

  • 미발권(unissued)unissuedCancel(VOID). 결제 위약금 없이 즉시 무효화. OrderReshop(환불계산) 호출조차 안 한다.
  • 발권완료refundCalculate로 받은(혹은 6-1에서 캐시된) offerId를 가지고 issuedCancel(REFUND). offerId가 없으면 detail.offerId!!에서 NPE → 사실상 사전 calculate가 선행되어야 함. 취소 타임아웃은 예외가 아니라 slackService.sendCancelFailTimeout 슬랙 경보로 알린다(:41).

7. 비동기/코루틴 사용 위치 총정리

위치메커니즘목적파일:라인
검색 OD 병렬 호출withBlocking(Dispatchers.IO) + pmap멀티-OD 동시 검색, 부분성공 허용KoreanairFlightSearchService.kt:58~78
예약 후 검색키 제거CoroutineScope(IO).withLaunchfire-and-forget 캐시 정리KoreanairBookingService.kt:96
발권 실패 시 결제취소CoroutineScope(IO).withLaunch { delay(5000) }보상 트랜잭션(결제)KoreanairTicketingService.kt:125
발권 실패 시 예약취소CoroutineScope(IO).withLaunch { delay(10000) }보상 트랜잭션(예약)KoreanairTicketingService.kt:144
재발행 비동기 실행polling{...} (내부 withLaunch) + poller장시간 작업 Deferred 처리KoreanairTicketingController.kt:55, PollingUtils.kt:21
재발행 후 5초 대기withBlocking { delay(5000) }수하물 누락 회피KoreanairTicketingService.kt:114

withBlocking / withLaunch / pmap 한눈에

모두 support/util/CoroutineExtensions.kt에 있다. 공통적으로 SupervisorJob() + AdapterCoroutineExceptionHandler() + MDCContext()를 합성한다 — 즉 자식 코루틴 실패가 형제에 전파되지 않고(Supervisor), 예외는 핸들러가 잡고, 로깅 MDC가 코루틴 경계를 넘어 유지된다. 자세한 내용은 async-coroutines.


8. 오퍼레이션 → URL → 외부 메시지 빠른 참조

오퍼레이션HTTP 메서드/경로컨트롤러 메서드외부 NDC 메시지
검색POST /internals/KOREANAIR/searchsearchAirShoppingRQ
검색 상세GET /internals/KOREANAIR/searchdetail(캐시)
운임규정GET /internals/KOREANAIR/fare-rulesgetFareRulesOfferPriceRQ
구조화 운임규정GET .../fare-rules/structuredgetStructuredFareRules(캐시)
예약 생성POST /internals/KOREANAIR/bookingscreateOfferPriceRQ+OrderCreateRQ
예약 조회GET .../bookings/{pnr}retrieve/confirmOrderRetrieveRQ
APIS 변경PUT .../bookings/{pnr}changeApisOrderRetrieveRQ+OrderChangeRQ(ofContactInfo)
PNR 분리POST .../bookings/{pnr}/dividedivideOrderRetrieveRQ+OrderChangeRQ(ofSplit)+OrderRetrieveRQ
취소가능/예상GET .../bookings/{pnr}/cancelable(또는 expected-cancel)cancelable/expectedCancelOrderRetrieveRQ+OrderReshopRQ(refundCalculate)
취소PUT .../bookings/{pnr}/cancelcancelOrderRetrieveRQ+OrderChangeRQ(ofUnIssuedCancel 또는 ofIssuedCancel)
발권POST /internals/KOREANAIR/ticketingissueOrderRetrieveRQ+NicePay+OrderChangeRQ(ofIssue)
재발행 검색POST .../search/reissuereissueSearchOrderRetrieveRQ+OrderReshopRQ(ofReissueSearch)
재발행 상세GET .../search/reissuereissueDetail(캐시 + 음수운임 가드)
재발행 발행POST .../ticketing/additionreissueOrderRetrieveRQ+OrderChangeRQ(ofReissue)+OrderRetrieveRQ
재발행 결과조회GET .../ticketing/addition/{key}checkReissue(Redis Deferred)

9. 핵심 정리

이 노트에서 꼭 가져갈 것

  • 모든 항공 API는 KoreanairClient 단일 진입점을 거치고, 발권/취소/변경/분리/재발행은 전부 OrderChangeRQ 한 메시지의 변주다.
  • **예약(book)과 재발행(reissue) 전에는 항상 재가격(OfferPrice/Reshop)**이 끼며, 재발행은 음수 운임(결제금액 감소)을 어댑터에서 차단한다.
  • 발권은 선결제→발권→실패 시 비동기 보상취소(결제 5초·예약 10초 지연) 사가 패턴이다.
  • 재발행만 비동기 Deferred 폴링(202 + key, 이후 폴링)으로 처리되고, 내부에 결제 미구현(TODO)·5초 강제대기 함정이 있다.
  • 취소는 미발권 VOID vs 발권 REFUND로 분기하고, 환불계산 결과를 offer 만료 전까지 Redis에 캐싱해 재사용한다.
  • 서킷브레이커는 검색 한 곳뿐, 나머지 신뢰성은 예외 전파 + Slack 경보로 처리된다( resilience-and-events ).

관련 노트: koreanair-overview · koreanair-protocol · koreanair-pitfalls · common-operations · request-flow · caller-callee-map · async-coroutines