Phase 5: Galileo 취소 API 심층 분석

1. API 엔드포인트 개요

1.1 취소 관련 엔드포인트

엔드포인트메서드API 타입기능위치
/internals/GALILEO/bookings/{pnr}/cancelPUTSOAP + KPS예약/티켓 취소GalileoBookingController.kt:53-63
/internals/GALILEO/bookings/{pnr}/expected-cancelGETSOAPVoid 가능 여부 조회GalileoBookingController.kt:72-81
/internals/GALILEO/bookings/{pnr}/cancelableGETSOAP취소 타입 조회GalileoBookingController.kt:83-88

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

2.1 메인 취소 플로우

flowchart TD
    A[cancel 호출] --> B[예약 조회<br/>getBooking]
    B --> C{티켓 존재?}

    C -->|없음| D{PNR 생성일<br/>어제 이전 or NoShow?}
    D -->|Yes| E[CANCEL_UNABLE<br/>Ticket is Empty]
    D -->|No| F[PNR 취소<br/>pnrCancelRepeat]
    F --> G[취소 완료]

    C -->|있음| H{isVoidable?}
    H -->|No| I[CANCEL_UNABLE<br/>Not voidable]
    H -->|Yes| J[voidAll<br/>모든 티켓 Void]

    J --> K{Payment<br/>존재?}
    K -->|Yes| L[결제 취소 비동기<br/>paymentCancelAsync]
    K -->|No| M[PNR 취소 비동기<br/>pnrCancelAsync]
    L --> M

    M --> N[취소 완료]

    style C fill:#F4E4B1,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 H fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style I fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
    style G fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000
    style N fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

2.2 단계별 상세 분석

Step 1: 예약 조회

위치: GalileoCancelService.kt:29-60

fun cancel(pnr: String, payment: Payment?) {
    val booking = galileoClient.getBooking(providerPnr = pnr)
 
    if (booking.tickets.isNullOrEmpty()) {
        // 티켓 없음 처리
    } else {
        // 티켓 있음 처리
    }
}

Step 2: 티켓 없음 처리

위치: GalileoCancelService.kt:32-40

if (booking.tickets.isNullOrEmpty()) {
    val departureAt = with(booking.schedules!!.first()) {
        calculateTimezoneService.calculateToUTC(at = this.departureAt, iata = this.departure)
    }
    if (isPnrCreatedAtBeforeYesterdayOrNoShow(pnrCreatedAt = booking.pnrCreatedAt, departureAt = departureAt)) {
        throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, pnr, "Ticket is Empty").capture()
    }
 
    pnrCancelRepeat(pnr)
}

검증 로직:

fun isPnrCreatedAtBeforeYesterdayOrNoShow(
    pnrCreatedAt: LocalDateTime?,
    departureAt: LocalDateTime
): Boolean {
    // PNR 생성일이 어제 이전이거나 출발일이 지난 경우
    return pnrCreatedAt?.isBefore(LocalDateTime.now().minusDays(1)) == true
            || departureAt.isBefore(LocalDateTime.now())
}

판별 기준:

  • PNR 생성일 어제 이전: 취소 불가 (오래된 예약)
  • 출발일 경과 (NoShow): 취소 불가
  • 조건 미충족: PNR 취소 가능

Step 3: 티켓 있음 처리

위치: GalileoCancelService.kt:42-59

} else {
    if (isVoidable(booking).not()) {
        throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, pnr)
    }
 
    voidAll(booking)
 
    if (payment != null) {
        paymentCancelAsync(
            pnr = pnr,
            payment = payment,
            reservationCode = booking.supplierIdentificationKey!!,
            validatingCarrier = booking.validatingCarrier
        )
    }
 
    pnrCancelAsync(pnr)
}

3. Void 가능 여부 판별

3.1 isVoidable 로직

위치: GalileoCancelService.kt:234-246

private fun isVoidable(booking: Booking): Boolean {
    if (NonVoidableAirline.contains(booking.validatingCarrier) || booking.hasEmdTicket || booking.tickets.isNullOrEmpty()) {
        return false
    }
 
    return galileoClient.getTicketDocuments(
        providerPnr = booking.pnr,
        universalRecordPnr = booking.supplierIdentificationKey!!,
        reservationPnr = booking.subPnr!!,
    ).let {
        if (booking.tickets!!.size != it.size) false else it.isVoidable()
    }
}

