Sabre GDS API 분석 문서

1. 개요

1.1 Sabre GDS 시스템

  • 공급사 타입: Global Distribution System (GDS)
  • 통신 방식: REST API + SOAP API 혼합 아키텍처
  • 인증 방식:
    • REST: OAuth 2.0 Client Credentials (Token 기반)
    • SOAP: Session Token 기반 인증
  • 트랜잭션 관리: Stateful Session Management (SOAP), Stateless (REST)
  • 주요 특징: PNR 기반 예약 관리, 하이브리드 API 구조

1.2 아키텍처 특징

Controller Layer → Service Layer → Client Layer → Sabre API
     ↓                  ↓               ↓
  Request DTO      Business Logic   REST/SOAP Client
     ↓                  ↓               ↓
  Response DTO     Domain Model    Session Management

API 타입별 분류

  • REST API: 검색(BargainFinderMax), 운임 규칙, 환불 처리, Token 발급
  • SOAP API: 예약, 발권, Void, 큐 관리, Session 관리

1.3 인증 메커니즘

1.3.1 REST Token 인증

// SabreRestClient.kt:67-96
fun getToken(): String {
    val secret = with(sabreApiProperties) {
        val clientIdRaw = "V1:$epr:${pcc.online}:AA".encodeUtf8().base64Url()
        val passwordRaw = password.encodeUtf8().base64Url()
        "$clientIdRaw:$passwordRaw".encodeUtf8().base64Url()
    }
 
    // POST /v2/auth/token (OAuth 2.0 Client Credentials)
    // Redis 캐싱: 토큰 만료 전까지 재사용
}

1.3.2 SOAP Session Token 관리

// SabreClient.kt:293-340
fun getSessionToken(channel, funnel, targetDate): String {
    // SessionCreateRQ - SOAP Session 생성
    // 반환: SessionToken (모든 SOAP API 호출 시 필수)
}
 
fun closeSessionToken(token, channel, funnel, targetDate) {
    // SessionCloseRQ - SOAP Session 종료 (리소스 해제)
}

2. API 엔드포인트별 상세 분석

2.1.1 검색 API

엔드포인트: POST /internals/SABRE/search

API 타입: REST API (BargainFinderMax)

기능 설명

  • Sabre GDS를 통한 항공편 검색
  • REST API 기반 병렬 검색 처리
  • Redis 캐싱 및 Token 재사용

요청 파라미터

data class SearchRequest(
    val originDestinationLocationInfos: List<OriginDestinationLocationInfo>,
    val cabins: List<CabinType>,
    val preferences: List<SearchPreference>,
    val airlines: List<String>?,
    val sotoAirlines: List<String>?,
    val onlyDirect: Boolean,
    val onlyFreeBaggageInclude: Boolean,
    val advancedOption: AdvancedOption?,
    val useCache: Boolean = true,
    val useMultiTicket: Boolean = false,
    val logging: Boolean = false
)

비즈니스 로직 단계

  1. 캐시 확인: Redis에서 기존 검색 결과 확인
  2. Token 발급: OAuth 2.0 Client Credentials (Redis 캐싱)
  3. 공항 확장: Cartesian Product로 모든 조합 생성
  4. 병렬 검색: pmap으로 병렬 처리
  5. REST 요청: BargainFinderMaxRQ 호출 (30초 타임아웃)
  6. 결과 필터링:
    • NonAir 제외 (isNonAir)
    • NonTicketableCarrier 제외 (hasNonTicketableCarrier)
    • 항공사 필터링 (airlines, sotoAirlines)
  7. 캐싱 저장: Redis에 결과 저장 (TTL 적용)

에러 처리

  • Circuit Breaker 없음 (REST API 특성)
  • Fallback: 예외 발생 시 빈 리스트 반환 (onFailure)
  • Retry: pmap 레벨에서 실패 시 다른 요청 계속 진행

2.1.2 Revalidate API

API 타입: SOAP API

기능 설명

  • 운임 재검증 (가격 변동 확인)
  • FareRule 조회 전 필수 단계

처리 로직

  • Session Token 생성 → Revalidate → Session 종료

2.2 예약 API (Booking)

2.2.1 예약 생성

