Phase 5: Galileo 취소 API 심층 분석
1. API 엔드포인트 개요
1.1 취소 관련 엔드포인트
| 엔드포인트 | 메서드 | API 타입 | 기능 | 위치 |
|---|---|---|---|---|
/internals/GALILEO/bookings/{pnr}/cancel | PUT | SOAP + KPS | 예약/티켓 취소 | GalileoBookingController.kt:53-63 |
/internals/GALILEO/bookings/{pnr}/expected-cancel | GET | SOAP | Void 가능 여부 조회 | GalileoBookingController.kt:72-81 |
/internals/GALILEO/bookings/{pnr}/cancelable | GET | SOAP | 취소 타입 조회 | 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 가능 조건:
- 당일 발권:
maxOf { it.issuedDate }.isEqual(today()) - 상태 검증: 모든 티켓이
ISSUE또는AIRPORT_CONTROL - 체크인 예외:
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)
}
)
}에러 처리:
- 이미 취소됨:
"has already been cancelled"→ALREADY_CANCELED_PNR - Alive Booking 존재:
hasAliveBooking == true→CANCEL_FAILED - 기타 에러:
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 PNRvalidatingCarrier: 발권 항공사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 비교
| 항목 | Galileo | Amadeus | Sabre |
|---|---|---|---|
| NonVoidableAirline | 없음 (모두 지원) | 없음 (모두 지원) | HY, MF, SU, QH |
| EMD 티켓 | Void 불가 | Void 불가 | 별도 처리 |
| Conjunction Ticket | 자동 필터링 | N/A | N/A |
| Void 재시도 | 3회 | 없음 | 3회 (sequential) |
| PNR 재시도 | 2회 | 2회 | 2회 |
| 비동기 취소 | 5초 지연 | 즉시 | 5초 지연 |
| 결제 취소 | KPS | KPS | Sabre Payment |
| Refund API | 없음 (Void만) | 없음 (Void만) | REST API (refundTickets) |
8.2 Void 판별 비교
| 조건 | Galileo | Amadeus | Sabre |
|---|---|---|---|
| 당일 발권 | 필수 | 필수 | 필수 |
| 티켓 상태 | ISSUE, AIRPORT_CONTROL | OPEN | ISSUE, AIRPORT_CONTROL |
| 체크인 체크 | 예외 발생 | 예외 발생 | 예외 발생 |
| 티켓 수 일치 | 필수 | N/A | N/A |
| EMD 티켓 | Void 불가 | Void 불가 | 별도 처리 |
8.3 독특한 특징
Galileo 고유 특징
-
NonVoidableAirline 없음:
- 모든 항공사 Void 지원
- 대조: Sabre는 4개 항공사 Void 불가
-
Conjunction Ticket 필터링:
- 자동으로 연계 티켓 제외
matchingTicketsFrom메서드
-
티켓 수 일치 검증:
- PNR 티켓 수와 조회된 티켓 수 일치 필수
- 불일치 시 Void 불가
-
부분 성공 지원:
- Void 성공한 티켓 제외하고 재시도
voidedTicketNumbers추적
-
EMD 티켓 엄격 체크:
hasEmdTicket플래그로 Void 차단- EMD는 환불만 가능
9. 주요 발견사항
9.1 Galileo 취소 시스템의 특징
- Void 전용: Refund API 없음 (Void만 지원)
- NonVoidableAirline 없음: 모든 항공사 지원
- Conjunction Ticket 자동 처리: 연계 티켓 제외
- 부분 성공 허용: 일부 티켓 Void 실패해도 성공한 티켓 반환
- KPS 통합: 한국 결제 시스템 연동
9.2 개선 가능 영역
1. Void 재시도 간격 설정화
현황: 하드코딩된 1초 대기
delay(1000)제안: 설정 파일로 관리
galileo:
cancel:
void-retry-delay: 1000
void-retry-count: 3
pnr-cancel-retry-count: 22. 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 심층 분석 문서입니다.