Singapore Airlines 취소 API 심층 분석
1. API 엔드포인트 개요
1.1 취소 관련 엔드포인트
| 엔드포인트 | 메서드 | API 타입 | 기능 | 위치 |
|---|---|---|---|---|
/internals/SINGAPOREAIR/bookings/{pnr}/cancel | PUT | SOAP (NDC) | 예약 취소 | SingaporeairCancelService.kt:16-29 |
/internals/SINGAPOREAIR/bookings/{pnr}/expected-cancel | GET | SOAP (NDC) | 취소 가능 여부 조회 | SingaporeairCancelService.kt:31-41 |
/internals/SINGAPOREAIR/bookings/{pnr}/cancelable | GET | SOAP (NDC) | 취소 타입 조회 | SingaporeairCancelService.kt:43-58 |
2. 취소 실행 (Cancel)
2.1 전체 취소 플로우
flowchart TD A[취소 요청] --> B[expectedCancel<br/>호출] B --> C[예약 조회<br/>retrieve] C --> D{Check-in<br/>상태?} D -->|Check-in 됨| E[CANCEL_UNABLE_BY_ALREADY_CHECK_IN<br/>예외 발생] D -->|Check-in 안됨| F{Voidable<br/>또는<br/>티켓 없음?} F -->|Yes| G[환불 금액 계산<br/>생략] F -->|No| H[환불 금액 계산<br/>refundCalculate] G --> I[단순 취소<br/>OrderCancelRQ] H --> J[환불 포함 취소<br/>OrderCancelRQ + expectedRefundAmount] I --> K[취소 완료] J --> K style C fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000 style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style E fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style F fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000 style H fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style K fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
2.2 단계별 상세 분석
Step 1: expectedCancel 호출
위치: SingaporeairCancelService.kt:16-29
fun cancel(pnr: String): Pair<Boolean, List<Passenger>> {
return expectedCancel(pnr).also { (voidable, passengers) ->
if (voidable || passengers.all { it.tickets.isNullOrEmpty() }) {
// Void 가능 또는 티켓 없음 → 단순 취소
singaporeairClient.cancel(pnr = pnr)
} else {
// Refund → 환불 금액 포함 취소
singaporeairClient.cancel(
pnr = pnr,
expectedRefundAmount = passengers.sumOf { passenger ->
passenger.fare?.expectedRefundAmount ?: 0
}
)
}
}
}취소 유형 판별:
| 조건 | 취소 유형 | API 파라미터 |
|---|---|---|
voidable == true | Void (무효화) | pnr만 |
tickets.isNullOrEmpty() | 단순 취소 | pnr만 |
voidable == false && tickets 존재 | Refund (환불) | pnr + expectedRefundAmount |
Step 2: expectedCancel (취소 가능 여부 확인)
위치: SingaporeairCancelService.kt:31-41
fun expectedCancel(pnr: String): Pair<Boolean, List<Passenger>> {
val booking = singaporeairClient.retrieve(pnr = pnr)
if (booking.passengers.any { it.isCheckIn }) {
throw StatusInvalidException(ErrorMessage.CANCEL_UNABLE_BY_ALREADY_CHECK_IN, pnr).capture()
}
return booking.voidable to if (booking.voidable || booking.passengers.all { it.tickets.isNullOrEmpty() }) {
booking.passengers
} else {
singaporeairClient.refundCalculate(booking)
}
}Check-in 상태 검증:
if (booking.passengers.any { it.isCheckIn }) {
throw StatusInvalidException(ErrorMessage.CANCEL_UNABLE_BY_ALREADY_CHECK_IN, pnr).capture()
}- Check-in된 탑승객이 있으면 취소 불가
CANCEL_UNABLE_BY_ALREADY_CHECK_IN예외 발생
Voidable 판별:
booking.voidable to if (booking.voidable || booking.passengers.all { it.tickets.isNullOrEmpty() }) {
booking.passengers // 환불 금액 계산 생략
} else {
singaporeairClient.refundCalculate(booking) // 환불 금액 계산
}Step 3: 환불 금액 계산 (Refund Calculate)
위치: SingaporeairClient.kt:411-457
fun refundCalculate(booking: Booking): List<Passenger> {
val singaporeairApiProperties = singaporeairProperties.getApiProperties()
val request = OrderReshopRQ.ofRefundCalculate(
booking = booking,
iataNumber = singaporeairApiProperties.iataCode,
agencyName = singaporeairApiProperties.agencyName
)
return singaporeairApiProperties.endpoint
.post(request)
.header(getHeaderMap(request))
.requestBodyConvert(soapRequestBodyConverter(singaporeairApiProperties))
.execute<OrderReshopRS>(soapBodyDeserializerOf(logger, objectMapper))
.fold(
success = { orderReshopRS ->
orderReshopRS.checkError { code, message ->
throw InternationalAdapterException(ErrorMessage.CALCULATE_CANCEL_FEE_FAILED, code, message)
}
val response = orderReshopRS.response!!
booking.passengers.map { passenger ->
val deleteOrderItem = response.reshopResult.reshopOffers
.find { it.id.replace("Refund-P", "") == passenger.identificationKey!!.replace("PAX", "") }
?.deleteOrderItems?.firstOrNull()
if (deleteOrderItem != null) {
passenger.copy(
fare = passenger.fare!!.copy(
refundFee = deleteOrderItem.penaltyDifferential?.amount?.value?.toLong(),
expectedRefundAmount = abs(deleteOrderItem.differentialAmountDue.amount.value.toLong()),
usedTax = deleteOrderItem.usedTax
)
)
} else {
passenger
}
}
},
failure = { failure ->
throw failure.handleSoapFaultException(ErrorMessage.CALCULATE_CANCEL_FEE_FAILED)
}
)
}환불 금액 계산 로직:
- OrderReshopRQ.ofRefundCalculate 생성
- SOAP 요청 실행
- 응답에서 deleteOrderItem 추출
- 승객별로 환불 정보 설정:
refundFee: 환불 수수료expectedRefundAmount: 예상 환불 금액usedTax: 사용된 세금
ID 매칭:
it.id.replace("Refund-P", "") == passenger.identificationKey!!.replace("PAX", "")- 응답 ID:
Refund-P1,Refund-P2, … - 승객 ID:
PAX1,PAX2, … P부분을 제거하여 매칭
Step 4: OrderCancelRQ 실행
위치: SingaporeairClient.kt:324-364
fun cancel(pnr: String, expectedRefundAmount: Long? = null): Long {
val singaporeairApiProperties = singaporeairProperties.getApiProperties()
val request = OrderCancelRQ.of(
pnr = pnr,
expectedRefundAmount = expectedRefundAmount,
iataNumber = singaporeairApiProperties.iataCode,
agencyName = singaporeairApiProperties.agencyName
)
return singaporeairApiProperties.endpoint
.post(request)
.header(getHeaderMap(request))
.requestBodyConvert(soapRequestBodyConverter(singaporeairApiProperties))
.execute<OrderCancelRS>(soapBodyDeserializerOf(logger, objectMapper))
.fold(
success = { orderCancelRS ->
orderCancelRS.checkError { code, message ->
if (code == "911" && message == "RESERVATION PREVIOUSLY CANCELLED") {
throw InternationalAdapterException(
ErrorMessage.ALREADY_CANCELED_PNR,
pnr,
code,
message
)
}
throw InternationalAdapterException(ErrorMessage.CANCEL_FAILED, code, message).capture()
}
orderCancelRS.response?.changeFees?.penaltyAmount?.value?.toLong() ?: 0
},
failure = { failure ->
if (failure.isTimeout) {
slackService.sendCancelFailTimeout(
supplier = Supplier.SINGAPOREAIR,
pnr = pnr,
)
}
throw failure.handleSoapFaultException(ErrorMessage.CANCEL_FAILED)
}
)
}에러 처리:
| 에러 코드 | 메시지 | 처리 |
|---|---|---|
| 911 + “RESERVATION PREVIOUSLY CANCELLED” | 이미 취소된 PNR | ALREADY_CANCELED_PNR |
| 기타 | … | CANCEL_FAILED (Sentry 캡처) |
| 타임아웃 | … | Slack 알림 + CANCEL_FAILED |
반환값:
- 환불 수수료 (
penaltyAmount) - 없으면 0 반환
3. 취소 타입 조회 (Cancelable)
3.1 전체 플로우
flowchart TD A[Cancelable 요청] --> B[예약 조회<br/>retrieve] B --> C{Check-in<br/>상태?} C -->|Check-in 됨| D[CANCEL_UNABLE_BY_ALREADY_CHECK_IN<br/>예외 발생] C -->|Check-in 안됨| E{Voidable?} E -->|Yes| F[VOID<br/>refunds = null] E -->|No| G[환불 금액 계산<br/>refundCalculate] G --> H[REFUND<br/>refunds = 계산 결과] F --> I[CancelableTypeDetail<br/>반환] H --> I style B fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000 style C fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000 style D fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style E fill:#9FB4CE,stroke:#333,stroke-width:2px,color:#000 style I fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
3.2 상세 분석
위치: SingaporeairCancelService.kt:43-58
fun cancelable(pnr: String): CancelableTypeDetail {
val booking = singaporeairClient.retrieve(pnr = pnr)
if (booking.passengers.any { it.isCheckIn }) {
throw StatusInvalidException(ErrorMessage.CANCEL_UNABLE_BY_ALREADY_CHECK_IN, pnr).capture()
}
return when (booking.voidable) {
true -> CancelActionType.VOID to null
false -> CancelActionType.REFUND to singaporeairClient.refundCalculate(booking = booking)
}.let { (action, refunds) ->
CancelableTypeDetail(
action = action,
refunds = refunds
)
}
}CancelableTypeDetail 구조:
data class CancelableTypeDetail(
val action: CancelActionType, // VOID 또는 REFUND
val refunds: List<Passenger>?, // 환불 정보 (REFUND인 경우만)
)
enum class CancelActionType {
VOID, // 무효화 (수수료 없음)
REFUND, // 환불 (수수료 있을 수 있음)
}4. Request/Response 구조 상세
4.1 OrderCancelRQ 구조
위치: infrastructure/request/OrderCancelRQ.kt
data class OrderCancelRQ(
val query: Query,
) : SingaporeairRequest {
override val action = "http://www.iata.org/IATA/2015/00/2018.2/OrderCancelRQ"
data class Query(
val order: Order,
)
companion object {
fun of(
pnr: String,
expectedRefundAmount: Long?,
iataNumber: String,
agencyName: String,
): OrderCancelRQ {
return OrderCancelRQ(
query = Query(
order = Order(
orderId = OrderId(value = pnr),
expectedRefundAmount = expectedRefundAmount?.let {
ExpectedRefundAmount(
currencyAmountValue = CurrencyAmountValue(
currencyCode = CurrencyCode(value = "KRW"),
value = Value(value = it.toString())
)
)
},
pointOfSale = PointOfSale(
country = Country(countryCode = "KR"),
agencyID = AgencyID(value = iataNumber),
agencyName = AgencyName(value = agencyName),
)
)
)
)
}
}
}Order 구조:
data class Order(
val orderId: OrderId, // PNR
val expectedRefundAmount: ExpectedRefundAmount?, // 예상 환불 금액 (선택)
val pointOfSale: PointOfSale, // 판매 지점
)4.2 OrderCancelRS 구조
위치: infrastructure/response/OrderCancelRS.kt
data class OrderCancelRS(
val response: Response?,
val errors: List<Error>?,
) {
data class Response(
val orderId: OrderId, // 취소된 PNR
val statusCode: StatusCode, // CANCELLED
val changeFees: ChangeFees?, // 취소 수수료
)
}ChangeFees 구조:
data class ChangeFees(
val penaltyAmount: PenaltyAmount, // 수수료 금액
)
data class PenaltyAmount(
val currencyCode: CurrencyCode, // KRW
val value: Value, // 금액
)4.3 OrderReshopRQ.ofRefundCalculate 구조
위치: infrastructure/request/OrderReshopRQ.kt
companion object {
fun ofRefundCalculate(
booking: Booking,
iataNumber: String,
agencyName: String,
): OrderReshopRQ {
return OrderReshopRQ(
query = Query(
order = Order.ofRefundCalculate(
booking = booking,
iataNumber = iataNumber,
agencyName = agencyName
)
)
)
}
}Order.ofRefundCalculate 구조:
data class Order(
val orderId: OrderId, // PNR
val deleteOrderItems: List<DeleteOrderItem>?, // 삭제할 항목 (전체)
val pointOfSale: PointOfSale, // 판매 지점
) {
companion object {
fun ofRefundCalculate(
booking: Booking,
iataNumber: String,
agencyName: String,
): Order {
return Order(
orderId = OrderId(value = booking.pnr),
deleteOrderItems = booking.schedules!!.map { schedule ->
DeleteOrderItem(
orderItemRefId = OrderItemRefId(value = schedule.orderItemId)
)
},
pointOfSale = PointOfSale(
country = Country(countryCode = "KR"),
agencyID = AgencyID(value = iataNumber),
agencyName = AgencyName(value = agencyName),
)
)
}
}
}4.4 OrderReshopRS 구조 (환불 계산)
위치: infrastructure/response/OrderReshopRS.kt
data class OrderReshopRS(
val response: Response?,
val errors: List<Error>?,
) {
data class Response(
val reshopResult: ReshopResult, // 재구매 결과
)
}ReshopResult 구조:
data class ReshopResult(
val reshopOffers: List<ReshopOffer>, // 재구매 제안
)
data class ReshopOffer(
val id: String, // Refund-P1, Refund-P2, ...
val deleteOrderItems: List<DeleteOrderItem>?, // 삭제할 항목
)DeleteOrderItem 구조:
data class DeleteOrderItem(
val orderItemRefId: OrderItemRefId, // 항목 ID
val penaltyDifferential: PenaltyDifferential?, // 수수료 차액
val differentialAmountDue: DifferentialAmountDue, // 차액
val usedTax: Long?, // 사용된 세금
)
data class PenaltyDifferential(
val amount: Amount, // 수수료 금액
)
data class DifferentialAmountDue(
val amount: Amount, // 차액 금액 (음수: 환불, 양수: 추가 결제)
)5. 에러 처리 및 재시도 메커니즘
5.1 에러 타입별 처리 매트릭스
| 에러 타입 | 발생 시점 | 처리 방법 | Slack 알림 | Sentry 캡처 |
|---|---|---|---|---|
CANCEL_UNABLE_BY_ALREADY_CHECK_IN | expectedCancel, cancelable | StatusInvalidException | No | Yes |
ALREADY_CANCELED_PNR | cancel | InternationalAdapterException | No | No |
CANCEL_FAILED | cancel | InternationalAdapterException | 타임아웃 시 | Yes |
CALCULATE_CANCEL_FEE_FAILED | refundCalculate | InternationalAdapterException | No | No |
5.2 타임아웃 처리
failure = { failure ->
if (failure.isTimeout) {
slackService.sendCancelFailTimeout(
supplier = Supplier.SINGAPOREAIR,
pnr = pnr,
)
}
throw failure.handleSoapFaultException(ErrorMessage.CANCEL_FAILED)
}타임아웃 특징:
- 60초 타임아웃 (기본값)
- 타임아웃 시 Slack 알림 전송
- 예외 재발생
6. GDS와의 차이점 비교
6.1 취소 플로우 비교
| 항목 | Singapore Air (NDC) | Galileo | Amadeus | Sabre |
|---|---|---|---|---|
| 취소 유형 판별 | voidable 플래그 | 발권일=오늘 + 항공사별 제약 | 발권일=오늘 + 항공사별 제약 | 발권일=오늘 |
| 환불 금액 계산 | OrderReshopRQ.ofRefundCalculate | N/A | RefundQuote | RefundQuote |
| 취소 API | OrderCancelRQ | UniversalRecordCancelRQ | PNR_Cancel | CancelClient |
| Check-in 검증 | isCheckIn 플래그 | N/A | 상태 체크 | 상태 체크 |
| Void 조건 | voidable 플래그 | 발권일=오늘 + 상태 체크 | 발권일=오늘 | 발권일=오늘 |
6.2 에러 처리 비교
| 에러 시나리오 | Singapore Air | Galileo | Amadeus | Sabre |
|---|---|---|---|---|
| 이미 취소 | 911 + “PREVIOUSLY CANCELLED” | ALREADY_CANCELED_PNR | ALREADY_CANCELED_PNR | ALREADY_CANCELED_PNR |
| Check-in 됨 | CANCEL_UNABLE_BY_ALREADY_CHECK_IN | N/A | CANCEL_UNABLE | CANCEL_UNABLE |
| 타임아웃 | Slack 알림 | Slack 알림 | Slack 알림 | Slack 알림 |
6.3 독특한 특징
Singapore Airlines NDC 고유 특징
-
Voidable 플래그:
- Booking에 voidable 플래그 포함
- GDS는 발권일 기준 판별
-
OrderReshopRQ 환불 계산:
- NDC 표준의 Reshop 기능 활용
- GDS는 별도 RefundQuote API
-
Check-in 상태 검증:
- Booking에 isCheckIn 플래그 포함
- GDS는 별도 검증 로직
-
통합 응답 구조:
- OrderCancelRS에 수수료 정보 포함
- GDS는 별도 조회 필요
7. 성능 최적화
7.1 조건부 환불 계산
return booking.voidable to if (booking.voidable || booking.passengers.all { it.tickets.isNullOrEmpty() }) {
booking.passengers // 환불 금액 계산 생략
} else {
singaporeairClient.refundCalculate(booking) // 환불 금액 계산
}성능 향상:
- Void 가능 또는 티켓 없음: 환불 계산 생략
- Refund 필요: 환불 계산 수행
8. 주요 발견사항
8.1 Singapore Airlines NDC 취소 시스템의 특징
- Voidable 플래그: Booking에 포함
- OrderReshopRQ 환불 계산: NDC 표준 활용
- Check-in 상태 검증: isCheckIn 플래그
- 통합 응답: OrderCancelRS에 수수료 포함
8.2 개선 가능 영역
1. 환불 금액 계산 로깅
현황: 로그 없음
제안: 로깅 추가
logger.info("[SINGAPOREAIR] Calculating refund amount for PNR: $pnr")
val refunds = singaporeairClient.refundCalculate(booking)
logger.info("[SINGAPOREAIR] Refund calculated, total: ${refunds.sumOf { it.fare?.expectedRefundAmount ?: 0 }}")2. ID 매칭 로직 개선
현황: String replace 사용
it.id.replace("Refund-P", "") == passenger.identificationKey!!.replace("PAX", "")제안: 명시적 매핑
val passengerId = it.id.removePrefix("Refund-P")
val passengerKey = passenger.identificationKey!!.removePrefix("PAX")
if (passengerId == passengerKey) {
// ...
}9. 참고 자료
9.1 주요 클래스
SingaporeairCancelService.kt: 취소 서비스 (16-58)SingaporeairClient.kt: SOAP 클라이언트 (324-364, 411-457)OrderCancelRQ.kt: 취소 요청 DTOOrderCancelRS.kt: 취소 응답 DTOOrderReshopRQ.kt: 환불 계산 요청 DTOOrderReshopRS.kt: 환불 계산 응답 DTO
9.2 주요 메소드
SingaporeairCancelService.cancel(): 취소 실행 (16-29)SingaporeairCancelService.expectedCancel(): 취소 가능 여부 (31-41)SingaporeairCancelService.cancelable(): 취소 타입 조회 (43-58)SingaporeairClient.cancel(): SOAP 취소 API (324-364)SingaporeairClient.refundCalculate(): SOAP 환불 계산 API (411-457)
9.3 관련 API
- OrderCancelRQ: 예약 취소
- OrderCancelRS: 예약 취소 응답
- OrderReshopRQ: 환불 금액 계산
- OrderReshopRS: 환불 금액 계산 응답
이 문서는 Triple Air International Adapter 프로젝트의 Singapore Airlines 취소 API 심층 분석 문서입니다.