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 베이스 경로는 JejuairClientjejuairProperties.getApiProperties()로 endpoint/funnel을 얻어 조립한다(Properties.ktgetApiProperties). 모든 호출은 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)
Controllerinterfaces/controller/internals/JejuairSearchController.ktsearch() (L30)
Serviceapplication/JejuairFlightSearchService.ktsearch() (L35)
Clientinfrastructure/JejuairClient.ktsearch() (L64) → flightSearch() (L203)
외부 APIPOST {endpoint}/booking/getAvailability/v1.0 (L208)
응답 매핑infrastructure/response/AvailabilityRS.kttoFareItineraries(...) (호출부 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건 제한
  • withBlockingrunBlockingSupervisorJob + AdapterCoroutineExceptionHandler + MDCContext로 감싼다(support/util/CoroutineExtensions.kt L13). 동기 컨트롤러 스레드에서 코루틴 세계로 진입하는 다리.
  • pmap은 각 원소를 async로 띄워 awaitAllAsyncResults(성공/실패 분리)로 모은다(CoroutineExtensions.kt L36). 한 공항 조합이 실패해도 다른 조합은 살아남는 부분 실패 허용 패턴 → 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.kt L41-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.kt L46.

외부 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)
ControllerJejuairBookingController.ktbook() (L26)
Booking 서비스application/JejuairBookingService.ktbook() (L26)
Pricing 서비스application/JejuairPricingService.ktdoPricing() (L12)
Client pricingJejuairClient.ktpricing() (L221) → getTripSell() (L454)
Client bookingJejuairClient.ktcreateBooking() (L285)

2.2 왜 pricing이 booking 직전에 또 돈다

검색 결과의 운임은 시점 운임이라 예약 직전에 좌석/가격을 재확정해야 한다. getTripSell(=trip sell)이 그 역할이며, 두 가지를 산출한다:

  1. PssTokencreateBook이 같은 좌석 hold 세션을 가리키도록 응답 헤더로 전달되는 토큰. jejuairDeserializerOf(L654)가 응답 헤더 PssToken을 읽어 JejuairResponse.pssToken에 주입한다.
  2. priced passengers — 좌석시퀀스키/식별키/확정 운임이 채워진 승객. JejuairPricingService.doPricing()(L21-29)이 입력 승객과 인덱스 매칭해 passengerSequenceKey/identificationKey/fares를 복사한다.

매진(SOLD_OUT) 시 캐시 자가 정화

book()의 catch 블록(JejuairBookingService.kt L43-49): pricing/booking이 StatusInvalidException(ErrorMessage.SOLD_OUT)이면 비동기로removeFlightSearchKey()saveUnexposedFareItinerary()를 실행해 매진된 운임을 검색 캐시에서 추방한다. 둘 다 CoroutineScope(Dispatchers.IO).withLaunch { ... }로 fire-and-forget(L52-62). 같은 SOLD_OUT 자가정화 로직이 JejuairFareRuleService에도 있다(L75-82). → jejuair-pitfalls

7C가 매진을 알리는 코드는 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.kt L70-74).

/repricing 엔드포인트는 단순히 현재 예약의 승객별 운임을 조회만 한다. 여기서의 repricing은 “재발행 운임 재계산”이 아니라 “현재 운임 재표시”다. 진짜 운임 재계산은 reissue 흐름의 calculateChange(아래 §5)에서 일어난다.

retrieve()에는 스케줄 취소 검증이 붙어 있다: validateScheduleStatus=truevalidateSchedulesCancellation()(L673)이 leg 중 status == "Canceled"가 있으면 NON_RETRIEVABLE_SCHEDULE_STATUS를 던지고, response.data.canceledALREADY_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)
ControllerJejuairTicketingController.ktissue() (L28)
Serviceapplication/JejuairTicketingService.ktissue() (L28)
Client 결제+발권JejuairClient.ktpaymentAndIssue() (L372) → requestApprovalPay() (L440)
결제 요청 빌더infrastructure/request/PaymentRQ.ktofIssue() (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.kt L382-396). 이 경우 결제 자체가 안 됐으므로 취소할 게 없다cancelAsync 스킵. 반대로 그 외 예외(결제는 됐는데 후속 실패)는 보상 취소가 필요하다. 이 분기 조건이 잘못되면 이중취소 또는 미취소가 난다 → jejuair-pitfalls / error-handling.

결제 타임아웃 → Slack 경보

