Jeju Air — 오퍼레이션 흐름
module-jejuair arch-request-flow pattern-repricing api-rest
이 노트의 범위
Jeju Air(제주항공, 7C) 모듈의 모든 오퍼레이션을 코드 레벨에서 추적한다. 각 오퍼레이션을
Controller → application 서비스 → infrastructure 클라이언트(JejuairClient) → 외부 7C API → 응답 매핑체인으로 분해하고, 비동기/코루틴·@Retryable·@CircuitBreaker위치, 그리고 운임 재계산(repricing)·재발행(reissue)·환불(refund)·PNR 분리(divide) 같은 복잡 플로우를 강조한다.모듈 개요·도메인 모델은 jejuair-overview, 외부 API 프로토콜(REST/SEED 헤더)은 jejuair-protocol, 함정은 jejuair-pitfalls 참고. 전 공급사 공통 오퍼레이션 규약은 common-operations, 요청 진입 흐름은 request-flow, 콜체인 전체 지도는 caller-callee-map, 코루틴 기반은 async-coroutines.
0. 한눈에 보는 구조
Jeju Air는 LCC/REST 공급사다. GDS(SOAP)와 달리 stateful PNR 세션이 없는 대신, 7C PSS(Passenger Service System)가 발급하는 PssToken 헤더가 pricing→booking→ticketing 사이의 임시 상태를 잇는다. 11개 공급사 중 작은 편에 속하지만, 재발행/환불/PNR 분리까지 모두 구현된 완전한 모듈이다.
interfaces (controller/internals/*)
├─ JejuairSearchController → JejuairFlightSearchService
├─ JejuairBookingController → JejuairBookingService / JejuairCancelService
├─ JejuairTicketingController → JejuairTicketingService
└─ JejuairFareRuleController → JejuairFareRuleService
application (서비스)
├─ JejuairFlightSearchService (검색 / reissueSearch / reissueDetail)
├─ JejuairPricingService (doPricing — getTripSell 래핑)
├─ JejuairBookingService (book / retrieve / confirm / divide)
├─ JejuairTicketingService (issue / reissue)
├─ JejuairCancelService (cancelable / cancel) ← @Retryable
└─ JejuairFareRuleService (getFareRules)
infrastructure
└─ JejuairClient ← 모든 외부 7C REST 호출 단일 진입점
request/* (AvailabilityRQ, TripSellRQ, CreateBookRQ, PaymentRQ,
CancelFeeRQ, ExecuteCancelRQ, ChangeFeeRQ, ExecuteChangeRQ,
DividePnrRQ, AgreeUntkChargeRQ, RetrieveRQ, FareRuleRQ)
response/* (AvailabilityRS, RetrieveRS, CancelFeeRS, ChangeFeeRS, ...)
중앙 디스패처가 없다
이 시스템에는 공급사를 라우팅하는 중앙 디스패처가 없다. Triple 예약 시스템은
/internals/JEJUAIR/...경로로 직접 이 모듈의 컨트롤러를 호출한다. URL prefix가 곧 공급사 식별자다. → caller-callee-map
7C 외부 API 베이스 경로는 JejuairClient가 jejuairProperties.getApiProperties()로 endpoint/funnel을 얻어 조립한다(Properties.kt의 getApiProperties). 모든 호출은 OkHttp 기반 ClientSupport의 .post().header().execute() 체인을 쓴다.
1. Search (검색)
1.1 콜러→콜리 체인
flowchart TD R["POST /internals/JEJUAIR/search"] Ctrl["JejuairSearchController.search()"] Svc["JejuairFlightSearchService.search()"] Cache["flightSearchKeyRepository.findKey()"] Hit["저장된 FareItinerary 반환"] Blocking["withBlocking Dispatchers.IO 진입"] Fanout["cartesianProduct + pmap 병렬 fan-out"] Client["JejuairClient.search()"] Ext["flightSearch POST endpoint/booking/getAvailability/v1.0"] Map["AvailabilityRS.toFareItineraries 응답 매핑"] R --> Ctrl Ctrl --> Svc Svc --> Cache Cache -->|"캐시 HIT"| Hit Svc -->|"캐시 MISS"| Blocking Blocking --> Fanout Fanout --> Client Client --> Ext Ext --> Map
JejuairSearchController.search()에@CircuitBreaker name=jejuairSearch적용.withBlocking(Dispatchers.IO)안에서AirportUtils.makeOriginDestinations(...).cartesianProduct()후.pmap { jejuairClient.search(...) }로 병렬 fan-out.
핵심 파일/메서드:
| 단계 | 파일 | 메서드 (file:line) |
|---|---|---|
| Controller | interfaces/controller/internals/JejuairSearchController.kt | search() (L30) |
| Service | application/JejuairFlightSearchService.kt | search() (L35) |
| Client | infrastructure/JejuairClient.kt | search() (L64) → flightSearch() (L203) |
| 외부 API | — | POST {endpoint}/booking/getAvailability/v1.0 (L208) |
| 응답 매핑 | infrastructure/response/AvailabilityRS.kt | toFareItineraries(...) (호출부 L99) |
1.2 병렬 검색(fan-out)과 코루틴
검색은 이 모듈에서 코루틴을 가장 적극적으로 쓰는 지점이다. JejuairFlightSearchService.search() (L51-74):
withBlocking(Dispatchers.IO) {
AirportUtils.makeOriginDestinations(originDestinations = originDestinationLocationInfos)
.cartesianProduct() // 출발/도착 공항 조합 전개
.pmap { originDestinations -> // 조합마다 병렬 검색
jejuairClient.search(...)
}
.onFailure { exceptions, successes ->
if (successes.isEmpty()) { // 전부 실패한 경우만 throw
throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, exceptions.first())
}
}
.getOrEmpty()
}.flatten()
.distinctBy { it.id }
.take(advancedOption?.ratio?.total ?: 30) // 기본 30건 제한withBlocking은runBlocking을SupervisorJob + AdapterCoroutineExceptionHandler + MDCContext로 감싼다(support/util/CoroutineExtensions.ktL13). 동기 컨트롤러 스레드에서 코루틴 세계로 진입하는 다리.pmap은 각 원소를async로 띄워awaitAll후AsyncResults(성공/실패 분리)로 모은다(CoroutineExtensions.ktL36). 한 공항 조합이 실패해도 다른 조합은 살아남는 부분 실패 허용 패턴 → async-coroutines.onFailure는 전부 실패할 때만 예외를 던지고, 일부라도 성공하면 그 결과만 반환한다(L68-72). 신입이 흔히 오해하는 부분.
검색 결과 캐싱 키 구조
useCache && 결과 있음이면flightSearchKeyRepository.addKey(requestKey, key)로 requestKey→key 매핑을 저장하고,fareItineraryRepository.saveFareItineraries(key, itemKey별 map)으로 개별 운임을 Redis Hash에 저장한다(L78-84). 이후detail·getFareRules·book은 모두 이itemKey(=detailKey)로 캐시된FareItinerary를 꺼내 쓴다.itemKey = "$key::$id",id는 스케줄+운임의 SHA3 해시(domain/model/FareItinerary.ktL41-44).
1.3 서킷브레이커 폴백
@CircuitBreaker(name = "jejuairSearch", fallbackMethod = "searchFallback")이 검색에만 걸려 있다(JejuairSearchController.kt L27). 회로가 OPEN이면 CallNotPermittedException이 던져지고 searchFallback()(L94)이 Datadog span에 supplier.circuit-breaker=OPEN 태그를 찍은 뒤 빈 리스트를 반환한다. 즉, 검색은 장애 시 “결과 없음”으로 graceful degrade 된다. 설정은 application.yml L69 jejuairSearch: → resilience-and-events.
다른 오퍼레이션에는 @CircuitBreaker가 없다
Booking/Ticketing/Cancel/FareRule 컨트롤러에는
@CircuitBreaker가 없다. 발권·취소 같은 부수효과(side-effect) 있는 거래는 회로 차단으로 조용히 삼키면 안 되기 때문이다. 검색만 idempotent하므로 폴백이 안전하다.
1.4 search detail (운임 상세)
flowchart TD R["GET /internals/JEJUAIR/search"] Ctrl["JejuairSearchController.detail() L50"] Restore["flightSearchService.getFareItinerary(key) Redis에서 FareItinerary 복원"] Validate["fareItinerary.validate(child, infant) 소아/유아 운임 존재 검증"] Amenity["flightAmenityService.findAmenityMap 공통 어메니티 조회"] R --> Ctrl Ctrl --> Restore Restore --> Validate Ctrl --> Amenity
fareItinerary.validate(child, infant)검증은FareItinerary.ktL46.
외부 API 호출 없이 캐시 + 어메니티만 조합한다.
2. Booking (예약)
2.1 콜러→콜리 체인 (가장 중요: pricing이 선행)
flowchart TD R["POST /internals/JEJUAIR/bookings"] Ctrl["JejuairBookingController.book() L26"] Restore["jejuairFlightSearchService.getFareItinerary(key) 캐시에서 운임 복원"] Svc["JejuairBookingService.book()"] Pricing["JejuairPricingService.doPricing(fareItinerary, passengers)"] PClient["JejuairClient.pricing()"] TripSell["getTripSell POST endpoint/booking/getTripSell/v1.0"] PResult["응답 헤더 PssToken + 좌석운임 확정된 passengers 반환"] Create["JejuairClient.createBooking(pssToken, reservationUser, pricedPassengers)"] CreateExt["POST endpoint/booking/createBook/v1.0 PssToken 헤더 동봉"] Map["RetrieveRS.toBooking(pssToken) 응답 매핑 Booking"] R --> Ctrl Ctrl --> Restore Ctrl --> Svc Svc --> Pricing Pricing --> PClient PClient --> TripSell TripSell --> PResult Svc --> Create Create --> CreateExt CreateExt --> Map
JejuairClient.pricing()에@Retryable maxAttempts=3, backoff 2s적용.- pricing이 booking보다 선행한다 (가장 중요): 좌석운임 재확정 후 PssToken으로 createBook 연결.
| 단계 | 파일 | 메서드 (file:line) |
|---|---|---|
| Controller | JejuairBookingController.kt | book() (L26) |
| Booking 서비스 | application/JejuairBookingService.kt | book() (L26) |
| Pricing 서비스 | application/JejuairPricingService.kt | doPricing() (L12) |
| Client pricing | JejuairClient.kt | pricing() (L221) → getTripSell() (L454) |
| Client booking | JejuairClient.kt | createBooking() (L285) |
2.2 왜 pricing이 booking 직전에 또 돈다
검색 결과의 운임은 시점 운임이라 예약 직전에 좌석/가격을 재확정해야 한다. getTripSell(=trip sell)이 그 역할이며, 두 가지를 산출한다:
PssToken—createBook이 같은 좌석 hold 세션을 가리키도록 응답 헤더로 전달되는 토큰.jejuairDeserializerOf(L654)가 응답 헤더PssToken을 읽어JejuairResponse.pssToken에 주입한다.- priced passengers — 좌석시퀀스키/식별키/확정 운임이 채워진 승객.
JejuairPricingService.doPricing()(L21-29)이 입력 승객과 인덱스 매칭해passengerSequenceKey/identificationKey/fares를 복사한다.
매진(SOLD_OUT) 시 캐시 자가 정화
book()의 catch 블록(JejuairBookingService.ktL43-49): pricing/booking이StatusInvalidException(ErrorMessage.SOLD_OUT)이면 비동기로 ①removeFlightSearchKey()②saveUnexposedFareItinerary()를 실행해 매진된 운임을 검색 캐시에서 추방한다. 둘 다CoroutineScope(Dispatchers.IO).withLaunch { ... }로 fire-and-forget(L52-62). 같은 SOLD_OUT 자가정화 로직이JejuairFareRuleService에도 있다(L75-82). → jejuair-pitfalls7C가 매진을 알리는 코드는
pricing()의"COMESV504"(L234)와createBooking()의"COMESV504"(L307).
2.3 retrieve / confirm / repricing (조회 계열)
flowchart TD subgraph G1 ["retrieve 조회"] R1["GET /internals/JEJUAIR/bookings/PNR"] Ctrl1["retrieve() L75"] Svc1["JejuairBookingService.retrieve validateScheduleStatus=false"] Cl1["JejuairClient.retrieve()"] R1 --> Ctrl1 --> Svc1 --> Cl1 end subgraph G2 ["confirm 스케줄 변경 동의"] R2["GET /internals/JEJUAIR/bookings/PNR/confirm"] Ctrl2["confirm() L85"] Svc2["JejuairBookingService.confirm()"] Cl2["JejuairClient.retrieveWithToken(pnr)"] Cl2b["JejuairClient.confirm()"] Ext2["POST endpoint/reservation/change/agreeUntkChange/v1.0"] R2 --> Ctrl2 --> Svc2 --> Cl2 Cl2 -->|"booking.reference.scheduleChanged"| Cl2b Cl2b --> Ext2 end subgraph G3 ["repricing 현재 운임 재표시"] R3["GET /internals/JEJUAIR/bookings/PNR/repricing"] Ctrl3["repricing() L98"] Svc3["retrieve validateScheduleStatus=false"] View3["RepricingView.of(passengers, validatingCarrier)"] R3 --> Ctrl3 --> Svc3 --> View3 end
JejuairClient.retrieve()에@Retryable maxAttempts=3적용.- repricing은 재발행 운임 재계산이 아니라 현재 운임 재표시. 진짜 재계산은 reissue의
calculateChange(§5).
confirm = 스케줄 변경 동의
confirm은 7C가 일방적으로 스케줄을 변경했을 때(booking.reference.scheduleChanged == true)agreeUntkChange(미발권 변경 동의)를 호출해 예약을 정상화한다. 변경이 없으면 조회 결과를 그대로 돌려준다(JejuairBookingService.ktL70-74).
/repricing엔드포인트는 단순히 현재 예약의 승객별 운임을 조회만 한다. 여기서의 repricing은 “재발행 운임 재계산”이 아니라 “현재 운임 재표시”다. 진짜 운임 재계산은 reissue 흐름의calculateChange(아래 §5)에서 일어난다.
retrieve()에는 스케줄 취소 검증이 붙어 있다: validateScheduleStatus=true면 validateSchedulesCancellation()(L673)이 leg 중 status == "Canceled"가 있으면 NON_RETRIEVABLE_SCHEDULE_STATUS를 던지고, response.data.canceled면 ALREADY_CANCELED_PNR를 던진다(L345). 컨트롤러는 대부분 validateScheduleStatus=false로 호출한다.
3. Ticketing (발권)
3.1 콜러→콜리 체인
flowchart TD R["POST /internals/JEJUAIR/ticketing"] Ctrl["JejuairTicketingController.issue() L28"] Svc["JejuairTicketingService.issue(pnr)"] Retrieve["JejuairClient.retrieveWithToken(pnr) PssToken 보장된 조회"] Pay["JejuairClient.paymentAndIssue(booking, timeoutCallback)"] Approve["requestApprovalPay POST endpoint/payment/requestApprovalPay/v1.0"] Final["JejuairClient.retrieve(pnr) 발권 후 최종 상태 재조회"] R --> Ctrl Ctrl --> Svc Svc --> Retrieve Svc --> Pay Pay --> Approve Svc --> Final
PaymentRQ.ofIssue:productTypeCode=RSV,amount=승객 운임 합.
| 단계 | 파일 | 메서드 (file:line) |
|---|---|---|
| Controller | JejuairTicketingController.kt | issue() (L28) |
| Service | application/JejuairTicketingService.kt | issue() (L28) |
| Client 결제+발권 | JejuairClient.kt | paymentAndIssue() (L372) → requestApprovalPay() (L440) |
| 결제 요청 빌더 | infrastructure/request/PaymentRQ.kt | ofIssue() (L26) |
3.2 발권 실패 시 자동 취소(보상 트랜잭션)
발권은 돈이 움직이는 오퍼레이션이라 실패 처리가 정교하다. JejuairTicketingService.issue()(L42-47):
} catch (e: Exception) {
if (e !is MethodArgumentInvalidException) {
cancelAsync(pnr) // 결제 성공 후 후속 단계 실패면 보상 취소
}
throw e
}cancelAsync()(L114-128)는 코루틴으로:
CoroutineScope(Dispatchers.IO).withLaunch {
delay(5000) // 7C 측 상태 안정화 대기
try {
jejuairCancelService.cancel(pnr)
} catch (e: Exception) {
slackService.sendCancelFail(supplier = JEJUAIR, pnr, reason = e.message)
throw e
}
}MethodArgumentInvalidException 은 자동 취소 대상이 아니다
paymentAndIssue()는 카드 거절/입력값 오류(OTAUSV51/53/55,PAYESV006,PAYESV010)를MethodArgumentInvalidException으로 던진다(JejuairClient.ktL382-396). 이 경우 결제 자체가 안 됐으므로 취소할 게 없다 →cancelAsync스킵. 반대로 그 외 예외(결제는 됐는데 후속 실패)는 보상 취소가 필요하다. 이 분기 조건이 잘못되면 이중취소 또는 미취소가 난다 → jejuair-pitfalls / error-handling.
결제 타임아웃 → Slack 경보
paymentAndIssue()의failure분기에서it.isTimeout이면timeoutCallback()을 호출하고(L401-403), 이는slackService.sendTicketingTimeout(JEJUAIR, pnr)로 연결된다(JejuairTicketingService.ktL33-37). 이 시스템의 “이벤트 전파”는 MQ가 아니라 예외 + Slack 경보 + Resilience4j 상태전이로 이뤄진다 → resilience-and-events.
4. FareRule (운임 규정)
4.1 콜러→콜리 체인
flowchart TD R["GET /internals/JEJUAIR/fare-rules"] Ctrl["JejuairFareRuleController.getFareRules() L18"] Svc["JejuairFareRuleService.getFareRules(detailKey, adult, child, infant)"] Cache["fareRuleRepository.findFareRules(fareRuleKey)"] Hit["캐시 HIT 즉시 반환"] Itin["fareItineraryRepository.getFareItinerary(detailKey)"] AirMap["공항맵 구성 cityClient.getAirportByIata"] Pricing["JejuairClient.pricing(fareItinerary) PssToken 획득용 승객 null"] GetRule["JejuairClient.getFareRules(pssToken, airportMap)"] Ext["POST endpoint/booking/getFareRule/v1.0"] Map["FareRuleRS.toFareRules 후 fareRuleRepository.saveFareRules()"] R --> Ctrl Ctrl --> Svc Svc --> Cache Cache -->|"HIT"| Hit Svc --> Itin Svc --> AirMap Svc --> Pricing Svc --> GetRule GetRule --> Ext Ext --> Map
| 단계 | 파일 | 메서드 (file:line) |
|---|---|---|
| Controller | JejuairFareRuleController.kt | getFareRules() (L18), getStructuredFareRules() (L31) |
| Service | application/JejuairFareRuleService.kt | getFareRules() (L32) |
| Client | JejuairClient.kt | getFareRules() (L252) |
4.2 주의점
- FareRule도 pricing(getTripSell)을 먼저 호출해
PssToken을 얻는다(L61-63). 7C는 운임 규정 조회에도 좌석 hold 세션이 필요하기 때문. 따라서 FareRule 호출이 매진을 유발할 수 있고, §2.2와 동일한 SOLD_OUT 자가정화가 catch에 있다(L75-82). getStructuredFareRules(structured)는 외부 호출 없이 캐시된FareItinerary만으로StructuredFareRuleView를 만든다.- 결과는
fareRuleKey(detailKey + 승객수 조합,CacheKeyGenerator.generateFareRuleKey)로 캐싱된다.
5. 재발행(Reissue) — 가장 복잡한 플로우
재발행은 검색(reissueSearch) → 운임 재계산(reissueDetail/calculateChange) → 재발권 실행(reissue) 의 3단계로 나뉘고, 비동기 폴링까지 얹혀 있다. 신입이 가장 헷갈리는 곳이므로 단계별로 본다.
5.1 reissue 단계 지도
flowchart TD subgraph S1 ["1단계 변경 가능 항공편 검색"] P1["POST /internals/JEJUAIR/search/reissue"] Svc1["JejuairFlightSearchService.reissueSearch()"] Ret1["JejuairClient.retrieve(pnr) 기존 예약 조회"] RS1["JejuairClient.reissueSearch getAvailability/v1.0"] P1 --> Svc1 Svc1 --> Ret1 Svc1 --> RS1 end subgraph S2 ["2단계 변경 운임 상세 재계산 미리보기"] P2["GET /internals/JEJUAIR/search/reissue?key=..."] Svc2["JejuairFlightSearchService.reissueDetail(key)"] Ret2["JejuairClient.retrieveWithToken(pnr)"] Block2["hasPaidAncillary 있으면 NON_CHANGEABLE ANCILLARY 차단"] Calc2["JejuairClient.calculateChange(booking, fareItinerary) searchChangePenaltyFeeInfo/v1.0"] Diff2["getCalculatePassengerFares(originBooking) 차액 계산"] Block2b["차액 airPrice 또는 tax 음수면 REISSUE_NON_CHANGEABLE_FARE_SCHEDULE 차단"] P2 --> Svc2 Svc2 --> Ret2 Ret2 --> Block2 Svc2 --> Calc2 Calc2 --> Diff2 Diff2 --> Block2b end subgraph S3 ["3단계 실제 재발행 비동기 폴링"] P3a["POST /internals/JEJUAIR/ticketing/addition HTTP 202 ACCEPTED"] Poll["polling(key) reissue(pnr, detailKey) 백그라운드 코루틴"] P3b["GET /internals/JEJUAIR/ticketing/addition/REISSUEKEY"] Poller["poller ReissueResult Booking Passenger 결과 조회"] P3a --> Poll P3b --> Poller end S1 --> S2 --> S3
- 1단계 검색 실패 메시지는
REISSUE_SEARCH_FAILED, 기존 승객수 기준. - 2단계 차단 규칙 두 가지: ①
hasPaidAncillary있으면NON_CHANGEABLE...ANCILLARY, ② 차액(airPrice/tax) 음수면REISSUE_NON_CHANGEABLE_FARE_SCHEDULE.
5.2 ② 운임 재계산(repricing)의 핵심 — calculateChange + getCalculatePassengerFares
calculateChange()(JejuairClient.kt L622)는 searchChangePenaltyFeeInfo API로 새 일정 적용 시의 예약(newBooking) 을 받아온다. 그 차액은 Booking.getCalculatePassengerFares(originBooking)(support/model/Booking.kt L50-68)로 계산한다:
PassengerFare(
airPrice = current.airPrice - origin.airPrice, // 항공운임 차액
tax = current.tax - origin.tax, // 세금 차액
fuelCharge = current.fuelCharge - origin.fuelCharge,
carrierFee = current.carrierFee ?: 0, // 변경 수수료(EMD)
)reissueDetail()(JejuairFlightSearchService.kt L121-166)은 이 차액으로 두 가지 사전 차단을 한다:
재발행 차단 규칙 두 가지 (반드시 기억)
- 부가서비스 결제 이력:
booking.passengers.any { it.hasPaidAncillary }이면 즉시NON_CHANGEABLE_SCHEDULES_BY_ANCILLARY(L124-127). 부가서비스가 붙은 스케줄은 재발행 불가.- 결제 감소(환불성 변경) 차단: 차액
airPrice < 0 || tax < 0이면REISSUE_NON_CHANGEABLE_FARE_SCHEDULE(L134-163). 메시지에 “결제 금액이 감소하여 재발행이 불가합니다. 항공사 사이트에서 재발행 진행해 주시기 바랍니다.”를 승객별 금액과 함께 담아 반환한다. → 어댑터는 추가 결제(balanceDue > 0) 재발행만 처리하고, 환불성 재발행은 거부한다.
5.3 ③ 실제 재발행 실행 — JejuairTicketingService.reissue()
reissue()(JejuairTicketingService.kt L52-112)의 분기가 핵심이다:
val newBooking = jejuairClient.calculateChange(originBooking, fareItinerary)
if (newBooking.reference.balanceDue > 0) {
jejuairClient.paymentAndReissue(pssToken, newBooking) // 추가 결제 후 변경
} else {
jejuairClient.executeChange(pssToken, newBooking) // 차액 0 → 결제 없이 변경만
}paymentAndReissue()(JejuairClient.ktL409):PaymentRQ.ofReissue(productTypeCode="EXC", amount=balanceDue) →requestApprovalPay. 실패 코드 처리는 issue와 동일(RETICKETING_FAILED).executeChange()(L569):ExecuteChangeRQ→reservation/change/executeChange/v1.0. 추가 결제 없는 동일가/감액 한도 내 변경.
재발행 후 retrieveWithToken(pnr)로 최신 예약을 다시 읽고, EMD(변경 수수료) 티켓을 별도 Ticket으로 추가한다(L87-102): carrierFee > 0이면 TicketType.EMD 티켓을 만들어 항공권 티켓 뒤에 붙인다. 결과는 ReissueResult<Booking, Passenger>(support/model/ReissueResult.kt)로 래핑.
5.4 비동기 폴링 메커니즘 (Deferred + Redis)
재발행은 7C 왕복이 길어 비동기 폴링으로 처리된다. JejuairTicketingController.reissue()(L40)는 즉시 HTTP 202 + pollingKey를 반환하고, 실제 작업은 백그라운드에서 돈다.
polling(key="ADAPTER...::JEJUAIR_{pnr}", ttl) { // PollingUtils.kt L12
redis[key] = DeferredResult.pending()
CoroutineScope(Dispatchers.IO).withLaunch {
runCatching { reissue(...) }
.onSuccess { redis[key] = DeferredResult.complete(it) }
.onFailure { redis[key] = DeferredResult.error(it) } // 예외도 Redis에 저장
}
return key // 즉시 반환
}
poller<ReissueResult<...>>(reissueKey) { // PollingUtils.kt L43
DeferredResult 읽어 PENDING / ERROR(throw) / COMPLETE 로 분기
}
컨트롤러의 checkReissue()(L56-75)가 DeferredStatus에 따라 DeferredView.Pending / throw throwable / DeferredView.Complete(ReticketingView)를 반환한다.
비동기 예외는 사라지지 않는다
백그라운드 코루틴의 예외는
runCatching{}.onFailure{}로 잡혀DeferredResult.error(throwable)로 Redis에 직렬화 저장된다. 폴링 클라이언트가poller로 읽을 때if (it.throwable != null) throw it.throwable로 재던져진다(PollingUtils.ktL46-48). 이 우회가 없으면 fire-and-forget 코루틴의 예외는AdapterCoroutineExceptionHandler에서 로깅만 되고 호출자에게 전달되지 않는다 → async-coroutines / error-handling.
6. 환불/취소(Cancel & Refund)
6.1 콜러→콜리 체인
flowchart TD subgraph GC ["cancelable 취소 가능 여부"] Rc1["GET /internals/JEJUAIR/bookings/PNR/cancelable Controller L53"] Rc2["GET /internals/JEJUAIR/bookings/PNR/expected-cancel expectedCancel L46"] Svc1["JejuairCancelService.cancelable(pnr)"] Ret1["JejuairClient.retrieveWithToken(pnr)"] Val1["validateCancellation(booking)"] Branch{"booking.isVoidable"} Void["VOID, null"] Refund["REFUND, JejuairClient.calculateCancelFee(booking)"] Rc1 --> Svc1 Rc2 --> Svc1 Svc1 --> Ret1 --> Val1 --> Branch Branch -->|"true"| Void Branch -->|"false"| Refund end subgraph GX ["cancel 취소 실행"] Rx["PUT /internals/JEJUAIR/bookings/PNR/cancel Controller L60"] Svc2["JejuairCancelService.cancel(pnr)"] Ret2["JejuairClient.retrieveWithToken(pnr)"] Val2["validateCancellation(booking)"] Fee2["JejuairClient.calculateCancelFee(booking) searchCancelPenaltyFeeInfo/v1.0"] Exec2["JejuairClient.cancel(booking, timeoutCallback) reservation/cancel/executeCancel/v1.0"] Rx --> Svc2 Svc2 --> Ret2 --> Val2 --> Fee2 Fee2 -->|"not booking.nonTicketing"| Exec2 end
cancelable와cancel모두@Retryable maxAttempts=2, backoff 3s적용.
| 단계 | 파일 | 메서드 (file:line) |
|---|---|---|
| Service | application/JejuairCancelService.kt | cancelable() (L26), cancel() (L46) |
| Client 수수료 | JejuairClient.kt | calculateCancelFee() (L464) |
| Client 취소 | JejuairClient.kt | cancel() (L518) |
6.2 VOID vs REFUND 분기
Booking.isVoidable(Booking.kt L30-34)은 모든 승객의 항공권이 오늘 발권됐는지로 판단한다(당일 발권 = 무료 취소/void 가능). cancelable()은 이를 CancelActionType.VOID 또는 CancelActionType.REFUND + 환불 수수료 리스트로 매핑한다(JejuairCancelService.kt L29-37).
calculateCancelFee 호출이 곧 미발권 예약 취소다
cancel()(L46-64)의 주석(L49): “발권 되지 않은 예약의 경우 calculateCancelFee 호출시 예약 취소 처리됨”. 그래서booking.nonTicketing(status=Hold&& paymentStatus=UnderPaid,Booking.ktL42-43)이면executeCancel을 호출하지 않는다(L51). 발권된 예약만 별도executeCancel로 취소한다. 이 차이를 모르면 미발권 예약을 이중 취소하려다 에러가 난다 → jejuair-pitfalls.
calculateCancelFee()는 승객별 Refund(passengerType, identificationKey, refundFee)를 만든다(L499-510). 컨트롤러의 cancel()은 refundFee > 0인 환불만 RefundView로 노출한다(JejuairBookingController.kt L66-70).
6.3 취소 사전 검증 & 재시도
validateCancellation()(JejuairCancelService.kt L66-76):
hasPaidAncillary→CANCEL_UNABLE(부가서비스 결제 예약 취소 불가)- 체크인 승객 존재 →
CANCEL_UNABLE_BY_ALREADY_CHECK_IN
@Retryable + shouldRetry(SpEL) 패턴
cancelable/cancel둘 다@Retryable(maxAttempts=2, backoff 3s, exceptionExpression="@jejuairCancelService.shouldRetry(#root)")로 감싸여 있다(L20-25, L40-45).shouldRetry()(L81-83)는(exception as? ApiException)?.retryable를 본다.calculateCancelFee의"OTAUSV900"은.retry()로 마킹돼 있어(JejuairClient.ktL485-489) 토큰 재발급 후 재시도를 유도한다. 즉, 클라이언트에서 예외에 retryable 플래그를 심고, 서비스의 @Retryable이 그것을 보고 재시도하는 계층 분리 패턴이다.JejuairClient자체도retrieve/retrieveWithToken/pricing에@Retryable(maxAttempts=3, backoff 2s)를 단다 → resilience-and-events / error-handling.
취소 타임아웃 시 timeoutCallback은 slackService.sendCancelFailTimeout(JEJUAIR, pnr)로 연결된다(L53-58).
7. PNR 분리(Divide) — 부분취소 기반 작업
flowchart TD R["POST /internals/JEJUAIR/bookings/PNR/divide"] Ctrl["JejuairBookingController.divide() L105"] Svc["JejuairBookingService.divide(pnr, passengers)"] Ret["JejuairClient.retrieveWithToken(pnr)"] Val["validateDivideCondition 승객 존재 성인유아 짝 검증"] Seq["passengerSequenceKey 매핑"] Div["JejuairClient.divide(booking, INF 제외 승객) reservation/dividePNR/v1.0"] New["JejuairClient.retrieve(newPnr) 분리된 새 PNR 재조회"] R --> Ctrl Ctrl --> Svc Svc --> Ret Svc --> Val Svc --> Seq Svc --> Div Svc --> New
divide는 "일부 승객만 떼어내 별도 PNR로 분리"
부분취소/일부 승객 처리를 위해 PNR을 나누는 작업이다.
JejuairBookingService.divide()(L77-93)는 유아(INF)는 부모 승객을 자동으로 따라가므로divide요청에서 제외한다(L89 주석).validateDivideCondition()(L95-140)이 ① 요청 승객이 실제 예약에 존재하는지 ② 성인↔유아 짝이 깨지지 않는지 검증하고, 깨지면DIVIDE_FAILED를.capture()해서 던진다 → error-handling.
8. 오퍼레이션 × 외부 7C API 매핑 표
| 오퍼레이션 | application 메서드 | JejuairClient 메서드 | 외부 7C 엔드포인트 |
|---|---|---|---|
| 검색 | FlightSearchService.search | search/flightSearch | /booking/getAvailability/v1.0 |
| 재발행 검색 | FlightSearchService.reissueSearch | reissueSearch | /booking/getAvailability/v1.0 |
| 운임 확정(pricing) | PricingService.doPricing | pricing/getTripSell | /booking/getTripSell/v1.0 |
| 운임 규정 | FareRuleService.getFareRules | getFareRules | /booking/getFareRule/v1.0 |
| 예약 생성 | BookingService.book | createBooking | /booking/createBook/v1.0 |
| 조회 | BookingService.retrieve | retrieve | /reservation/retrievePNR/v1.0 |
| 결제+발권 | TicketingService.issue | paymentAndIssue | /payment/requestApprovalPay/v1.0 |
| 재발행 결제 | TicketingService.reissue | paymentAndReissue | /payment/requestApprovalPay/v1.0 |
| 변경 실행 | TicketingService.reissue | executeChange | /reservation/change/executeChange/v1.0 |
| 변경 운임 재계산 | FlightSearchService.reissueDetail / TicketingService.reissue | calculateChange | /reservation/change/searchChangePenaltyFeeInfo/v1.0 |
| 스케줄변경 동의 | BookingService.confirm | confirm | /reservation/change/agreeUntkChange/v1.0 |
| 취소 수수료 | CancelService.cancelable/cancel | calculateCancelFee | /reservation/cancel/searchCancelPenaltyFeeInfo/v1.0 |
| 취소 실행 | CancelService.cancel | cancel | /reservation/cancel/executeCancel/v1.0 |
| PNR 분리 | BookingService.divide | divide | /reservation/dividePNR/v1.0 |
9. 비동기/Resilience 어노테이션 위치 요약
| 위치 (file:line) | 어노테이션/메커니즘 | 대상 |
|---|---|---|
JejuairSearchController.kt L27 | @CircuitBreaker(name="jejuairSearch") + fallback | 검색만 |
JejuairClient.kt L216, L324, L359 | @Retryable(maxAttempts=3, backoff 2s) | pricing, retrieve, retrieveWithToken |
JejuairCancelService.kt L20, L40 | @Retryable(maxAttempts=2, backoff 3s) + SpEL shouldRetry | cancelable, cancel |
JejuairFlightSearchService.kt L51 | withBlocking(Dispatchers.IO) + pmap fan-out | 검색 병렬화 |
JejuairBookingService.kt L53, L59 | CoroutineScope(IO).withLaunch{} | SOLD_OUT 캐시 정화(fire-and-forget) |
JejuairFareRuleService.kt L86, L92 | CoroutineScope(IO).withLaunch{} | SOLD_OUT 캐시 정화 |
JejuairTicketingService.kt L115 | CoroutineScope(IO).withLaunch{ delay(5000) } | 발권 실패 보상 취소 |
JejuairTicketingController.kt L41, L57 | polling() / poller() (Redis Deferred) | 재발행 비동기화 |
핵심 한 문장 정리
Jeju Air는 PssToken으로 세션을 잇는 LCC REST 어댑터다. 검색만
@CircuitBreaker로 graceful degrade하고, pricing/retrieve는@Retryable, cancel은 SpEL 조건부@Retryable로 견딘다. 재발행은 추가 결제(balanceDue>0)만 허용·환불성 변경 거부하고 Redis Deferred 폴링으로 비동기 처리한다. 발권 실패는 5초 지연 후 보상 취소를 코루틴으로 던진다.
다음에 읽을 노트
- Jeju Air 모듈 개요 — 도메인 모델·캐시 구조·전체 그림
- Jeju Air 프로토콜 — 7C REST 헤더(x-client-id/secret, PssToken), funnel/channel
- Jeju Air 함정 — SOLD_OUT 자가정화, 보상취소 분기, 재발행 차단 규칙
- 공통 오퍼레이션 규약 · 요청 진입 흐름 · 콜체인 지도 · 코루틴 기반