엔드포인트: POST /internals/SABRE/bookings

API 타입: SOAP API

비즈니스 로직 단계

// SabreBookingService.kt:34-134
val token = sabreClient.getSessionToken()
try {
    // 1. markSeat: 좌석 마킹 + PriceQuote 생성
    val priceQuoteMap = sabreClient.markSeat(token, fareItinerary, passengers)
 
    // 2. savePassengerInfo: 승객 정보 저장 (SSR, OSI, Contact 포함)
    val pnr = sabreClient.savePassengerInfo(
        token, validatingCarrier, reservationUser, passengers,
        priceQuoteMap, departureDate, accountCode
    )
 
    // 3. getBooking: 예약 조회 및 검증
    val booking = sabreClient.getBooking(token, pnr)
 
    // 검증 1: 유아 SSR 상태 체크 (KK/HK/NN)
    // 검증 2: 스케줄 상태 (confirmed/confirming)
    // 검증 3: CarrierTimeLimit null 처리 (3초 대기 후 재조회)
    // 검증 4: Cabin null 처리 (재조회)
    // 검증 5: 승객 수 불일치 (재조회)
 
} catch (e: Exception) {
    // SOLD_OUT, INFANT_SOLD_OUT 발생 시 비동기 PNR 취소
} finally {
    sabreClient.closeSessionToken(token)
}

주요 처리 사항

  • 좌석 마킹: 재고 확보 (MarkSeat)
  • 승객 정보: 이름, 생년월일, 여권, 연락처
  • SSR 추가:
    • DOCS (여권 정보)
    • CTCE (이메일)
    • CTCM (전화번호)
    • INFT (유아 정보)
  • PNR 생성: 6자리 영숫자 코드
  • CarrierTimeLimit null 특별 처리:
    // SabreBookingService.kt:101-111
    when {
        booking.carrierTimeLimit == null -> {
            // 3초 대기 후 재조회
            delay(3000)
            retrieveOrNull(booking.pnr!!) ?: booking
        }
    }

2.2.2 예약 조회

엔드포인트: GET /internals/SABRE/bookings/{pnr}

처리 로직

  1. Session Token 생성
  2. GetBooking 호출 (SOAP)
  3. 예약 정보 파싱
  4. Session Token 종료

2.2.3 예약 확인 (Confirm)

엔드포인트: POST /internals/SABRE/bookings/{pnr}/confirm

검증 사항

  • 스케줄 상태가 confirmed인지 체크
  • 미확인 시 PNR 취소 및 예외 발생

2.2.4 Repricing

엔드포인트: POST /internals/SABRE/bookings/{pnr}/repricing

처리 로직

// SabreBookingService.kt:273-290
if (booking.priceQuoteCreatedAt.toLocalDate() != today()) {
    // PriceQuote가 당일이 아니면 재생성
    sabreClient.deletePriceQuote(token)
    sabreClient.repricing(token, booking.passengers)
    sabreClient.endTransaction(token)
 
    // FareBasis 변경 검증 (변경 시 PNR 취소)
    validateFareBasisChange(originBooking, newBooking)
}

2.2.5 PNR 분할 (Divide)

엔드포인트: POST /internals/SABRE/bookings/{pnr}/divide

처리 로직

  • 특정 승객을 새 PNR로 분리
  • SplitPnr 명령 사용
  • 새 PNR 반환

2.3 발권 API (Ticketing)

2.3.1 발권 준비

엔드포인트: POST /internals/SABRE/ticketing/ready

검증 사항

  • Carrier Time Limit 확인 (2분 이상 남아있어야 함)
  • Carrier PNR 존재 여부
  • 스케줄 상태 (confirmed)
  • Repricing 필요 여부 (priceQuoteCreatedAt 당일 체크)

2.3.2 발권 실행

엔드포인트: POST /internals/SABRE/ticketing

API 타입: SOAP API

발권 프로세스

// SabreTicketingService.kt:52-134
val token = sabreClient.getSessionToken()
var payment: Payment? = null
 