3.2 Void 불가 조건

flowchart TD
    A[isVoidable 체크] --> B{NonVoidableAirline<br/>포함?}
    B -->|Yes| C[false 반환]
    B -->|No| D{EMD 티켓<br/>존재?}
    D -->|Yes| C
    D -->|No| E{티켓 없음?}
    E -->|Yes| C
    E -->|No| F[티켓 문서 조회<br/>getTicketDocuments]

    F --> G{티켓 수<br/>일치?}
    G -->|No| C
    G -->|Yes| H[PassengerTicket<br/>isVoidable 체크]

    H --> I{모든 티켓<br/>당일 발권?}
    I -->|No| C
    I -->|Yes| J{모든 티켓<br/>ISSUE/AIRPORT_CONTROL?}
    J -->|No| K{CHECKIN 상태?}
    K -->|Yes| L[CANCEL_UNABLE_BY_ALREADY_CHECK_IN]
    K -->|No| C
    J -->|Yes| M[true 반환]

    style B fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style D fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style G fill:#F4E4B1,stroke:#333,stroke-width:2px,color:#000
    style I 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 M fill:#95D5A6,stroke:#333,stroke-width:2px,color:#000

3.3 NonVoidableAirline

Galileo는 NonVoidableAirline 목록이 없습니다.

  • 이유: 모든 항공사 Void 지원
  • 대조: Sabre는 HY, MF, SU, QH 항공사 Void 불가

3.4 PassengerTicket.isVoidable

위치: GalileoCancelService.kt:248-261

private fun List<PassengerTicket>.isVoidable(): Boolean {
    if (this.isEmpty()) return false
 
    return this.maxOf { it.issuedDate }.isEqual(today()) &&
            this.all { passengerTicket ->
                passengerTicket.tickets.all {
                    when (it.status) {
                        TicketStatus.ISSUE, TicketStatus.AIRPORT_CONTROL -> true
                        TicketStatus.CHECKIN -> throw StatusInvalidException(ErrorMessage.CANCEL_UNABLE_BY_ALREADY_CHECK_IN).capture()
                        else -> false
                    }
                }
            }
}

Void 가능 조건:

  1. 당일 발권: maxOf { it.issuedDate }.isEqual(today())
  2. 상태 검증: 모든 티켓이 ISSUE 또는 AIRPORT_CONTROL
  3. 체크인 예외: CHECKIN 상태면 CANCEL_UNABLE_BY_ALREADY_CHECK_IN

4. Void 처리 (voidAll)

4.1 Void 플로우

위치: GalileoCancelService.kt:117-151

private fun voidAll(booking: Booking) {
    if (booking.tickets.isNullOrEmpty()) {
        throw StatusInvalidException(ErrorMessage.NOT_FOUND_TICKET, booking.pnr, "tickets is empty")
    }
 
    if (booking.tickets!!.all { it.isVoidable }.not()) {
        throw StatusInvalidException(ErrorMessage.INVALID_VOID_TICKET, booking.pnr, "voidable tickets is empty")
    }
 
    if (booking.tickets!!.any { it.status == TicketStatus.CHECKIN }) {
        throw StatusInvalidException(ErrorMessage.CANCEL_UNABLE_BY_ALREADY_CHECK_IN)
    }
 
    try {
        galileoClient.getTicketDocuments(
            providerPnr = booking.pnr,
            universalRecordPnr = booking.supplierIdentificationKey!!,
            reservationPnr = booking.subPnr!!,
        ).run {
            if (this.isVoidable()) {
                voidRepeat(
                    providerPnr = booking.pnr,
                    reservationPnr = booking.subPnr,
                    ticketNumbers = this.flatMap { tickets -> tickets.tickets.map { it.ticketNumber } }
                        .matchingTicketsFrom(booking.tickets!!)
                )
            } else {
                throw StatusInvalidException(ErrorMessage.INVALID_VOID_TICKET, booking.pnr, this)
            }
        }
 
    } catch (e: Exception) {
        throw e
    }
}

4.2 티켓 번호 매칭

위치: GalileoCancelService.kt:153-164

