Jin Air — 오퍼레이션 흐름

module-jinair arch-flow pattern-async api-rest

이 노트의 범위

Jin Air(진에어, LCC/REST, 코드 JINAIR) 모듈의 모든 오퍼레이션의 내부 처리 흐름을 다룬다. 각 오퍼레이션을 Controller → application 서비스 → infrastructure 클라이언트(JinairClient) → 외부 API → 응답 매핑의 콜러→콜리 체인으로 추적하고, 비동기/코루틴·@Retryable/@CircuitBreaker 위치, 그리고 재계산(repricing)·재발행(reissue)·취소/환불 같은 복잡 플로우를 강조한다. 모듈 개요·특성은 jinair-overview, 외부 API 프로토콜 상세(SOAP/XML 래핑·SEED 암호화)는 jinair-protocol, 함정은 jinair-pitfalls 참고.


0. 한눈에 보는 오퍼레이션 ↔ 컨트롤러 ↔ 서비스 ↔ 클라이언트

진에어는 중앙 디스패처가 없다. 각 오퍼레이션마다 별도의 @RestController/internals/JINAIR/... 경로로 노출되어 Triple 예약 시스템이 직접 호출한다(공통 구조는 caller-callee-map·request-flow 참고).

오퍼레이션컨트롤러application 서비스핵심 JinairClient 메서드외부 API service 명
SearchJinairSearchControllerJinairFlightSearchServicesearch / doPricinggetAirAvailability / confirmPrice
BookingJinairBookingControllerJinairBookingService, JinairCancelService, JinairPassengerServicedoPricingmarkSeatcreateBookingconfirmPrice/adjustFlightInventory/saveCreateBooking
TicketingJinairTicketingControllerJinairTicketingServiceconfirmPriceissue / reissuemodifyBooking/saveModifyBooking
FareRuleJinairFareRuleControllerJinairFareRuleServicegetFareRulefareRegulationData
AncillaryJinairAncillaryControllerJinairAncillaryServicesearchBaggageAvail/searchBaggagelistBaggageServices
AgencyCreditJinairAgencyCreditControllerJinairAgencyCreditServicegetAgencyCreditretrieveAgencyCredit

컨트롤러 ↔ 서비스가 1:1이 아니다

JinairBookingController 하나가 3개 서비스(JinairBookingService·JinairCancelService·JinairPassengerService)를 주입받아 예약 생성·조회·재계산·분리(divide)·APIS 변경·취소/환불을 모두 처리한다. 또 JinairFareRuleControllerJinairFareRuleServiceJinairFlightSearchService를 함께 쓴다(구조화 운임규정용). “오퍼레이션 = 컨트롤러 1개 = 서비스 1개”라는 단순 가정은 진에어에서 깨진다.

진에어 전용 검색 차단 규칙 — JinairSearchController.search (line 30-33)

//진에어는 24시간 이내 출발편 스케쥴 예약시 예약과 결제가 동시에 처리되야하므로 당일/익일 검색은 불가 처리
if (request.departureDate < today().plusDays(2)) {
    return emptyList()
}

진에어는 출발 24시간 이내 편은 예약·결제가 동시에 일어나야 하는 외부 제약 때문에, 어댑터가 출발일이 모레(today()+2) 미만이면 검색을 빈 리스트로 막아버린다. 다른 공급사에는 없는 진에어 고유 규칙이다.


1. Search — 항공편 검색 & 상세(가격 재확인)

검색은 getAirAvailability(가용성 조회)로 운임 후보를 만들고, 상세 진입 시 confirmPrice실시간 가격 재확인(repricing) 을 한 번 더 한다.

1-1. 콜러 → 콜리 체인 (POST /internals/JINAIR/search)