try {
    // 1. 예약 조회 및 검증
    val passengers = sabreClient.getBooking(token, pnr)
        .validateBookingConditionForTicketing()
        .passengers
 
    // 2. 결제 승인 (TossPay 또는 KeyInCard)
    payment = paymentService.approve(pnr, validatingCarrier, passengerPrices, paymentInfo)
 
    // 3. 발권 처리
    val passengerTickets = sabreClient.ticketing(
        token, pnr, validatingCarrier, passengers, passengerPrices, payment, paymentInfo,
        addEndorsement = "RFND ONLY TO ISSUE AGT".takeIf { validatingCarrier == "PR" },
        timeoutCallback = { slackService.sendTicketingTimeout(Supplier.SABRE, pnr) }
    )
 
    // 4. Uncommitted 티켓 검증
    val uncommittedTickets = passengerTickets.filterNot { it.committed }
    if (uncommittedTickets.isNotEmpty()) {
        throw StatusInvalidException(ErrorMessage.TICKETING_FAILED, pnr, "uncommitted tickets")
    }
 
    // 5. 티켓 정보 조회
    return retrieveTicket(token, pnr, passengerTickets, validatingCarrier)
        .withPayment(payment)
 
} catch (e: Exception) {
    // 발권 실패 시 5초 대기 후 비동기 취소
    if (payment != null) {
        cancelAsync(pnr, validatingCarrier, payment, keepPnr, ticketCount)
    }
    throw e
} finally {
    sabreClient.closeSessionToken(token)
}

주요 API 호출

  • AirTicketLLSRQ: 티켓 발행
  • EnhancedAirTicket: 티켓 정보 조회

항공사별 특별 처리

  • PR 항공사: “RFND ONLY TO ISSUE AGT” endorsement 추가 (SabreTicketingService.kt:83)

2.4 운임 규정 API (Fare Rules)

엔드포인트: GET /internals/SABRE/fare-rules

API 타입: REST API + SOAP API 조합

기능 설명

  • 운임 규정 및 제한 사항 조회
  • 환불/변경 수수료 정보

처리 로직

// SabreFareRuleService.kt:24-56
fun findFareRules(key, adult, child, infant): List<FareRule> {
    // 1. Redis 캐시 확인
    val savedFareRules = fareRuleRepository.findFareRules(fareRuleKey)
    if (savedFareRules != null) return savedFareRules
 
    // 2. FareItinerary 조회
    val fareItinerary = sabreFlightSearchService.getFareItinerary(key)
 
    // 3. Revalidate (SOAP API)
    val revalidateFareItinerary = sabreFlightSearchService.revalidate(
        fareItinerary, adult, child, infant
    )
 
    // 4. FareBasis 및 Price 비교 검증
    compareFarebasis(fareItinerary, revalidateFareItinerary)
    comparePrice(fareItinerary, revalidateFareItinerary)
 
    // 5. FareRule 조회 (REST API)
    return sabreFareRuleClient.findFareRules(
        adult, child, infant, revalidateFareItinerary ?: fareItinerary, airportMap
    ).also {
        // 6. Redis 캐싱
        fareRuleRepository.saveFareRules(fareRuleKey, it)
    }
}

2.5 취소 API (Cancel)

엔드포인트: PUT /internals/SABRE/bookings/{pnr}/cancel

API 타입: SOAP (Void) + REST (Refund) 조합

취소 유형 판별

// SabreCancelService.kt:38-102
fun cancel(pnr, validatingCarrier, payment, autoRefundable, waivers): List<Refund> {
    val token = sabreClient.getSessionToken()
    try {
        val booking = sabreClient.getBooking(token, pnr)
 
        // 티켓 히스토리 없음 → PNR만 취소
        if (booking.ticketHistories.isNullOrEmpty()) {
            handleEmptyTicketHistories(booking, validatingCarrier, payment, token)
            return emptyList()
        }
 
        return when {
            // Case 1: Void 가능
            booking.isVoidable -> {
                voidTickets(token, booking, validatingCarrier, payment)
                emptyList()
            }
 
            // Case 2: Waiver 또는 자동 환불
            waiverRefundable || autoRefundable -> {
                validateRefundableTickets(booking, waiverRefundable)
 
                // Waiver 저장 (OSI/SSR/REMARK)
                if (waiverRefundable) saveWaiverRefunds(waivers, token, booking)
 
                // CancellableTicket 조회
                val cancellableTickets = getCancellableTickets(token, pnr)
                if (!cancellableTickets.isRefundable) {
                    throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, pnr)
                }
 
                // 환불 처리 (REST API)
                refundTickets(token, booking, cancellableTickets, waivers)
            }
 
            // Case 3: 취소 불가
            else -> throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, pnr)
        }
    } finally {
        pnrCancelAsync(booking.pnr!!) // 5초 대기 후 PNR 취소
        sabreClient.closeSessionToken(token)
    }
}

