Singapore Airlines 취소 API 심층 분석

1. API 엔드포인트 개요

1.1 취소 관련 엔드포인트

엔드포인트메서드API 타입기능위치
/internals/SINGAPOREAIR/bookings/{pnr}/cancelPUTSOAP (NDC)예약 취소SingaporeairCancelService.kt:16-29
/internals/SINGAPOREAIR/bookings/{pnr}/expected-cancelGETSOAP (NDC)취소 가능 여부 조회SingaporeairCancelService.kt:31-41
/internals/SINGAPOREAIR/bookings/{pnr}/cancelableGETSOAP (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 == trueVoid (무효화)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)
            }
        )
}

환불 금액 계산 로직:

  1. OrderReshopRQ.ofRefundCalculate 생성
  2. SOAP 요청 실행
  3. 응답에서 deleteOrderItem 추출
  4. 승객별로 환불 정보 설정:
    • 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”이미 취소된 PNRALREADY_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_INexpectedCancel, cancelableStatusInvalidExceptionNoYes
ALREADY_CANCELED_PNRcancelInternationalAdapterExceptionNoNo
CANCEL_FAILEDcancelInternationalAdapterException타임아웃 시Yes
CALCULATE_CANCEL_FEE_FAILEDrefundCalculateInternationalAdapterExceptionNoNo

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)GalileoAmadeusSabre
취소 유형 판별voidable 플래그발권일=오늘 + 항공사별 제약발권일=오늘 + 항공사별 제약발권일=오늘
환불 금액 계산OrderReshopRQ.ofRefundCalculateN/ARefundQuoteRefundQuote
취소 APIOrderCancelRQUniversalRecordCancelRQPNR_CancelCancelClient
Check-in 검증isCheckIn 플래그N/A상태 체크상태 체크
Void 조건voidable 플래그발권일=오늘 + 상태 체크발권일=오늘발권일=오늘

6.2 에러 처리 비교

에러 시나리오Singapore AirGalileoAmadeusSabre
이미 취소911 + “PREVIOUSLY CANCELLED”ALREADY_CANCELED_PNRALREADY_CANCELED_PNRALREADY_CANCELED_PNR
Check-in 됨CANCEL_UNABLE_BY_ALREADY_CHECK_INN/ACANCEL_UNABLECANCEL_UNABLE
타임아웃Slack 알림Slack 알림Slack 알림Slack 알림

6.3 독특한 특징

Singapore Airlines NDC 고유 특징

  1. Voidable 플래그:

    • Booking에 voidable 플래그 포함
    • GDS는 발권일 기준 판별
  2. OrderReshopRQ 환불 계산:

    • NDC 표준의 Reshop 기능 활용
    • GDS는 별도 RefundQuote API
  3. Check-in 상태 검증:

    • Booking에 isCheckIn 플래그 포함
    • GDS는 별도 검증 로직
  4. 통합 응답 구조:

    • 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 취소 시스템의 특징

  1. Voidable 플래그: Booking에 포함
  2. OrderReshopRQ 환불 계산: NDC 표준 활용
  3. Check-in 상태 검증: isCheckIn 플래그
  4. 통합 응답: 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: 취소 요청 DTO
  • OrderCancelRS.kt: 취소 응답 DTO
  • OrderReshopRQ.kt: 환불 계산 요청 DTO
  • OrderReshopRS.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 심층 분석 문서입니다.