Sabre 환불(Refund) API 심층 분석

1. API 개요

1.1 주요 기능

  • 환불 가능 여부 조회: checkFlightTickets REST API
  • 환불 예상 금액 계산: 실제 환불 없이 예상 환불 금액 조회
  • 실제 환불 처리: refundFlightTickets REST API (단일 호출)
  • Waiver 처리: 무료 환불 조건 (OSI/SSR/REMARK/AUTH_CODE)
  • 결제 수단별 환불: 카드/현금 혼합 결제 처리
  • 환불 수수료 계산: Waiver 여부에 따른 수수료 차등 적용

1.2 관련 파일 구조

supplier/sabre/
├── application/
│   └── SabreCancelService.kt           # 환불 서비스 메인 로직
├── infrastructure/
│   ├── rest/
│   │   ├── SabreRestClient.kt          # REST API 클라이언트
│   │   ├── request/bookingmanagement/
│   │   │   ├── RefundTicketsRequest.kt    # 환불 요청
│   │   │   ├── RefundFlightTicket.kt      # 티켓별 환불 정보
│   │   │   └── RefundQualifier.kt         # 환불 조건
│   │   └── response/bookingmanagement/
│   │       ├── RefundTicketsResponse.kt   # 환불 응답
│   │       └── CheckTicketsResponse.kt    # 환불 가능 여부 응답
│   └── soap/
│       └── SabreClient.kt              # SOAP API (Waiver 저장)
└── support/
    └── model/
        ├── WaiverModel.kt              # Waiver 모델
        ├── Refund.kt                   # 최종 환불 결과
        ├── Ticket.kt                   # 티켓 모델
        ├── CancellableTicket.kt        # 환불 가능 티켓
        └── RefundablePrice.kt          # 환불 가능 금액

1.3 Amadeus vs Sabre 환불 API 비교

항목AmadeusSabre
프로토콜SOAPREST
프로세스3단계 (Init → Update → Process)단일 호출 (refundFlightTickets)
조회 APIinitRefund (세션 필요)checkFlightTickets (세션 불필요)
캐싱InitRefund 결과 (당일 자정까지)없음
Waiver 저장PnrAddMultiElements (SOAP)SpecialServiceRQ + SaveRemark (SOAP)
Waiver AUTH_CODEUpdateRefund에 포함RefundQualifier.waiverCode에 포함
환불 금액 계산복잡 (카드 우선 차감)단순 (현금 우선 차감)
수수료 처리Update 단계에서 overrideoverrideCancelFee 파라미터
FOP 관리FOP 그룹 재구성 필요splitRefundAmounts로 자동 처리
재시도 메커니즘없음없음 (단일 호출)
세션 관리Stateful (Start → InSeries → End)Token 기반 (getSessionToken → closeSessionToken)

2. 핵심 비즈니스 로직

2.1 환불 프로세스 플로우

flowchart TD
    A[환불 요청] --> B{조회 or 실행?}
    B -->|조회| C[cancelable]
    B -->|실행| D[cancel]

    C --> E[getBooking]
    E --> F{Voidable?}
    F -->|Yes| G[CancelActionType.VOID 반환]
    F -->|No| H[checkCancellableTickets]
    H --> I[findRefunds]
    I --> J[환불 예상 정보 반환]

    D --> K[getBooking]
    K --> L{ticketHistories 존재?}
    L -->|No| M[handleEmptyTicketHistories]
    L -->|Yes| N{Voidable?}

    N -->|Yes| O[voidTickets]
    N -->|No| P{Waiver or AutoRefundable?}

    P -->|No| Q[CANCEL_UNABLE 예외]
    P -->|Yes| R[validateRefundableTickets]

    R --> S{Waiver 존재?}
    S -->|Yes| T[saveWaiverRefunds]
    S -->|No| U[checkCancellableTickets]
    T --> U

    U --> V{isRefundable?}
    V -->|No| W[CANCEL_UNABLE 예외]
    V -->|Yes| X[refundTickets]

    X --> Y[pnrCancelAsync]
    O --> Y
    M --> Y

    Y --> Z[완료]

    style F fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style N fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style P fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style S fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style V fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000

