Phase 5: Sabre 취소(Cancel) API 심층 분석

1. API 개요

1.1 주요 기능

  • 취소 유형 판별: Void(당일 취소) vs Refund(환불) 자동 결정
  • NonVoidableAirline 처리: HY, MF, SU, QH 항공사 Void 불가
  • 비동기 처리: PNR 취소 및 결제 취소의 비동기 처리 (5초 대기)
  • 재시도 메커니즘: Void 3회, PNR 취소 2회 재시도
  • Waiver 처리: OSI/SSR/REMARK 타입별 환불 사유 저장

1.2 관련 파일 구조

supplier/sabre/
├── application/
│   ├── SabreCancelService.kt          # 취소 서비스 메인 로직
│   └── SabrePaymentService.kt         # 결제 취소 서비스
├── infrastructure/
│   ├── soap/
│   │   └── SabreClient.kt             # void, pnrCancel, ignore
│   └── rest/
│       └── SabreRestClient.kt         # refundTickets, checkCancellableTickets
└── support/
    ├── enums/NonVoidableAirline.kt    # Void 불가능 항공사
    └── model/
        ├── Booking.kt                  # isVoidable 속성
        └── CancellableTicket.kt        # 환불 가능 여부

2. 취소 유형 판별 (Void vs Refund)

2.1 메인 취소 플로우

flowchart TD
    A[cancel 호출] --> B[Session Token 획득]
    B --> C[PNR 정보 조회<br/>getBooking]
    C --> D{ticketHistories<br/>존재?}

    D -->|없음| E[handleEmptyTicketHistories]
    E --> F{pnrCreatedAt<br/>어제 이전 or NoShow?}
    F -->|Yes| G[CANCEL_UNABLE 예외]
    F -->|No| H[결제 취소 비동기]
    H --> I[PNR 취소<br/>pnrCancelRepeat]
    I --> J[emptyList 반환]

    D -->|있음| K{isVoidable?}
    K -->|Yes| L[voidTickets]
    K -->|No| M{waiverRefundable<br/>or autoRefundable?}

    M -->|Yes| N[saveWaiverRefunds<br/>OSI/SSR/REMARK]
    M -->|No| O[CANCEL_UNABLE 예외]

    N --> P[getCancellableTickets<br/>REST API]
    P --> Q{isRefundable?}
    Q -->|No| R[CANCEL_UNABLE 예외]
    Q -->|Yes| S[refundTickets<br/>REST API]

    L --> T[결제 취소 비동기]
    S --> U[PNR 취소 비동기<br/>pnrCancelAsync]
    T --> U

    U --> V[closeSessionToken]
    J --> V
    V --> W[취소 완료 반환]

     다크/라이트 모드 호환 색상
    style B fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style F fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style J fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style C fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style G fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

Passenger.isVoidable

  • 조건: 당일 발권된 티켓만 Void 가능
  • 체크: eTicket?.issuedAt?.toLocalDate() == today()

TicketHistory.isVoidable

  • 조건: 당일 발권된 티켓 + EMD가 아닌 경우
  • 체크: issuedAt.toLocalDate() == today() && isEmd.not()

3. NonVoidableAirline 처리

3.1 NonVoidableAirline Enum

enum class NonVoidableAirline {
    HY,  // 우즈베키스탄 항공
    MF,  // 샤먼 항공
    SU,  // 아에로플로트
    QH,  // 밤부 항공
    ;
 
    companion object {
        fun notContains(airline: String): Boolean {
            return entries.none { it.name == airline }
        }
 
        fun contains(airline: String): Boolean {
            return entries.any { it.name == airline }
        }
    }
}

위치: NonVoidableAirline.kt:3-19

3.2 NonVoidableAirline 체크 플로우

flowchart LR
    A[validatingCarrier] --> B{HY/MF/SU/QH?}
    B -->|Yes| C[notContains = false<br/>isVoidable = false]
    B -->|No| D[notContains = true<br/>추가 검증 진행]

    C --> E[Refund로 처리]
    D --> F[Passenger/TicketHistory<br/>isVoidable 확인]

     다크/라이트 모드 호환 색상
    style C fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style H fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style G fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style K fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style L fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

위치: SabreCancelService.kt:334-380

5.2 환불 검증 (validateRefundableTickets)

private fun validateRefundableTickets(booking: Booking, waiverRefundable: Boolean) {
    if (booking.passengers.any { it.emdTicket != null }) {
        throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, booking.pnr!!, "EMD Ticket Exists")
    }
 
    if (!waiverRefundable && booking.schedules?.all { it.confirmed } == false) {
        throw InternationalAdapterException(
            ErrorMessage.CANCEL_UNABLE_BY_SCHEDULE_STATUS,
            booking.pnr!!,
            booking.schedules.joinToString { it.status })
    }
}

