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 참고).
JinairBookingController 하나가 3개 서비스(JinairBookingService·JinairCancelService·JinairPassengerService)를 주입받아 예약 생성·조회·재계산·분리(divide)·APIS 변경·취소/환불을 모두 처리한다. 또 JinairFareRuleController는 JinairFareRuleService와 JinairFlightSearchService를 함께 쓴다(구조화 운임규정용). “오퍼레이션 = 컨트롤러 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"
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로 매핑할 때 두 가지 진에어 고유 처리가 들어간다.
minimumAdultAirPrice: 원 예약의 성인 운임(취소 대상 스케줄의 airPrice)을 하한으로 잡아, 그보다 싼 대체편은 제외한다(재발행 시 환불 차익 방지).
원편 자기 자신 제외(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() == true 면 NON_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}
retrieve
bookingService.retrieve
retrieve (retrieveBooking)
PNR 단순 조회
GET /{pnr}/confirm
confirm
bookingService.retrieve
retrieve
retrieve와 동일 구현(별칭)
GET /{pnr}/repricing
repricing
bookingService.confirmPrice
retrieve→confirmPrice
예약 후 가격 재계산
PUT /{pnr}
changeApis
passengerService.changeApis
retrieve→changeApis
여권/체류정보(APIS) 변경
POST /{pnr}/divide
divide
bookingService.divide
retrieve→divide→registerDsr×2→retrieve
PNR 분리
GET /{pnr}/expected-cancel
expectedCancel
cancelService.expectedCancel
retrieve+getCancelInfo
취소 예상 환불액
GET /{pnr}/cancelable
cancelable
cancelService.cancelable
retrieve(+getCancelInfo)
VOID/REFUND 판정
PUT /{pnr}/cancel
cancel
cancelService.cancel
retrieve→getCancelInfo→cancelBooking
실제 취소
Repricing(가격 재계산)은 진에어에서 3곳에서 일어난다
같은 외부 confirmPrice/modifyBooking을 다른 맥락에서 호출한다.
상세 진입: detail → doPricing (/price, confirmPrice)
예약 직전: book → doPricing (탑승객 수 반영 후 운임 확정)
예약 후 명시적 repricing: repricing/ready → confirmPrice (retrieve 후 modifyBooking 호출, ModifyBookingRS.toBooking)
헷갈리는 점: JinairClient.doPricing(service=confirmPrice, /price)과 JinairClient.confirmPrice(service=modifyBooking, /reservation)는 이름은 비슷하지만 다른 외부 오퍼레이션이다. (jinair-pitfalls)
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.
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) 내부의 실제 재발행 시퀀스:
polling/poller 유틸의 동작 — support/util/PollingUtils.kt
polling(...)은 Redis에 ADAPTER-DEFERRED-RESULT::{key}를 PENDING으로 먼저 박고, CoroutineScope(IO).withLaunch로 init()(여기선 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를 안 부른다
/structured는 JinairClient를 거치지 않고 캐시(getCombinedFareItinerary)에 이미 담긴 운임 정보를 뷰로 변환할 뿐이다. 반면 /fare-rules(평문)는 캐시 미스 시 fareRegulationData(/information)를 JSON 바디로 호출한다(JinairJsonBodyRequest).
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"
에러 콜백 없음
getAgencyCredit은 checkError를 호출하지 않고 response.body!!를 바로 역참조한다. body가 null이면 NPE로 터진다(다른 메서드와 달리 방어가 없다).
getCancelInfo/cancelBooking에서 외부 코드 "BKG_CONCURRECY_01"(PNR 동시성 잠금)을 받으면 InternationalAdapterException(LOCKED_PNR).retry()를 던진다. .retry()가 ApiException.retryable=true를 세팅하고, shouldCancelRetryable(line 1086-1088)이 이를 읽어 @Retryable의 exceptionExpression을 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 부수효과(결과를 안 기다림).
withLaunch { registerDsr } + cancelAsync(delay(5000) 후 취소)
DSR 등록·보상 취소
JinairTicketingService.reissue
B
withLaunch { registerDsr }
DSR 등록
JinairTicketingController.reissue
A/폴링
polling{...} → CoroutineScope(IO).withLaunch
재발행 발권 비동기 실행 + Redis 결과 보관
JinairClient.issue/reissue
B
withLaunch { getTicketingErrorMessage }
결제 PG 상세 메시지 비동기 조회
fire-and-forget 코루틴의 예외는 호출 흐름으로 전파되지 않는다
CoroutineScope(Dispatchers.IO).withLaunch{...}로 던진 작업(registerDsr, removeFlightSearchKey, 매진 마킹 등)은 본 응답과 무관하게 백그라운드에서 돈다. 예외는 AdapterCoroutineExceptionHandler가 받는다(withLaunch/withBlocking이 SupervisorJob + AdapterCoroutineExceptionHandler + MDCContext를 컨텍스트로 주입 — CoroutineExtensions.kt:20-26). 즉 DSR 등록이 실패해도 예약/발권 응답은 정상 200이다. 자세한 예외 핸들러 동작은 error-handling·async-coroutines 참고.
10. 직접 확인 퀴즈
Q1. 출발일이 내일인 진에어 항공편을 검색하면 왜 결과가 비어 있는가? 어느 코드 한 줄 때문인가?
정답 보기 JinairSearchController.search(line 31)의 if (request.departureDate < today().plusDays(2)) return emptyList(). 진에어는 출발 24시간 이내 편의 예약·결제가 동시에 처리돼야 하는 제약이 있어, 어댑터가 출발일 today()+2 미만 검색을 막는다. 즉 모레부터만 검색 가능.
Q2. 발권( issue)이 카드 입력 오류가 아닌 사유로 실패하면 어떤 일이 벌어지나? 카드 오류일 때와 무엇이 다른가?
정답 보기 JinairTicketingService.issue의 catch 블록: 예외가 MethodArgumentInvalidException이 아니면cancelAsync(pnr)를 호출해 5초 지연 후 비동기로 getCancelInfo→cancelBooking 보상 취소를 수행한다(취소 실패 시 slackService.sendCancelFail). 카드/결제 입력 오류(MethodArgumentInvalidException)는 결제가 안 됐다는 의미이므로 보상 취소를 하지 않고 예외만 던진다.
Q3. 재발행 발권만 다른 오퍼레이션과 통신 패턴이 다르다. 어떻게 다르며, 그 구현 유틸은 무엇인가?
정답 보기 POST /ticketing/addition은 즉시 HTTP 202 Accepted + DeferredKeyView(pollingKey)를 주고, 실제 reissue는 support/util/PollingUtils.kt의 polling{...}이 CoroutineScope(IO).withLaunch로 백그라운드 실행하며 결과를 Redis(ADAPTER-DEFERRED-RESULT::REISSUE::JINAIR_{pnr})에 PENDING→COMPLETE/ERROR로 저장한다. 클라이언트는 GET /ticketing/addition/{reissueKey}로 poller를 통해 폴링한다. 외부 SOAP 왕복(retrieve→reissueConfirmPrice→reissue)이 길어도 요청 스레드를 점유하지 않기 위함.
Q4. JinairClient.doPricing과 JinairClient.confirmPrice는 둘 다 "가격 확정"처럼 들린다. 외부적으로 무엇이 다른가?
정답 보기 doPricing은 외부 service명 confirmPrice로 POST {endpoint}/price를 호출한다(검색 상세·예약 직전 사용, 응답 ConfirmPriceRS.toPricing). confirmPrice(메서드명)는 외부 service명 modifyBooking으로 POST {endpoint}/reservation을 호출한다(발권 ready·예약 후 repricing, 응답 ModifyBookingRS.toBooking). 메서드명과 외부 오퍼레이션명이 교차로 겹쳐 매우 헷갈리는 지점이다.