2.2 환불 가능 여부 조회 (cancelable)

2.2.1 조회 전용 로직

// SabreCancelService.kt:183-223
fun cancelable(
    pnr: String,
    autoRefundable: Boolean,
    waiverRefundable: Boolean,
): CancelableTypeDetail {
    val token = sabreClient.getSessionToken()
 
    return try {
        val booking = sabreClient.getBooking(token = token, pnr = pnr)
 
        when {
            // 1. Void 가능 여부 체크
            booking.isVoidable -> CancelableTypeDetail(
                action = CancelActionType.VOID,
                waiverRefundable = false,
            )
 
            // 2. 환불 가능 여부 체크
            waiverRefundable || autoRefundable -> {
                validateRefundableTickets(booking = booking, waiverRefundable = waiverRefundable)
                val cancellableTicket = getCancellableTickets(token = token, pnr = pnr)
                CancelableTypeDetail(
                    action = CancelActionType.REFUND,
                    waiverRefundable = waiverRefundable,
                    refunds = findRefunds(
                        booking = booking,
                        cancellableTicket = cancellableTicket,
                        waiverRefundable = waiverRefundable,
                    )
                )
            }
 
            // 3. 환불 불가
            else -> throw InternationalAdapterException(
                ErrorMessage.CANCEL_UNABLE,
                booking.pnr!!,
                booking.validatingCarrier
            )
        }
 
    } finally {
        sabreClient.closeSessionToken(token)
    }
}

2.2.2 환불 예상 금액 계산

// SabreCancelService.kt:275-295
private fun findRefunds(
    booking: Booking,
    cancellableTicket: CancellableTicket?,
    waiverRefundable: Boolean,
): List<Refund>? {
    return cancellableTicket
        // 환불, 자동환불이 가능하고 emd티켓이 없는 경우에 한정하여 환불예상금을 세팅한다.
        ?.takeIf {
            it.isRefundable && booking.passengers.mapNotNull { passenger -> passenger.emdTicket }.isEmpty()
        }
        ?.let { ticket ->
            ticket.cancellableTicketInfos.map { ticketInfo ->
                // EMD 티켓을 걸렀기 때문에 eTicket으로 티켓을 매칭하여 준다.
                Refund.of(
                    passenger = booking.passengers.first { it.eTicket?.ticketNumber == ticketInfo.ticketNumber },
                    cancellableTicket = ticketInfo,
                    waiverRefundable = waiverRefundable,
                )
            }
        }
}

2.3 실제 환불 처리 (cancel)

2.3.1 메인 환불 로직

// SabreCancelService.kt:38-102
fun cancel(
    pnr: String,
    validatingCarrier: String,
    payment: Payment? = null,
    autoRefundable: Boolean,
    waivers: List<WaiverModel>?,
): List<Refund> {
    val token = sabreClient.getSessionToken()
    try {
        val booking = sabreClient.getBooking(token = token, pnr = pnr)
 
        // 1. 티켓 이력 없는 경우 처리
        if (booking.ticketHistories.isNullOrEmpty()) {
            handleEmptyTicketHistories(
                booking = booking,
                validatingCarrier = validatingCarrier,
                payment = payment,
                token = token
            )
            return emptyList()
        }
 
        val waiverRefundable = waivers?.isNotEmpty() ?: false
 
        // 2. 처리 분기
        return when {
            // 2-1. Void 처리
            booking.isVoidable -> {
                voidTickets(
                    token = token,
                    booking = booking,
                    validatingCarrier = validatingCarrier,
                    payment = payment,
                )
                emptyList()
            }
 
            // 2-2. Refund 처리
            waiverRefundable || autoRefundable -> {
                validateRefundableTickets(booking = booking, waiverRefundable = waiverRefundable)
 
                // Waiver 처리 시 OSI/SSR/REMARK 타입별 설정
                if (waiverRefundable) saveWaiverRefunds(waivers, token, booking)
 
                val cancellableTickets = getCancellableTickets(token = token, pnr = booking.pnr!!).also {
                    if (!it.isRefundable) {
                        throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, booking.pnr)
                    }
                }
 
                refundTickets(
                    token = token,
                    booking = booking,
                    cancellableTicket = cancellableTickets,
                    waivers = waivers,
                )
            }
 
            // 2-3. 환불 불가
            else -> throw InternationalAdapterException(
                ErrorMessage.CANCEL_UNABLE,
                booking.pnr!!,
                booking.validatingCarrier
            )
 
        }.also {
            // 3. PNR 비동기 취소
            pnrCancelAsync(booking.pnr!!)
        }
    } finally {
        sabreClient.closeSessionToken(token)
    }
}

