Sabre — 지뢰요소

module-sabre pattern-error-handling pattern-resilience arch-stateful-session

이 노트의 범위

Sabre(1S, GDS/SOAP + 일부 REST) 모듈을 운영하다 다치는 지점을 전수했다. 각 지뢰는 실제 코드 위치(file:line)왜 위험한지, 밟지 않는 법을 함께 적었다. 동작/흐름 자체는 Sabre — 오퍼레이션, 전문(電文)·프로토콜 디테일은 Sabre — 프로토콜, 모듈 전반의 지뢰 색인은 landmines, 어댑터 공통 예외 모델은 error-handling, 서킷브레이커/리트라이/Slack 경보 메커니즘은 resilience-and-events를 함께 보라.

Sabre 모듈은 11개 공급사 중 두 번째로 큰 모듈이며, stateful 세션(토큰) + SOAP 본문 + GDS 특유의 영문 에러 메시지 문자열 매칭이 결합되어 함정의 밀도가 매우 높다. 특히 “예외가 던져지는 순간 결제는 이미 됐는데 발권은 안 된” 상태가 흔하기 때문에, 보상 트랜잭션(결제취소/PNR취소/void) 이 코드 곳곳에 비동기로 흩어져 있다. 이 비동기 보상 로직이 Sabre 운영의 가장 큰 지뢰밭이다.


0. 한눈에 보는 지뢰 지도

flowchart TD
    Search["Search 검색"] --> SearchM["영문메시지 화이트리스트 미스시 SEARCH 전체 실패로 오분류<br/>ECONOMY 단독검색시 PREMIUM_ECONOMY 강제추가 임시코드"]
    Book["Book 예약"] --> BookM["compareWithFareItinerary 는 검증이라 쓰고 로그만 찍음 throw 없음<br/>INFT SsrInfo 상태 또는 schedule 미확정시 cancelAsync fire-and-forget"]
    Ticket["Ticket 발권"] --> TicketM["payment 가 null 아니면 무조건 cancelAsync errorMessage 무시<br/>closeSessionToken 실패시 sendIncompleteTicketing 후 rethrow<br/>uncommitted 또는 partially ticket 은 capture Sentry"]
    Cancel["Cancel 취소"] --> CancelM["void 는 두 번 호출 컨펌, RETRY 또는 특정메시지만 ignore 후 재시도<br/>refund 전건환불 아니면 REFUND_FAILED, EMD 있으면 환불불가<br/>delay 5000 locked pnr 방지 시간 의존 매직넘버"]
    Session["Session 세션"] --> SessionM["토큰 캐시 TTL 6일 getToken vs getSessionToken 은 매번 새로 Retryable<br/>PCC 선택이 channel funnel targetDate period 3중 키"]
    Payment["Payment 결제"] --> PaymentM["PaymentError 는 에러코드에서 ErrorMessage 로 하드코딩 해시셋 매핑<br/>cardCode 정규식, INCORRECT CARD NUMBER 문자열 분기"]

1. 공급사 고유 예외·에러코드와 ErrorMessage 매핑

1-1. Sabre는 “영문 메시지 문자열”로 성패를 가린다 (가장 중요)

화이트리스트에 없는 정상 메시지가 들어오면 "검색 실패"로 오분류된다

SOAP OtaAirLowFareSearch / REST BargainFinderMax 모두, 응답에 에러 메시지가 있으면 “무재고를 뜻하는 정상 메시지 화이트리스트” 와 대조한다. 화이트리스트에 하나도 안 걸리면 SEARCH_FAILED로 간주하고 .capture()(Sentry 경보)까지 한다.

infrastructure/soap/SabreClient.kt:190-211, infrastructure/rest/SabreRestClient.kt:134-156

// SabreClient.kt:191-205 — 검색 "성공인데 무재고"를 판별하는 화이트리스트
if (messages.none {
        listOf(
            "NO AVAILABILITY", "NO COMBINABLE FARES FOR CLASS USED", "NO FARE",
            "NO FARE FOR CLASS USED", "NO FLIGHT SCHEDULES FOR QUALIFIERS USED",
            "NO SCHEDULE", "NO SOLUTION FOUND",
            "NO SOLUTION PASSED INTERLINE TICKETING VALIDATION",
            "NO SUCH FLIGHT", "NO SOLUTIONS TIMEOUT ERROR", "INTERNAL ERROR"
        ).contains(it.uppercase())
    }) {
    throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, ...).capture()
}

함정 포인트:

  • Sabre가 메시지 문구를 한 글자라도 바꾸면 즉시 오작동한다(대문자 정규화만 함, 부분일치 아님 — contains(it.uppercase())는 리스트가 메시지 전체와 정확히 같아야 매칭).
  • 동일 로직이 SOAP와 REST 두 곳에 복붙돼 있어 한쪽만 고치면 불일치 발생.
  • "INTERNAL ERROR" 까지 “정상 무재고”로 취급한다 — Sabre 측 실제 내부 오류를 빈 결과로 삼키는 셈이다.

