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

주요 검증 단계:

  1. 취소 불가 티켓 체크 (AmadeusCancelService.kt:43-49)

    • Non-cancelable 티켓 존재 시 예외 발생
  2. EMD 티켓 체크 (AmadeusCancelService.kt:51-58)

    • EMD(Electronic Miscellaneous Document) 티켓은 취소 불가
  3. 티켓 없는 경우 처리 (AmadeusCancelService.kt:60-70)

    • 어제 이전 생성되었거나 No-Show인 경우 취소 불가
    • 그 외에는 PNR만 취소
  4. 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): CancelView

6.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,
): CancelableTypeDetailView

6.3 예상 취소 확인

// AmadeusBookingController.kt:77-89
@GetMapping("/{pnr}/expected-cancel")
@ResponseStatus(code = HttpStatus.OK)
fun expectedCancel(
    @PathVariable pnr: String,
    @RequestParam approvedAt: LocalDateTime?,
    @RequestParam waiverRefundable: Boolean,
): ExpectedCancelView

7. 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 알림 발송 케이스

  1. PNR 취소 실패: 2차 시도 실패 시
  2. Void 실패: 3차 시도 실패 또는 특정 예외 발생 시
  3. 결제 취소 실패: GPS 결제 취소 실패 시

10.2 로깅 전략

  • 모든 재시도에 대한 에러 로깅
  • 이미 취소된 PNR은 info 레벨 로깅
  • Stateful session 예외 시 세션 정리 로깅

11. 개선 권장사항

11.1 현재 이슈

  1. 하드코딩된 재시도 횟수: PNR 취소 2회, Void 3회 고정
  2. 동기적 대기 시간: Locked PNR 방지를 위한 5초 고정 대기
  3. 항공사별 처리 미흡: NonVoidableAirline 외 특별 처리 부재

11.2 개선 방안

  1. 재시도 정책 외부화: 설정 파일에서 관리
  2. 동적 대기 시간: PNR 상태에 따른 동적 대기
  3. 항공사별 취소 전략: Strategy Pattern 적용
  4. Circuit Breaker 적용: 반복적인 실패 방지

12. 참고사항

12.1 주요 에러 코드

  • CANCEL_UNABLE: 일반 취소 불가
  • CANCEL_UNABLE_BY_ALREADY_CHECK_IN: 체크인 완료로 취소 불가
  • CANCEL_UNABLE_BY_SCHEDULE_STATUS: 스케줄 상태로 취소 불가
  • ALREADY_CANCELED_PNR: 이미 취소된 PNR
  • INVALID_VOID_TICKET: Void 불가능한 티켓
  • NOT_FOUND_TICKET: 티켓을 찾을 수 없음
  • VOID_FAILED: Void 실패
  • PAYMENT_CANCEL_FAILED: 결제 취소 실패

12.2 관련 문서