3. Waiver 처리

3.1 Waiver 타입별 저장

// SabreCancelService.kt:516-540
private fun saveWaiverRefunds(waivers: List<WaiverModel>, token: String, booking: Booking) {
    val filteredWaivers = waivers.filter { it.type != WaiverType.AUTH_CODE }
    if (filteredWaivers.isEmpty()) return
 
    // 1. OSI/SSR 타입 저장 (SpecialServiceRQ)
    val specialServiceWaivers = filteredWaivers.filter { it.type == WaiverType.OSI || it.type == WaiverType.SSR }
    if (specialServiceWaivers.isNotEmpty()) {
        sabreClient.saveWaiverRefunds(
            token = token,
            waivers = specialServiceWaivers,
            validatingCarrier = booking.validatingCarrier
        )
    }
 
    // 2. REMARK 타입 개별 저장 (SaveRemark)
    val remarkWaivers = filteredWaivers.filter { it.type == WaiverType.REMARK }
    if (remarkWaivers.isNotEmpty()) {
        remarkWaivers.forEach { waiver ->
            sabreClient.saveRemark(
                token = token,
                remarkText = waiver.text,
            )
        }
    }
 
    // 3. 트랜잭션 종료 (저장 확정)
    sabreClient.endTransaction(token = token)
}

3.2 Waiver 처리 흐름

flowchart TD
    A[Waiver 존재?] --> B{AUTH_CODE 제외 필터링}
    B --> C{OSI/SSR 존재?}
    C -->|Yes| D[saveWaiverRefunds<br/>SpecialServiceRQ]
    C -->|No| E{REMARK 존재?}

    D --> E
    E -->|Yes| F[saveRemark 반복 호출]
    E -->|No| G[endTransaction]
    F --> G

    G --> H[AUTH_CODE는 별도 처리]
    H --> I[RefundQualifier.waiverCode에 포함]

    style C fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style E fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000

3.3 AUTH_CODE 처리

// RefundFlightTicket.kt:54-56
return RefundQualifier(
    splitRefundAmounts = splitRefundAmounts,
    overrideCancelFee = if (waivers?.isNotEmpty() == true) "0" else null,
    waiverCode = waivers?.find { it.type == WaiverType.AUTH_CODE }?.text,  // AUTH_CODE는 여기에
)

4. 환불 금액 계산

4.1 환불 수수료 계산

4.1.1 Refund 모델 생성

// Refund.kt:15-36
fun of(
    passenger: Passenger,
    cancellableTicket: CancellableTicketInfo,
    waiverRefundable: Boolean,
): Refund {
    // net발권때문에 ticket을 기본으로 한다. (EMD 티켓을 걸렀기 때문에 eTicket으로 티켓을 매칭)
    val airPrice = passenger.eTicket?.airPrice ?: 0
    val tax = passenger.eTicket?.tax ?: 0
 
    return Refund(
        passengerType = passenger.type,
        identificationKey = passenger.identificationKey!!,
        ticketNumber = cancellableTicket.ticketNumber,
        // refundFee 의 경계가 애매하여 usedAirPrice를 발라낼수 없음 (usedAirPrice는 없을 것으로 간주 모두 refundFee로 처리)
        refundFee = if (waiverRefundable) {
            0L
        } else {
            airPrice - (cancellableTicket.refundablePrice?.totalAirPrice ?: 0)
        },
        usedTax = tax - (cancellableTicket.refundablePrice?.totalTaxPrice ?: 0)
    )
}