isVoidable 판별 로직

// Booking.kt:23-25
val isVoidable: Boolean
    get() = NonVoidableAirline.notContains(validatingCarrier)
 
// NonVoidableAirline.kt:3-8
enum class NonVoidableAirline {
    HY,  // 우즈베키스탄 항공
    MF,  // 샤먼 항공
    SU,  // 아에로플로트
    QH,  // 밤부 항공
}

Void 프로세스

// SabreCancelService.kt:297-332, 430-478
private fun voidTickets(token, booking, validatingCarrier, payment, cancellableTickets) {
    val voidableTickets = booking.ticketHistories!!
        .filter { it.isVoidable }
        .map { it.ticketNumber to it.rph!! }
 
    // 부분 Void 알림
    if (booking.passengers.size != voidableTickets.size) {
        slackService.sendIncompleteVoid(Supplier.SABRE, pnr, passengerCount, ticketCount)
    }
 
    // 순차 Void (3회 재시도)
    voidAll(tickets = voidableTickets, token = token, pnr = booking.pnr!!)
 
    // Ignore 처리
    sabreClient.ignore(token)
 
    // 비동기 결제 취소
    if (validatingCarrier != null && payment != null) {
        paymentService.cancelAsync(pnr, validatingCarrier, payment)
    }
}
 
private fun voidRepeat(token, pnr, rph) {
    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") == true ||
                e.message?.contains("PROCESSING ERROR DETECTED") == true
            )) {
                sabreClient.ignore(token)
            } else {
                throw e
            }
        }
    }
}
 
private fun void(token, pnr, rph) {
    sabreClient.openPnr(token, pnr)
    sabreClient.void(token, rph)
    sabreClient.void(token, rph)  // 2번 호출 (Void 확정)
    delay(1000)
}

Waiver 처리

// SabreCancelService.kt:516-540
private fun saveWaiverRefunds(waivers, token, booking) {
    // AUTH_CODE 제외
    val filteredWaivers = waivers.filter { it.type != WaiverType.AUTH_CODE }
 
    // OSI/SSR 타입 처리
    val specialServiceWaivers = filteredWaivers.filter {
        it.type == WaiverType.OSI || it.type == WaiverType.SSR
    }
    if (specialServiceWaivers.isNotEmpty()) {
        sabreClient.saveWaiverRefunds(token, specialServiceWaivers, booking.validatingCarrier)
    }
 
    // REMARK 타입 처리
    val remarkWaivers = filteredWaivers.filter { it.type == WaiverType.REMARK }
    if (remarkWaivers.isNotEmpty()) {
        remarkWaivers.forEach { waiver ->
            sabreClient.saveRemark(token, waiver.text)
        }
    }
 
    sabreClient.endTransaction(token)
}

PNR 취소

// SabreCancelService.kt:382-428
private fun pnrCancelAsync(pnr: String) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        delay(5000)  // Locked PNR 방지
        val token = sabreClient.getSessionToken()
        try {
            pnrCancelRepeat(pnr, token)
        } finally {
            sabreClient.closeSessionToken(token)
        }
    }
}
 
private fun pnrCancelRepeat(pnr, token) {
    for (count in 1..2) {  // 2회 재시도
        try {
            pnrCancel(token, pnr)
            break
        } catch (e: Exception) {
            if (e is ApiException && e.errorMessage == ErrorMessage.ALREADY_CANCELED_PNR) {
                logger.info(e.message, e)
                break
            }
 
            if (count == 2) {
                slackService.sendCancelFail(Supplier.SABRE, pnr, reason = e.message)
                throw e
            }
 
            sabreClient.ignore(token)
        }
    }
}

2.6 환불 API (Refund)

엔드포인트: N/A (Cancel API 내부에서 호출)

