Amadeus 취소(Cancel) API 심층 분석
1. API 개요
1.1 주요 기능
- 취소 유형 판별: Void(당일 취소) vs Refund(환불) 자동 결정
- 취소 가능성 검증: 티켓 상태, 항공사 정책, EMD 여부 확인
- 비동기 처리: PNR 취소 및 결제 취소의 비동기 처리
- 재시도 메커니즘: 실패 시 자동 재시도 및 Slack 알림
1.2 관련 파일 구조
supplier/amadeus/
├── application/
│ ├── AmadeusCancelService.kt # 취소 서비스 메인 로직
│ ├── AmadeusRefundService.kt # 환불 처리 서비스
│ └── AmadeusRetrieveService.kt # PNR 조회 서비스
├── infrastructure/
│ ├── AmadeusClient.kt # SOAP API 클라이언트
│ └── topas/
│ └── GpsClient.kt # GPS 결제 취소 클라이언트
├── interfaces/controller/
│ └── internals/
│ └── AmadeusBookingController.kt # 취소 API 엔드포인트
└── support/enums/
└── NonVoidableAirline.kt # Void 불가능 항공사 목록
2. 핵심 비즈니스 로직
2.1 취소 프로세스 플로우
flowchart TD A[취소 요청] --> B[PNR 정보 조회] B --> C{취소 가능?} C -->|No| D[예외 발생] C -->|Yes| E{티켓 존재?} E -->|No| F[PNR만 취소] E -->|Yes| G[티켓 문서 조회] G --> H{Void 가능?} H -->|Yes| I[Void 처리] H -->|No| J{Refund 가능?} J -->|Yes| K[Refund 처리] J -->|No| L[취소 불가 예외] I --> M[결제 취소 비동기] I --> N[PNR 취소 비동기] K --> N F --> O[완료] N --> O M --> O style C fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000 style H fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000 style J fill:#A8D5BA,stroke:#333,stroke-width:2px,color:#000
2.2 취소 서비스 구현
2.2.1 메인 취소 로직
// AmadeusCancelService.kt:35-103
fun cancel(
pnr: String,
validatingCarrier: String,
payment: Payment? = null,
autoRefundable: Boolean,
waivers: List<WaiverModel>?,
): CancelDetail주요 검증 단계:
-
취소 불가 티켓 체크 (AmadeusCancelService.kt:43-49)
- Non-cancelable 티켓 존재 시 예외 발생
-
EMD 티켓 체크 (AmadeusCancelService.kt:51-58)
- EMD(Electronic Miscellaneous Document) 티켓은 취소 불가
-
티켓 없는 경우 처리 (AmadeusCancelService.kt:60-70)
- 어제 이전 생성되었거나 No-Show인 경우 취소 불가
- 그 외에는 PNR만 취소
-
Void/Refund 판별 (AmadeusCancelService.kt:79-102)
return when {
voidable -> {
voidRepeat(pnr = pnr)
if (payment != null) {
paymentCancelAsync(pnr = pnr, validatingCarrier = validatingCarrier, payment = payment)
}
pnrCancelAsync(pnr)
CancelDetail(action = CancelActionType.VOID)
}
waiverRefundable || autoRefundable -> {
val refunds = refundService.refund(pnr = pnr, waivers = waivers)
CancelDetail(action = CancelActionType.REFUND, waiverRefundable = waiverRefundable, refunds = refunds)
}
else -> throw InternationalAdapterException(ErrorMessage.CANCEL_UNABLE, pnr)
}2.3 Void 가능 여부 판별
2.3.1 Void 불가능 항공사
// NonVoidableAirline.kt:3-8
enum class NonVoidableAirline {
HY, // Uzbekistan Airways
MF, // Xiamen Airlines
SU, // Aeroflot
QH, // Bamboo Airways
}2.3.2 Void 가능 조건
// AmadeusCancelService.kt:220-235
private fun isVoidable(
pnrInfo: PnrInfo,
validatingCarrier: String? = null,
ticketDocuments: List<PnrTicketDocument>,
pnrFares: List<PnrFare>? = null,
): Boolean {
// 1. Void 불가능 항공사 체크
if (carrier == null || NonVoidableAirline.contains(carrier) || pnrInfo.hasEmdTicket) return false
// 2. 모든 티켓이 조회되었는지 확인
if (pnrInfo.tickets.size != ticketDocuments.size) return false
// 3. 당일 발권 티켓인지 확인
return ticketDocuments.isVoidable() // 모든 티켓이 오늘 발권된 경우만 true
}3. 재시도 및 예외 처리
3.1 재시도 메커니즘
flowchart TD A[작업 시작] --> B{시도 횟수} B -->|1차| C[작업 실행] C --> D{성공?} D -->|No| E{특정 예외?} E -->|Yes| F[즉시 종료] E -->|No| G[로깅] G --> B B -->|2차| H[작업 실행] H --> I{성공?} I -->|No| J[Slack 알림] J --> K[예외 발생] B -->|3차 이상| L[Slack 알림] L --> M[최종 예외] D -->|Yes| N[성공] I -->|Yes| N F --> N style E fill:#E8B4B8,stroke:#333,stroke-width:2px,color:#000 style J fill:#F39C9C,stroke:#333,stroke-width:2px,color:#000
3.1.1 PNR 취소 재시도
// AmadeusCancelService.kt:237-260
private 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.AMADEUS, pnr = pnr, reason = e.message)
throw e
} else {
logger.error(e.message, e)
}
}
}
}3.1.2 Void 재시도 (3회)
// AmadeusCancelService.kt:262-309
fun voidRepeat(pnr: String) {
for (count in 1..3) {
try {
void(pnr)
break
} catch (e: Exception) {
// 특정 예외는 즉시 종료
if (e is ApiException && e.errorMessage == ErrorMessage.NOT_FOUND_TICKET) break
// 3차 시도 또는 특정 예외 발생 시
if (count == 3 || e is ApiException &&
(e.errorMessage == ErrorMessage.ALREADY_CANCELED_PNR ||
e.errorMessage == ErrorMessage.INVALID_VOID_TICKET)) {
// Slack 알림 발송
slackService.sendVoidFail(...)
throw e
}
}
}
}4. 비동기 처리 전략
4.1 비동기 PNR 취소
// AmadeusCancelService.kt:311-316
fun pnrCancelAsync(pnr: String) {
CoroutineScope(Dispatchers.IO).withLaunch {
delay(5000) // locked PNR 방지를 위한 대기
pnrCancelRepeat(pnr)
}
}4.2 비동기 결제 취소
// AmadeusCancelService.kt:427-449
fun paymentCancelAsync(pnr: String, validatingCarrier: String, payment: Payment) {
CoroutineScope(Dispatchers.IO).withLaunch {
try {
gpsClient.cancel(
validatingCarrier = validatingCarrier,
payment = payment
)
} catch (e: Exception) {
slackService.sendPaymentCancelFail(
supplier = Supplier.AMADEUS,
pnr = pnr,
approvalNumber = payment.approvalNumber,
price = payment.price,
reason = e.message
)
throw InternationalAdapterException(ErrorMessage.PAYMENT_CANCEL_FAILED, ...)
}
}
}5. Stateful Session 관리
5.1 PNR 취소 세션
// AmadeusCancelService.kt:318-347
private fun pnrCancel(pnr: String) {
stateful {
try {
val pnrInfo = start {
amadeusClient.getPnrInfo(pnr = pnr, statefulBuilder = this)
}
// 기내식 취소 처리
pnrInfo.reference.mealElements?.let { elements ->
inSeries {
amadeusClient.removeElements(
pnr = pnr,
referenceNumbers = elements.map { it.number },
statefulBuilder = this
)
}
}
inSeries {
amadeusClient.pnrCancel(pnr = pnr, statefulBuilder = this)
}
end {
amadeusClient.saveCancel(statefulBuilder = this)
}
} catch (e: Exception) {
if (session?.transactionStatusCode == TransactionStatusCode.InSeries) {
end { amadeusClient.signOut(statefulBuilder = this) }
}
throw e
}
}
}5.2 Void 세션 관리
// AmadeusCancelService.kt:349-412
private fun void(pnr: String) {
stateful {
try {
// Start: 티켓 발급일 조회
val ticketIssuedDateMap = start {
retrieveService.getTicketIssuedDateMap(pnr = pnr, statefulBuilder = this)
}
// InSeries: PNR 정보 조회
val pnrInfo = inSeries {
amadeusClient.getPnrInfo(...)
}
// Void 가능한 티켓 필터링
val filteredTickets = pnrInfo.tickets
.filterNot { it.voided == true }
.filter { it.isVoidable }
// 티켓별 Void 처리
voidTickets(ticketNumbers = retrievedTickets.map { it.ticketNumber }, statefulBuilder = this)
// End: 세션 종료
end { amadeusClient.signOut(statefulBuilder = this) }
} catch (e: Exception) {
// 세션 정리
if (session?.transactionStatusCode == TransactionStatusCode.InSeries) {
end { amadeusClient.signOut(statefulBuilder = this) }
}
throw e
}
}
}6. API 엔드포인트
6.1 취소 실행
// AmadeusBookingController.kt:58-75
@PutMapping("/{pnr}/cancel")
@ResponseStatus(code = HttpStatus.OK)
fun cancel(@PathVariable pnr: String, @RequestBody cancelRequest: CancelRequest): CancelView6.2 취소 가능 여부 확인
// AmadeusBookingController.kt:91-105
@GetMapping("/{pnr}/cancelable")
@ResponseStatus(code = HttpStatus.OK)
fun cancelable(
@PathVariable pnr: String,
@RequestParam approvedAt: LocalDateTime?,
@RequestParam autoRefundable: Boolean,
@RequestParam waiverRefundable: Boolean,
): CancelableTypeDetailView6.3 예상 취소 확인
// AmadeusBookingController.kt:77-89
@GetMapping("/{pnr}/expected-cancel")
@ResponseStatus(code = HttpStatus.OK)
fun expectedCancel(
@PathVariable pnr: String,
@RequestParam approvedAt: LocalDateTime?,
@RequestParam waiverRefundable: Boolean,
): ExpectedCancelView7. GPS 결제 취소
7.1 GPS Client 구현
// GpsClient.kt:106-127
fun cancel(validatingCarrier: String, payment: Payment) {
return "${amadeusProperties.gps.endpoint}/GPS_Approval_RequestService".post(
ApprovalRequest.ofCancel(
validatingCarrier = validatingCarrier,
officeId = amadeusApiProperties.officeId,
iataCode = amadeusApiProperties.iataCode,
userName = amadeusApiProperties.userName,
payment = payment
)
)
.header(headerMap)
.requestBodyConvert(offlineSoapRequestBodyConverter)
.execute<ApprovalResponse>(soapBodyDeserializerOf(logger, objectMapper))
.fold(
success = { it.checkError() },
failure = { throw it.handleSoapFaultException(ErrorMessage.PAYMENT_CANCEL_FAILED) }
)
}8. 티켓 상태 검증
8.1 취소 불가능 상태
// AmadeusCancelService.kt:105-115
private fun validateTicketStatus(tickets: List<PnrTicketDocument>) {
tickets.forEach { ticket ->
if (ticket.status == TicketStatus.CHECKIN) {
throw StatusInvalidException(ErrorMessage.CANCEL_UNABLE_BY_ALREADY_CHECK_IN)
}
if (ticket.status !in listOf(TicketStatus.ISSUE, TicketStatus.AIRPORT_CONTROL)) {
throw StatusInvalidException(ErrorMessage.CANCEL_UNABLE_BY_SCHEDULE_STATUS)
}
}
}8.2 Void 가능 조건
// AmadeusCancelService.kt:117-121
private fun List<PnrTicketDocument>.isVoidable(): Boolean {
if (this.isEmpty()) return false
val today = today()
return this.all { ticket -> ticket.issuedDate.isEqual(today) } // 모든 티켓이 당일 발권
}9. 특별 처리 사항
9.1 기내식 취소
- PNR 취소 전 기내식 요소가 있으면 먼저 제거 (AmadeusCancelService.kt:325-333)
9.2 EMD 티켓 처리
- EMD 티켓 포함 시 취소 불가 (AmadeusCancelService.kt:51-58, 174-181)
9.3 Locked PNR 방지
- 비동기 취소 시 5초 대기 (AmadeusCancelService.kt:313)
10. 모니터링 및 알림
10.1 Slack 알림 발송 케이스
- PNR 취소 실패: 2차 시도 실패 시
- Void 실패: 3차 시도 실패 또는 특정 예외 발생 시
- 결제 취소 실패: GPS 결제 취소 실패 시
10.2 로깅 전략
- 모든 재시도에 대한 에러 로깅
- 이미 취소된 PNR은 info 레벨 로깅
- Stateful session 예외 시 세션 정리 로깅
11. 개선 권장사항
11.1 현재 이슈
- 하드코딩된 재시도 횟수: PNR 취소 2회, Void 3회 고정
- 동기적 대기 시간: Locked PNR 방지를 위한 5초 고정 대기
- 항공사별 처리 미흡: NonVoidableAirline 외 특별 처리 부재
11.2 개선 방안
- 재시도 정책 외부화: 설정 파일에서 관리
- 동적 대기 시간: PNR 상태에 따른 동적 대기
- 항공사별 취소 전략: Strategy Pattern 적용
- Circuit Breaker 적용: 반복적인 실패 방지
12. 참고사항
12.1 주요 에러 코드
CANCEL_UNABLE: 일반 취소 불가CANCEL_UNABLE_BY_ALREADY_CHECK_IN: 체크인 완료로 취소 불가CANCEL_UNABLE_BY_SCHEDULE_STATUS: 스케줄 상태로 취소 불가ALREADY_CANCELED_PNR: 이미 취소된 PNRINVALID_VOID_TICKET: Void 불가능한 티켓NOT_FOUND_TICKET: 티켓을 찾을 수 없음VOID_FAILED: Void 실패PAYMENT_CANCEL_FAILED: 결제 취소 실패