4.2 결제 수단별 환불 금액 계산

4.2.1 Waiver 환불 금액 계산 (세금만 차감)

// RefundFlightTicket.kt:59-69
private fun calculateWaiverRefundAmounts(
    ticket: Ticket,
    cancellableTicket: CancellableTicketInfo
): Pair<Long, Long> {
    val tax = if (ticket.type == TicketType.TICKET) ticket.tax else 0
    val usedTax = tax - (cancellableTicket.refundablePrice?.totalTaxPrice ?: 0)
 
    // 카드 우선 차감, 부족하면 현금에서 차감
    val cardDeduction = min(ticket.cardPrice, usedTax)
    val cashDeduction = min(ticket.cashPrice, usedTax - cardDeduction)
    return (ticket.cardPrice - cardDeduction) to (ticket.cashPrice - cashDeduction)
}

4.2.2 일반 환불 금액 계산 (현금 우선 차감)

// RefundFlightTicket.kt:71-79
private fun calculateRefundAmounts(
    ticket: Ticket,
    cancellableTicket: CancellableTicketInfo
): Pair<Long, Long> {
    val totalRefundPrice = cancellableTicket.refundablePrice?.totalRefundPrice ?: 0
    // 현금 우선 차감, 부족하면 카드에서 차감
    val cashPrice = min(totalRefundPrice, ticket.cashPrice)
    val cardPrice = totalRefundPrice - cashPrice
    return cardPrice to cashPrice
}

4.2.3 환불 금액 계산 플로우

flowchart TD
    A[원본 결제 정보] --> B{Waiver?}
    B -->|Yes| C[calculateWaiverRefundAmounts]
    B -->|No| D[calculateRefundAmounts]

    C --> E[usedTax 계산]
    E --> F[tax - refundablePrice.totalTaxPrice]
    F --> G{카드 금액으로 충분?}
    G -->|Yes| H[카드에서만 차감]
    G -->|No| I[카드 + 현금에서 차감]

    D --> J[totalRefundPrice 계산]
    J --> K{현금으로 충분?}
    K -->|Yes| L[현금에서만 차감]
    K -->|No| M[현금 전체 + 카드에서 차감]

    H --> N[splitRefundAmounts 생성]
    I --> N
    L --> N
    M --> N

    N --> O[RefundFlightTicket 생성]

    style B fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000
    style G fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style K fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000

4.3 RefundQualifier 구성

// RefundFlightTicket.kt:34-57
private fun calculateRefundQualifiers(
    ticket: Ticket,
    cancellableTicket: CancellableTicketInfo,
    waivers: List<WaiverModel>?,
): RefundQualifier {
    // 1. 혼합 결제인 경우 splitRefundAmounts 계산
    val splitRefundAmounts = if (ticket.mixedPayment) {
        val (refundCardPrice, refundCashPrice) = if (waivers?.isNotEmpty() == true) {
            calculateWaiverRefundAmounts(ticket = ticket, cancellableTicket = cancellableTicket)
        } else {
            calculateRefundAmounts(ticket = ticket, cancellableTicket = cancellableTicket)
        }
 
        listOf(
            SplitRefundAmount(amount = refundCardPrice.toString()),
            SplitRefundAmount(amount = refundCashPrice.toString())
        )
    } else null
 
    return RefundQualifier(
        splitRefundAmounts = splitRefundAmounts,
        overrideCancelFee = if (waivers?.isNotEmpty() == true) "0" else null,
        waiverCode = waivers?.find { it.type == WaiverType.AUTH_CODE }?.text,
    )
}

5. 환불 실행 프로세스

5.1 REST API 호출