API 타입: REST API

환불 프로세스

// SabreCancelService.kt:334-380
private fun refundTickets(token, booking, cancellableTicket, waivers): List<Refund> {
    val (isAllRefunded, refundedTickets) = try {
        // REST API: RefundTickets
        sabreRestClient.refundTickets(token, booking, cancellableTicket, waivers)
    } catch (e: Exception) {
        // Socket Timeout 특별 처리
        when (e) {
            is SocketTimeoutException, is SocketException, is IOException ->
                slackService.sendCancelFailTimeout(Supplier.SABRE, booking.pnr!!)
            else -> slackService.sendRefundFail(Supplier.SABRE, booking.pnr!!)
        }
        throw InternationalAdapterException(ErrorMessage.REFUND_FAILED, e.message!!)
    }
 
    // 부분 환불 실패 처리
    if (isAllRefunded.not()) {
        val targetTickets = booking.passengers.map { it.eTicket!!.ticketNumber }
        val failTickets = targetTickets - refundedTickets.map { it.ticketNumber }.toSet()
 
        slackService.sendAllTicketRefundFail(Supplier.SABRE, booking.pnr!!, targetTickets, failTickets)
        throw InternationalAdapterException(ErrorMessage.REFUND_FAILED, "All ticket is not refunded")
    }
 
    return refundedTickets
}

CancellableTicket 조회

// SabreCancelService.kt:502-514
private fun getCancellableTickets(token, pnr): CancellableTicket {
    return try {
        // REST API: CheckTickets
        sabreRestClient.checkCancellableTickets(token, pnr)
    } catch (e: Exception) {
        slackService.sendCheckFlightTicketsFail(Supplier.SABRE, pnr, e.message)
        throw e
    }
}

환불 검증

// SabreCancelService.kt:489-500
private fun validateRefundableTickets(booking, waiverRefundable) {
    // EMD 티켓 존재 시 환불 불가
    if (booking.passengers.any { it.emdTicket != null }) {
        throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, booking.pnr!!, "EMD Ticket Exists")
    }
 
    // Waiver 아닐 때 스케줄 상태 확인
    if (!waiverRefundable && booking.schedules?.all { it.confirmed } == false) {
        throw InternationalAdapterException(
            ErrorMessage.CANCEL_UNABLE_BY_SCHEDULE_STATUS,
            booking.pnr!!,
            booking.schedules.joinToString { it.status }
        )
    }
}

2.7 큐 관리 API (Queue Management)

2.7.1 큐 목록 조회

엔드포인트: GET /internals/SABRE/queues

API 타입: SOAP API

처리 로직

// SabreQueueService.kt:32-83
fun getQueuePnrs(): List<QueuePnrInfo> {
    // TARGET_ORIGIN_QUEUE_NUMBERS = [5, 6, 7, 20]
    // LEGACY_PCC = [3OGJ, 7CZJ] (제외)
 
    return sabreProperties.channels
        .flatMap { channel -> channel.funnels.flatMap { listOf(it.pcc.online, it.pcc.offline) } }
        .filter { it !in LEGACY_PCC }
        .distinct()
        .flatMap { pcc ->
            val token = sabreClient.getSessionToken(channel, funnel, targetDate)
 
            try {
                // 병렬 처리 (pmap)
                TARGET_ORIGIN_QUEUE_NUMBERS.pmap { originQueueNumber ->
                    val queueCount = sabreClient.getPnrCountInQueue(token, originQueueNumber, pcc)
 
                    if (queueCount > 0) {
                        sabreClient.getPnrsInQueueAction(
                            token, originQueueNumber, listOfRecordLocator = true, pcc
                        ).map { pnr ->
                            QueuePnrInfo(pnr, originQueueNumber, pcc)
                        }
                    } else {
                        emptyList()
                    }
                }.getOrEmpty()
            } catch (e: Exception) {
                slackService.sendQueueFail(supplier = "${Supplier.SABRE.name}($pcc)", e.message)
                emptyList()
            } finally {
                sabreClient.closeSessionToken(token, channel, funnel, targetDate)
            }.flatten()
        }
}

2.7.2 큐 제거

엔드포인트: PUT /internals/SABRE/queues