1-2. 발권 실패 메시지의 특수 분기 (카드번호/커미션)

SabreClient.ticketing()checkError 콜백(SabreClient.kt:565-596)은 에러 메시지 안에서 특정 문구를 찾아 디버깅 컨텍스트를 추가한다.

메시지 부분 문자열처리위치
INCORRECT CARD NUMBER카드번호 앞 8자리 + cardCode를 에러 obj에 추가 (토스페이면 카드번호 제외)SabreClient.kt:571-580
VERIFY COMMISSION-2133요청 커미션 타입 vs PQ 커미션 타입을 비교해 obj에 추가SabreClient.kt:582-590
그 외원본 messages 그대로SabreClient.kt:592

에러 메시지에 카드번호 앞 8자리가 들어간다

payment?.cardNumber?.take(8) 가 예외 객체(objs)에 담긴다. 로그/Sentry 마스킹이 풀리면 카드 BIN+일부가 노출될 수 있다. 마스킹은 support/log/EncryptValueMasker.kt 에 의존한다.

1-3. void 응답의 “이미 체크인” 분기

SabreClient.void()(SabreClient.kt:780-792)는 void 실패 메시지가 "1 OR MORE COUPONS UNABLE TO VOID - DISPLAY ETR TO VERIFY-0294" 를 포함하면 CANCEL_UNABLE_BY_ALREADY_CHECK_IN, 아니면 VOID_FAILED로 분기한다. 문자열에 의존하므로 Sabre가 코드(-0294)를 바꾸면 분기가 깨진다.

1-4. 결제 에러코드 → ErrorMessage 하드코딩 매핑

PaymentError 해시셋 매핑 ( infrastructure/soap/PaymentError.kt)

카드/토스페이 승인·취소 응답의 errorCode 를 사람이 직접 분류한 해시셋으로 ErrorMessage 에 매핑한다. 매핑에 없으면 전부 PAYMENT_ETC 로 떨어지고 .capture() 된다(CardApprovalRS.kt:25, TossPayApprovalRS.kt:29).

// PaymentError.kt — 일부
hashSetOf("0061","8326","KM51","05KM","8339","BC70","0001") to PAYMENT_MAXIMUM_EXCEEDED
hashSetOf("0051","KML9","8373","0430","8327","HNLF","NH38","9999","8375","BC38") to PAYMENT_INSUFFICIENT_FUNDS
hashSetOf("0053","8336","0014","KM56","8314","E0413") to PAYMENT_INVALID_CARD_INFORMATION
hashSetOf("8330") to PAYMENT_DAILY_LIMIT_EXCEEDED
hashSetOf("HNKR","NHD1","KMI5","8038","8417") to PAYMENT_INSTALLMENT_NOT_ALLOWED
hashSetOf("KMT4","0200") to PAYMENT_ONCE_LIMIT_EXCEEDED
hashSetOf("0201") to PAYMENT_INVALID_INSTALLMENT_MONTH
hashSetOf("BC81") to PAYMENT_TODAY_REGISTRATION_CARD
// 그 외 → PAYMENT_ETC

지뢰:

  • PG사가 신규 에러코드를 추가하면 모두 PAYMENT_ETC로 뭉뚱그려진다. 운영 중 “왜 자꾸 PAYMENT_ETC 가 뜨지?”의 80%는 미등록 코드다.
  • CardApprovalRS.checkError()errorCode != "0" && errorMsg != null 두 조건을 모두 충족해야만 throw한다(CardApprovalRS.kt:20). errorMsg가 null이면 errorCode가 비정상이어도 통과한다.

ErrorMessage enum 전체는 support/exception/ErrorMessage.kt(122개). Sabre가 던지는 대표 값: SEARCH_FAILED, BOOKING_FAILED, TICKETING_FAILED, PARTIALLY_TICKETING_FAILED, REPRICING_FAILED, SOLD_OUT, INFANT_SOLD_OUT, NOT_OK_SCHEDULE, NON_RETRIEVABLE_SCHEDULE_STATUS, ALREADY_CANCELED_PNR, NO_PRICE_QUOTE, CANCEL_UNABLE(_BY_*), VOID_FAILED, REFUND_FAILED, DIVIDE_FAILED, PAYMENT_* 등.


2. 상태/세션 함정 (세션·토큰 만료)

Sabre는 두 종류의 토큰을 쓴다. 둘의 수명·캐시 전략이 완전히 다르다는 것이 첫 번째 함정이다.