sequenceDiagram
    participant T as "Triple 예약"
    participant C as "JinairSearchController.search"
    participant S as "JinairFlightSearchService"
    participant K as "flightSearchKeyRepository"
    participant Cl as "JinairClient.search"
    T->>C: "POST /internals/JINAIR/search (SearchRequest)"
    Note over C: "CircuitBreaker jinairSearch fallback searchFallback"
    Note over C: "① 출발일 less than today plus 2 면 emptyList 반환<br/>② request.isSearchable JINAIR 확인<br/>③ CacheKeyGenerator.generateSearchRequestKey"
    C->>S: "search requestKey ..."
    S->>K: "findKey requestKey"
    alt 캐시 HIT
        K-->>S: "fareItineraryRepository.getFareItineraries key (캐시 재사용)"
    else 캐시 MISS
        Note over S: "AirportUtils.makeOriginDestinationsFilterByRoutes 라우트 필터<br/>withBlocking Dispatchers.IO 코루틴 진입<br/>filtered.cartesianProduct.pmap OD 조합별 병렬 호출"
        loop OD 조합별 병렬 pmap
            S->>Cl: "jinairClient.search ..."
            Note over Cl: "JinairClient.kt:78<br/>AirAvailabilityRQ.of.wrapXmlBody officeId service getAirAvailability<br/>POST endpoint/availability searchClient timeout 15s x-api-key"
            Cl-->>S: "JinairXmlBodyResponse AirAvailabilityRS"
            Note over Cl: "checkError body.errorType<br/>warningCodes/startWithErrorCodes 면 warn-log만<br/>그 외 SEARCH_FAILED throw<br/>body.originDestinationInfos.toItineraries → List FareItinerary"
        end
        Note over S: "onFailure 성공이 하나도 없을 때만 SEARCH_FAILED<br/>getOrEmpty.flatten.distinctBy it.id<br/>useCache 및 결과 있음 → addKey plus saveFareItineraries"
    end
    Note over S: "후처리<br/>toCombinedFareItinerary OW × RT 페어링 plus validateTimeGap 도착plus3h 이하 복편 출발<br/>filterByUnexposedFareItinerary 이미 매진 itinerary 제외<br/>advancedOption.ratio.total 만큼 take"
    S-->>C: "List Pair FareItinerary, FareItinerary? → FareItineraryView.ofJinair"
    C-->>T: "검색 결과"

검색의 비동기 코어 — withBlocking + pmap

캐시 미스 시 OD(origin-destination) 조합을 cartesianProduct()로 펼친 뒤 pmap(병렬 map)으로 동시에 jinairClient.search를 호출한다. pmap은 각 호출의 성공/실패를 AsyncResults로 모으고, onFailure에서 “성공이 하나도 없을 때만” SEARCH_FAILED를 던진다(부분 성공은 허용). 이 패턴/유틸 전반은 async-coroutines 참고.

1-2. 상세 — GET /internals/JINAIR/search (detail)

상세 조회는 JinairSearchController.detail(line 56) → flightSearchService.getCombinedFareItinerary(key)로 캐시에서 운임을 복원한 뒤, getPricedPassengerFares로 가격을 다시 받아온다. 이것이 진에어의 1차 repricing 지점이다.

sequenceDiagram
    participant D as "detail"
    participant S as "flightSearchService"
    participant Cl as "JinairClient.doPricing"
    participant A as "flightAmenityService"
    D->>S: "getCombinedFareItinerary key"
    Note over S: "key.destructKey 로 departureKey / returnKey 분해<br/>→ fareItineraryRepository.getFareItinerary<br/>.first.validate child, infant 유아/소아 가능 여부 검증"
    S->>Cl: "getPricedPassengerFares → doPricing adult child infant dep ret"
    Note over Cl: "service confirmPrice<br/>POST endpoint/price"
    Cl-->>S: "pricing"
    S->>A: "findAmenityMap ... 좌석 편의시설 공통 서비스"
    A-->>S: "amenityMap"
    S-->>D: "FareItineraryDetailView.of dep, ret, amenityMap, pricing"

키 구조( destructKey)를 모르면 캐시 조회가 깨진다 — JinairFlightSearchService.destructKey (line 131-137)

combinedKey는 "...prefix::idxDep_idxRet" 형태. substringBeforeLast("::")로 prefix를, substringAfterLast("::")_로 쪼개 "prefix::idxDep", "prefix::idxRet" 두 개의 fareItinerary 키를 만든다. JinairFareRuleService.destructKey(line 32-36)는 비슷하지만 List를 반환하는 별도 구현이라 시그니처가 다르다. 둘을 헷갈리지 말 것.


2. Search(reissue) — 재발행용 대체편 검색 & 재발행 가격 재계산

재발행(reissue) 플로우는 검색 컨트롤러에 별도 엔드포인트(/reissue)로 들어온다. 기존 PNR을 조회해 그 항공편을 기준으로 대체편을 찾고(getEnhancedAirAvailability), 상세에서 재발행 차액 가격 재계산(modifyBooking reissue 모드)을 한다.

2-1. POST /internals/JINAIR/search/reissue (reissueSearch)