API 타입: SOAP API

처리 로직

// SabreQueueService.kt:85-138
@Retryable(maxAttempts = 3, backoff = Backoff(delay = 5000))
fun remove(queueNumber, pcc, pnrs): Boolean {
    val token = sabreClient.getSessionToken(channel, funnel, targetDate)
 
    val removePnrs = mutableListOf<String>()
    return try {
        val queueCount = sabreClient.getPnrCountInQueue(token, queueNumber, pcc)
 
        // 큐 작업 모드 첫 진입 시 첫 번째 PNR 응답
        var currentPnr = sabreClient.getPnrsInQueueAction(
            token, pcc, queueNumber
        ).firstOrNull()
 
        // 순차 처리
        repeat(queueCount) {
            // QR: 삭제, I: 건너뛰기
            val nextPnr = sabreClient.getPnrsInQueueAction(
                token, pcc,
                actionType = if (currentPnr in pnrs) QueueAccessActionType.QR else QueueAccessActionType.I
            ).firstOrNull()
 
            if (currentPnr in pnrs) removePnrs.add(currentPnr!!)
            currentPnr = nextPnr
        }
 
        true
    } catch (e: Exception) {
        slackService.sendQueueFail(supplier = "${Supplier.SABRE.name}($pcc)", e.message)
        false
    } finally {
        sabreClient.closeSessionToken(token, channel, funnel, targetDate)
    }
}

3. 공통 컴포넌트

3.1 Session Token 관리

REST Token vs SOAP Session Token

// REST Token (무상태)
val restToken = sabreRestClient.getToken()  // Redis 캐싱
// - OAuth 2.0 Client Credentials
// - 만료 시까지 재사용
// - 모든 REST API에 Authorization Header로 전달
 
// SOAP Session Token (상태 유지)
val sessionToken = sabreClient.getSessionToken(channel, funnel, targetDate)
try {
    // 모든 SOAP API 호출 시 sessionToken 전달
    sabreClient.markSeat(sessionToken, ...)
    sabreClient.savePassengerInfo(sessionToken, ...)
} finally {
    sabreClient.closeSessionToken(sessionToken, channel, funnel, targetDate)
}

3.2 Redis 캐싱 전략

캐시 키 구조

SABRE_{type}_{key}_{timestamp}

TTL 설정

  • REST Token: 만료 시까지 (sabreAccessToken:{pcc})
  • 검색 결과: 설정된 TTL (flightSearchKey, fareItinerary)
  • 운임 규정: 설정된 TTL (fareRule)

3.3 재시도 메커니즘

Void 재시도 (3회)

// SabreCancelService.kt:459-478
for (count in 1..3) {
    try {
        void(token, pnr, rph)
        break
    } catch (e: Exception) {
        if (count < 3 && isRetryableError(e)) {
            sabreClient.ignore(token)
        } else {
            throw e
        }
    }
}

PNR 취소 재시도 (2회)

// SabreCancelService.kt:394-418
for (count in 1..2) {
    try {
        pnrCancel(token, pnr)
        break
    } catch (e: Exception) {
        if (count == 2) {
            slackService.sendCancelFail(pnr)
            throw e
        }
        sabreClient.ignore(token)
    }
}

큐 제거 재시도 (3회)

// SabreQueueService.kt:85
@Retryable(maxAttempts = 3, backoff = Backoff(delay = 5000))
fun remove(queueNumber, pcc, pnrs): Boolean { ... }

3.4 비동기 처리 패턴

5초 대기 후 비동기 처리

// 1. PNR 취소 비동기
CoroutineScope(Dispatchers.IO).withLaunch {
    delay(5000)  // Locked PNR 방지
    cancelService.onlyPnrCancel(pnr)
}
 
// 2. 발권 실패 시 취소 비동기
CoroutineScope(Dispatchers.IO).withLaunch {
    delay(5000)
    cancelService.ticketingFailedCancel(pnr, validatingCarrier, payment, keepPnr, ticketCount)
}
 
// 3. 결제 취소 비동기
CoroutineScope(Dispatchers.IO).withLaunch {
    paymentService.cancelAsync(pnr, validatingCarrier, payment)
}

3.5 에러 처리 패턴