private fun List<String>.matchingTicketsFrom(tickets: List<PnrTicket>): List<String> {
    if (tickets.all { it.conjunctionTicketNumbers == null }) {
        return this
    }
 
    val validTicketNumbers = tickets
        .filter { it.type == TicketType.TICKET }
        .map { it.ticketNumber }
        .toSet()
 
    return this.filter { it in validTicketNumbers }
}

목적:

  • Conjunction Ticket 제외 (연계 티켓)
  • 실제 티켓만 Void 대상

예시:

티켓 목록:
- 123-4567890123 (TICKET)
- 123-4567890124 (TICKET)
- 123-4567890125 (CONJUNCTION, 연계)

Void 대상:
- 123-4567890123
- 123-4567890124

4.3 Void 재시도 로직

위치: GalileoCancelService.kt:166-201

fun voidRepeat(
    providerPnr: String,
    reservationPnr: String,
    ticketNumbers: List<String>,
) {
    val voidedTicketNumbers = mutableSetOf<String>()
    for (count in 1..3) {
        val aliveTicketNumbers = ticketNumbers - voidedTicketNumbers
        try {
            withBlocking {
                delay(1000)
                galileoClient.void(
                    providerPnr = providerPnr,
                    reservationPnr = reservationPnr,
                    ticketNumbers = aliveTicketNumbers
                ).apply {
                    voidedTicketNumbers.addAll(this)
                }
            }
            break
 
        } catch (e: Exception) {
            logger.info(e.message, e)
            if (count >= 3) {
                slackService.sendVoidFail(
                    supplier = Supplier.GALILEO,
                    pnr = providerPnr,
                    targetTickets = ticketNumbers,
                    failTickets = aliveTicketNumbers,
                    reason = e.message
                )
                throw e
            }
        }
    }
}

재시도 전략:

  • 최대 3회 시도
  • 1초 대기 (각 시도 간)
  • 부분 성공 허용: Void 성공한 티켓 제외하고 재시도
  • 실패 시: Slack 알림 + 예외 발생

4.4 SOAP Void API

위치: GalileoClient.kt:415-464

fun void(
    providerPnr: String,
    reservationPnr: String,
    ticketNumbers: List<String>,
): List<String> {
    val galileoApiProperties = galileoProperties.getApiProperties()
    val request = AirVoidDocumentRQ.of(
        targetBranch = galileoApiProperties.soap.branchCode,
        reservationPnr = reservationPnr,
        ticketNumbers = ticketNumbers,
    )
    return "${galileoApiProperties.soap.endpoint}/AirService"
        .post(request)
        .authenticate(galileoApiProperties.soap.userName, galileoApiProperties.soap.password)
        .header(headerMap)
        .requestBodyConvert(soapRequestBodyConverter())
        .execute<GalileoResponse<AirVoidDocumentRS>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.checkError { errorMessages ->
                    throw InternationalAdapterException(
                        ErrorMessage.VOID_FAILED,
                        errorMessages.joinToString { (code, message) -> "$code: $message" }
                    )
                }
 
                response.body!!.airVoidRS
                    ?.filter { it.voidResultInfo?.status?.type == "Successful" }
                    ?.map { it.ticketNumber }
                    ?: emptyList()
            },
            failure = {
                throw it.handleSoapFaultException(ErrorMessage.VOID_FAILED)
            }
        )
}

특징:

  • 반환값: 성공한 티켓 번호 리스트
  • 부분 성공: 일부 티켓만 성공해도 성공한 티켓 반환
  • 에러 처리: VOID_FAILED 예외

5. PNR 취소

5.1 PNR 취소 재시도

위치: GalileoCancelService.kt:78-102

fun pnrCancelRepeat(pnr: String) {
    for (count in 1..2) {
        try {
            pnrCancel(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.GALILEO,
                    pnr = pnr,
                    reason = e.message
                )
                throw e
            } else {
                logger.info(e.message, e)
            }
        }
    }
}

재시도 전략:

  • 최대 2회 시도
  • 즉시 재시도 (대기 없음)
  • 이미 취소된 경우: 정상 처리 (break)
  • 실패 시: Slack 알림 + 예외 발생

5.2 PNR 취소 API

위치: GalileoClient.kt:317-366