sequenceDiagram
    participant C as "JinairSearchController.reissueSearch"
    participant S as "JinairFlightSearchService.reissueSearch"
    participant Cl as "JinairClient"
    C->>S: "reissueSearch pnr, originDestinationInfos ... (ReissueSearchRequest)"
    S->>Cl: "① retrieve pnr"
    Cl-->>S: "원 예약 Booking 로드"
    Note over S: "② getOriginDestinationInfo schedules, infos 편도/왕복 OD 매핑<br/>③ key 는 generateFareItineraryKey JINAIR"
    S->>Cl: "④ reissueSearch key, depOD, retOD ..., booking 아웃바운드 대체편"
    Note over Cl: "EnhancedAirAvailabilityRQ.ofOneWay / ofOutbound / ofInbound 편 수에 따라 분기"
    Cl-->>S: "아웃바운드 fareItineraries"
    alt originDestinationInfos.size 는 2 왕복
        Note over S: "withBlocking Dispatchers.IO"
        loop 아웃바운드편마다 병렬 pmap
            S->>Cl: "inboundReissueSearch key, retOD ..., departureFareItinerary=dep 인바운드 조합"
            Cl-->>S: "인바운드 조합 결과"
        end
        Note over S: "onFailure 성공 없을 때만 SEARCH_FAILED.getOrEmpty<br/>flatten → useCache 시 저장"
    else 편도
        Note over S: "distinctBy → toCombinedFareItinerary"
    end
    Note over S: "⑥ filterByUnexposedFareItinerary"
    S-->>C: "List Pair FareItinerary, FareItinerary? → FareItineraryView.ofJinair"

재발행 검색은 "원편 제외 + 최소가 하한" 필터가 핵심 — JinairClient.reissueSearch (private, line 858-938)

외부 응답을 toItineraries로 매핑할 때 두 가지 진에어 고유 처리가 들어간다.

  1. minimumAdultAirPrice: 원 예약의 성인 운임(취소 대상 스케줄의 airPrice)을 하한으로 잡아, 그보다 싼 대체편은 제외한다(재발행 시 환불 차익 방지).
  2. 원편 자기 자신 제외(filterNot): 대체편 목록에서 “출발지/도착지·편명·출발시각·캐빈이 원 스케줄과 동일한” itinerary를 걸러낸다. 같은 편으로의 재발행을 막는다. 두 메서드(reissueSearch/inboundReissueSearch)는 모두 이 private reissueSearch(...)로 수렴한다. EnhancedAirAvailabilityRS는 일반 검색의 AirAvailabilityRS와 다른 응답 타입이다.

2-2. GET /internals/JINAIR/search/reissue (reissueDetail)

sequenceDiagram
    participant C as "JinairSearchController.reissueDetail"
    participant S as "JinairFlightSearchService.reissueDetail"
    participant Cl as "JinairClient"
    C->>S: "reissueDetail pnr, key"
    S->>Cl: "① retrieve pnr"
    Cl-->>S: "originBooking"
    Note over S: "② originBooking.reference?.ancillaries?.isNotEmpty 면<br/>NON_CHANGEABLE_SCHEDULES_BY_ANCILLARY throw 부가서비스 있으면 일정변경 불가<br/>③ getCombinedFareItinerary key → 대체 fareItineraries"
    S->>Cl: "④ reissueConfirmPrice originBooking, fareItineraries"
    Note over Cl: "service modifyBooking<br/>ModifyBookingRQ.of agencyCode, originBooking, fareItineraries<br/>→ toBooking isReissue=true"
    Cl-->>S: "pricedBooking"
    S-->>C: "Pair Pair dep, ret, pricedBooking.passengers → FareItineraryDetailView.ofJinair ..., passengers"

부가서비스가 붙은 예약은 일정 변경(재발행) 불가 — reissueDetail (line 262-264)

originBooking.reference?.ancillaries?.isNotEmpty() == trueNON_CHANGEABLE_SCHEDULES_BY_ANCILLARY를 던진다. 수하물 등 부가서비스가 결합된 PNR은 재발행 흐름에 진입조차 못 한다. 재발행 디버깅 시 가장 먼저 의심할 분기.


3. Booking — 예약 생성 (가격확인 → 좌석확보 → PNR 생성)

진에어 예약은 3단계 외부 호출(price → reservation/adjustFlightInventory → reservation/saveCreateBooking)을 순차로 수행한다.