에러 타입별 처리

  • StatusInvalidException: 예약/발권 상태 불일치
  • InternationalAdapterException: 비즈니스 로직 에러
  • SocketTimeoutException: 환불 타임아웃 (Slack 알림)
  • ApiException: API 호출 실패 (특정 에러 메시지 처리)

Slack 알림 케이스

  • 발권 타임아웃 (SabreTicketingService.kt:85-89)
  • 부분 발권 실패 (SabreTicketingService.kt:184-193)
  • Incomplete Void (SabreCancelService.kt:309-314)
  • 환불 실패 (SabreCancelService.kt:350-352, 366-371)
  • PNR 취소 실패 (SabreCancelService.kt:406-410)
  • 큐 조회/제거 실패 (SabreQueueService.kt:72, 128)

4. Sabre 특징

4.1 하이브리드 API 아키텍처

REST API vs SOAP API 분류

작업API 타입이유
검색 (BargainFinderMax)REST무상태, 병렬 처리 최적화
운임 규칙 조회REST무상태, 빠른 응답
환불 처리REST무상태, 비동기 처리 가능
Token 발급RESTOAuth 2.0 표준
예약SOAP상태 유지 필요 (Session)
발권SOAP트랜잭션 보장 필요
VoidSOAP순차 처리 필요
큐 관리SOAP상태 유지 필요

4.2 항공사별 특별 처리

4.2.1 PR 항공사 (필리핀 항공)

// SabreTicketingService.kt:83
addEndorsement = "RFND ONLY TO ISSUE AGT".takeIf { validatingCarrier == "PR" }

4.2.2 NonVoidableAirline (Void 불가능 항공사)

// NonVoidableAirline.kt:3-8
enum class NonVoidableAirline {
    HY,  // 우즈베키스탄 항공
    MF,  // 샤먼 항공
    SU,  // 아에로플로트
    QH,  // 밤부 항공
}
 
// Booking.kt:23-25
val isVoidable: Boolean
    get() = NonVoidableAirline.notContains(validatingCarrier)

4.2.3 OSI 필수 항공사

// Constants.kt:5
const val OSI_CARRIERS = "MF,CZ,MU,NX,TG,ZE"

4.2.4 FOID Passport 필수 항공사

// Constants.kt:6
const val FOID_PASSPORT_CARRIERS = "MU,CZ,CA,MF,KC,C6"

4.2.5 항공사별 카드 코드 특별 처리

// CardCode.kt:32-53
private val airlineCardBrandsMap = mapOf(
    // CORPORATE 코드 사용 항공사 + 카드 브랜드 조합
    // 예: OZ + 삼성카드 → CORPORATE
)
 
fun of(cardNumber, cardBrand, validatingCarrier): CardCode {
    return when {
        airlineCardBrandsMap[validatingCarrier]?.contains(cardBrand) == true ->
            CORPORATE
        else -> fromCardBrand(cardBrand)
    }
}

4.3 PNR 시스템

  • 구조: 6자리 영숫자 (예: ABC123)
  • 정보 포함: 승객, 일정, 요금, SSR, OSI, PriceQuote
  • 상태 코드:
    • HK: 확정
    • KK: 확인 중
    • UN: 대기
    • NN: 유아 좌석 (SabreBookingService.kt:71-78)

4.4 PriceQuote 시스템

  • 용도: 발권 전 운임 정보 저장
  • 생성: markSeat 단계에서 자동 생성
  • 유효기간: priceQuoteCreatedAt 기준 당일까지
  • 재생성: 당일이 아니면 deletePriceQuote → repricing

4.5 특수 요청 (SSR/OSI)

SSR (Special Service Request)

  • DOCS: 여권 정보
  • CTCE: 이메일
  • CTCM: 전화번호
  • INFT: 유아 정보

OSI (Other Service Information)

  • OSI_CARRIERS 항공사에 필수
  • 추가 정보 전달용

5. 주의사항

5.1 성능 고려사항

  • SOAP Session Token 리소스 관리 중요 (반드시 closeSessionToken 호출)
  • REST API 병렬 처리 적극 활용 (pmap)
  • Redis Token 캐싱으로 불필요한 인증 요청 방지
  • 검색 결과 캐싱으로 반복 요청 최소화

