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 결제 게이트웨이"]
| 컨트롤러 | 파일 | 주입받는 서비스 |
|---|---|---|
KoreanairSearchController | interfaces/controller/internals/KoreanairSearchController.kt | KoreanairFlightSearchService |
KoreanairBookingController | interfaces/controller/internals/KoreanairBookingController.kt | KoreanairBookingService, KoreanairPassengerService, KoreanairCancelService |
KoreanairFareRuleController | interfaces/controller/internals/KoreanairFareRuleController.kt | KoreanairFareRuleService, KoreanairFlightSearchService |
KoreanairTicketingController | interfaces/controller/internals/KoreanairTicketingController.kt | KoreanairTicketingService, 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) | AirShoppingRQ → AirShoppingRS | FlightSearch |
pricing (:162) | OfferPriceRQ → OfferPriceRS | Booking, FareRule |
book (:202) | OrderCreateRQ → OrderViewRS | Booking |
retrieve (:239) | OrderRetrieveRQ → OrderViewRS | Booking, Ticketing, Cancel, Passenger, FlightSearch |
issue (:274) | OrderChangeRQ.ofIssue → OrderViewRS | Ticketing |
unissuedCancel (:304) | OrderChangeRQ.ofUnIssuedCancel → OrderViewRS | Cancel |
issuedCancel (:324) | OrderChangeRQ.ofIssuedCancel → OrderViewRS | Cancel |
refundCalculate (:353) | OrderReshopRQ.ofRefundCalculate → OrderReshopRS | Cancel |
changeContactInfo (:387) | OrderChangeRQ.ofContactInfo → OrderViewRS | Passenger |
splitPnr (:420) | OrderChangeRQ.ofSplit → OrderViewRS | Booking |
reissueSearch (:467) | OrderReshopRQ.ofReissueSearch → OrderReshopRS | FlightSearch |
reissue (:522) | OrderChangeRQ.ofReissue → OrderViewRS | Ticketing |
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.yml의resilience4j.circuitbreaker.instances.koreanairSearch가baseConfig: 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. 출발지가 국내공항(domesticAirports16개 하드코딩 셋, :36)이면, 경유 구간(최초 출발/최종 도착을 제외한 모든 leg)에 국내공항이 끼면 그 여정을 버린다. 국내선 경유로 위장된 국제선 운임을 걸러내는 비즈니스 룰.
1-2. detail / structured fare rule
GET /internals/KOREANAIR/search→detail(SearchDetailRequest)(:50): 캐시에서getFareItinerary(key)후validate(adult,child,infant)로 좌석 수 검증.GET /internals/KOREANAIR/fare-rules/structured→KoreanairFareRuleController.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는 운임 규정을 독립 메시지로 주지 않는다.
findFareRules는OfferPriceRQ(가격 재산출) 응답에 딸려오는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이라 키 삭제 실패가 예약 응답을 막지 않는다.withLaunch는SupervisorJob+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)에서:
paymentCancelAsync(payment, pnr)— 5초 지연 후 결제 취소(:125). 취소 실패 시slackService.sendPaymentCancelFail로 슬랙 경보, 그 후 throw.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초 강제 대기 두 가지 함정
- 결제 미구현:
koreanairClient.reissue(... payment = null /* TODO: 카드 결제 기능 추가 필요 */)(KoreanairTicketingService.kt:110). 현재 재발행은 추가 결제 없이 진행되는 경로만 구현되어 있다. (그래서 5-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):
deleteOrderItems의priceDifferential.differentialTypeCode가 전부VOID면CancelActionType.VOID, 전부REFUND면REFUND, 섞이면CALCULATE_CANCEL_FEE_FAILED(:44~55).- REFUND이면 승객별로 환불 상세 산출(:61~95):
usedTax = 원본Tax − 미사용TaxunUsedAirPrice = differencePrice.dueByAirlineAmount − 미사용TaxrefundFee(위약금) = penaltyIds 합산(dataList.penaltyMap 조회)expectedRefundAmount = 미사용항공운임 + 미사용TaxusedAirPrice = 원본항공운임 − 미사용항공운임 − 위약금offerId와offerTimeLimit(Asia/Seoul로 변환) 추출 — 이 offerId가 실제 취소(issuedCancel)에 필수.
offer 만료 전까지 환불계산 결과를 캐싱한다
cancelable은offerTimeLimit - 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).withLaunch | fire-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/search | search | AirShoppingRQ |
| 검색 상세 | GET /internals/KOREANAIR/search | detail | (캐시) |
| 운임규정 | GET /internals/KOREANAIR/fare-rules | getFareRules | OfferPriceRQ |
| 구조화 운임규정 | GET .../fare-rules/structured | getStructuredFareRules | (캐시) |
| 예약 생성 | POST /internals/KOREANAIR/bookings | create | OfferPriceRQ+OrderCreateRQ |
| 예약 조회 | GET .../bookings/{pnr} | retrieve/confirm | OrderRetrieveRQ |
| APIS 변경 | PUT .../bookings/{pnr} | changeApis | OrderRetrieveRQ+OrderChangeRQ(ofContactInfo) |
| PNR 분리 | POST .../bookings/{pnr}/divide | divide | OrderRetrieveRQ+OrderChangeRQ(ofSplit)+OrderRetrieveRQ |
| 취소가능/예상 | GET .../bookings/{pnr}/cancelable(또는 expected-cancel) | cancelable/expectedCancel | OrderRetrieveRQ+OrderReshopRQ(refundCalculate) |
| 취소 | PUT .../bookings/{pnr}/cancel | cancel | OrderRetrieveRQ+OrderChangeRQ(ofUnIssuedCancel 또는 ofIssuedCancel) |
| 발권 | POST /internals/KOREANAIR/ticketing | issue | OrderRetrieveRQ+NicePay+OrderChangeRQ(ofIssue) |
| 재발행 검색 | POST .../search/reissue | reissueSearch | OrderRetrieveRQ+OrderReshopRQ(ofReissueSearch) |
| 재발행 상세 | GET .../search/reissue | reissueDetail | (캐시 + 음수운임 가드) |
| 재발행 발행 | POST .../ticketing/addition | reissue | OrderRetrieveRQ+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