sequenceDiagram
    participant T as "Triple 예약"
    participant C as "JinairBookingController.create"
    participant S as "JinairBookingService.book"
    participant Cl as "JinairClient"
    T->>C: "POST /internals/JINAIR/bookings (BookingRequest)"
    Note over C: "Passenger.of ... 매핑 plus ReservationUser.of request"
    C->>S: "book key, reservationUser, passengers"
    Note over S: "① getCombinedFareItinerary key plus JSON 로깅"
    S->>Cl: "② doPricing passengers, dep, ret -- price 재계산"
    Cl-->>S: "confirmedPricing → passengers.map withFares passengerFaresMap type"
    S->>Cl: "③ markSeat pricedPassengers, schedules -- adjustFlightInventory 좌석 선점"
    Cl-->>S: "pnrSessionId"
    S->>Cl: "④ createBooking reservationUser, schedules, passengers, pnrSessionId -- saveCreateBooking"
    Cl-->>S: "Booking"
    Note over S: "⑤ removeFlightSearchKey requestKey<br/>CoroutineScope IO withLaunch fire-and-forget"
    alt booking.schedules.any not it.confirmed
        Note over S: "⑥ saveUnexposedFareItinerary 비동기 매진 마킹<br/>plus throw StatusInvalidException SOLD_OUT, pnr"
    end
    S-->>C: "Booking → BookingView.of"
    C-->>T: "예약 결과"

좌석 선점( markSeat)의 오버부킹 차단 — JinairClient.markSeat (line 344-373)

adjustFlightInventory 응답 코드가 "BKG_BOE_1628"(No access to overbook flight)이면 BOOKING_FAILED가 아니라 SOLD_OUT(StatusInvalidException) 으로 변환한다. 호출자(book)는 이 단계에서 받은 pnrSessionId를 다음 createBooking에 넘긴다 — 세션 토큰을 단계 간 전달하는 LCC stateful 패턴.

미확정 예약은 즉시 매진 처리 + 예외 — JinairBookingService.book (line 67-74)

createBooking 성공 후에도 booking.schedules.any { !it.confirmed }면, 해당 fareItinerary를 unexposedFareItineraryRepository에 비동기로 저장(이후 검색에서 filterByUnexposedFareItinerary가 제외)하고 SOLD_OUT을 던진다. 단, 코드에 //TODO 확정 예약이 아닐경우 PNR 취소 처리 주석만 있고 실제 PNR 취소(rollback)는 아직 없다jinair-pitfalls.

3-1. 조회·재계산(repricing)·분리(divide)·APIS 변경

엔드포인트컨트롤러 메서드서비스클라이언트비고
GET /{pnr}retrievebookingService.retrieveretrieve (retrieveBooking)PNR 단순 조회
GET /{pnr}/confirmconfirmbookingService.retrieveretrieveretrieve와 동일 구현(별칭)
GET /{pnr}/repricingrepricingbookingService.confirmPriceretrieveconfirmPrice예약 후 가격 재계산
PUT /{pnr}changeApispassengerService.changeApisretrievechangeApis여권/체류정보(APIS) 변경
POST /{pnr}/dividedividebookingService.divideretrievedivideregisterDsr×2→retrievePNR 분리
GET /{pnr}/expected-cancelexpectedCancelcancelService.expectedCancelretrieve+getCancelInfo취소 예상 환불액
GET /{pnr}/cancelablecancelablecancelService.cancelableretrieve(+getCancelInfo)VOID/REFUND 판정
PUT /{pnr}/cancelcancelcancelService.cancelretrievegetCancelInfocancelBooking실제 취소

Repricing(가격 재계산)은 진에어에서 3곳에서 일어난다

같은 외부 confirmPrice/modifyBooking을 다른 맥락에서 호출한다.

  1. 상세 진입: detaildoPricing (/price, confirmPrice)
  2. 예약 직전: bookdoPricing (탑승객 수 반영 후 운임 확정)
  3. 예약 후 명시적 repricing: repricing/readyconfirmPrice (retrievemodifyBooking 호출, ModifyBookingRS.toBooking) 헷갈리는 점: JinairClient.doPricing(service=confirmPrice, /price)과 JinairClient.confirmPrice(service=modifyBooking, /reservation)는 이름은 비슷하지만 다른 외부 오퍼레이션이다. (jinair-pitfalls)

divide(PNR 분리)의 비동기 후처리 — JinairBookingService.divide (line 107-122)