fun pnrCancel(universalPnr: String, version: Int) {
    val galileoApiProperties = galileoProperties.getApiProperties()
    val request = UniversalRecordCancelRQ(
        targetBranch = galileoApiProperties.soap.branchCode,
        version = version,
        universalPnr = universalPnr
    )
    "${galileoApiProperties.soap.endpoint}/UniversalRecordService"
        .post(request)
        .authenticate(galileoApiProperties.soap.userName, galileoApiProperties.soap.password)
        .header(headerMap)
        .requestBodyConvert(soapRequestBodyConverter())
        .execute<GalileoResponse<UniversalRecordCancelRS>>(soapBodyDeserializerOf(logger, objectMapper))
        .fold(
            success = { response ->
                response.checkError { errorMessages ->
                    errorMessages.forEach { (code, message) ->
                        if (message.contains("has already been cancelled")) {
                            throw InternationalAdapterException(
                                ErrorMessage.ALREADY_CANCELED_PNR,
                                universalPnr,
                                code,
                                message
                            )
                        }
                    }
 
                    throw InternationalAdapterException(
                        ErrorMessage.CANCEL_FAILED,
                        universalPnr,
                        errorMessages.joinToString { (code, message) -> "$code: $message" }
                    )
                }
 
                if (response.body?.hasAliveBooking == true) {
                    throw InternationalAdapterException(
                        ErrorMessage.CANCEL_FAILED,
                        response.body.providerReservationStatuses!!
                            .filter { it.cancelled.not() }
                            .mapNotNull { reservationStatus -> reservationStatus.takeIf { it.hasError }?.cancelInfo?.code },
                        "Alive booking exists"
                    )
                }
            },
            failure = {
                throw it.handleSoapFaultException(ErrorMessage.CANCEL_FAILED)
            }
        )
}

에러 처리:

  1. 이미 취소됨: "has already been cancelled"ALREADY_CANCELED_PNR
  2. Alive Booking 존재: hasAliveBooking == trueCANCEL_FAILED
  3. 기타 에러: CANCEL_FAILED

5.3 비동기 PNR 취소

위치: GalileoCancelService.kt:104-109

fun pnrCancelAsync(pnr: String) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        delay(5000)
        pnrCancelRepeat(pnr)
    }
}

특징:

  • 5초 대기: GDS 시스템 안정화
  • 비동기 처리: API 응답 지연 방지
  • 재시도 포함: pnrCancelRepeat 호출

6. 결제 취소

6.1 결제 취소 비동기

위치: GalileoCancelService.kt:203-232

fun paymentCancelAsync(
    pnr: String,
    validatingCarrier: String,
    reservationCode: String,
    payment: Payment,
) {
    CoroutineScope(Dispatchers.IO).withLaunch {
        try {
            kpsPaymentClient.cancel(
                payment = payment,
                pnr = pnr,
                validatingCarrier = validatingCarrier,
                reservationCode = reservationCode,
            )
        } catch (e: Exception) {
            slackService.sendPaymentCancelFail(
                supplier = Supplier.GALILEO,
                pnr = pnr,
                approvalNumber = payment.approvalNumber,
                price = payment.price,
                reason = e.message
            )
            throw InternationalAdapterException(
                ErrorMessage.PAYMENT_CANCEL_FAILED,
                pnr,
                payment.approvalNumber
            ).capture()
        }
    }
}

KPS 결제 취소:

  • API: KpsBspCardAuthXRQ
  • 파라미터:
    • payment: 결제 정보 (승인번호 등)
    • pnr: Provider PNR
    • validatingCarrier: 발권 항공사
    • reservationCode: Universal Record PNR
  • 에러 처리: Slack 알림 + PAYMENT_CANCEL_FAILED

7. 취소 타입 조회

7.1 cancelable API

위치: GalileoCancelService.kt:67-76

fun cancelable(pnr: String): CancelableTypeDetail {
    val booking = galileoClient.getBooking(providerPnr = pnr)
 
    return CancelableTypeDetail(
        action = when (isVoidable(booking)) {
            true -> CancelActionType.VOID
            false -> CancelActionType.REFUND
        }
    )
}

CancelActionType:

enum class CancelActionType {
    VOID,    // 당일 취소
    REFUND   // 환불
}

7.2 isVoidable API

위치: GalileoCancelService.kt:62-65

fun isVoidable(pnr: String): Boolean {
    val booking = galileoClient.getBooking(providerPnr = pnr)
    return isVoidable(booking)
}