토큰발급 위치캐시용도주의
검색용 토큰 (getToken)SabreClient.kt:122-139Redis SABRE-TOKEN, TTL 6일 (CacheSet.kt:38-41)SOAP 검색@Cacheable(key="'SEARCH'") 고정 키. 6일 안에 Sabre가 무효화하면 캐시는 죽은 토큰을 계속 반환
세션 토큰 (getSessionToken)SabreClient.kt:310-341캐시 안 함, 매 호출 신규 발급예약/발권/취소 등 stateful 작업작업마다 getSessionToken → ... → closeSessionToken finally 쌍 필수
REST 토큰 (getToken)SabreRestClient.kt:67-98Redis SABRE-ACCESS-TOKEN::{pcc}, TTL 6일REST(BFM/취소/환불)PCC별 키

세션 토큰은 반드시 try/finally로 닫아야 한다 — 안 닫으면 세션 풀 고갈

거의 모든 application 서비스가 val token = sabreClient.getSessionToken(); try { ... } finally { sabreClient.closeSessionToken(token) } 패턴이다. SabreClient.getSessionToken@Retryable(include=[IOException], maxAttempts=2) 이고 callTimeout(5000)(SESSION_TIMEOUT)으로 별도 타임아웃을 건다(SabreClient.kt:329). finally를 빠뜨리면 Sabre 세션이 누수되어 결국 신규 세션 발급이 막힌다.

2-1. 토큰 만료 시 6일 캐시가 죽은 토큰을 준다

getToken() / REST getToken() 은 둘 다 6일 TTL 캐시를 쓴다. Sabre가 그 전에 토큰을 무효화하면(보안 정책/세션 정리), 캐시는 만료까지 죽은 토큰을 계속 내준다. 검색이 갑자기 전량 실패하면 가장 먼저 의심할 곳이다.

2-2. closeSessionToken 실패의 두 얼굴

// SabreTicketingService.issue() finally — SabreTicketingService.kt:124-137
finally {
    if (token != null) {
        try {
            sabreClient.closeSessionToken(token)
        } catch (e: Exception) {
            slackService.sendIncompleteTicketing(supplier = SABRE, pnr = pnr, reason = e.message)
            throw e   // ← finally 안에서 rethrow: 원래 예외를 가린다
        }
    }
}

finally 블록 안의 throw가 try의 원래 예외를 덮어쓴다

발권 본문에서 예외가 났는데 closeSessionToken 도 실패하면, 세션 종료 실패 예외가 던져지고 원인 예외는 사라진다(Java/Kotlin은 finally의 throw가 try의 throw를 suppress). 발권 실패 디버깅 시 “왜 SESSION_CLOSE 에러만 보이지?”의 원인. 반대로 SabreFareRuleService.getStructuredFareRules(:101-109)는 close를 비동기(withLaunch) 로 빼서 이 문제를 회피한다 — 같은 모듈인데 패턴이 다르다.

2-3. PCC(Pseudo City Code)는 3중 키로 선택된다

SabreProperties.getApiProperties(salesChannel, salesFunnel, targetDate)(configuration/Properties.kt:108-118)는 channel → funnel → period(targetDate가 period 안에 드는지) 순으로 API 설정을 고른다.

// Properties.kt:99-105
fun get(funnel: String, targetDate: LocalDateTime): SabreApiProperties {
    return funnels.firstOrNull { it.funnel == funnel && it.period?.between(targetDate) ?: true }
        ?: throw InternationalAdapterException(NOT_SUPPORTED_SALES_FUNNEL, funnel)
}

함정:

  • targetDate 기본값은 MDCHolder.PnrCreatedAt.get(). MDC가 비어 있으면 엉뚱한 날짜로 PCC가 선택된다(취소/큐 같은 배치성 작업에서 특히 위험).
  • 세션을 A funnel/PCC로 열고 B로 닫으면 세션이 안 닫힌다. closeSessionToken 도 동일한 channel/funnel/targetDate 인자를 받으므로(SabreClient.kt:343-369), 열 때와 닫을 때 MDC가 바뀌면(예: 비동기 코루틴으로 넘어가며 MDC 유실) PCC 불일치 위험.
  • 큐 서비스는 명시적으로 getSessionToken(channel, funnel, targetDate) 를 넘겨 이 문제를 막는다(SabreQueueService.kt:38-42, 92-96). 단 LEGACY_PCC = ["3OGJ","7CZJ"] 는 큐 수집에서 제외한다(SabreQueueService.kt:29, 34).

2-4. retrieve 실패 후 ignore — 세션 상태 롤백

getBooking(SabreClient.kt:709-729)은 retrieve 중 예외가 나면 (e as? ApiException)?.retryable != false 일 때 ignore(token) 으로 세션의 미커밋 작업을 버린다. noRetry() 가 찍힌 예외(NON_RETRIEVABLE_SCHEDULE_STATUS, RETRIEVE_FAILED, ALREADY_CANCELED_PNR)는 ignore를 건너뛴다. 즉 retryable 플래그가 세션 정리 동작까지 좌우한다.