위치: SabreCancelService.kt:489-500

환불 불가 조건

  1. EMD 티켓 존재: EMD는 별도 프로세스 필요
  2. 스케줄 미확정 & Waiver 없음: confirmed가 아닌 경우 환불 불가

5.3 CancellableTicket 조회

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
    }
}

위치: SabreCancelService.kt:502-514

CancellableTicket 구조

  • isRefundable: 환불 가능 여부
  • cancellableTicketInfos: 각 티켓별 환불 예상 금액

6. Waiver 처리

6.1 Waiver 저장 플로우

flowchart TD
    A[saveWaiverRefunds] --> B[Waiver 필터링<br/>AUTH_CODE 제외]
    B --> C{필터링된<br/>Waiver 존재?}
    C -->|없음| D[return]

    C -->|있음| E[OSI/SSR Waiver 추출]
    E --> F{OSI/SSR<br/>Waiver 있음?}
    F -->|Yes| G[saveWaiverRefunds<br/>SpecialServiceRQ]

    F -->|No| H[REMARK Waiver 추출]
    G --> H
    H --> I{REMARK<br/>Waiver 있음?}
    I -->|Yes| J[saveRemark<br/>각각 저장]

    I -->|No| K[endTransaction<br/>PNR 저장]
    J --> K
    K --> L[Waiver 저장 완료]

    %% 다크/라이트 모드 호환 색상
    style C fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style F fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style I fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style L fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

위치: SabreCancelService.kt:516-540

6.2 Waiver 타입별 처리

private fun saveWaiverRefunds(waivers: List<WaiverModel>, token: String, booking: Booking) {
    val filteredWaivers = waivers.filter { it.type != WaiverType.AUTH_CODE }
    if (filteredWaivers.isEmpty()) return
 
    val specialServiceWaivers = filteredWaivers.filter { it.type == WaiverType.OSI || it.type == WaiverType.SSR }
    if (specialServiceWaivers.isNotEmpty()) {
        sabreClient.saveWaiverRefunds(
            token = token,
            waivers = specialServiceWaivers,
            validatingCarrier = booking.validatingCarrier
        )
    }
 
    val remarkWaivers = filteredWaivers.filter { it.type == WaiverType.REMARK }
    if (remarkWaivers.isNotEmpty()) {
        remarkWaivers.forEach { waiver ->
            sabreClient.saveRemark(
                token = token,
                remarkText = waiver.text,
            )
        }
    }
 
    sabreClient.endTransaction(token = token)
}

위치: SabreCancelService.kt:516-540

WaiverType 분류

WaiverType처리 방법API
OSISpecial Service RequestsaveWaiverRefunds
SSRSpecial Service RequestsaveWaiverRefunds
REMARK개별 저장 (forEach)saveRemark
AUTH_CODE필터링 제외처리 없음

7. PNR 취소 (pnrCancel)

7.1 비동기 PNR 취소 (pnrCancelAsync)

private fun pnrCancelAsync(pnr: String) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        delay(5000)     //locked pnr 방지
        val token = sabreClient.getSessionToken()
        try {
            pnrCancelRepeat(pnr = pnr, token = token)
        } finally {
            sabreClient.closeSessionToken(token)
        }
    }
}

위치: SabreCancelService.kt:382-392

5초 대기 이유

  • 목적: PNR Lock 방지
  • 시나리오: Void/Refund 처리 직후 PNR이 잠겨있을 수 있음
  • 해결: 5초 대기 후 PNR 취소

7.2 PNR 취소 재시도 (pnrCancelRepeat - 2회)

private fun pnrCancelRepeat(pnr: String, token: String) {
    for (count in 1..2) {
        try {
            pnrCancel(token = token, pnr = 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 = Supplier.SABRE,
                    pnr = pnr,
                    reason = e.message
                )
                throw e
            } else {
                logger.error(e.message, e)
            }
 
            sabreClient.ignore(token)
        }
    }
}

위치: SabreCancelService.kt:394-419

재시도 로직

  • 최대 횟수: 2회
  • ALREADY_CANCELED_PNR: 이미 취소된 경우 정상 종료
  • 1차 실패: ignore 후 재시도
  • 2차 실패: Slack 알림 + 예외 발생

7.3 PNR 취소 메서드