sequenceDiagram
    participant S as "JinairBookingService.divide"
    participant Cl as "JinairClient"
    Note over S: "divideValidate 요청 승객 - retrieve 승객 매칭 plus 성인/유아 페어 검증"
    S->>Cl: "divide pnr, identificationKeys service splitReservation"
    Cl-->>S: "childPnr"
    Note over S: "CoroutineScope IO withLaunch registerDsr 원pnr, registerDsr childPnr<br/>비동기 DSR 등록 원/자식 둘 다"
    S->>Cl: "retrieve childPnr"
    Cl-->>S: "BookingView"

divideValidate(line 124-199)는 성인-유아 페어 무결성을 보장한다: 성인을 분리하면 그에 묶인 유아도 같이 분리 요청에 포함돼야 하고(validateAdultInfantPair), 유아를 분리하면 부모 성인도 포함돼야 한다(validateInfantParentPair). 위반 시 DIVIDE_FAILED.


4. Ticketing — 발권(ready → issue) & 재발행 발권(reissue, 폴링)

4-1. 발권 준비 & 발권

sequenceDiagram
    participant T as "Triple 예약"
    participant Rd as "JinairTicketingService.ready"
    participant C as "JinairTicketingController.issue"
    participant S as "JinairTicketingService.issue"
    participant Cl as "JinairClient"
    T->>Rd: "POST /ticketing/ready ready pnr"
    Rd->>Cl: "retrieve pnr .run confirmPrice this 발권 전 가격 재확인"
    Cl-->>Rd: "booking"
    Rd-->>T: "TicketingReadyView.of booking"
    T->>C: "POST /ticketing issue (TicketingRequest)"
    Note over C: "cardInfo 는 PaymentInfo.ofKeyInCard request.paymentInfo 키인 카드"
    C->>S: "issue pnr, passengerPrices, cardInfo"
    S->>Cl: "① retrieve pnr .run confirmPrice this 또 한 번 repricing"
    Cl-->>S: "pricedBooking"
    S->>Cl: "② issue pricedBooking, priceMap, cardInfo, timeoutCallback service saveModifyBooking"
    Note over S: "catch e 면 e is not MethodArgumentInvalidException 일 때 cancelAsync pnr, throw e 발권 실패 보상취소"
    Cl-->>S: "ticketedBooking"
    Note over S: "③ CoroutineScope IO withLaunch registerDsr ticketedBooking.pnr 비동기 DSR"
    S-->>C: "ticketedBooking.passengers → TicketingPassenger.of → TicketingView"
    C-->>T: "발권 결과"

발권 실패 시 비동기 보상 취소 — JinairTicketingService.cancelAsync (line 71-86)

issue가 실패하고 그 예외가 MethodArgumentInvalidException(= 카드/결제 입력 오류류)이 아니면 cancelAsync(pnr)를 호출한다. 이는 CoroutineScope(IO).withLaunch { delay(5000); getCancelInfo → cancelBooking }5초 지연 후 비동기 취소다. 취소 자체가 또 실패하면 slackService.sendCancelFail(JINAIR, pnr, reason)로 슬랙 경보를 보낸다(상태 전파를 큐가 아니라 Slack+예외로 하는 이 시스템의 전형 — resilience-and-events). 결제 오류(MethodArgumentInvalidException)일 때는 결제가 안 됐으니 취소하지 않는다 — 의도적 분기.

발권 타임아웃 처리 — JinairClient.issue (line 476-528)

외부 응답이 isTimeoutError(errorMessage에 “timeout” 포함)면 예외 대신 retrieve(pnr)로 실제 상태를 재조회해서 booking으로 간주한다(타임아웃이지만 실제로는 발권됐을 수 있음). 또한 HTTP 레벨 타임아웃(failure { it.isTimeout })이면 timeoutCallback()을 호출 → 서비스단에서 slackService.sendTicketingTimeout(JINAIR, pnr) 경보. 결제 에러코드 분류: WS_000 + PYM_* 메시지 / PAYMENT_*·PYM_*·AMEX_*·FDMS_* 접두 코드는 MethodArgumentInvalidException(silence=true), 그 외는 TICKETING_FAILED. WS_000+PYM_ 케이스는 비동기로 getTicketingErrorMessage(clientSessionId)를 조회해 결제 PG 상세 메시지를 warn-log로 남긴다.

4-2. 재발행 발권(reissue) — Redis 폴링 기반 비동기 오퍼레이션