3. @Retry / @CircuitBreaker 동작과 주의·폴백

Sabre는 두 가지 리트라이를 섞어 쓴다 — 헷갈리지 말 것

  1. Spring Retry (@Retryable, org.springframework.retry) — SabreClient / SabreQueueService 메서드 단위
  2. 수동 for-루프 재시도SabreCancelServicevoidRepeat, pnrCancelRepeat Resilience4j 서킷브레이커는 “검색”에만 걸려 있다(application.yml:53 sabreSearch). 예약/발권/취소에는 서킷브레이커가 없다.

3-1. Spring @Retryable 위치와 조건

메서드설정조건/주의
SabreClient.getSessionTokenmaxAttempts=2, backoff=1000ms, include=[IOException] (:310-314)IOException만 재시도
SabreClient.getBookingmaxAttempts=3, backoff=2000ms, exceptionExpression="@sabreClient.shouldRetrieveRetryable(#root)" (:704-708)SpEL로 retryable 플래그 검사
SabreQueueService.removemaxAttempts=3, backoff=5000ms (:85)조건 없음 = 모든 예외 재시도

getBooking 재시도는 부수효과를 동반한다

getBooking 은 재시도 가능 예외일 때 catch 안에서 ignore(token) 를 호출하고(SabreClient.kt:722-728) 다시 throw → Spring Retry가 또 호출. 재시도마다 ignore + 새 retrieve 가 일어나며, retrieve 자체가 또 ignore를 부를 수 있다. 세션 상태가 시도마다 변하므로 멱등하지 않다. shouldRetrieveRetryableretryable ?: true플래그가 null(기본)인 일반 예외도 재시도한다.

@Retryable 은 self-invocation 시 동작하지 않는다

Spring Retry는 AOP 프록시 기반이다. 같은 빈 내부에서 this.getBooking(...) 으로 부르면 프록시를 안 거쳐 재시도가 안 먹는다. 다행히 getBooking 은 application 서비스(다른 빈)에서 호출되므로 동작한다. exceptionExpression@sabreClient 도 자기 자신을 빈 이름으로 참조하는 트릭이다 — 빈 이름이 sabreClient 가 아니게 되면 깨진다.

3-2. 수동 재시도: void는 “특정 메시지만” 재시도

// SabreCancelService.voidRepeat — SabreCancelService.kt:459-478
for (count in 1..3) {
    try { void(token, pnr, rph); break }
    catch (e: Exception) {
        if (count < 3 && (e.message?.contains("RETRY") == true
                || e.message?.contains("NO TCN/AT NBR MATCH-VERIFY-POSSIBLE MANUAL RFND REQUIRED") == true
                || e.message?.contains("PROCESSING ERROR DETECTED") == true))
            sabreClient.ignore(token)   // 세션 롤백 후 재시도
        else throw e
    }
}

지뢰:

  • 재시도 트리거가 영문 메시지 문자열 3종이다. Sabre 문구가 바뀌면 재시도 안 함.
  • void() 자체가 내부에서 두 번 호출된다(voidvoid “한번더 보이드 컨펌 해야함” SabreCancelService.kt:483-484). 즉 voidRepeat 한 번에 SOAP void가 2회 나간다.

pnrCancelRepeat(:394-419)는 2회 재시도이고, ALREADY_CANCELED_PNR 이면 정상 종료(break), 2회째 실패 시 sendCancelFail Slack 후 rethrow, 그 외엔 ignore(token) 후 재시도.

3-3. 서킷브레이커 폴백

resilience4j.circuitbreaker.instances.sabreSearch(application.yml:53-54)는 search baseConfig(slidingWindow=180s TIME_BASED, minimumNumberOfCalls=30, waitDurationInOpenState=120s, failureRateThreshold=35%)를 쓴다. 검색만 보호되며, 1-1의 “INTERNAL ERROR를 정상 취급” 때문에 실제 Sabre 장애가 실패율로 집계되지 않아 서킷이 안 열릴 수 있다(중요한 부작용). 자세한 전이/경보는 resilience-and-events.


4. 운임·통화·세금·수수료 계산 함정

4-1. compareWithFareItinerary 는 “검증”이 아니라 “로그”다

예약 후 운임/farebasis/bookingClass 가 검색 결과와 달라도 예약은 그대로 진행된다

SabreBookingService.compareWithFareItinerary(:201-251)는 totalFare/fareBasis/bookingClass 가 검색 시점과 다르면 logger.error 만 찍고 throw하지 않는다. 즉 “값이 바뀌었어도 그 PNR로 발권이 진행”된다. 운임 변동을 막는 게 아니라 사후 추적용이다.

// SabreBookingService.kt:220-222
if (fareItineraryTotalFare != bookingTotalFare) {
    logger.error("[compareWithFareItinerary] different totalfare. (${booking.pnr}, ...)")
}   // ← throw 없음