paymentAndIssue()failure 분기에서 it.isTimeout이면 timeoutCallback()을 호출하고(L401-403), 이는 slackService.sendTicketingTimeout(JEJUAIR, pnr)로 연결된다(JejuairTicketingService.kt L33-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)
ControllerJejuairFareRuleController.ktgetFareRules() (L18), getStructuredFareRules() (L31)
Serviceapplication/JejuairFareRuleService.ktgetFareRules() (L32)
ClientJejuairClient.ktgetFareRules() (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)은 이 차액으로 두 가지 사전 차단을 한다:

재발행 차단 규칙 두 가지 (반드시 기억)

  1. 부가서비스 결제 이력: booking.passengers.any { it.hasPaidAncillary }이면 즉시 NON_CHANGEABLE_SCHEDULES_BY_ANCILLARY(L124-127). 부가서비스가 붙은 스케줄은 재발행 불가.
  2. 결제 감소(환불성 변경) 차단: 차액 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.kt L409): PaymentRQ.ofReissue(productTypeCode="EXC", amount=balanceDue) → requestApprovalPay. 실패 코드 처리는 issue와 동일(RETICKETING_FAILED).
  • executeChange()(L569): ExecuteChangeRQreservation/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.kt L46-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
  • cancelablecancel 모두 @Retryable maxAttempts=2, backoff 3s 적용.
단계파일메서드 (file:line)
Serviceapplication/JejuairCancelService.ktcancelable() (L26), cancel() (L46)
Client 수수료JejuairClient.ktcalculateCancelFee() (L464)
Client 취소JejuairClient.ktcancel() (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.kt L42-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):

  • hasPaidAncillaryCANCEL_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.kt L485-489) 토큰 재발급 후 재시도를 유도한다. 즉, 클라이언트에서 예외에 retryable 플래그를 심고, 서비스의 @Retryable이 그것을 보고 재시도하는 계층 분리 패턴이다. JejuairClient 자체도 retrieve/retrieveWithToken/pricing@Retryable(maxAttempts=3, backoff 2s)를 단다 → resilience-and-events / error-handling.

취소 타임아웃 시 timeoutCallbackslackService.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.searchsearch/flightSearch/booking/getAvailability/v1.0
재발행 검색FlightSearchService.reissueSearchreissueSearch/booking/getAvailability/v1.0
운임 확정(pricing)PricingService.doPricingpricing/getTripSell/booking/getTripSell/v1.0
운임 규정FareRuleService.getFareRulesgetFareRules/booking/getFareRule/v1.0
예약 생성BookingService.bookcreateBooking/booking/createBook/v1.0
조회BookingService.retrieveretrieve/reservation/retrievePNR/v1.0
결제+발권TicketingService.issuepaymentAndIssue/payment/requestApprovalPay/v1.0
재발행 결제TicketingService.reissuepaymentAndReissue/payment/requestApprovalPay/v1.0
변경 실행TicketingService.reissueexecuteChange/reservation/change/executeChange/v1.0
변경 운임 재계산FlightSearchService.reissueDetail / TicketingService.reissuecalculateChange/reservation/change/searchChangePenaltyFeeInfo/v1.0
스케줄변경 동의BookingService.confirmconfirm/reservation/change/agreeUntkChange/v1.0
취소 수수료CancelService.cancelable/cancelcalculateCancelFee/reservation/cancel/searchCancelPenaltyFeeInfo/v1.0
취소 실행CancelService.cancelcancel/reservation/cancel/executeCancel/v1.0
PNR 분리BookingService.dividedivide/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 shouldRetrycancelable, cancel
JejuairFlightSearchService.kt L51withBlocking(Dispatchers.IO) + pmap fan-out검색 병렬화
JejuairBookingService.kt L53, L59CoroutineScope(IO).withLaunch{}SOLD_OUT 캐시 정화(fire-and-forget)
JejuairFareRuleService.kt L86, L92CoroutineScope(IO).withLaunch{}SOLD_OUT 캐시 정화
JejuairTicketingService.kt L115CoroutineScope(IO).withLaunch{ delay(5000) }발권 실패 보상 취소
JejuairTicketingController.kt L41, L57polling() / poller() (Redis Deferred)재발행 비동기화

핵심 한 문장 정리

Jeju Air는 PssToken으로 세션을 잇는 LCC REST 어댑터다. 검색만 @CircuitBreaker로 graceful degrade하고, pricing/retrieve는 @Retryable, cancel은 SpEL 조건부 @Retryable로 견딘다. 재발행은 추가 결제(balanceDue>0)만 허용·환불성 변경 거부하고 Redis Deferred 폴링으로 비동기 처리한다. 발권 실패는 5초 지연 후 보상 취소를 코루틴으로 던진다.


다음에 읽을 노트