5.2 에러 처리

  • Void 실패 시 재시도 (3회)
  • PNR 취소 실패 시 재시도 (2회)
  • 큐 제거 실패 시 재시도 (3회, @Retryable)
  • Socket Timeout 특별 처리 (환불 시)
  • Locked PNR 방지 (5초 대기)

5.3 보안

  • Session Token 안전 관리
  • REST Token Redis 캐싱 (만료 관리)
  • PCI DSS 준수 (카드 정보)
  • 민감 정보 로깅 금지

5.4 비동기 처리 주의

  • PNR 취소, 결제 취소는 5초 대기 후 비동기 처리
  • 발권 실패 시 취소도 5초 대기 후 비동기 처리
  • Locked PNR 방지가 목적

6. Amadeus와의 차이점 비교

항목AmadeusSabre
API 타입SOAP onlyREST + SOAP 하이브리드
인증 방식Session 기반 (SOAP)OAuth 2.0 (REST) + Session (SOAP)
검색 APISOAP (FareMasterPricer)REST (BargainFinderMax)
운임 규칙SOAP (CommandCryptic)REST (FareRuleClient)
환불 APISOAP (DocRefund)REST (RefundTickets)
Session 관리Start → InSeries → EndgetSessionToken → closeSessionToken
Token 캐싱없음Redis 캐싱 (REST Token)
병렬 처리cartesianProduct (SOAP)pmap (REST)
Circuit Breaker있음 (Resilience4j)없음
Void 재시도3회3회
PNR 취소 재시도2회2회
비동기 처리5초 대기5초 대기
항공사 특별 처리AB, KE, CZ, MU, PRPR, HY, MF, SU, QH, OSI_CARRIERS, FOID_PASSPORT_CARRIERS
Void 불가 항공사HY, MF, SU, QHHY, MF, SU, QH
EndorsementMU: 생년월일, PR: RFND ONLYPR: RFND ONLY TO ISSUE AGT
TST/PriceQuoteTST (FarePricePnr)PriceQuote (markSeat)

7. 트러블슈팅 가이드

7.1 일반적인 문제

문제원인해결 방법
SESSION EXPIREDSOAP Session 타임아웃새 Session 생성
PNR LOCKED동시 접근 또는 작업 중5초 대기 후 재시도
TOKEN EXPIREDREST Token 만료새 Token 발급 (자동)
VOID FAILEDVoid 불가 항공사Refund로 전환
UNCOMMITTED TICKETS발권 미완료재조회 또는 에러 발생
CARRIER TIME LIMIT발권 기한 초과Repricing 또는 재예약

7.2 디버깅 팁

  • SOAP 요청/응답 로깅 활성화 (supplierLoggingProperties)
  • REST API 로깅 활성화 (enableSearchLog)
  • Session Token 추적
  • PNR 히스토리 확인 (getBooking)
  • Redis 캐시 확인 (Token, FareItinerary, FareRule)
  • Slack 알림 확인 (에러 발생 시)

8. 참고 자료

8.1 주요 클래스

  • SabreClient: SOAP 통신 클라이언트
  • SabreRestClient: REST 통신 클라이언트
  • SabreFlightSearchService: 검색 서비스
  • SabreBookingService: 예약 서비스
  • SabreTicketingService: 발권 서비스
  • SabreCancelService: 취소 서비스
  • SabreQueueService: 큐 관리 서비스
  • SabrePaymentService: 결제 서비스
  • SabreFareRuleService: 운임 규칙 서비스

8.2 설정 파일

  • application.yml: 기본 설정
  • application-{env}.yml: 환경별 설정
  • redisson-{env}.yml: Redis 설정
  • SabreProperties.kt: Sabre 공급사 설정

8.3 상수 파일

  • NonVoidableAirline.kt: Void 불가능 항공사 (HY, MF, SU, QH)
  • Constants.kt: OSI_CARRIERS, FOID_PASSPORT_CARRIERS
  • CardCode.kt: 항공사별 카드 코드 매핑

8.4 관련 문서

  • Sabre Web Services Documentation
  • Sabre REST API Guide
  • Sabre SOAP API Guide
  • OAuth 2.0 Specification