실제 운임 변동을 막는 검증은 다른 곳이다:

  • revalidate (REST/SOAP): 재검증 운임이 다르면 REVALIDATE_FAILED/SOLD_OUT throw (SabreRestClient.kt:500-517, SabreClient.kt:264-281).
  • validateFareBasisChange (repricing 시): fareComponents 문자열이 바뀌면 PNR 취소 + SOLD_OUT throw (SabreBookingService.kt:348-365, SabreTicketingService.kt:207-224).

4-2. fareBasis 비교의 황당한 버그 후보

// SabreBookingService.kt:233-234, SabreFareRuleService.kt:69
val bookingFarebasis = it.fareBasisCodes?.distinct()?.joinToString { "_" }

joinToString { "_" } 는 fareBasis 값을 버리고 "_"만 나열한다

joinToString { "_" } 의 람다는 변환 함수다. 각 원소를 무조건 "_" 로 바꾼 뒤 합치므로 결과는 "_, _, _"(원소 개수만큼 언더스코어)이 된다. 실제 fareBasis 코드는 비교에서 사라진다. 의도는 joinToString("_")(구분자) 였을 가능성이 높다. 이 코드는 개수만 같으면 “동일”로 판정한다. (단 4-1처럼 어차피 로그용이라 운영 영향은 제한적이지만, FareRule 비교에도 같은 패턴이 있다.)

4-3. 현금영수증 금액·결제수단 검증

SabreCashReceiptService.validateTicket(:81-98):

  • 티켓 이력 없으면 CASH_RECEIPT_ISSUE_FAILED.
  • 카드 발권(cardPrice>0)이면 현금영수증 발행 불가passengerTickets.sumOf { it.cardPrice } > 0 (:88).
  • 요청 금액이 총 현금가(cashPrice 합) 초과면 실패 (:92).

cardPrice/cashPrice 는 REST getBooking 경로에서 0으로 고정된다

FlightTicket.toTicket(infrastructure/rest/.../FlightTicket.kt:46-53)은 cardPrice = 0, cashPrice = 0, commissionRate = 0.0, conjunctionTicketNumber = null하드코딩한다(미구현 TODO). REST getBooking으로 만든 Booking에 cashReceipt 검증을 적용하면 항상 “현금가 0”으로 판정될 위험. 현금영수증은 SOAP 경로(SabreClient.getBooking)를 전제로 동작한다.

4-4. 커미션 타입 불일치

발권 시 VERIFY COMMISSION-2133 메시지가 나오면, 요청한 passengerPrices.first().commission?.type 과 PNR의 passengers.first().fare?.commission?.type 을 비교해 에러에 남긴다(SabreClient.kt:582-590). 즉 요청 커미션과 발권 시점 PQ 커미션이 다르면 발권이 거부된다.


5. 인코딩 / 암호화 / 날짜·시간대 함정

5-1. SOAP 헤더의 placeholder 자격증명

// SabreClient.kt:1044-1049 — soapRequestBodyConverter
text { "[email protected]" } //?      ← From PartyId
text { "webservice.sabre.com" } //? ← To PartyId

PartyId가 placeholder(" [email protected]")로 박혀 있고 //? 주석이 달려 있다