8. Amadeus/Sabre와의 비교

8.1 취소 API 비교

항목GalileoAmadeusSabre
NonVoidableAirline없음 (모두 지원)없음 (모두 지원)HY, MF, SU, QH
EMD 티켓Void 불가Void 불가별도 처리
Conjunction Ticket자동 필터링N/AN/A
Void 재시도3회없음3회 (sequential)
PNR 재시도2회2회2회
비동기 취소5초 지연즉시5초 지연
결제 취소KPSKPSSabre Payment
Refund API없음 (Void만)없음 (Void만)REST API (refundTickets)

8.2 Void 판별 비교

조건GalileoAmadeusSabre
당일 발권필수필수필수
티켓 상태ISSUE, AIRPORT_CONTROLOPENISSUE, AIRPORT_CONTROL
체크인 체크예외 발생예외 발생예외 발생
티켓 수 일치필수N/AN/A
EMD 티켓Void 불가Void 불가별도 처리

8.3 독특한 특징

Galileo 고유 특징

  1. NonVoidableAirline 없음:

    • 모든 항공사 Void 지원
    • 대조: Sabre는 4개 항공사 Void 불가
  2. Conjunction Ticket 필터링:

    • 자동으로 연계 티켓 제외
    • matchingTicketsFrom 메서드
  3. 티켓 수 일치 검증:

    • PNR 티켓 수와 조회된 티켓 수 일치 필수
    • 불일치 시 Void 불가
  4. 부분 성공 지원:

    • Void 성공한 티켓 제외하고 재시도
    • voidedTicketNumbers 추적
  5. EMD 티켓 엄격 체크:

    • hasEmdTicket 플래그로 Void 차단
    • EMD는 환불만 가능

9. 주요 발견사항

9.1 Galileo 취소 시스템의 특징

  1. Void 전용: Refund API 없음 (Void만 지원)
  2. NonVoidableAirline 없음: 모든 항공사 지원
  3. Conjunction Ticket 자동 처리: 연계 티켓 제외
  4. 부분 성공 허용: 일부 티켓 Void 실패해도 성공한 티켓 반환
  5. KPS 통합: 한국 결제 시스템 연동

9.2 개선 가능 영역

1. Void 재시도 간격 설정화

현황: 하드코딩된 1초 대기

delay(1000)

제안: 설정 파일로 관리

galileo:
  cancel:
    void-retry-delay: 1000
    void-retry-count: 3
    pnr-cancel-retry-count: 2

2. Void 로깅 강화

현황: 최소 로깅

제안: 상세 로깅

logger.info("[VOID_SUCCESS] PNR: $providerPnr, " +
            "Target: ${ticketNumbers.size}, " +
            "Voided: ${voidedTicketNumbers.size}, " +
            "Attempt: $count")

3. Refund API 지원

현황: Void만 지원

제안: Sabre처럼 REST API로 Refund 추가

  • 당일 이후 취소 지원
  • 환불 금액 계산
  • 페널티 적용

10. 참고 자료

10.1 주요 클래스

  • GalileoCancelService.kt: 취소 서비스 (21-262)
  • GalileoClient.kt: SOAP 클라이언트 (317-464)
  • KpsPaymentClient.kt: KPS 결제 취소
  • AirVoidDocumentRQ.kt: Void 요청 모델
  • UniversalRecordCancelRQ.kt: PNR 취소 요청 모델

10.2 주요 메소드

  • GalileoCancelService.cancel(): 메인 취소 (29-60)
  • GalileoCancelService.isVoidable(): Void 가능 여부 (234-246)
  • GalileoCancelService.voidAll(): 모든 티켓 Void (117-151)
  • GalileoCancelService.voidRepeat(): Void 재시도 (166-201)
  • GalileoCancelService.pnrCancelRepeat(): PNR 취소 재시도 (78-102)
  • GalileoClient.void(): SOAP Void API (415-464)
  • GalileoClient.pnrCancel(): SOAP PNR 취소 API (317-366)

10.3 관련 API

  • AirVoidDocumentRQ: 티켓 Void
  • UniversalRecordCancelRQ: PNR 취소
  • KpsBspCardAuthXRQ: KPS 결제 취소

이 문서는 Triple Air International Adapter 프로젝트의 Galileo 취소 API 심층 분석 문서입니다.