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
환불 불가 조건
- EMD 티켓 존재: EMD는 별도 프로세스 필요
- 스케줄 미확정 & 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 |
|---|---|---|
| OSI | Special Service Request | saveWaiverRefunds |
| SSR | Special Service Request | saveWaiverRefunds |
| 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 취소 단계
- delay(1000): 1초 대기
- openPnr: PNR 열기
- pnrCancel: OtaCancelRQ 전송
- 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 재시도 횟수 비교
| 작업 | 재시도 횟수 | 메서드 | 위치 |
|---|---|---|---|
| Void | 3회 | voidRepeat | SabreCancelService.kt:459-478 |
| PNR 취소 | 2회 | pnrCancelRepeat | SabreCancelService.kt:394-419 |
| Refund | 없음 | refundTickets | SabreCancelService.kt:334-380 |
9.2 Slack 알림 시나리오
| 시나리오 | 메서드 | 위치 |
|---|---|---|
| 부분 Void 실패 | sendVoidFail | SabreCancelService.kt:447-453 |
| 부분 Refund 실패 | sendAllTicketRefundFail | SabreCancelService.kt:366-371 |
| PNR 취소 실패 | sendCancelFail | SabreCancelService.kt:406-410 |
| 환불 타임아웃 | sendCancelFailTimeout | SabreCancelService.kt:350 |
| 일반 환불 실패 | sendRefundFail | SabreCancelService.kt:352 |
| CancellableTicket 조회 실패 | sendCheckFlightTicketsFail | SabreCancelService.kt:507-510 |
| 불완전 Void | sendIncompleteVoid | SabreCancelService.kt:150-155, 309-314 |
10. Amadeus vs Sabre 취소 비교
| 항목 | Amadeus | Sabre |
|---|---|---|
| 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_CODE | OSI/SSR/REMARK/AUTH_CODE (동일) |
| Refund API | REST 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 취소 프로세스 핵심
- isVoidable 판별: NonVoidableAirline + 당일 발권 체크
- Void 처리: 3회 재시도, 2회 void 호출 (확인), sequential 처리
- Refund 처리: REST API, CancellableTicket 조회, Waiver 저장
- PNR 취소: 2회 재시도, 5초 대기 후 비동기 처리
- 특수 시나리오:
- ticketingFailedCancel: keepPnr 플래그
- onlyPnrCancel: 어제 이전 PNR 취소 불가
- Waiver: OSI/SSR (SpecialServiceRQ), REMARK (개별 저장)
12.2 코드 참조 요약
| 기능 | 파일 | 라인 |
|---|---|---|
| cancel (메인) | SabreCancelService.kt | 38-102 |
| isVoidable | Booking.kt | 23-26 |
| NonVoidableAirline | NonVoidableAirline.kt | 3-19 |
| voidTickets | SabreCancelService.kt | 297-332 |
| voidRepeat (3회) | SabreCancelService.kt | 459-478 |
| void (2회 호출) | SabreCancelService.kt | 480-487 |
| SabreClient.void | SabreClient.kt | 721-746 |
| VoidTicketRQ | VoidTicketRQ.kt | 8-28 |
| refundTickets | SabreCancelService.kt | 334-380 |
| validateRefundableTickets | SabreCancelService.kt | 489-500 |
| getCancellableTickets | SabreCancelService.kt | 502-514 |
| saveWaiverRefunds | SabreCancelService.kt | 516-540 |
| pnrCancelAsync | SabreCancelService.kt | 382-392 |
| pnrCancelRepeat (2회) | SabreCancelService.kt | 394-419 |
| pnrCancel | SabreCancelService.kt | 421-428 |
| SabreClient.pnrCancel | SabreClient.kt | 748-764 |
| ticketingFailedCancel | SabreCancelService.kt | 131-171 |
| onlyPnrCancel | SabreCancelService.kt | 105-128 |
| cancelable | SabreCancelService.kt | 183-223 |
문서 버전: 1.0 작성일: 2025-09-30 분석 대상: Sabre GDS Cancel API (SOAP + REST) 참조 프로젝트: air-intl-adapter