private fun pnrCancel(token: String, pnr: String) {
    withBlocking {
        delay(1000)
        sabreClient.openPnr(token = token, pnr = pnr)
        sabreClient.pnrCancel(token = token, pnr = pnr)
        sabreClient.endTransaction(token)
    }
}

위치: SabreCancelService.kt:421-428

PNR 취소 단계

  1. delay(1000): 1초 대기
  2. openPnr: PNR 열기
  3. pnrCancel: OtaCancelRQ 전송
  4. endTransaction: 취소 저장

7.4 SabreClient.pnrCancel

fun pnrCancel(token: String, pnr: String) {
    val sabreApiProperties = sabreProperties.getApiProperties()
    val request = OtaCancelRQ().withToken(token)
    sabreApiProperties.endpoint
        .post(request)
        .header(headerMap)
        .requestBodyConvert(soapRequestBodyConverter(sabreApiProperties))
        .execute<SabreResponse<OtaCancelRS>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.body!!.checkError()
            },
            failure = {
                throw it.handleSoapFaultException(ErrorMessage.CANCEL_FAILED)
            }
        )
}

위치: SabreClient.kt:748-764


8. 특수 취소 시나리오

8.1 발권 실패 시 취소 (ticketingFailedCancel)

fun ticketingFailedCancel(
    pnr: String,
    validatingCarrier: String,
    payment: Payment,
    keepPnr: Boolean,
    ticketCount: Int? = null,
) {
    val token = sabreClient.getSessionToken()
    try {
        val booking = sabreClient.getBooking(token = token, pnr = pnr)
        if (booking.ticketHistories.isNullOrEmpty()) {
            handleEmptyTicketHistories(
                token = token,
                keepPnr = keepPnr,
                booking = booking,
                validatingCarrier = validatingCarrier,
                payment = payment
            ).also {
                ticketCount?.takeIf { it > 0 }?.run {
                    slackService.sendIncompleteVoid(
                        supplier = Supplier.SABRE,
                        pnr = pnr,
                        passengerCount = booking.passengers.size,
                        ticketCount = this
                    )
                }
            }
 
        } else {
            handleVoidTickets(
                token = token,
                keepPnr = keepPnr,
                booking = booking,
                validatingCarrier = validatingCarrier,
                payment = payment
            )
        }
    } finally {
        sabreClient.closeSessionToken(token)
    }
}

위치: SabreCancelService.kt:131-171

발권 실패 시 분기

조건처리
ticketHistories 없음handleEmptyTicketHistories - 결제 취소 + PNR 취소 (keepPnr에 따라)
ticketHistories 있음handleVoidTickets - Void 처리 + 결제 취소 + PNR 취소 (keepPnr에 따라)

keepPnr 플래그

  • true: PNR 유지 (취소하지 않음)
  • false: PNR 취소 (pnrCancelRepeat)

8.2 PNR만 취소 (onlyPnrCancel)

fun onlyPnrCancel(pnr: String) {
    val token = sabreClient.getSessionToken()
    try {
        sabreClient.getBooking(token = token, pnr = pnr).also {
            val departureAt = with(it.schedules!!.first()) {
                calculateTimezoneService.calculateToUTC(
                    this.departureAt,
                    this.departure
                )
            }
            if (isPnrCreatedAtBeforeYesterdayOrNoShow(pnrCreatedAt = it.pnrCreatedAt, departureAt = departureAt)) {
                throw InternationalAdapterException(
                    ErrorMessage.CANCEL_UNABLE,
                    pnr,
                    "Pnr Created At Before Yesterday"
                ).capture()
            }
        }
        pnrCancelRepeat(pnr = pnr, token = token)
 
    } finally {
        sabreClient.closeSessionToken(token)
    }
}

위치: SabreCancelService.kt:105-128

onlyPnrCancel 용도

  • 발권 실패 후 PNR만 제거: 티켓이 없는 경우
  • Repricing 실패 후 PNR 정리: FareBasis 변경 등

취소 불가 조건

  • pnrCreatedAt 어제 이전: PNR 생성일이 어제 이전인 경우
  • NoShow: 출발 시각 지난 경우

9. 재시도 및 에러 처리 요약

9.1 재시도 횟수 비교

작업재시도 횟수메서드위치
Void3회voidRepeatSabreCancelService.kt:459-478
PNR 취소2회pnrCancelRepeatSabreCancelService.kt:394-419
Refund없음refundTicketsSabreCancelService.kt:334-380

9.2 Slack 알림 시나리오

