Sabre — 오퍼레이션 흐름
module-sabre arch-supplier-module api-internals pattern-caller-callee
관련 노트: 개요·모듈 지도 · 세션) · 지뢰·주의점 · 공통 오퍼레이션 패턴 · 요청 흐름 · 콜러-콜리 맵 · 코루틴 기존 API 분석: sabre-gds
이 노트가 답하는 질문
“Sabre의 6개 오퍼레이션(Search / Booking / Ticketing / FareRule / Queue / CashReceipt)이 컨트롤러부터 외부 Sabre API까지 실제로 어떤 메서드를 거쳐 흐르는가?” 를 코드 라인 단위로 추적한다. 복잡 플로우(검색 캐시·재검증, 예약 정합성 검증, 발권 실패 보상, repricing, 환불/보이드/부분취소, divide)에 특히 무게를 둔다.
0. 전체 지도 — 컨트롤러 / 서비스 / 클라이언트
Sabre 모듈은 중앙 디스패처 없이 컨트롤러 6개가 각자 /internals/SABRE/...로 노출된다. 그 뒤에 애플리케이션 서비스 9개, 인프라 클라이언트 4개가 있다.
| 오퍼레이션 | 컨트롤러 (interfaces/controller/internals) | 주 서비스 (application) | 외부 호출 클라이언트 (infrastructure) |
|---|---|---|---|
| Search | SabreSearchController | SabreFlightSearchService | SabreRestClient(검색=REST), SabreClient(재검증=SOAP) |
| Booking | SabreBookingController | SabreBookingService, SabrePassengerService, SabreCancelService | SabreClient(SOAP), SabreRestClient(환불) |
| Ticketing | SabreTicketingController | SabreTicketingService, SabrePaymentService, SabreCancelService | SabreClient(SOAP), SabrePaymentClient(결제 SOAP) |
| FareRule | SabreFareRuleController | SabreFareRuleService | SabreFareRuleClient(REST), SabreClient(구조화 규정 SOAP) |
| Queue | SabreQueueController | SabreQueueService | SabreClient(SOAP, 멀티 PCC) |
| CashReceipt | SabreCashReceiptController | SabreCashReceiptService | SabrePaymentClient(결제 SOAP) |
flowchart LR subgraph CTRL["interfaces.controller.internals"] SC["SabreSearchController"] BC["SabreBookingController"] TC["SabreTicketingController"] FC["SabreFareRuleController"] QC["SabreQueueController"] CC["SabreCashReceiptController"] end subgraph APP["application"] FSS["SabreFlightSearchService"] BS["SabreBookingService"] PS["SabrePassengerService"] CANS["SabreCancelService"] TS["SabreTicketingService"] FRS["SabreFareRuleService"] QS["SabreQueueService"] CRS["SabreCashReceiptService"] PAYS["SabrePaymentService"] end subgraph INFRA["infrastructure (soap/rest)"] REST["SabreRestClient (BFM/REST)"] SOAP["SabreClient (SOAP)"] FRC["SabreFareRuleClient (REST)"] PAYC["SabrePaymentClient"] end subgraph EXT["외부"] ESREST["Sabre REST v5"] ESOAP["Sabre SOAP (1S)"] end SC --> FSS FSS --> REST FSS -->|"revalidate SOAP"| SOAP BC --> BS BC --> PS BC --> CANS BS -->|"SOAP 세션"| SOAP PS --> SOAP CANS --> SOAP CANS -->|"refund"| REST TC --> TS TS --> SOAP TS --> PAYC TS -->|"보상"| CANS TS -->|"보상"| PAYS FC --> FRS FRS --> FRC FRS -->|"구조화 규정 SOAP"| SOAP QC --> QS QS -->|"QueueCount/QueueAccess"| SOAP CC --> CRS CRS -->|"PaymentService"| PAYC REST --> ESREST SOAP --> ESOAP FRC --> ESREST PAYC --> ESOAP
회복탄력성(Resilience) 어노테이션은 단 4곳뿐
이 모듈에서 Resilience4j/Spring-Retry 어노테이션은 딱 4곳이다. 위치를 외워두면 디버깅이 빨라진다.
어노테이션 위치 의미 @CircuitBreaker(name="sabreSearch", fallbackMethod="searchFallback")SabreSearchController.kt:24검색만 서킷 차단, OPEN 시 emptyList()반환 + Datadog 스팬 태그@Retryable(maxAttempts=3, backoff=Backoff(delay=2000), exceptionExpression="@sabreClient.shouldRetrieveRetryable(#root)")SabreClient.kt:704getBooking조회(retrieve) 재시도. ApiException.retryable==false면 재시도 안 함@Retryable(include=[IOException], maxAttempts=2, backoff=Backoff(delay=1000))SabreClient.kt:310getSessionToken세션 토큰 생성 IO 오류 재시도 @Retryable(maxAttempts=3, backoff=Backoff(delay=5000))SabreQueueService.kt:85remove큐 제거 재시도 즉 예약/발권/취소 본 트랜잭션에는 @Retry/@CircuitBreaker가 없다. 돈·재고 정합성을 위해 부분적·수동적 재시도(아래 pnrCancelRepeat,voidRepeat)를 코드로 구현한다. 자세한 이유는 resilience-and-events 참고.
1. Search — 검색 (REST/BFM + 캐시 + fan-out 병렬)
콜러→콜리 체인
SabreSearchController.search()(:24-47) └▶SabreFlightSearchService.search()(:47-129) └▶ (캐시 HIT)SabreFareItineraryRepository.getFareItineraries(key)└▶ (캐시 MISS)SabreRestClient.getToken()→SabreRestClient.search()(SabreRestClient.kt:100) ──▶POST {restEndpoint}/v5/offers/shop(BargainFinderMax) └▶ 응답 매핑Itinerary.toFareItineraries(...)
핵심 디테일:
- 캐시 우선:
flightSearchKeyRepository.findKey(requestKey)로 이전 검색키가 있으면fareItineraryRepository.getFareItineraries(key)로 즉시 반환(외부 호출 0회).requestKey는 컨트롤러에서CacheKeyGenerator.generateSearchRequestKey(Supplier.SABRE, request)로 생성(:27). - fan-out 병렬:
makeOriginDestinations(...).cartesianProduct()로 OD 조합을 펼친 뒤withBlocking(Dispatchers.IO) { ... .pmap { sabreRestClient.search(...) } }(SabreFlightSearchService.kt:67-89).pmap은support/util/CoroutineExtensions.kt의 병렬 map이며,.onFailure { exceptions, successes -> if (successes.isEmpty()) throw ... }— 전부 실패해야만SEARCH_FAILED를 던지고 부분 성공은 살린다. - 검색은 REST: 실제 외부 호출은
SabreClient(SOAP)가 아니라SabreRestClient.search(/v5/offers/shop). SOAP 검색 메서드(SabreClient.search,:141)도 존재하지만 현재FlightSearchService.search는 REST 경로를 탄다. (sabre-pitfalls 참고 — “두 개의 search가 공존”) - 결과 후처리(체이닝):
flatten → distinctBy(id) → OD 일치 필터 → filterByUnexposedFareItinerary → filterIncludedAirline. 마지막 두 필터는 매진(미노출) 운임 제거(unexposedFareItineraryRepository)와 항공사 화이트리스트(airlines/sotoAirlines) 적용. - 편의시설(amenity) 비동기 저장: 검색 결과를 반환하기 직전
CoroutineScope(Dispatchers.IO).withLaunch { flightAmenityService.saveFlightAmenities(...) }(:111-118)로 fire-and-forget. 응답을 막지 않는다. - 검색 상세(detail)
GET /internals/SABRE/search(:49-64)는getFareItinerary(key)로 단건 조회 후 amenity 맵을 붙여 반환. 외부 호출 없음(캐시 조회).
flowchart TD A["Controller.search<br/>requestKey = generateSearchRequestKey(SABRE, req)"] --> B["Service.search<br/>findKey?"] B -->|"HIT"| C["getFareItineraries(key)"] C --> D["필터 적용"] D --> RET1["return"] B -->|"MISS"| E["token = sabreRestClient.getToken()"] E --> F["withBlocking(IO)<br/>cartesianProduct().pmap 병렬"] F --> G["sabreRestClient.search<br/>POST /v5/offers/shop (BFM)"] G --> H["toFareItineraries 매핑"] H --> I["onFailure: 전부 실패 시 SEARCH_FAILED"] I --> J["flatten → distinctBy → OD 필터"] J --> K["withLaunch saveAmenity (fire-and-forget)"] K --> L["(useCache) saveFareItineraries"] L --> M["filterByUnexposedFareItinerary"] M --> N["filterIncludedAirline"] N --> RET2["return List of FareItinerary"]
revalidatevssearch검색(
search)은 REST, 재검증(revalidate,SabreFlightSearchService.kt:182)은 SOAPSabreClient.revalidate(:236)를 쓴다.revalidate는 예외를 삼키고null을 반환(catch → logger.error → null)하므로, 호출부(FareRule)는 실패해도 원본 운임으로 진행할 수 있다.
2. Booking — 예약 (SOAP 세션 트랜잭션 + 정합성 검증)
SabreBookingController는 예약 생성 외에도 변경(APIS), 취소, 조회, repricing, divide까지 묶는 가장 무거운 컨트롤러다. 여기서는 생성(book), repricing, divide 를 집중적으로 본다. (취소는 §6에서 별도)
2-1. 예약 생성 POST /internals/SABRE/bookings
콜러→콜리 체인 (
SabreBookingService.book,:31-135)Controller.create (
:23-36) └▶bookingService.book(key, reservationUser, passengers)├▶flightSearchService.getFareItinerary(key)— 캐시에서 운임 복원 + MDC 세팅 ├▶validate(fareItinerary, passengers)— 소아 생년월일 검증(:186) ├▶sabreClient.getSessionToken()— SOAP 세션 시작 ├▶sabreClient.markSeat(...)(SabreClient.kt:371, EnhancedAirBookRQ) → priceQuoteMap ├▶sabreClient.savePassengerInfo(...)(:401, PassengerDetailsRQ) → pnr ├▶sabreClient.getBooking(token, pnr)(:709, GetReservationRQ) → Booking └▶compareWithFareItinerary(booking, fareItinerary)— 정합성 검증 finally ▶sabreClient.closeSessionToken(token)— 세션 종료(필수)
핵심 디테일:
- 세션 위에서 좌석점유(markSeat) → 승객저장(savePassengerInfo) → 조회(getBooking) 가 한 트랜잭션처럼 직렬로 묶인다.
markSeat은 승객타입별 PriceQuote의rph를 모아Map<PassengerType,String>로 돌려주고, 이게savePassengerInfo의 입력이 된다. - identity 주입:
passengers.map { it.copy(identity = fareItinerary.passengerFares.first{...}.identityCode) }(:43-45) — 운임의 승객타입 코드를 승객에 박아 PQ 매칭에 사용. - 유아 매진 검사: 생성 직후
ssrInfos에서isInfantSoldOutAtBooking()이면cancelAsync(pnr)후StatusInvalidException(INFANT_SOLD_OUT). 스케줄 미확정(!confirmed && !confirming)이면cancelAsync후SOLD_OUT(:83-86). - 보상취소는 비동기:
cancelAsync(pnr)(:147-151)는CoroutineScope(Dispatchers.IO).withLaunch { cancelService.onlyPnrCancel(pnr) }— fire-and-forget. 예약 실패 응답을 막지 않으면서 만들어진 PNR을 백그라운드에서 정리한다. - 미노출 운임 학습:
catch에서isUnexposedFareItinerary(e)(SOLD_OUT/INFANT_SOLD_OUT/MINIMUM_CONNECTION_TIME)면 검색키 제거 +saveUnexposedFareItinerary로 매진 운임을 기록해 다음 검색에서 거른다. - 정합성 검증(
compareWithFareItinerary,:201): 총운임/fareBasis/bookingClass를 운임과 비교 — 불일치해도 throw가 아니라logger.error만 한다(예약은 진행). 단 이름/승객수 불일치는 throw. - 반환 직전 보정(
when블록:101-134):carrierTimeLimit==null→ 3초 delay 후 재조회,cabin==null→ 재조회, 승객 수 불일치 → 재조회 후에도 다르면BOOKING_FAILED. 즉 즉시 응답이 불완전하면 한 번 더 retrieve 한다.
flowchart TD A["getSessionToken (SOAP 세션 시작)"] --> B["markSeat<br/>좌석 점유, EnhancedAirBook → priceQuote rph"] B --> C["savePassengerInfo<br/>PassengerDetails → PNR"] C --> D["getBooking<br/>GetReservation"] D --> E{"유아매진? 또는 schedule 미확정?"} E -->|"yes"| F["cancelAsync + throw<br/>INFANT_SOLD_OUT 또는 SOLD_OUT"] E -->|"no"| G["compareWithFareItinerary<br/>불일치=로그만 (이름/승객수 불일치는 throw)"] G --> H["closeSessionToken (finally)"] H --> I{"when: carrierTimeLimit/cabin/승객수 보정 필요?"} I -->|"필요"| J["재retrieve (3초 delay 후 재조회)"] J --> K["return Booking"] I -->|"불필요"| K
2-2. repricing — 운임 재계산 GET /internals/SABRE/bookings/{pnr}/repricing
복잡 플로우: 오늘 자 PriceQuote가 아니면 삭제 후 재계산
SabreBookingService.repricing(pnr)(:310-327):
getBooking(token, pnr)로 현재 예약과priceQuoteCreatedAt조회priceQuoteCreatedAt.toLocalDate() != today()인 경우에만:
sabreClient.deletePriceQuote(token)(SabreClient.kt:497, DeletePriceQuoteRQ)sabreClient.repricing(token, passengers)(:520, OtaAirPriceRQ)sabreClient.endTransaction(token)(:610, EndTransactionRQ) — 세션에 커밋getBooking재조회 →validateFareBasisChange(originBooking, newBooking)(:348)- 오늘 자 PQ면 그대로 반환(재계산 생략)
validateFareBasisChange: 승객별 fare.fareComponents(fareBasis)가 바뀌면 cancelService.onlyPnrCancel(pnr) 후 StatusInvalidException(SOLD_OUT). 운임 구성이 바뀌면 곧 매진/조건변동으로 보고 예약을 취소한다. (동일 로직이 SabreTicketingService.ready에도 중복 존재 — sabre-pitfalls)
2-3. divide — PNR 분리 POST /internals/SABRE/bookings/{pnr}/divide
승객 일부를 별도 PNR로 떼어내는 SOAP 시퀀스(SabreBookingService.divide, :329-346):
sequenceDiagram participant S as "SabreBookingService.divide" participant X as "SabreClient (SOAP)" S->>X: "retrieve(pnr) — 요청 승객의 nameId 수집" X-->>S: "Booking" S->>X: "getSessionToken" X-->>S: "token" S->>X: "openPnr(token, pnr) — GetReservationRQ로 PNR 오픈" S->>X: "splitPnr(token, nameNumbers) — TravelItineraryDivideRQ.of → 새 PNR" S->>X: "confirmWithEndTransaction(token) — EndTransactionRQ(endTransaction=null)" S->>X: "saveSplitPnr(token) — TravelItineraryDivideRQ.ofSave" S->>X: "endTransaction(token)" Note over S,X: "closeSessionToken (finally) — 세션 정리" S->>X: "retrieve(newPnr)" X-->>S: "return 새 Booking"
divide는 4단계 SOAP 호출이 한 세션에서 순서대로 일어난다
splitPnr → confirmWithEndTransaction → saveSplitPnr → endTransaction. 중간 실패 시endTransaction이 호출되지 않아 세션에 미커밋 상태가 남을 수 있다. 세션은finally의closeSessionToken으로만 정리된다.
2-4. 기타 Booking 엔드포인트
| 엔드포인트 | 서비스 메서드 | 비고 |
|---|---|---|
PUT /{pnr} (changeApis) | SabrePassengerService.changeApis | §3 참조 (코루틴 집중 지점) |
PUT /{pnr}/cancel | SabreCancelService.cancel | §6 |
GET /{pnr}/expected-cancel | SabreCancelService.isVoidable | voidable 여부만 |
GET /{pnr}/cancelable | SabreCancelService.cancelable | 환불예상금 계산 |
GET /{pnr} (retrieve) | SabreBookingService.retrieve | validateScheduleStatus=false |
GET /{pnr}/check-pnr | SabreBookingService.checkPnr | sabreClient.checkPnr |
GET /{pnr}/confirm | SabreBookingService.confirm | 스케줄 OK + carrierPnr 검증, 실패 시 pnrCancelRepeat |
3. changeApis — 승객 APIS 변경 (코루틴 + 역순 삭제)
콜러→콜리 (
SabrePassengerService.changeApis,:12-71)Controller.changeApis (
PUT /{pnr}) └▶passengerService.changeApis(pnr, validatingCarrier, passengers)getSessionToken()→runBlocking { ... }→ finallycloseSessionToken
이 서비스는 모듈에서 코루틴(runBlocking)을 직접 쓰는 대표 지점이다(async-coroutines 참고). 흐름:
getBooking(token, pnr)로 기존 승객의 식별자(nameId, passportIds, ssr/osi id 등)를 매칭해changePassengers생성.- 기존 SSR/OSI id를 모아 연속 번호를 역순 범위로 변환(
consecutiveNumbersToRange,:75-92) —[1,2,3,5,7,8,9,10] → [7-10,5,1-3]. sabreClient.deleteSsr/deleteOsi를 큰 id부터 호출(작은 것부터 지우면 id가 밀려 엉뚱한 항목 삭제).sabreClient.createApis(token, validatingCarrier, changePassengers)(SpecialServiceRQ)로 새 APIS 작성.sabreClient.endTransaction(token)후 다시getBooking으로 갱신된 승객 반환.
삭제 순서가 정합성의 핵심
Sabre는 SSR/OSI를 삭제하면 뒤 id가 앞으로 당겨진다.
consecutiveNumbersToRange가 깨지면 다른 승객의 여권/연락처 정보가 삭제될 수 있다. 자세한 위험은 sabre-pitfalls.
4. Ticketing — 발권 (결제 → 발권 → 실패 시 보상취소)
콜러→콜리 체인 (
SabreTicketingService.issue,:52-138)Controller.issue (
POST /internals/SABRE/ticketing,:30-52) └▶sabreTicketingService.issue(pnr, validatingCarrier, passengerPrices, paymentInfo, keepPnr)getSessionToken()├▶sabreClient.getBooking+validateBookingConditionForTicketing()├▶ (결제 있으면)paymentService.approve(...)→Payment├▶sabreClient.ticketing(...)(SabreClient.kt:538, AirTicketRQ) →List<PassengerTicket>└▶retrieveTicket(...)→ 발권 누락 승객 검증 catch ▶ 결제했으면cancelAsync(...)/ 미결제+NOT_OK_SCHEDULE이면onlyPnrCancelfinally ▶closeSessionToken(실패 시 Slack 알림 후 throw)
핵심 디테일:
- 결제 분기(
SabrePaymentService.approve,:23-49):PaymentInfo.TossPay→sabrePaymentClient.approveTossPay,PaymentInfo.KeyInCard→sabrePaymentClient.pgCardCode(...)로 카드코드 조회 후sabrePaymentClient.approve. 결제 시스템은 별도 SOAP 엔드포인트(PaymentService?wsdl,TossPayService) + 별도 토큰(PaymentTokenRQ). - 발권 SOAP:
SabreClient.ticketing(:538)은 AirTicketRQ. 오류 메시지에INCORRECT CARD NUMBER/VERIFY COMMISSION-2133가 있으면 진단 정보를 채워TICKETING_FAILED. 타임아웃이면timeoutCallback()(→ Slack)을 호출. - 부분 발권 검증:
retrieveTicket(:170-205)에서 발권 응답 승객과 PNR 승객을 이름으로 매칭. 누락 승객이 있으면 Slack(sendPartiallyTicketingFail) 후PARTIALLY_TICKETING_FAILED. 커밋 안 된 티켓(!committed)이 있으면 그 전에TICKETING_FAILED. - 실패 보상(
cancelAsync,:140-168): 결제가 됐는데 발권 흐름이 실패하면 5초 delay 후cancelService.ticketingFailedCancel(...)을 백그라운드 코루틴으로 실행(결제 취소 + 보이드/PNR 취소). delay는 “locked pnr 방지”.
/ticketing/ready는 Deprecated
SabreTicketingController.ready→SabreTicketingService.ready(:25-50)는@Deprecated("API분리 후 제거 예정"). 내부 로직은 repricing과 동일(오늘 자 PQ 아니면 delete→repricing→endTransaction→fareBasis 검증). 발권 전 재계산을 묶었던 레거시 경로다.
flowchart TD A["getSessionToken"] --> B["getBooking + validateBookingConditionForTicketing"] B --> C["payment = approve(paymentInfo)?<br/>TossPay/KeyInCard 분기 (별도 SOAP)"] C --> D["passengerTickets = sabreClient.ticketing<br/>AirTicketRQ"] D --> E{"uncommitted 티켓 있음?"} E -->|"yes"| F["throw TICKETING_FAILED"] E -->|"no"| G["retrieveTicket"] G --> H{"누락 승객 있음?"} H -->|"yes"| I["throw PARTIALLY_TICKETING_FAILED"] H -->|"no"| J["발권 성공"] F --> CATCH I --> CATCH CATCH{"catch (e)"} -->|"payment != null"| K["cancelAsync<br/>delay 5s → ticketingFailedCancel<br/>결제+보이드 보상"] CATCH -->|"else NOT_OK_SCHEDULE"| L["onlyPnrCancel"] J --> FIN["finally: closeSessionToken<br/>실패 시 sendIncompleteTicketing"] K --> FIN L --> FIN
5. FareRule — 운임 규정 (REST 규정 + SOAP 구조화)
두 엔드포인트가 서로 다른 클라이언트를 탄다.
5-1. 텍스트 규정 GET /internals/SABRE/fare-rules
(
SabreFareRuleService.findFareRules,:31-64)Controller.getFareRules └▶
findFareRules(key, adult, child, infant)├▶ 캐시fareRuleRepository.findFareRules(fareRuleKey)HIT → 반환 ├▶ MISS:getFareItinerary(key)→sabreFlightSearchService.revalidate(...)(SOAP, 실패 시 null) ├▶airportService.getAirportsMap(...)└▶sabreFareRuleClient.findFareRules(...)(SabreFareRuleClient.kt:37) ──▶ GETfareRule.endpoint→saveFareRules캐시 저장
- 재검증을 먼저 한다:
revalidate로 받은 운임(revalidateFareItinerary ?: fareItinerary)을 규정 조회에 쓴다. 재검증 결과와 원본의 fareBasis/가격 차이는logger.error로만 남기고 진행(compareFarebasis/comparePrice). - 요청 본문 압축:
SabreFareRuleClient는BfmRule을 Deflate 압축 후 16진수 문자열(toCompressString)로 만들어BfmRule쿼리 파라미터에 싣는다(:112-130). 응답에서 “여권정보”/“항공환불수수료 면제 약관”은 제외.
5-2. 구조화 규정 GET /internals/SABRE/fare-rules/structured
SabreFareRuleService.getStructuredFareRules(:89-110): getSessionToken → sabreClient.getStructureFareRules(token, fareItinerary)(SOAP StructureFareRulesRQ). 예외를 삼키고 실패 시 세그먼트별 기본 정책(SegmentFareRulePolicy.of)으로 폴백한다. 세션 종료는 CoroutineScope(Dispatchers.IO).withLaunch { closeSessionToken }로 비동기 처리(응답 지연 최소화).
6. Cancel / Refund / Void — 취소 (가장 복잡한 분기)
SabreCancelService.cancel(:38-102)은 예약 상태에 따라 보이드 / 환불 / PNR취소로 갈린다. cancel 엔드포인트는 PUT /internals/SABRE/bookings/{pnr}/cancel.
flowchart TD A["getSessionToken"] --> B["booking = getBooking(pnr)"] B --> C{"ticketHistories 비어있음?"} C -->|"yes (발권 전)"| D["handleEmptyTicketHistories<br/>결제취소 + pnrCancelRepeat<br/>return emptyList"] C -->|"no"| W{"when 분기"} W -->|"① booking.isVoidable"| V["voidTickets(...)<br/>return emptyList (=voided)"] W -->|"② waiverRefundable 또는 autoRefundable"| R1["validateRefundableTickets<br/>EMD 존재/스케줄 미확정이면 CANCEL_UNABLE"] W -->|"③ else"| E3["throw CANCEL_UNABLE"] R1 --> R2{"waiverRefundable?"} R2 -->|"yes"| R3["saveWaiverRefunds<br/>OSI/SSR/REMARK 타입별 SOAP"] R2 -->|"no"| R4["getCancellableTickets<br/>REST checkFlightTickets"] R3 --> R4 R4 --> R5["refundTickets<br/>REST refundFlightTickets → List of Refund"] V --> ALSO["also: pnrCancelAsync(pnr)<br/>5초 delay 후 백그라운드 PNR 취소"] R5 --> ALSO D --> FIN["finally: closeSessionToken"] ALSO --> FIN E3 --> FIN
핵심 디테일:
- isVoidable=발권 당일 보이드 가능.
voidTickets(:297-332)는 보이드 대상 티켓의rph를 모아voidAll로 순차(sequential) 처리. 각 티켓은voidRepeat(:459)에서 최대 3회 재시도하고,RETRY/NO TCN.../PROCESSING ERROR면ignore(token)후 재시도.void는 한 번 더 보이드 컨펌을 위해 SOAP void를 2회 호출(:483-484). - 환불은 REST:
refundTickets(:334-380)는sabreRestClient.refundTickets(/v1/trip/orders/refundFlightTickets). 타임아웃/소켓 예외는sendCancelFailTimeout, 그 외는sendRefundFail. 부분 환불(일부 티켓만 환불)이면sendAllTicketRefundFail후REFUND_FAILED. - 환불 가능 티켓 조회:
getCancellableTickets(:502) →sabreRestClient.checkCancellableTickets(/v1/trip/orders/checkFlightTickets). 실패 시 Slack. - Waiver(면제) 처리:
saveWaiverRefunds(:516-540)는 타입별로 분기 —OSI/SSR→sabreClient.saveWaiverRefunds(SpecialServiceRQ),REMARK→sabreClient.saveRemark(AddRemarkRQ),AUTH_CODE는 제외. 마지막에endTransaction. - PNR 취소 재시도:
pnrCancelRepeat(:394-419)는 최대 2회.ALREADY_CANCELED_PNR이면 정상 종료, 마지막 시도 실패면sendCancelFail후 throw, 중간 실패면ignore(token)후 재시도. 실제 취소(pnrCancel,:421)는withBlocking { delay(1000); openPnr; pnrCancel; endTransaction }. - 비동기 후처리:
pnrCancelAsync(:382-392)는withLaunch { delay(5000); 새 세션으로 pnrCancelRepeat }— 보이드/환불 후 PNR 정리를 백그라운드로. delay는 “locked pnr 방지”.
취소 진입점이 4개다 — 각자 보호조건이 다르다
진입점 호출자 핵심 가드 cancel사용자 취소(Controller) voidable/refundable 분기, 처리 후 pnrCancelAsynconlyPnrCancel예약/repricing 실패 보상 isPnrCreatedAtBeforeYesterdayOrNoShow면CANCEL_UNABLEticketingFailedCancel발권 실패 보상 발권데이터 유무로 emptyTicket/voidTickets 분기, keepPnr옵션pnrCancelRepeat(public)confirm실패 시 직접 호출토큰을 외부에서 받아 재시도만
cancelable(:183-223)는 cancel과 같은 분기를 타지만 실제 취소 없이 CancelableTypeDetail(VOID/REFUND + 환불예상금 findRefunds)만 계산한다. findRefunds(:275)는 EMD 티켓이 없을 때만 환불예상금을 채운다.
7. Queue — 큐 (멀티 PCC, 순차 작업모드)
(
SabreQueueService,:32-138)
GET /internals/SABRE/queues→getQueuePnrs()PUT /internals/SABRE/queues→remove(queueNumber, pcc, pnrs)@Retryable(3, 5s)
- 조회(
getQueuePnrs): 설정(sabreProperties.channels)의 모든 PCC(레거시3OGJ/7CZJ제외)에 대해 세션을 열고, 큐 번호5,6,7,20(TARGET_ORIGIN_QUEUE_NUMBERS)을withBlocking(Dispatchers.IO){ ...pmap{} }로 병렬 조회.getPnrCountInQueue(QueueCountRQ) > 0 일 때만getPnrsInQueueAction(QueueAccessRQ,listOfRecordLocator=true)로 PNR 목록 수집. PCC 단위 실패는 Slack(sendQueueFail) 후emptyList()로 격리. - 제거(
remove): 큐 “작업모드”는 상태형이다 — 최초 진입 PNR을 받고,queueCount만큼 반복하며currentPnr in pnrs면QR(제거), 아니면I(건너뛰기) 액션을 보내고 다음 PNR을 받는다(:107-122). 컨트롤러는(queueNumber, pccOid)로 그룹핑해 PCC별로 한 번씩 호출(SabreQueueController.kt:24-34).
큐 토큰은 채널/퍼널/날짜로 다른 PCC 자격을 고른다
getSessionToken(channel, funnel, targetDate)은sabreProperties.getApiProperties(pcc=...)로 해당 PCC의 자격을 골라 세션을 연다. 일반 트랜잭션의getSessionToken()(MDC 기반)과 인자가 다르다.
8. CashReceipt — 현금영수증 (발행 / 취소)
(
SabreCashReceiptService,:24-79)
POST /cash-receipts/issue→issue(...);PUT /cash-receipts/cancel→cancel(...)
- 발행(
issue):getSessionToken→getBooking+validateTicket(:81-98) → 대표 티켓번호 선택(passengers.sortedBy{type.order}.firstNotNullOf{...}) →sabrePaymentClient.cashReceipt(...)(CardApprovalRQ.ofCashReceipt). 검증 규칙: 티켓 없음/카드결제 존재/요청가 > 현금가면CASH_RECEIPT_ISSUE_FAILED. - 취소(
cancel): 세션 없이runCatching { sabrePaymentClient.cancelCashReceipt(...) }. 실패 시handleCancellationFailure로 비동기 Slack 후 rethrow. - 실패 알림은 모두
CoroutineScope(Dispatchers.IO).withLaunch { slackService... }로 비동기(:100-120).
9. 비동기/코루틴 — Sabre에서의 사용 위치
Sabre는 "동기 트랜잭션 + 비동기 보상/알림" 패턴
본 트랜잭션(예약/발권/취소)은
withBlocking { ... }로 블로킹하지만, 보상취소·알림·캐시정리·세션종료 일부는 fire-and-forget으로 흘려보낸다. 유틸은 모두support/util/CoroutineExtensions.kt, 예외는support/exception/AdapterCoroutineExceptionHandler.kt가 잡는다(async-coroutines).
| 용도 | 코드 | 위치 |
|---|---|---|
| 검색 fan-out 병렬 | withBlocking(Dispatchers.IO){ ...pmap{ search } } | SabreFlightSearchService.kt:67 |
| amenity 비동기 저장 | CoroutineScope(IO).withLaunch{ saveFlightAmenities } | SabreFlightSearchService.kt:111 |
| APIS 변경(직접 코루틴) | runBlocking { ... } | SabrePassengerService.kt:19 |
| 예약 실패 보상취소 | CoroutineScope(IO).withLaunch{ onlyPnrCancel } | SabreBookingService.kt:148 |
| 발권 실패 보상취소(delay 5s) | CoroutineScope(IO).withLaunch{ delay(5000); ticketingFailedCancel } | SabreTicketingService.kt:147 |
| 취소 후 PNR 정리(delay 5s) | withLaunch{ delay(5000); pnrCancelRepeat } | SabreCancelService.kt:382 |
| PNR 취소 본체 | withBlocking{ delay(1000); openPnr; pnrCancel; endTransaction } | SabreCancelService.kt:421 |
| void 본체 | withBlocking{ openPnr; void; void; delay(1000) } | SabreCancelService.kt:480 |
| 결제취소 비동기 | CoroutineScope(IO).withLaunch{ cancel/cancelTossPay } | SabrePaymentService.kt:51 |
| 큐 병렬 조회 | withBlocking(IO){ ...pmap{} } | SabreQueueService.kt:45 |
| 구조화 규정 세션종료 비동기 | withLaunch{ closeSessionToken } | SabreFareRuleService.kt:102 |
10. 자가 점검
Q1. Sabre의 검색(search)과 재검증(revalidate)은 같은 프로토콜을 쓰는가?
아니다.
SabreFlightSearchService.search는 REST(SabreRestClient.search→/v5/offers/shop, BargainFinderMax)를,revalidate는 SOAP(SabreClient.revalidate→ OtaAirLowFareSearchRQ.ofRevalidate)를 탄다. 발권/예약/취소 트랜잭션은 SOAP 세션 기반이다. sabre-protocol 참조.
Q2. 예약 생성 중 총운임이 예약과 운임캐시가 다르면 어떻게 되는가?
compareTotalFare(SabreBookingService.kt:212)는 다르면logger.error만 찍고 예약을 진행한다(throw 아님). 반면 fareBasis는 repricing 단계(validateFareBasisChange)에서 바뀌면onlyPnrCancel+SOLD_OUT로 취소된다. 즉 검증 강도가 단계마다 다르다.
Q3. 발권이 결제까지 됐는데 실패하면 무엇이 자동으로 돌아가는가?
SabreTicketingService.issue의 catch에서payment != null이면cancelAsync(:140)가 5초 후cancelService.ticketingFailedCancel을 실행한다. 이 메서드는 발권데이터 유무에 따라 결제취소(paymentService.cancelAsync) + 보이드 또는 PNR 취소를 수행하고, 미완료 보이드는 Slack(sendIncompleteVoid)으로 알린다.
Q4. 취소(cancel)의 세 갈래는 무엇으로 결정되는가?
ticketHistories가 비면 발권 전 →handleEmptyTicketHistories(결제취소+PNR취소). 발권됐고isVoidable(당일)이면voidTickets. 그 외autoRefundable || waiverRefundable이면checkFlightTickets→refundFlightTickets(REST 환불). 어디에도 해당 안 되면CANCEL_UNABLE. 모든 성공 경로 끝에pnrCancelAsync로 PNR을 백그라운드 정리한다. (SabreCancelService.kt:38)
다음 학습 경로
이 노트(오퍼레이션) → 프로토콜(SOAP 세션 vs REST 토큰, 요청/응답 모델) → 지뢰(세션 누수·삭제 순서·중복 코드·이중 search) → 공통 비교는 common-operations·caller-callee-map, 비동기는 async-coroutines.