// SabreCancelService.kt:334-380
private fun refundTickets(
    token: String,
    booking: Booking,
    cancellableTicket: CancellableTicket,
    waivers: List<WaiverModel>? = null,
): List<Refund> {
    val (isAllRefunded, refundedTickets) = try {
        sabreRestClient.refundTickets(
            token = token,
            booking = booking,
            cancellableTicket = cancellableTicket,
            waivers = waivers,
        )
    } catch (e: Exception) {
        // 예외별 Slack 알림
        when (e) {
            is SocketTimeoutException, is SocketException, is IOException ->
                slackService.sendCancelFailTimeout(supplier = Supplier.SABRE, pnr = booking.pnr!!)
 
            else -> slackService.sendRefundFail(supplier = Supplier.SABRE, pnr = booking.pnr!!)
        }
 
        if (e is InternationalAdapterException) {
            throw e
        } else {
            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 = Supplier.SABRE,
            pnr = booking.pnr!!,
            targetTickets = targetTickets,
            failTickets = failTickets
        )
 
        throw InternationalAdapterException(
            ErrorMessage.REFUND_FAILED,
            "All ticket is not refunded"
        ).capture()
    }
 
    return refundedTickets
}

5.2 REST API 클라이언트

// SabreRestClient.kt:408-457
fun refundTickets(
    token: String,
    booking: Booking,
    cancellableTicket: CancellableTicket,
    waivers: List<WaiverModel>?,
): Pair<Boolean, List<Refund>> {
    val sabreApiProperties = sabreProperties.getApiProperties()
    val refundTickets = booking.passengers.mapNotNull { it.eTicket }
 
    return "${sabreApiProperties.restEndpoint}/v1/trip/orders/refundFlightTickets"
        .post(
            RefundTicketsRequest.of(
                ipcc = sabreApiProperties.pcc.online,
                printerAddress = sabreApiProperties.printAddress,
                refundTickets = refundTickets,
                cancellableTicketInfos = cancellableTicket.cancellableTicketInfos,
                waivers = waivers,
            )
        )
        .bearer(token)
        .execute<RefundTicketsResponse>()
        .fold(
            success = { response ->
                response.checkError { code, message ->
                    throw InternationalAdapterException(
                        ErrorMessage.REFUND_FAILED,
                        code,
                        message
                    ).capture()
                }
 
                Pair(
                    refundTickets.isAllRefunded(ticketNumbers = response.refundedTickets),
                    booking.passengers.mapNotNull { passenger ->
                        response.tickets
                            ?.find { it.number == passenger.eTicket?.ticketNumber }
                            ?.takeIf { it.refundTotals != null }
                            ?.let { ticket ->
                                Refund.of(
                                    passenger = passenger,
                                    ticket = ticket,
                                    waiverRefundable = waivers?.isNotEmpty() == true
                                )
                            }
                    }
                )
            },
            failure = { throw it.exception }
        )
}

5.3 환불 실행 플로우

sequenceDiagram
    participant CS as CancelService
    participant RC as RestClient
    participant SA as Sabre REST API

    CS->>RC: refundTickets(token, booking, cancellableTicket, waivers)
    RC->>RC: RefundTicketsRequest 생성
    RC->>SA: POST /v1/trip/orders/refundFlightTickets

    alt 환불 성공
        SA-->>RC: RefundTicketsResponse
        RC->>RC: isAllRefunded 체크
        RC->>RC: Refund 모델 생성
        RC-->>CS: (isAllRefunded, refundedTickets)

        alt 부분 실패
            CS->>CS: Slack 알림 발송
            CS-->>CS: REFUND_FAILED 예외
        end
    else 환불 실패
        SA-->>RC: Error Response
        RC-->>CS: InternationalAdapterException
        CS->>CS: Slack 알림 발송
        CS-->>CS: 예외 재발생
    end

6. 환불 가능 여부 조회

6.1 checkCancellableTickets