시나리오메서드위치
부분 Void 실패sendVoidFailSabreCancelService.kt:447-453
부분 Refund 실패sendAllTicketRefundFailSabreCancelService.kt:366-371
PNR 취소 실패sendCancelFailSabreCancelService.kt:406-410
환불 타임아웃sendCancelFailTimeoutSabreCancelService.kt:350
일반 환불 실패sendRefundFailSabreCancelService.kt:352
CancellableTicket 조회 실패sendCheckFlightTicketsFailSabreCancelService.kt:507-510
불완전 VoidsendIncompleteVoidSabreCancelService.kt:150-155, 309-314

10. Amadeus vs Sabre 취소 비교

항목AmadeusSabre
Void 불가 항공사HY, MF, SU, QH (동일)HY, MF, SU, QH (동일)
Void 재시도3회3회
PNR 취소 재시도2회2회
Void 2회 호출없음있음 (void 확인 필요)
비동기 PNR 취소5초 대기5초 대기
Waiver 타입OSI/SSR/REMARK/AUTH_CODEOSI/SSR/REMARK/AUTH_CODE (동일)
Refund APIREST API (ART)REST API (Booking Management)
CancellableTicket없음checkCancellableTickets (REST)
EMD 처리취소 불가취소 불가 (동일)
스케줄 미확정Waiver 필수Waiver 필수 (동일)
void 에러 특별 처리없음체크인 완료 에러 (CANCEL_UNABLE_BY_ALREADY_CHECK_IN)

11. 취소 가능 여부 확인 (cancelable)

11.1 cancelable 메서드

fun cancelable(
    pnr: String,
    autoRefundable: Boolean,
    waiverRefundable: Boolean,
): CancelableTypeDetail {
    val token = sabreClient.getSessionToken()
 
    return try {
        val booking = sabreClient.getBooking(token = token, pnr = pnr)
 
        when {
            booking.isVoidable -> CancelableTypeDetail(
                action = CancelActionType.VOID,
                waiverRefundable = false,
            )
 
            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,
                    )
                )
            }
 
            else -> throw InternationalAdapterException(
                ErrorMessage.CANCEL_UNABLE,
                booking.pnr!!,
                booking.validatingCarrier
            )
        }
 
    } finally {
        sabreClient.closeSessionToken(token)
    }
}

위치: SabreCancelService.kt:183-223

11.2 CancelableTypeDetail 구조

data class CancelableTypeDetail(
    val action: CancelActionType,     // VOID or REFUND
    val waiverRefundable: Boolean,
    val refunds: List<Refund>? = null // 환불 예상 금액
)

CancelActionType

  • VOID: 당일 취소 가능
  • REFUND: 환불 처리 필요

12. 요약 및 핵심 포인트

12.1 취소 프로세스 핵심

  1. isVoidable 판별: NonVoidableAirline + 당일 발권 체크
  2. Void 처리: 3회 재시도, 2회 void 호출 (확인), sequential 처리
  3. Refund 처리: REST API, CancellableTicket 조회, Waiver 저장
  4. PNR 취소: 2회 재시도, 5초 대기 후 비동기 처리
  5. 특수 시나리오:
    • ticketingFailedCancel: keepPnr 플래그
    • onlyPnrCancel: 어제 이전 PNR 취소 불가
  6. Waiver: OSI/SSR (SpecialServiceRQ), REMARK (개별 저장)

12.2 코드 참조 요약

기능파일라인
cancel (메인)SabreCancelService.kt38-102
isVoidableBooking.kt23-26
NonVoidableAirlineNonVoidableAirline.kt3-19
voidTicketsSabreCancelService.kt297-332
voidRepeat (3회)SabreCancelService.kt459-478
void (2회 호출)SabreCancelService.kt480-487
SabreClient.voidSabreClient.kt721-746
VoidTicketRQVoidTicketRQ.kt8-28
refundTicketsSabreCancelService.kt334-380
validateRefundableTicketsSabreCancelService.kt489-500
getCancellableTicketsSabreCancelService.kt502-514
saveWaiverRefundsSabreCancelService.kt516-540
pnrCancelAsyncSabreCancelService.kt382-392
pnrCancelRepeat (2회)SabreCancelService.kt394-419
pnrCancelSabreCancelService.kt421-428
SabreClient.pnrCancelSabreClient.kt748-764
ticketingFailedCancelSabreCancelService.kt131-171
onlyPnrCancelSabreCancelService.kt105-128
cancelableSabreCancelService.kt183-223

문서 버전: 1.0 작성일: 2025-09-30 분석 대상: Sabre GDS Cancel API (SOAP + REST) 참조 프로젝트: air-intl-adapter