작성자도 이 값이 맞는지 확신하지 못한 흔적(//?)이다. MessageId"mid:[email protected]"(:1066). Sabre가 PartyId 검증을 강화하면 깨질 수 있는 미확정 영역.

5-2. xmlns="" 강제 제거 핵

soapRequestBodyConverter 마지막에 .replace(" xmlns=\"\"", "")(SabreClient.kt:1105)가 있다. Jackson XML 직렬화가 자식 요소에 빈 네임스페이스 xmlns="" 를 붙이는 것을 문자열 치환으로 제거한다.

본문에 정상적인 xmlns="" 가 필요한 경우가 생기면 함께 지워진다

문자열 전역 치환이라 의도치 않은 곳의 빈 네임스페이스 선언도 제거된다. SOAP 본문 구조가 바뀌면 디버깅이 까다롭다. 응답 역직렬화는 soapBodyDeserializerOf(SabreClient.kt:96-111)가 담당하며, 파싱 실패 시 원문(content)을 ResponseLog 로그로 남긴다.

5-3. 토스페이/카드 cardCode 정규식

// PaymentUtils.kt:4
val CARD_CODE_REGEX = "^(?!XX$)(?:[A-Z0-9]{2}/[A-Z0-9]{2}|(?!XX$)[A-Z0-9]{2})$".toRegex()

PG 응답(PgCardTktCode.lts)은 JC/TK 또는 JC 또는 XX(거부)를 String 타입으로 200 OK 로 준다(SabrePaymentClient.kt:219). 정규식에 안 맞으면(XX 등) PAYMENT_PROVIDER_REJECT. 카드코드는 take(2) 로 앞 2자만 쓴다.

정규식에 XX 부정 전후방탐색이 중복되어 있다

(?!XX$) 가 바깥과 안쪽 alternation에 둘 다 있다. 동작은 하지만 의도가 모호하다. PG가 새로운 거부 코드를 String으로 주면 형식만 맞으면 통과할 수 있다.

5-4. 날짜·시간대

  • 결제 승인시각: "${apprDate}${apprTime}".toLocalDateTime("yyyyMMddHHmmss").toUTC()(CardApprovalRS.kt:32-34, TossPayApprovalRS.kt:55-57). PG가 주는 시각은 KST 가정이고 toUTC() 로 변환한다. PG가 다른 TZ로 응답하면 9시간 어긋난다.
  • 취소 가능 시점 판정: onlyPnrCancel/handleEmptyTicketHistoriescalculateTimezoneService.calculateToUTC(departureAt, iata) 로 출발지 IATA 기준 UTC를 구해 isPnrCreatedAtBeforeYesterdayOrNoShow 와 비교(SabreCancelService.kt:109-121, 232-241). 공항 시간대 매핑이 누락되면 취소 가능 판정이 틀린다.
  • PQ(가격견적) 당일성: booking.priceQuoteCreatedAt.toLocalDate() != today() 이면 PQ 삭제 후 재산출(SabreTicketingService.kt:32, SabreBookingService.kt:314). today() 는 서버 로컬 기준 — 자정 경계에서 재산출이 갑자기 발생.
  • 항공사 타임리밋: 발권 전 carrierTimeLimit < now().plusMinutes(2)TICKETING_FAILED(Booking.kt:45-51). 2분 미만 버퍼는 매직넘버.
  • NameReference(소아/유아 월령): Format.Passenger.NameReference(support/util/FormatUtils.kt:21-31)는 CHILD=년, INFANT=월(ChronoUnit) 단위로 C##/I## 를 만들고, 유아 0개월은 "1" 로 보정(it == "0" -> "1"). 출발일과 생년월일 경계에서 월령이 어긋나면 항공사 거부 가능.

5-5. 유아→소아 전환 생년월일 검증

SabreBookingService.validate(:186-199): CHILD는 출발일 기준 만 2세 이상 ~ 만 12세 미만이어야 하며 아니면 INVALID_PASSENGERS_INFANT_TO_CHILD_BIRTH_DATE. 주석: “세이버의 경우 유아는 소아로 좌석 점유 불가”. 마지막 여정의 출발일(schedules.last().segments.last()) 기준이라 다구간에서 첫 구간이 아닌 마지막 구간 기준임에 주의.


6. 재발행 / 환불 / 부분취소 / void 엣지케이스

발권 직후 예외 = "결제는 됐는데 발권 실패" → 비동기 보상 (fire-and-forget)

SabreTicketingService.issue(:111-123)는 catch에서 payment != null 이면 errorMessage와 무관하게 cancelAsync 를 호출한다. cancelAsync(:140-168)는 CoroutineScope(Dispatchers.IO).withLaunch { delay(5000); ... } — 즉 요청 스레드와 분리된 별도 코루틴에서 5초 뒤 취소를 시도한다. 호출자는 보상 성공 여부를 모르고 원래 예외만 받는다.

6-1. delay(5000) “locked pnr 방지” 매직넘버

cancelAsync(:148), pnrCancelAsync(SabreCancelService.kt:384), pnrCancel(:423 delay 1000), void(:485 delay 1000)에 delay가 박혀 있다. 주석은 “locked pnr 방지”. Sabre가 직전 트랜잭션으로 PNR을 잠가서 즉시 재접근하면 실패하기 때문에 시간으로 회피한다.

5초/1초는 경험적 매직넘버다 — Sabre가 더 오래 잠그면 실패

부하가 높거나 Sabre 응답이 느린 날엔 5초로 부족할 수 있다. 실패 시 Slack(sendCancelFail 등)으로만 알리고 자동 복구는 없다 → 수동 개입 필요.

6-2. void는 항상 두 번, 그리고 전수 성공 강제

// SabreCancelService.void — :480-487
sabreClient.openPnr(token, pnr)
sabreClient.void(token, rph)
sabreClient.void(token, rph)   // 한번더 보이드 컨펌 해야함
delay(1000)
  • 쿠폰별 void가 순차(sequential) 로 진행되고, 하나라도 실패하면 onFailure 에서 sendVoidFail(성공/실패 티켓 목록 포함) 후 throw(:436-456).
  • 승객 수 ≠ voidable 티켓 수면 sendIncompleteVoid 경보(:308-315).
  • REST voidTicketscheckError() 는 비어 있다(VoidTicketsResponse.kt:19-25, TODO 주석: “정상 void인데 error가 담겨 오는 증상”). 즉 REST void는 에러 검사를 사실상 안 한다.

6-3. 환불은 “전건 환불” 아니면 실패

SabreCancelService.refundTickets(:334-380):

  • sabreRestClient.refundTickets 호출. 예외 시 타임아웃류(SocketTimeout/Socket/IOException)면 sendCancelFailTimeout, 아니면 sendRefundFail.
  • 전건 환불(isAllRefunded)이 아니면 미환불 티켓 목록 Slack(sendAllTicketRefundFail) 후 REFUND_FAILED.capture() throw(:362-377).
  • EMD 티켓이 하나라도 있으면 환불 불가(validateRefundableTickets :489-492, findRefunds는 EMD 없을 때만 환불예상금 세팅 :283).
  • waiver 미적용 + 스케줄이 전부 confirmed가 아니면 CANCEL_UNABLE_BY_SCHEDULE_STATUS(:494-499).

6-4. void vs refund vs onlyPnrCancel 분기

cancel()(:38-102)의 우선순위:

  1. ticketHistories 비어있음 → handleEmptyTicketHistories(결제취소 + PNR취소, 단 어제 이전 생성/노쇼면 CANCEL_UNABLE).
  2. isVoidable → void.
  3. waiverRefundable || autoRefundable → 환불.
  4. 그 외 → CANCEL_UNABLE. 마지막에 pnrCancelAsync(비동기 PNR 취소)를 항상 시도.

isVoidable 정의(Booking.kt:23-26): NonVoidableAirline(HY/MF/SU/QH) 아님 && (모든 승객 voidable || 모든 티켓이력 voidable). 티켓 voidable 은 발권일 == today()(FlightTicket.kt:47, SOAP은 별도) — 자정 지나면 void 불가, 환불로 전환.

FlightTicket.isVoidable 의 TODO

isVoidable = date == today()// TODO: && ticketCount == 1 주석(FlightTicket.kt:47). conjunction 티켓(여러 쿠폰)이면 당일이라도 void가 안 될 수 있는데 미반영.

6-5. 분할(divide) 시퀀스 — 한 단계라도 실패하면 잔여 세션

SabreBookingService.divide(:329-346): openPnr → splitPnr → confirmWithEndTransaction → saveSplitPnr → endTransaction. confirmWithEndTransactionEndTransactionRQ(endTransaction=null) 로 보내는 특수 호출(SabreClient.kt:628-644). 중간 실패 시 finally의 close는 되지만 이미 split된 PNR 정합성은 보장되지 않는다(부분 분할 가능).

6-6. 재발행(EWR/exchange)은 아직 미구현

// SabreBookingService.confirm() — :267, :300
// todo  EWR 이 완료 되면 schedule.confirming이 추가 될수 있게 되야 한다.
// TODO EWR

Sabre 재발행(EWR, Exchange/Reissue)은 코드상 미완성

confirm 에 EWR TODO가 두 곳, validateBookingConditionForTicketing(Booking.kt:29)에도 동일 주석. 재발행 시나리오는 “confirming 상태”를 아직 처리하지 못한다. ErrorMessage에 RETICKETING_*, REISSUE_*, TICKET_IS_NOT_ELIGIBLE_FOR_EXCHANGE 가 정의돼 있으나 Sabre 경로의 재발행은 본 분석 범위에서 활성 구현이 보이지 않는다.


7. 코드 내 TODO / FIXME / 주석 경고 전수

Sabre 모듈 TODO/경고 주석 인덱스 (messages/ 생성코드 제외)

위치(file:line)주석함의
SabreClient.kt:163-164//TODO 세이버 확인 후 수정 필요 / 임시코드 좌석등급 이코노미 단독검색시 에어부산 유아운임 누락 수정ECONOMY 단독 검색 시 PREMIUM_ECONOMY를 강제 추가(:165-169) — 검색 결과 오염 가능
SabreClient.kt:300// 제한 사유: VC OZ, Marketing carrier S7 정산 불가로 발권 불가hasNonTicketableCarrier 하드코딩 캐리어 목록
SabreClient.kt:684-686// 발권후 리트리브 응답에 티켓 없으면 ... 로그만 찍고 throw 안함티켓 누락을 묵인
SabreClient.kt:1044,1049,1066//? PartyId/MessageId placeholder5-1 참고
SabreBookingService.kt:267,300 / Booking.kt:29TODO EWR6-6 재발행 미구현
SabreTicketingService.kt:25@Deprecated("API분리 후 제거 예정") ready()제거 예정 메서드 — 신규 코드에서 쓰지 말 것
SabreCancelService.kt:484// 한번더 보이드 컨펌 해야함void 2회 호출 (6-2)
SabreRestClient.kt:233// TODO 검색 중복코드 합치자revalidate/search 복붙
SabreCashReceiptService.kt:41-42// todo 대표 ticket 여부 판단 필요현금영수증 티켓 매핑 미완
VoidTicketsResponse.kt:20// TODO 정상 void인데 error 담겨오는 증상REST void 에러검사 비활성 (6-2)
FlightTicket.kt:47,52// TODO && ticketCount==1 / 어떻게 테스트?void/conjunction 미반영 (6-4)
Fare.kt:87,127,142SOTO 전문 확인 필요 / parsing farebasis on rest 미구현 / PQ 8/1 레거시 삭제REST Fare 파싱 구멍
Traveler.kt:103-107CPNR 병합 이후 작업 (stayInfo/passport/ids = null)REST Traveler 파싱 구멍
GetBookingResponse.kt:134,146-147parentPnrs/payment/ticketHistories = null (이후 작업)REST getBooking은 결제·발권이력·부모PNR을 못 채운다 (4-3, 6-2 위험의 근원)
PassengerInfo.kt:80fareCalc=null, Sabre BFM Rest 미존재 문의중운임계산식 누락
FlightSegment.kt:46status="NN" // 확인 필요 NN/HL/GK/...예약 상태코드 하드코딩
TravelPreference.kt:35maxConnection = if(onlyDirect) 0 else 2 //도착지별 조절?경유 2회 고정
getreservation/Passenger.kt:160, Fare.kt:142PQ생성일 8/1 레거시 삭제 가능시한부 레거시 분기
StructureFareRulesRS.kt:98,110,149changePolicy=null 데이터정합성 문의중 / PenaltyBreakdown 응답확인 예정 / NoShow 추후구조화 운임규칙의 변경수수료·노쇼·일부 페널티가 미구현(주석처리)

REST getBooking의 광범위한 null 하드코딩

GetBookingResponse.kt:134-147parentPnrs / payment / ticketHistories 를 전부 null 로 둔다. 취소·현금영수증·void 로직은 ticketHistories/payment 를 핵심 입력으로 쓰므로, REST getBooking 결과를 이 흐름에 그대로 넣으면 오판정한다. 현재 취소/발권 흐름이 SOAP SabreClient.getBooking 을 쓰는 이유다. REST 전환 시 1순위 검증 대상.


8. 운영자 체크리스트 (요약)

Sabre 장애 대응 순서

  1. 검색 전량 실패 → 6일 TTL 토큰 캐시(SABRE-TOKEN) 죽은 토큰 의심 → Redis 키 삭제. 단 “INTERNAL ERROR 정상 취급”(1-1) 때문에 서킷이 안 열려 알람이 늦을 수 있음.
  2. 발권 실패인데 결제됨cancelAsync(비동기 5초) 성공했는지 Slack(sendCancelFail/sendPaymentCancelFail/sendIncompleteTicketing) 확인. 실패면 수동 결제취소.
  3. 부분 발권PARTIALLY_TICKETING_FAILED, sendPartiallyTicketingFail 의 승객/티켓번호로 수동 처리.
  4. 취소/환불 실패 → EMD 존재? voidable 당일? 전건환불 실패? sendAllTicketRefundFail/sendVoidFail 목록 확인.
  5. PAYMENT_ETC 빈발PaymentError.kt 미등록 에러코드 추가 필요(1-4).
  6. 세션 누수/발급 실패 → finally close 누락, PCC(channel+funnel+date) 불일치(2-3) 점검.

핵심 소스 인덱스

영역파일
SOAP 클라이언트(세션/검색/예약/발권/취소/큐)infrastructure/soap/SabreClient.kt (1109줄)
REST 클라이언트(BFM/취소/환불/void)infrastructure/rest/SabreRestClient.kt
결제(카드/토스페이/현금영수증)infrastructure/soap/SabrePaymentClient.kt, PaymentError.kt, response/payment/*RS.kt
발권 보상 트랜잭션application/SabreTicketingService.kt
취소/환불/voidapplication/SabreCancelService.kt
예약 검증application/SabreBookingService.kt, support/model/Booking.kt, Passenger.kt
비동기support/util/CoroutineExtensions.kt, application/SabrePassengerService.kt, support/exception/AdapterCoroutineExceptionHandler.kt
설정configuration/Properties.kt(SabreProperties), application.yml(resilience4j)

연습문제

Q1. SabreClient.search() 가 Sabre에서 받은 응답 메시지가 "NO INVENTORY"(화이트리스트에 없음)였다. 어떤 일이 벌어지고, 왜 위험한가?

Q2. 발권 중 getBooking 에서 NON_RETRIEVABLE_SCHEDULE_STATUS 예외가 났다. (a) Spring Retry가 재시도하는가? (b) 세션 ignore 가 호출되는가?

Q3. 발권 catch에서 cancelAsync 가 5초 뒤 비동기로 PNR/결제를 취소한다. 이 설계의 위험 3가지는?

Q4. REST getBooking 으로 만든 BookingSabreCancelService.cancel() 에 넘기면 무엇이 잘못되는가?


더 보기