// SabreRestClient.kt:361-385
fun checkCancellableTickets(token: String, pnr: String): CancellableTicket {
    val sabreApiProperties = sabreProperties.getApiProperties()
    return "${sabreApiProperties.restEndpoint}/v1/trip/orders/checkFlightTickets"
        .post(
            CheckTicketsRequest.of(
                pnr = pnr
            )
        )
        .bearer(token)
        .execute<CheckTicketsResponse>()
        .fold(
            success = { response ->
                response.checkError { errorMessages ->
                    throw InternationalAdapterException(
                        ErrorMessage.CANCELLABLE_TICKET_CHECK_FAILED,
                        errorMessages,
                    ).capture()
                }
                response.toCancellableTicket()
            },
            failure = {
                throw InternationalAdapterException(ErrorMessage.CANCELLABLE_TICKET_CHECK_FAILED)
            }
        )
}

6.2 getCancellableTickets (Slack 알림 포함)

// SabreCancelService.kt:502-514
private fun getCancellableTickets(token: String, pnr: String): CancellableTicket {
    return try {
        sabreRestClient.checkCancellableTickets(token = token, pnr = pnr)
    } catch (e: Exception) {
        // 자동환불 여부 및 환불예상금 조회 에러 시 추가
        slackService.sendCheckFlightTicketsFail(
            supplier = Supplier.SABRE,
            pnr = pnr,
            reason = e.message
        )
        throw e
    }
}

6.3 CancellableTicket 모델

// Ticket.kt:81-89
data class CancellableTicket(
    val cancellableTicketInfos: List<CancellableTicketInfo>,
) {
    val isVoidable: Boolean
        get() = cancellableTicketInfos.all { it.isVoidable }
 
    val isRefundable: Boolean
        get() = cancellableTicketInfos.all { !it.isVoidable && it.isRefundable && it.isAutomatedRefundable }
}
// Ticket.kt:91-97
data class CancellableTicketInfo(
    val ticketNumber: String,
    val isVoidable: Boolean,
    val isRefundable: Boolean,
    val isAutomatedRefundable: Boolean,
    val refundablePrice: RefundablePrice?,
)

7. 검증 로직

7.1 환불 가능 티켓 검증

// SabreCancelService.kt:489-500
private fun validateRefundableTickets(booking: Booking, waiverRefundable: Boolean) {
    // 1. EMD 티켓 체크
    if (booking.passengers.any { it.emdTicket != null }) {
        throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, booking.pnr!!, "EMD Ticket Exists")
    }
 
    // 2. 스케줄 확정 여부 체크 (Waiver 아닌 경우만)
    if (!waiverRefundable && booking.schedules?.all { it.confirmed } == false) {
        throw InternationalAdapterException(
            ErrorMessage.CANCEL_UNABLE_BY_SCHEDULE_STATUS,
            booking.pnr!!,
            booking.schedules.joinToString { it.status })
    }
}

7.2 환불 완료 검증

// SabreRestClient.kt:467-469
private fun List<Ticket>.isAllRefunded(ticketNumbers: List<String>?): Boolean {
    return this.all { ticketNumbers?.contains(it.ticketNumber) == true }
}

8. 예외 처리 및 알림

8.1 예외 종류별 처리

// SabreCancelService.kt:347-360
catch (e: Exception) {
    when (e) {
        // 타임아웃 에러
        is SocketTimeoutException, is SocketException, is IOException ->
            slackService.sendCancelFailTimeout(supplier = Supplier.SABRE, pnr = booking.pnr!!)
 
        // 기타 환불 실패
        else -> slackService.sendRefundFail(supplier = Supplier.SABRE, pnr = booking.pnr!!)
    }
 
    if (e is InternationalAdapterException) {
        throw e
    } else {
        throw InternationalAdapterException(ErrorMessage.REFUND_FAILED, e.message!!)
    }
}

8.2 부분 실패 알림

