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)
SearchSabreSearchControllerSabreFlightSearchServiceSabreRestClient(검색=REST), SabreClient(재검증=SOAP)
BookingSabreBookingControllerSabreBookingService, SabrePassengerService, SabreCancelServiceSabreClient(SOAP), SabreRestClient(환불)
TicketingSabreTicketingControllerSabreTicketingService, SabrePaymentService, SabreCancelServiceSabreClient(SOAP), SabrePaymentClient(결제 SOAP)
FareRuleSabreFareRuleControllerSabreFareRuleServiceSabreFareRuleClient(REST), SabreClient(구조화 규정 SOAP)
QueueSabreQueueControllerSabreQueueServiceSabreClient(SOAP, 멀티 PCC)
CashReceiptSabreCashReceiptControllerSabreCashReceiptServiceSabrePaymentClient(결제 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:704 getBooking조회(retrieve) 재시도. ApiException.retryable==false면 재시도 안 함
@Retryable(include=[IOException], maxAttempts=2, backoff=Backoff(delay=1000))SabreClient.kt:310 getSessionToken세션 토큰 생성 IO 오류 재시도
@Retryable(maxAttempts=3, backoff=Backoff(delay=5000))SabreQueueService.kt:85 remove큐 제거 재시도
예약/발권/취소 본 트랜잭션에는 @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). pmapsupport/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"]

revalidate vs search

검색(search)은 REST, 재검증(revalidate, SabreFlightSearchService.kt:182)은 SOAP SabreClient.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)이면 cancelAsyncSOLD_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):

  1. getBooking(token, pnr)로 현재 예약과 priceQuoteCreatedAt 조회
  2. 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)
  3. 오늘 자 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이 호출되지 않아 세션에 미커밋 상태가 남을 수 있다. 세션은 finallycloseSessionToken으로만 정리된다.

2-4. 기타 Booking 엔드포인트

엔드포인트서비스 메서드비고
PUT /{pnr} (changeApis)SabrePassengerService.changeApis§3 참조 (코루틴 집중 지점)
PUT /{pnr}/cancelSabreCancelService.cancel§6
GET /{pnr}/expected-cancelSabreCancelService.isVoidablevoidable 여부만
GET /{pnr}/cancelableSabreCancelService.cancelable환불예상금 계산
GET /{pnr} (retrieve)SabreBookingService.retrievevalidateScheduleStatus=false
GET /{pnr}/check-pnrSabreBookingService.checkPnrsabreClient.checkPnr
GET /{pnr}/confirmSabreBookingService.confirm스케줄 OK + carrierPnr 검증, 실패 시 pnrCancelRepeat

3. changeApis — 승객 APIS 변경 (코루틴 + 역순 삭제)

콜러→콜리 ( SabrePassengerService.changeApis, :12-71)

Controller.changeApis (PUT /{pnr}) └▶ passengerService.changeApis(pnr, validatingCarrier, passengers)   getSessionToken()runBlocking { ... } → finally closeSessionToken

이 서비스는 모듈에서 코루틴(runBlocking)을 직접 쓰는 대표 지점이다(async-coroutines 참고). 흐름:

  1. getBooking(token, pnr)로 기존 승객의 식별자(nameId, passportIds, ssr/osi id 등)를 매칭해 changePassengers 생성.
  2. 기존 SSR/OSI id를 모아 연속 번호를 역순 범위로 변환(consecutiveNumbersToRange, :75-92) — [1,2,3,5,7,8,9,10] → [7-10,5,1-3].
  3. sabreClient.deleteSsr / deleteOsi큰 id부터 호출(작은 것부터 지우면 id가 밀려 엉뚱한 항목 삭제).
  4. sabreClient.createApis(token, validatingCarrier, changePassengers) (SpecialServiceRQ)로 새 APIS 작성.
  5. 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이면 onlyPnrCancel   finally ▶ closeSessionToken (실패 시 Slack 알림 후 throw)

핵심 디테일:

  • 결제 분기(SabrePaymentService.approve, :23-49): PaymentInfo.TossPaysabrePaymentClient.approveTossPay, PaymentInfo.KeyInCardsabrePaymentClient.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.readySabreTicketingService.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) ──▶ GET fareRule.endpoint     → saveFareRules 캐시 저장

  • 재검증을 먼저 한다: revalidate로 받은 운임(revalidateFareItinerary ?: fareItinerary)을 규정 조회에 쓴다. 재검증 결과와 원본의 fareBasis/가격 차이는 logger.error로만 남기고 진행(compareFarebasis/comparePrice).
  • 요청 본문 압축: SabreFareRuleClientBfmRule을 Deflate 압축 후 16진수 문자열(toCompressString)로 만들어 BfmRule 쿼리 파라미터에 싣는다(:112-130). 응답에서 “여권정보”/“항공환불수수료 면제 약관”은 제외.

5-2. 구조화 규정 GET /internals/SABRE/fare-rules/structured

SabreFareRuleService.getStructuredFareRules(:89-110): getSessionTokensabreClient.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 ERRORignore(token) 후 재시도. void한 번 더 보이드 컨펌을 위해 SOAP void를 2회 호출(:483-484).
  • 환불은 REST: refundTickets(:334-380)는 sabreRestClient.refundTickets(/v1/trip/orders/refundFlightTickets). 타임아웃/소켓 예외는 sendCancelFailTimeout, 그 외는 sendRefundFail. 부분 환불(일부 티켓만 환불)이면 sendAllTicketRefundFailREFUND_FAILED.
  • 환불 가능 티켓 조회: getCancellableTickets(:502) → sabreRestClient.checkCancellableTickets(/v1/trip/orders/checkFlightTickets). 실패 시 Slack.
  • Waiver(면제) 처리: saveWaiverRefunds(:516-540)는 타입별로 분기 — OSI/SSRsabreClient.saveWaiverRefunds(SpecialServiceRQ), REMARKsabreClient.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 분기, 처리 후 pnrCancelAsync
onlyPnrCancel예약/repricing 실패 보상isPnrCreatedAtBeforeYesterdayOrNoShowCANCEL_UNABLE
ticketingFailedCancel발권 실패 보상발권데이터 유무로 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/queuesgetQueuePnrs() PUT /internals/SABRE/queuesremove(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 pnrsQR(제거), 아니면 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/issueissue(...); PUT /cash-receipts/cancelcancel(...)

  • 발행(issue): getSessionTokengetBooking + 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. 자가 점검


다음 학습 경로

이 노트(오퍼레이션) → 프로토콜(SOAP 세션 vs REST 토큰, 요청/응답 모델) → 지뢰(세션 누수·삭제 순서·중복 코드·이중 search) → 공통 비교는 common-operations·caller-callee-map, 비동기는 async-coroutines.