재발행 발권은 진에어에서 유일하게 동기 요청-응답이 아닌 폴링 패턴을 쓴다(HTTP 202 Accepted + key 반환 → 클라이언트가 별도로 결과 폴링).

sequenceDiagram
    participant T as "Triple 예약"
    participant C as "JinairTicketingController.reissue"
    participant Svc as "JinairTicketingService.reissue (백그라운드 IO)"
    participant R as "Redis"
    T->>C: "POST /ticketing/addition (ReticketingRequest)"
    Note over C: "pollingKey 는 polling key=REISSUE::JINAIR_pnr, ttl, redisTemplate"
    C->>R: "polling 이 PENDING 박음"
    C->>Svc: "CoroutineScope IO withLaunch 로 init reissue pnr, detailKey, cardInfo 백그라운드 실행"
    C-->>T: "ResponseEntity DeferredKeyView pollingKey, HTTP 202 ACCEPTED"
    Note over Svc,R: "성공 DeferredResult.complete data / 실패 DeferredResult.error throwable 를 Redis 에 set"
    Svc->>R: "결과 set complete 또는 error"
    T->>C: "GET /ticketing/addition/reissueKey checkReissue"
    C->>R: "poller ReissueResult Booking, TicketingPassenger reissueKey, redisTemplate"
    R-->>C: "status"
    alt PENDING
        C-->>T: "DeferredView.Pending"
    else ERROR
        C-->>T: "throw throwable"
    else COMPLETE
        C-->>T: "DeferredView.Complete ReticketingView.of ..."
    end

JinairTicketingService.reissue(line 88-126) 내부의 실제 재발행 시퀀스:

sequenceDiagram
    participant S as "JinairTicketingService.reissue"
    participant FS as "flightSearchService"
    participant Cl as "JinairClient"
    S->>Cl: "① retrieve pnr"
    Cl-->>S: "originBooking"
    S->>FS: "② getCombinedFareItinerary detailKey"
    FS-->>S: "dep, ret fareItineraries"
    S->>Cl: "③ reissueConfirmPrice originBooking, fareItineraries -- modifyBooking 재발행 차액 재계산"
    Cl-->>S: "pricedBooking"
    S->>Cl: "④ reissue cardInfo, fareItineraries, originBooking, pricedBooking.passengers -- saveModifyBooking"
    Cl-->>S: "reissuedBooking"
    Note over S: "⑤ CoroutineScope IO withLaunch registerDsr reissuedBooking.pnr<br/>⑥ ReissueResult booking, passengers=매핑, cardInfo=CardInfo.of cardInfo"

polling/poller 유틸의 동작 — support/util/PollingUtils.kt

polling(...)은 Redis에 ADAPTER-DEFERRED-RESULT::{key}를 PENDING으로 먼저 박고, CoroutineScope(IO).withLaunchinit()(여기선 reissue)을 백그라운드 실행한 뒤 즉시 key를 반환한다. 완료/실패 시 setIfPresent로 결과를 갱신(TTL 유지). poller는 그 결과를 꺼내 throwable이 있으면 던진다. 발권 재발행이 외부 SOAP 왕복으로 오래 걸려도 호출 스레드를 잡지 않기 위한 설계. 폴링 인프라 일반론은 async-coroutines 참고.

reissue의 가격 재계산도 reissueConfirmPrice(service=modifyBooking, toBooking(isReissue=true))를 거친다 — 2-2의 reissueDetail과 같은 외부 오퍼레이션을 발권 직전에 한 번 더 호출한다.


5. FareRule — 운임 규정 조회

sequenceDiagram
    participant T as "Triple 예약"
    participant C as "JinairFareRuleController"
    participant S as "JinairFareRuleService.findFareRules"
    participant Repo as "fareRuleRepository"
    participant Cl as "JinairClient.getFareRule"
    T->>C: "GET /internals/JINAIR/fare-rules key, adult, child, infant"
    C->>S: "findFareRules key, adult, child, infant"
    Note over S: "① fareRuleKey 는 CacheKeyGenerator.generateFareRuleKey key, adult, child, infant"
    S->>Repo: "② findFareRules fareRuleKey"
    alt 캐시 HIT
        Repo-->>S: "반환"
    else 캐시 MISS
        Note over S: "key.destructKey.map fareItineraryRepository.getFareItinerary it List 반환 destructKey"
        S->>Cl: "getFareRule fareItineraries service fareRegulationData POST endpoint/information"
        Cl-->>S: "fareRules"
        S->>Repo: "saveFareRules ... 캐시 저장"
    end
    S-->>C: "List FareRule → FareRuleView.of"
    C-->>T: "운임 규정"
    T->>C: "GET /internals/JINAIR/fare-rules/structured key getStructuredFareRules"
    C->>S: "getCombinedFareItinerary key 외부 호출 없이 캐시된 itinerary만 사용"
    S-->>C: "fareItineraries"
    C-->>T: "StructuredFareRuleView.of fareItineraries"