// SabreCancelService.kt:362-377
if (isAllRefunded.not()) {
    val targetTickets = booking.passengers.map { it.eTicket!!.ticketNumber }
    val failTickets = targetTickets - refundedTickets.map { it.ticketNumber }.toSet()
 
    slackService.sendAllTicketRefundFail(
        supplier = Supplier.SABRE,
        pnr = booking.pnr!!,
        targetTickets = targetTickets,
        failTickets = failTickets
    )
 
    throw InternationalAdapterException(
        ErrorMessage.REFUND_FAILED,
        "All ticket is not refunded"
    ).capture()
}

8.3 CheckFlightTickets 실패 알림

// SabreCancelService.kt:506-511
slackService.sendCheckFlightTicketsFail(
    supplier = Supplier.SABRE,
    pnr = pnr,
    reason = e.message
)

9. 성능 특성

9.1 Amadeus vs Sabre 성능 비교

항목AmadeusSabre
API 호출 횟수3회 (Init → Update → Process)1회 (refundFlightTickets)
조회 API 호출세션 필요 (Start → Init → End)세션 불필요 (단일 호출)
캐싱InitRefund 결과 캐싱없음
병렬 처리InitRefund pmap없음 (단일 API)
세션 관리 오버헤드높음 (Stateful Session)중간 (Token 기반)
네트워크 라운드트립많음 (3+ 회)적음 (1회)
복잡도높음 (FOP 재구성, 금액 계산)낮음 (단순 계산)

9.2 장단점

Sabre 장점:

  1. 단순한 프로세스: 단일 API 호출로 환불 완료
  2. 빠른 응답 시간: 네트워크 라운드트립 최소화
  3. 낮은 복잡도: FOP 재구성 불필요
  4. 세션 관리 간소화: Token 기반 인증

Sabre 단점:

  1. 캐싱 부재: 반복 조회 시 매번 API 호출
  2. 병렬 처리 불가: 단일 API 호출 방식
  3. 세밀한 제어 부족: Update 단계 없음

10. 개선 권장사항

10.1 현재 이슈

  1. 조회 결과 캐싱 부재: 동일 PNR 반복 조회 시 매번 API 호출
  2. Timeout 처리 미흡: SocketTimeoutException 발생 시 재시도 없음
  3. 부분 실패 롤백 부재: 일부 티켓만 환불된 경우 수동 처리 필요

10.2 개선 방안

  1. CheckFlightTickets 결과 캐싱:
    • 캐시 키: {pnr}
    • TTL: 5분
    • 목적: 반복 조회 방지
  2. 재시도 메커니즘 추가:
    • Timeout 에러: 3회 재시도
    • 일시적 오류: 2회 재시도
  3. 트랜잭션 관리:
    • 전체 성공/실패 보장
    • 부분 실패 시 자동 롤백
  4. Waiver 검증 강화:
    • PNR 조회로 저장 확인 (Amadeus 패턴)

11. 참고사항

11.1 주요 에러 코드

  • REFUND_FAILED: 환불 처리 실패
  • CANCELLABLE_TICKET_CHECK_FAILED: 환불 가능 여부 조회 실패
  • CANCEL_UNABLE: 환불 불가능 (EMD, 스케줄 상태 등)
  • CANCEL_UNABLE_BY_SCHEDULE_STATUS: 스케줄 상태로 환불 불가
  • CANCEL_UNABLE_BY_ALREADY_CHECK_IN: 체크인 완료로 환불 불가

11.2 Waiver 타입별 용도

  • OSI: Other Service Information (항공사 공지사항, SpecialServiceRQ)
  • SSR: Special Service Request (특별 서비스 요청, SpecialServiceRQ)
  • AUTH_CODE: Authorization Code (환불 승인 코드, RefundQualifier.waiverCode)
  • REMARK: Remark (비고, SaveRemark 개별 호출)

11.3 환불 가능 조건

  • isVoidable: 모든 티켓이 Void 가능
  • isRefundable: 모든 티켓이 Void 불가 + 환불 가능 + 자동 환불 가능
  • waiverRefundable: Waiver 코드 존재 (무료 환불)
  • autoRefundable: 자동 환불 가능 플래그

11.4 관련 문서