structured는 외부 API를 안 부른다

/structuredJinairClient를 거치지 않고 캐시(getCombinedFareItinerary)에 이미 담긴 운임 정보를 뷰로 변환할 뿐이다. 반면 /fare-rules(평문)는 캐시 미스 시 fareRegulationData(/information)를 JSON 바디로 호출한다(JinairJsonBodyRequest).

JinairClient.getFareRule(line 217-279)은 응답을 distinctBy { 구매기간/변경/환불/노쇼/나비포인트/기타 }로 중복 제거 후 flatMapIndexedgroupSequence를 부여해 매핑한다.


6. Ancillary — 부가서비스(수하물) 조회

부가서비스는 키 기반(검색 결과)과 PNR 기반(예약 후) 둘 다 지원하며, 외부 오퍼레이션은 동일하게 listBaggageServices(/ancillary)다. 응답 매핑만 다르다(toAncillaryAvailList vs toBaggageAvailList).

sequenceDiagram
    participant T as "Triple 예약"
    participant S as "ancillaryService"
    participant Cl as "JinairClient"
    T->>S: "GET /ancillary/avail/key searchAncillary key, adult, child, infant"
    S->>Cl: "getCombinedFareItinerary key → searchBaggageAvail passengerInfo, dep, ret"
    Cl-->>S: "toAncillaryAvailList"
    S-->>T: "AncillaryAvailView.of it.first.availAncillaries // FIXME 임시 처리 주석"
    T->>S: "GET /ancillary/avail/pnr searchAncillary pnr"
    S->>Cl: "retrieve pnr → searchBaggageAvail booking"
    Cl-->>S: "avail"
    S-->>T: "AncillaryAvailView"
    T->>S: "GET /ancillary/baggage/key searchBaggage key ..."
    S->>Cl: "searchBaggage ... → toBaggageAvailList"
    Cl-->>S: "baggage"
    S-->>T: "BaggageAvailView"
    T->>S: "GET /ancillary/baggage/pnr searchBaggage pnr"
    S-->>T: "BaggageAvailView"

it.first() 단일 추출은 임시 코드 — JinairAncillaryController (line 27, 36)

searchAncillary 응답을 AncillaryAvailView.of(it.first().availAncillaries)첫 결과만 쓰고 있으며 코드에 // FIXME: View 변경으로 임시 처리합니다. 주석이 명시돼 있다. 다구간/복편 부가서비스가 누락될 수 있는 알려진 임시 상태. (jinair-pitfalls)

좌석맵(searchSeatmap, service=showSeatMap, JSON 바디)도 JinairClient에 구현돼 있으나(line 1203-1239) 현재 어떤 컨트롤러/서비스도 호출하지 않는 dead path다.


7. AgencyCredit — 대리점 크레딧 조회

가장 단순한 오퍼레이션. 컨트롤러 1 → 서비스 1 → 클라이언트 1 직선 체인.

sequenceDiagram
    participant T as "Triple 예약"
    participant C as "JinairAgencyCreditController.getAgencyCredit"
    participant S as "JinairAgencyCreditService.getAgencyCredit"
    participant Cl as "JinairClient.getAgencyCredit"
    T->>C: "GET /internals/JINAIR/agency-credit"
    C->>S: "getAgencyCredit : Long"
    S->>Cl: "getAgencyCredit service retrieveAgencyCredit POST endpoint/reservation"
    Cl-->>S: "response.body!!.totalAmountAvaialable.toLong"
    S-->>C: "amount"
    C-->>T: "AgencyCreditView amount"

에러 콜백 없음

getAgencyCreditcheckError를 호출하지 않고 response.body!!를 바로 역참조한다. body가 null이면 NPE로 터진다(다른 메서드와 달리 방어가 없다).


8. Resilience(@CircuitBreaker / @Retryable) 위치 정리

위치애너테이션대상동작
JinairSearchController.search (line 26)@CircuitBreaker(name="jinairSearch", fallbackMethod="searchFallback")검색만회로 OPEN 시 searchFallback이 Datadog span에 supplier.circuit-breaker=OPEN 태깅 후 emptyList() 반환
JinairClient.getCancelInfo (line 585)@Retryable(maxAttempts=3, backoff=5s, exceptionExpression="@jinairClient.shouldCancelRetryable(#root)")환불정보 조회ApiException.retryable==true일 때만 3회 재시도
JinairClient.cancelBooking (line 639)@Retryable(maxAttempts=2, backoff=5s, 동일 expression)취소 실행2회 재시도

재시도 트리거는 LOCKED_PNR.retry()JinairClient (line 618-623, 682-687)

getCancelInfo/cancelBooking에서 외부 코드 "BKG_CONCURRECY_01"(PNR 동시성 잠금)을 받으면 InternationalAdapterException(LOCKED_PNR).retry()를 던진다. .retry()ApiException.retryable=true를 세팅하고, shouldCancelRetryable(line 1086-1088)이 이를 읽어 @RetryableexceptionExpression을 true로 만든다 → 백오프 5초 후 재시도. 즉 재시도는 “PNR 잠금” 한 경우로만 좁혀져 있다. BKG_BOE_46/5(체크인/삭제불가), BKG_BOE_64(이미취소) 등은 재시도 없이 즉시 상태예외. 진에어 모듈에는 @Bulkhead/@RateLimiter 사용처가 없다. Resilience4j 일반 구성은 resilience-and-events·configuration-and-infra 참고.


9. 비동기/코루틴 사용처 총정리

진에어의 코루틴은 두 부류다. (A) 병렬 검색(결과를 기다림)과 (B) fire-and-forget 부수효과(결과를 안 기다림).

위치유형패턴목적
JinairFlightSearchService.searchAwithBlocking(IO){ ...pmap{ client.search } }OD 조합 병렬 검색
JinairFlightSearchService.reissueSearchAwithBlocking(IO){ ...pmap{ inboundReissueSearch } }왕복 재발행 인바운드 조합 병렬
JinairBookingService.bookBCoroutineScope(IO).withLaunch { removeFlightSearchKey / saveUnexposed }검색키 제거·매진 마킹
JinairBookingService.divideBwithLaunch { registerDsr(pnr); registerDsr(childPnr) }DSR 등록
JinairCancelService.cancelBwithLaunch { registerDsr(pnr) } (발권 승객 있을 때만)DSR 등록
JinairTicketingService.issueBwithLaunch { registerDsr } + cancelAsync(delay(5000) 후 취소)DSR 등록·보상 취소
JinairTicketingService.reissueBwithLaunch { registerDsr }DSR 등록
JinairTicketingController.reissueA/폴링polling{...}CoroutineScope(IO).withLaunch재발행 발권 비동기 실행 + Redis 결과 보관
JinairClient.issue/reissueBwithLaunch { getTicketingErrorMessage }결제 PG 상세 메시지 비동기 조회

fire-and-forget 코루틴의 예외는 호출 흐름으로 전파되지 않는다

CoroutineScope(Dispatchers.IO).withLaunch{...}로 던진 작업(registerDsr, removeFlightSearchKey, 매진 마킹 등)은 본 응답과 무관하게 백그라운드에서 돈다. 예외는 AdapterCoroutineExceptionHandler가 받는다(withLaunch/withBlockingSupervisorJob + AdapterCoroutineExceptionHandler + MDCContext를 컨텍스트로 주입 — CoroutineExtensions.kt:20-26). 즉 DSR 등록이 실패해도 예약/발권 응답은 정상 200이다. 자세한 예외 핸들러 동작은 error-handling·async-coroutines 참고.


10. 직접 확인 퀴즈

Q1. 출발일이 내일인 진에어 항공편을 검색하면 왜 결과가 비어 있는가? 어느 코드 한 줄 때문인가?

Q2. 발권( issue)이 카드 입력 오류가 아닌 사유로 실패하면 어떤 일이 벌어지나? 카드 오류일 때와 무엇이 다른가?

Q3. 재발행 발권만 다른 오퍼레이션과 통신 패턴이 다르다. 어떻게 다르며, 그 구현 유틸은 무엇인가?

Q4. JinairClient.doPricingJinairClient.confirmPrice는 둘 다 "가격 확정"처럼 들린다. 외부적으로 무엇이 다른가?


관련 노트