Amadeus — 지뢰요소

module-amadeus arch-pitfalls pattern-stateful-session pattern-error-handling

이 노트를 먼저 읽어라

Amadeus는 11개 공급사 중 가장 크고 가장 위험한 모듈이다. stateful PNR 세션, 결제-발권의 비대칭, 환불 FOP 역산, 카드사별 하드코딩 에러코드, 비동기 정리(async cleanup)까지 — 한 줄만 잘못 건드려도 돈이 새거나 고객 좌석이 사라진다. 운영 장애의 대부분은 여기 적힌 함정에서 나온다. 코드 수정 전에 반드시 통독할 것.

관련 노트: 오퍼레이션 카탈로그 · 프로토콜·전문 · 에러 처리 · 복원력·이벤트 · 전체 지뢰밭


0. 지뢰 지도 (한눈에)

flowchart TD
    MAP["Amadeus 위험도 맵"]
    MAP --> SESSION["세션 ★★★<br/>stateful PNR 세션, signOut 누락 시 세션 누수"]
    MAP --> PAY["결제 ★★★<br/>결제 다음 발권 순서, 실패 시 비동기 void 및 환불"]
    MAP --> REFUND["환불 ★★★<br/>FOP 역산, refundFee, IgnoreRefund 미처리"]
    MAP --> ERROR["에러 ★★<br/>카드사 4000개 이상 코드 하드코딩, default는 PAYMENT_ETC"]
    MAP --> RETRY["재시도 ★★<br/>Retry 조건부 retryable 플래그, EOT 이중저장"]
    MAP --> FARE["운임 ★★<br/>100으로 나눔, A접미사, IT 및 NO ADC, toLongOrNull null삼킴"]
    MAP --> CURRENCY["통화 ★★<br/>now UTC vs approvalDate 파싱, KRW 가정"]
    MAP --> CARRIER["캐리어 ★★<br/>KE CZ MU PR AB MH 하드코딩 분기"]
    MAP --> CONCURRENCY["동시성 ★★<br/>delay 5000 locked pnr 방지, 비동기 예외 삼킴"]
    MAP --> TODO["TODO ★<br/>288 sold-out 임시처리, 멀티페어, ticketNumbers"]

1. 상태/세션 함정 (stateful PNR 세션)

Amadeus 코어 GDS는 stateful 이다. 한 트랜잭션(예약/발권/취소) 동안 Start → InSeries → … → End 로 이어지는 세션이 서버 측에 살아있고, 매 요청마다 SessionId/SequenceNumber/SecurityToken 을 넘겨야 한다. 이 상태머신은 support/util/StatefulBuilder.ktinfrastructure/Session.kt 에 구현돼 있고, 서비스 계층은 stateful { start{} inSeries{} end{} } DSL로 감싼다.

// support/util/StatefulBuilder.kt:10
fun withSession(transactionStatusCode: TransactionStatusCode): StatefulBuilder {
    val sequence: Int = session?.sequenceNumber ?: 0
    return apply {
        this.session = if (transactionStatusCode == TransactionStatusCode.Start) {
            Session(transactionStatusCode = transactionStatusCode)   // ← 새 세션, sequence 0
        } else {
            session!!.copy(transactionStatusCode = transactionStatusCode, sequenceNumber = sequence + 1)
        }
    }
}

지뢰 1-A: signOut() 누락 = 세션 누수

모든 서비스의 catch 블록은 session.transactionStatusCode == InSeries 일 때만 signOut() 을 호출한다. (AmadeusBookingService.kt:172, AmadeusTicketingService.kt:115 등 전부 동일 패턴)

} catch (e: Exception) {
    if (session?.transactionStatusCode == TransactionStatusCode.InSeries) {
        end { amadeusClient.signOut(statefulBuilder = this) }
    }
    throw e
}

Start 직후(아직 InSeries 진입 전)에 예외가 나면 signOut을 호출하지 않는다. start { getPnrInfo(...) } 자체가 던지면 세션은 서버에 열린 채로 방치된다. Amadeus는 동시 세션 수에 한도가 있어 누수가 쌓이면 신규 트랜잭션이 막힌다(세션 풀 고갈). 신규 개발자가 새 오퍼레이션을 추가할 때 이 catch 패턴을 그대로 복붙하면 같은 누수를 재생산한다.

지뢰 1-B: 세션 중간에 stateless 호출을 끼우면 시퀀스가 깨진다

AmadeusBookingService.book() 안에서 carrierTimeLimit가 null이면 withBlocking { delay(3000); amadeusClient.getPnrInfo(pnr, statefulBuilder = this@stateful) }같은 세션을 코루틴 안에서 재사용한다 (AmadeusBookingService.kt:134-138). SequenceNumber 는 단조 증가해야 하므로, 같은 세션을 여러 경로에서 동시에 만지면 시퀀스 충돌이 난다. 이 호출은 delay(3000) 으로 직렬화에 의존할 뿐 락이 없다 — 절대 병렬화하지 말 것.

세션 vs stateless 혼용

일부 진입점은 의도적으로 세션 없이(statefulBuilder = null) 단발 호출한다. 예) AmadeusCancelService.cancel() 첫 줄 amadeusClient.getPnrInfo(pnr = pnr) 는 세션 없이 조회만 한다. withSession(null) 이면 SOAP 헤더에 Security(UsernameToken)만 들어가고 매 호출이 새 인증이다(=느리지만 안전). 세션을 받는 메서드와 안 받는 메서드를 혼동하면 “세션이 없는데 InSeries로 보냈다” 류의 SOAP fault가 난다.


2. 결제·발권 순서 함정 (돈이 새는 지점)

AmadeusTicketingService.issue() 의 흐름은 결제가 발권보다 먼저 일어난다. 발권이 실패하면 이미 승인된 결제를 비동기로 취소해야 한다.

flowchart TD
    ISSUE["issue 진입"] --> PAY["payment 카드 승인<br/>KE는 GDS key-in, 그 외는 GPS VAN, 먼저 실행"]
    PAY --> TICKET["ticketing DocIssuance_IssueTicket, 나중 실행"]
    TICKET -->|"실패 시 throw"| CATCH["catch e<br/>CoroutineScope IO withLaunch"]
    CATCH --> DELAY["delay 5000, locked pnr 방지"]
    DELAY --> VOID["voidRepeat pnr, 발권 취소 당일"]
    VOID --> PCANCEL["payment 있으면 paymentCancelAsync, 결제 취소"]
    PCANCEL --> PNRCANCEL["keepPnr 아니면 pnrCancelAsync pnr, 예약 취소"]

지뢰 2-A: 결제 취소가 "비동기 + 예외 삼킴" 이다

발권 실패 시 보상(결제취소/void)이 CoroutineScope(Dispatchers.IO).withLaunch { ... } 안에서 돈다 (AmadeusTicketingService.kt:217). withLaunchAdapterCoroutineExceptionHandler 를 달고 있어 예외가 호출자에게 전파되지 않고 Sentry 로깅으로만 끝난다(support/util/CoroutineExtensions.kt:20, support/exception/AdapterCoroutineExceptionHandler.kt:15). 즉 결제 취소가 실패해도 issue() 호출자는 모른다. 이중 청구(고객 카드는 승인됐는데 항공권은 미발권) 가 여기서 발생한다. Slack 경보(sendPaymentCancelFail)가 유일한 안전망이므로 채널을 끄면 안 된다.

지뢰 2-B: KE 결제와 GPS 결제의 approvedAt 시간 기준이 다르다

  • KE(대한항공): GDS key-in → CommandCrypticReply.toPayment()approvedAt = now("UTC")호출 시각을 찍는다 (CommandCrypticReply.kt:31).
  • 그 외: GPS VAN → ApprovalResponse.Response.toPayment() 가 응답의 approvalDate(“yyyyMMddHHmmss”, KST 가정)를 파싱해 .toUTC() 한다 (GpsResponse.kt:68).

한쪽은 “내 시각”이고 한쪽은 “VAN 서버가 찍은 시각”이라 결제 취소 시 일자 비교(당일 void 여부 등)에서 경계 케이스(자정 전후)가 어긋날 수 있다. GpsResponsetoLocalDateTime("yyyyMMddHHmmss") 는 타임존 정보가 없어 KST로 간주하고 toUTC 한다 — VAN이 다른 TZ를 쓰면 9시간 틀어진다.

지뢰 2-C: 결제 시 NPE 지뢰가 깔려 있다

GpsResponse.Response.toPayment()approvalNumber!!, installment!!.toInt(), requestNumber!!, expiryDate!!non-null 단언한다 (GpsResponse.kt:64-75). VAN이 성공 코드를 주면서 이 필드 중 하나라도 빠지면 발권 직전에 KotlinNPE가 터지는데, 이 시점엔 이미 승인이 끝났을 수 있다 → 2-A의 보상 경로로 빠진다.


3. @Retry / 복원력 함정

Amadeus에는 Resilience4j @CircuitBreaker가 단 한 곳, 검색 컨트롤러에만 있다. 예약/발권/취소/환불에는 서킷브레이커가 없다.

// interfaces/controller/internals/AmadeusSearchController.kt:24
@CircuitBreaker(name = "amadeusSearch", fallbackMethod = "searchFallback")
@PostMapping
fun search(...) { ... }
 
private fun searchFallback(exception: CallNotPermittedException): ResponseEntity<List<FareItineraryView>> {
    // ... span 태그만 찍고
    return ResponseEntity.ok(emptyList())   // ← 빈 리스트 반환
}

지뢰 3-A: 서킷 OPEN 시 검색은 "장애"가 아니라 "결과 없음"으로 보인다

amadeusSearch 서킷 설정은 failureRateThreshold: 35, slidingWindowSize: 180(TIME_BASED), waitDurationInOpenState: 120s, minimumNumberOfCalls: 30 이다(application.yml). OPEN 되면 fallback이 빈 리스트를 200 OK로 돌려준다. 호출자(Triple 예약) 입장에서는 “Amadeus 좌석이 없다”와 “Amadeus가 죽어서 못 물어봤다”가 구분되지 않는다. Datadog span 태그 supplier.circuit-breaker=OPEN 만이 단서다. 검색 결과가 갑자기 0건이면 서킷 상태를 먼저 의심하라. 자세한 전이 동작은 resilience-and-events 참고.

스프링 @Retryable 은 여러 곳에 흩어져 있고 조건이 제각각이다.

위치설정재시도 조건함정
AmadeusClient.ticketing() :894maxAttempts=3, delay=2000msshouldTicketingRetryableApiException.retryable == true 일 때만발권 재시도는 세션 안에서 돈다. retry 시 동일 세션/시퀀스로 재호출 → 세션이 이미 오염됐으면 같은 실패 반복
AmadeusRetrieveService.getPnrInfoAndCheckInfantSoldOut() :28maxAttempts=5, delay=5000msretryable==true (유아 SSR이 HN 일 때만 retry 플래그)최대 25초 대기. 유아 좌석 대기(HN)가 안 풀리면 5번 다 소진 후 INFANT_SOLD_OUT
AmadeusClient.removePnrsInQueue() :1173maxAttempts=3, delay=5000ms무조건(예외 타입 무관)큐 제거는 멱등이 아닐 수 있음 — 중복 제거 호출 주의
ArtClient.findFareRules() :29maxAttempts=2무조건FareRule 조회는 stateless라 안전

지뢰 3-B: 재시도는 "플래그를 켠 예외"만 재시도한다 — 켜는 곳을 놓치기 쉽다

@Retryable(exceptionExpression = "@amadeusClient.shouldTicketingRetryable(#root)") 는 던져진 예외가 ApiException.retryable == true 일 때만 재시도한다. 이 플래그는 .retry() 확장함수로만 켜진다(support/exception/Exceptions.kt:72). 발권 코드에서 “BAGGAGE ALLOWANCE MISSING IN TST” 경고일 때만 .retry() 를 단다:

// AmadeusClient.kt:925
} else if (errorMessage.contains("WARNING: BAGGAGE ALLOWANCE MISSING IN TST")) {
    throw InternationalAdapterException(ErrorMessage.TICKETING_FAILED, errorMessage).retry()
}

다른 발권 실패는 .capture()(=Sentry 보고)만 하고 retry 플래그가 없어 재시도되지 않는다. 신규 개발자가 “발권은 3회 재시도된다”고 오해하면 안 된다. 거의 모든 발권 실패는 1회로 끝난다.

재시도 성공 시 Slack 통지

ticketing()RetrySynchronizationManager.getContext()?.retryCount 를 반환값에 실어 보내고(AmadeusClient.kt:937), retryCount>0 이면 sendBaggageMissingTicketing 으로 Slack 통지한다. 재시도로 발권에 성공해도 “수하물 누락 가능성”을 운영팀이 사후 확인하라는 의도. 폴백이 아니라 사후 경보다.


4. 운임·통화·세금·수수료 계산 함정

발권 운임은 infrastructure/response/ticketprocessedocreply/FareInfo.kt 가 파싱한다. 여기엔 통화·금액 파싱 함정이 밀집해 있다.

지뢰 4-A: 금액 문자열의 "A" 접미사 / "NO ADC" / "IT" 분기

// FareInfo.kt:91 findPriceByFareType()
?.amount?.let {
    when {
        it.endsWith("A") -> it.dropLast(1)   // "1234A" → "1234"
        it.contains("NO ADC") -> "0"          // 추가징수 없음 → 0
        else -> it
    }
}

Amadeus는 금액 필드에 문자(A 접미사, NO ADC, IT=Inclusive Tour 비공개운임)를 섞어 보낸다. 이 분기를 타지 못한 새 포맷이 오면 바로 아래 .toLong() 에서 NumberFormatException이 터진다.

지뢰 4-B: toLongOrNull() 이 파싱 실패를 조용히 null로 삼킨다

// FareInfo.kt:18
private val ticketTotalAmount: Long? = (findPriceByFareType(TICKET_DOCUMENT_AMOUNT)
    ?: throw ...PARSE_FAILED...).toLongOrNull()   // ← 파싱 실패 시 null

findPriceByFareType 가 값을 찾았는데 toLongOrNull() 이 실패하면 예외 대신 null 이 된다. 그러면 toTicketPrice() 의 분기 additionalTotalAmount == null && ticketTotalAmount == null → return null 으로 빠져 티켓 가격이 통째로 null 이 된다. 가격 0원도 아니고 “가격 정보 없음”으로 흘러가 후속 정산이 틀어진다. (반면 additionalTotalAmount.toLong() 이라 같은 상황에서 예외를 던진다 — 두 필드의 처리가 비대칭이다.)

지뢰 4-C: 커미션은 100으로 나눈다

// FareInfo.kt:85
fun toCommission(): Double? = findPriceByFareType(COMMISSION_RATE)?.let {
    (it.toBigDecimal().divide(BigDecimal(100))).toDouble()   // 7 → 0.07
}

Amadeus는 커미션율을 정수 퍼센트(예 7)로 주는데 코드는 /100 해서 비율(0.07)로 바꾼다. ticketprocessedocreply/FareInfo.kt:87 와 별개로 pnrreply/DataElementsIndiv.kt:310 에도 value.toDouble() 류 변환이 있어 단위 가정이 흩어져 있다. 단위(퍼센트 vs 비율, 원 vs 100분율) 를 코드마다 확인하지 않으면 커미션이 100배/1/100배 틀어진다.

지뢰 4-D: SQ(싱가포르항공)는 세금 계산이 특수하다

// faremasterpricertravelboardsearchreply/PaxFareProduct.kt:41
"SQ" -> PassengerFare(tax = tax.plus(qCharge), fuelCharge = qCharge, qCharge = 0, ...)
else -> PassengerFare(tax = tax, fuelCharge = (FUEL_SURCHARGE), qCharge = qCharge, ...)

SQ만 Q차지를 세금에 합산하고 qCharge를 0으로 비운다. 항공사별 세금 구성 가정이 코드에 하드코딩돼 있으니, 신규 캐리어 추가 시 이 분기를 반드시 검토하라.

환율/통화 가정

결제(approve/approveByKeyIn)의 price: Long 은 사실상 KRW(원) 정수 가정이다. 통화 코드를 별도로 들고 다니지 않는다. 외화 정산 시나리오는 이 코드 경로가 가정하지 않으므로 주의.


5. 인코딩 / 암호화 / 날짜·시간대 함정

SOAP 인증은 WS-Security UsernameToken + PasswordDigest(SHA-1) 방식이다.

// support/util/PasswordDigest.kt
sha1.update(Base64.getDecoder().decode(nonce.toByteArray()))   // nonce 디코드
sha1.update(created.toByteArray())
... Base64.getEncoder().encode(sha1.digest(getHash(clearPassword)))   // password는 한 번 더 SHA-1

지뢰 5-A: Created 시각은 반드시 UTC, 그리고 새 세션마다만 보낸다

SOAP 헤더의 CreatedPasswordDigest.getFormattedTime(now("UTC")) 로 만든다(AmadeusClient.kt:1349). now() 의 기본 타임존은 Asia/Seoul 이다(support/util/DateExtensions.kt:12). 즉 now("UTC")now() 로 잘못 바꾸면 KST(+9h)가 digest에 들어가 인증 실패한다. 또한 Security(UsernameToken) 헤더는 isStart(=TransactionStatusCode.Start) 일 때만 붙는다(AmadeusClient.kt:1345). 세션 중간(InSeries) 요청에 인증 헤더를 넣으면 거부된다 — 인증은 세션 시작 1회뿐.

지뢰 5-B: SHA-1 + SHA1PRNG 는 레거시 알고리즘

getMessageDigest()MessageDigest.getInstance("SHA-1"), nonce는 SecureRandom.getInstance("SHA1PRNG") 다(PasswordDigest.kt:43,91). Amadeus WS-Security 규격이라 바꿀 수 없지만, 보안 스캐너가 SHA-1 사용을 지적할 수 있다. 이 코드는 의도된 레거시 이므로 “보안 개선”이라며 SHA-256으로 바꾸면 인증이 깨진다.

지뢰 5-C: XML 빈 네임스페이스 강제 제거

SOAP 본문 생성 후 .replace(" xmlns=\"\"", "") 로 빈 네임스페이스 선언을 문자열 치환으로 지운다(AmadeusClient.kt:1415, GpsClient.kt:56). Jackson XML이 만드는 xmlns="" 가 Amadeus 파서를 깨기 때문. 문자열 치환이라 만약 데이터 값에 xmlns="" 가 우연히 포함되면 함께 지워진다(현실적으로 드물지만 원리상 위험).

지뢰 5-D: carrierTimeLimit 시간대 환산

발권 가능 시한(carrierTimeLimit)은 항공사 현지시각이라 calculateTimezoneService.calculateToUTC(at, iata) 로 IATA 공항코드 기준 UTC 변환한다(AmadeusTicketingService.kt:704, AmadeusBookingService.kt:427). 변환 실패/공항코드 누락 시 시한 검증(< now()+2분)이 어긋나 “시한 임박”을 잘못 판단할 수 있다. ssrCarrierTimeLimit 로 폴백한다.


6. 재발행 / 환불 / 부분취소 엣지케이스

취소는 cancel() 에서 void(당일 발권 취소) vs refund(환불) 로 갈린다. isVoidable() 이 그 판정을 한다.

flowchart TD
    CANCEL["cancel pnr validatingCarrier payment autoRefundable waivers"]
    CANCEL --> GETPNR["getPnrInfo"]
    GETPNR -->|"nonCancelableTicket 또는 EMD"| UNABLE1["CANCEL_UNABLE"]
    GETPNR -->|"tickets 비어있음"| EMPTYCHK{"어제이전 생성 또는 no-show"}
    EMPTYCHK -->|"예"| UNABLE2["CANCEL_UNABLE"]
    EMPTYCHK -->|"아니면"| VOIDPNR["pnrCancelRepeat 후 VOID"]
    GETPNR -->|"isVoidable true"| VOIDABLE["voidRepeat 후 paymentCancelAsync 후 pnrCancelAsync 후 VOID"]
    GETPNR -->|"waiverRefundable 또는 autoRefundable"| REFUND["refundService.refund 후 pnrCancelAsync 후 REFUND"]
    GETPNR -->|"else"| UNABLE3["CANCEL_UNABLE"]

6.1 환불 FOP 역산 (가장 위험한 계산)

AmadeusRefundService.refund()refund(ticket...) 의 FOP(Form Of Payment) 금액 분배 로직은 환불 함정의 정점이다.

// AmadeusRefundService.kt:250  (shouldUpdateRefund 일 때만)
val (cardAmount, cashAmount) = when {
    initRefund.fopGroups.size > 1 -> initRefund.cardAmount to initRefund.cashAmount
    // 카드만: 환불불가금액을 역산
    fopGroups.size == 1 && cashAmount == ZERO -> cardAmount + noneRefundAmount to ZERO
    fopGroups.size == 1 && cardAmount == ZERO -> ZERO to cashAmount + noneRefundAmount
    else -> ZERO to ZERO
}
val refundFee = if (isWaiverRefund) ZERO else initRefund.refundFee
val noneRefundAmount = initRefund.usedAirPrice + initRefund.usedTax + refundFee
// 카드 환불금 우선 차감
val (overrideCardAmount, overrideCashAmount) = if (cardAmount > noneRefundAmount) {
    cardAmount - noneRefundAmount to cashAmount
} else {
    ZERO to cashAmount - (noneRefundAmount - cardAmount)   // ← cash가 모자라면 음수 가능
}

지뢰 6-A: FOP 역산은 음수/언더플로 검증이 없다

“카드 환불금 우선 차감” 로직에서 noneRefundAmount(사용분+수수료)가 카드+현금 합보다 크면 cashAmount - (noneRefundAmount - cardAmount)음수가 될 수 있다. 코드는 이 경우를 막지 않는다. 사용분 계산이 틀리면 환불 override 금액이 음수로 Amadeus에 전달돼 거부되거나(운 좋으면) 잘못된 금액이 환불된다(운 나쁘면). 부분사용·복합결제(카드+현금) 환불은 반드시 실제 전문으로 검증할 것.

지뢰 6-B: shouldUpdateRefund 가 false면 override를 아예 안 한다

// AmadeusRefundService.kt:227
isWaiverRefund && initRefund.refundFee > ZERO -> true
initRefund.fopGroups.size > 1 && initRefund.noneRefundAmount > ZERO -> true
else -> false

이 조건을 만족하지 않으면 updateRefund 를 건너뛰고 Amadeus가 계산한 기본 환불액으로 processRefund 한다. 즉 무료환불(waiver)인데 수수료가 0이면 override 없이 진행 — 정상이지만, “분명 override 했는데 금액이 그대로”라면 이 분기를 먼저 의심.

지뢰 6-C: 환불은 stateful 세션을 못 쓴다 (IgnoreRefund 미처리)

refundCalculate() 의 병렬 InitRefund 블록에 주석이 박혀 있다:

// AmadeusRefundService.kt:56
//stateful을 사용하려면 DocRefund_IgnoreRefund 처리가 필요함.

DocRefund_InitRefund 는 서버에 환불 컨텍스트를 잡아두는데, 이를 stateful 세션에서 안 끝내면 DocRefund_IgnoreRefund 로 명시적으로 버려야 한다. 그 처리가 없어서 refundCalculate세션 없이 단발 호출 + Redis 캐시로 우회한다. 캐시 키 "$pnr$ticketNumber", TTL은 자정까지(Duration.between(now, 다음날 0시)). 즉 InitRefund 결과는 당일만 유효하다고 가정한다 — 자정 직전 호출이면 TTL이 몇 초여서 캐시가 거의 무용. 환불 계산을 stateful로 “최적화”하려다 IgnoreRefund를 빠뜨리면 서버에 환불 컨텍스트가 누수된다.

지뢰 6-D: 부분환불은 "전부 성공 아니면 전부 실패"로 강제 검증한다

refund() 는 티켓별로 환불한 뒤, 다시 조회해서 status != REFUND 인 티켓이 하나라도 남으면 REFUND_FAILED 를 던진다(AmadeusRefundService.kt:146). 즉 일부 티켓만 환불되고 일부가 남은 부분 환불 상태는 명시적으로 실패 처리하고 Slack(sendAllTicketRefundFail)을 쏜다. 하지만 이미 환불된 티켓은 롤백되지 않는다 — 환불은 비가역. 따라서 다승객 PNR에서 중간 티켓이 실패하면 “일부는 이미 환불됐는데 전체는 실패”인 불일치 상태가 남는다. 운영 수동 개입 필요.

6.2 재발행/엔도스먼트 엣지케이스

지뢰 6-E: MU 항공 유아/소아 엔도스먼트의 index++ / 3

// AmadeusTicketingService.kt:645  issueWithEndorsements()
val relationTicket = if (passenger.type == PassengerType.INFANT) {
    pnrInfo.tickets.first { it.identificationKey == passenger.identificationKey }
} else {
    pnrInfo.tickets[index++ / 3]   // ← 성인 1명당 티켓 3장(항공권/세금 등) 가정
}

주석 //그지같은 MU(:386)가 말해주듯 MU(중국동방항공)는 소아/유아 발권 전 성인 티켓번호를 엔도스먼트에 연결해야 한다. index++ / 3 은 “성인 한 명당 관련 티켓이 3장씩”이라는 강한 가정이다. 티켓 구조가 이 가정과 다르면 엉뚱한 성인 티켓에 연결되거나 IndexOutOfBounds 가 난다. CZ(:203), MU(:385), KE(:272), PR/MU(PricingService:60), AB(:60), MH(AmadeusClient:199) 등 캐리어별 하드코딩 분기가 곳곳에 있어, 신규 캐리어가 같은 특성을 가져도 자동 적용되지 않는다.

지뢰 6-F: MH(말레이시아항공) 코드쉐어는 검색 단계에서 통째로 버려진다

// AmadeusClient.kt:198 hasNonTicketableCarrier()
return this.validatingCarrier == "MH" && this.schedules.any { ... it.marketingCarrier != "MH" }

validatingCarrier가 MH인데 마케팅 캐리어가 MH가 아닌(=코드쉐어) 여정은 검색 결과에서 filterNot 으로 제거된다(AmadeusClient.kt:179). 즉 발권 불가 케이스를 검색에서 미리 쳐낸다. “MH 운임이 검색에 안 잡힌다”는 버그가 아니라 의도된 필터다.


7. 동시성 / 비동기 함정

지뢰 7-A: 도처의 delay(5000) // locked pnr 방지

발권 실패 보상(AmadeusTicketingService.kt:218)과 pnrCancelAsync(AmadeusCancelService.kt:313)는 작업 전 delay(5000) 을 건다. 직전 트랜잭션이 PNR을 잠그고 있어(서버 측 lock) 곧바로 취소하면 LOCKED_PNR 이 난다는 경험칙이다. 5초는 락이라기보다 “기도(hope)” 다. 서버 락이 5초 넘게 지속되면 그대로 실패한다. TicketCancelDocumentReply.kt:30 는 에러코드 284/55LOCKED_PNR 로 매핑하고, voidRepeat() 는 최대 3회 재시도하지만 각 시도 사이엔 추가 지연이 없다.

지뢰 7-B: 비동기 정리의 예외는 전부 삼켜진다

pnrCancelAsync, paymentCancelAsync, saveUnexposedFareItinerary, removeFlightSearchKey, handleIssuanceFailure 등은 전부 CoroutineScope(Dispatchers.IO).withLaunch {} 다. withLaunchAdapterCoroutineExceptionHandler 가 예외를 로깅/Sentry로만 처리하고 호출자에 전파하지 않는다. “취소 API는 성공 응답을 줬는데 실제 PNR은 안 취소됨” 류 불일치가 여기서 발생한다. Slack 경보가 사후 추적의 유일한 수단.

지뢰 7-C: pmap 병렬 환불계산의 부분 실패

refundCalculate 의 InitRefund 조회는 tickets...pmap { ... }.getOrThrow() 로 병렬 실행한다(AmadeusRefundService.kt:55, support/util/CoroutineExtensions.kt:36). getOrThrow()첫 번째 예외만 던지고 나머지 결과는 버린다. 일부 InitRefund는 이미 서버에 컨텍스트를 잡았는데(6-C 참조) 다른 티켓이 실패하면, 성공한 컨텍스트는 IgnoreRefund 없이 방치된다.

지뢰 7-D: 예약 시 savePnr 이중 호출

// AmadeusBookingService.kt:98
val (savedPnr, warnings) = inSeries { amadeusClient.savePnrWithShowWarnings(statefulBuilder = this) }
if (warnings != null) {
    // ... 경고 검사 후
    pnr = inSeries { amadeusClient.savePnrWithShowWarnings(statefulBuilder = this).first }  // ← 두 번째 EOT
}

경고가 있으면 savePnrWithShowWarnings(=EOT, End Of Transaction)를 한 번 더 호출한다. 첫 EOT가 PNR을 이미 확정했을 수 있어, 두 번째 호출은 멱등하지 않으면 부작용(중복 요소 등) 위험이 있다. “경고 후 재저장”의 의미를 정확히 이해하지 못한 채 흐름을 고치면 PNR이 오염된다.


8. 공급사 고유 예외·에러코드 매핑

Amadeus는 3개의 에러 표면을 가진다: (1) GDS SOAP 응답의 checkError, (2) GDS SOAP Fault, (3) GPS VAN 결제 결과코드. 매핑은 통합 ErrorMessage enum(support/exception/ErrorMessage.kt)으로 수렴한다.

flowchart TD
    CHECKERR["GDS checkError code msg"] --> ADAPTER["InternationalAdapterException ErrorMessage"]
    FAULT["GDS SOAP Fault"] --> ADAPTER
    RESULTCODE["GPS resultCode"] --> ADAPTER
    KEYIN["key-in longText"] --> ADAPTER
    ADAPTER --> CAPTURE["capture, Sentry 보고"]
    ADAPTER --> RETRY["retry, Retryable 대상"]
    ADAPTER --> HANDLER["RestExceptionHandler, HTTP 응답 및 Slack"]

지뢰 8-A: GPS 결제 에러코드 4000+개가 하드코딩, 매칭 실패 시 전부 PAYMENT_ETC

GpsError.kthashSetOf("IN1200FX", "VNV38350", ...) 형태로 수천 개의 VAN 결과코드PAYMENT_CREDIT_CARD_DENIAL 등으로 분류한다(파일 길이 667줄). 이 거대한 집합에 없는 코드가 오면 getErrorMessage()PAYMENT_ETC 로 떨어지고(GpsResponse.kt:33), PAYMENT_ETC 일 때만 .capture()(Sentry)된다(GpsResponse.kt:35). 즉 분류된 거절(CARD_DENIAL 등)은 Sentry에 안 잡히고, 미분류 코드만 Sentry로 본다. VAN이 새 코드를 추가하면 사용자에겐 “기타 결제오류”로 뭉뚱그려진다. 신규 거절 코드 발견 시 이 set에 수동으로 추가해야 하므로 유지보수 부채가 크다.

지뢰 8-B: key-in(KE) 결제 에러는 별도 텍스트 매핑

KE GDS key-in 결제는 AmadeusKeyInError.kt에러 문자열로 매핑한다:

"MAXIMUM EXCEEDED" → PAYMENT_MAXIMUM_EXCEEDED
"INSTALLMENT NOT ALLOWED" → PAYMENT_INSTALLMENT_NOT_ALLOWED
"CREDIT CARD DENIAL 5.51 - INSUFFICIENT FUNDS" → PAYMENT_INSUFFICIENT_FUNDS
// 그 외 → PAYMENT_ETC

단 3개만 매핑. 문자열 완전일치(contains 아님)라 메시지 포맷이 조금만 바뀌어도 PAYMENT_ETC 로 떨어진다. KE 결제 실패 사유가 항상 “기타”로 보이면 이 매핑부터 의심.

지뢰 8-C: 검색 에러코드 화이트리스트 — "에러인데 무시"

// AmadeusClient.kt:152
when (code) {
    "866", "931", "977", "996", "118", "950" -> Unit   // ← 무시(결과 없음으로 처리)
    else -> throw SEARCH_FAILED...
}

위 6개 코드는 “조회 결과 없음” 류라 예외 대신 빈 결과로 흘린다. 이 리스트에 없는 정상적 “no result” 코드가 추가되면 검색이 SEARCH_FAILED로 죽는다. 반대로 진짜 장애 코드가 우연히 이 리스트에 있으면 장애가 “결과 없음”으로 은폐된다.

에러 클래스의미비고
InternationalAdapterException일반 어댑터 예외대부분의 경로
MethodArgumentInvalidException입력/결제 거절류key-in/GPS 결제 거절
StatusInvalidException상태 불가SOLD_OUT, NOT_OK_SCHEDULE, INFANT_SOLD_OUT
ApiException.capturableSentry 보고 여부.capture() 로 켬
ApiException.notifiable알림 여부capture(silence=true) 면 끔
ApiException.retryable@Retryable 대상 여부.retry() 로만 켬

전체 매핑 철학은 error-handling 참고.


9. 코드 내 TODO/FIXME/주석 경고 (원문 인용)

발견된 모든 경고성 주석

위치주석의미
AirSellFromRecommendationReply.kt:33//특이 오류 한시적으로 sold out 처리합니다. //TODO 아마데우스 장애 복구 후 삭제 필요에러코드 288 을 임시로 SOLD_OUT으로 처리 중. 장애 복구 후 제거해야 하는데 남아있을 가능성 — 진짜 288 오류가 영구히 “매진”으로 둔갑할 위험
PaxFareProduct.kt:82// TODO: 멀티페어 조회를 병렬조회 하지 않고 additional 객체로 받아 같이 조회 할 때 이부분에서 무조건 예외 처리되는데 확인 필요!멀티페어를 additional로 받으면 getPassengerType() 가 무조건 NOT_FOUND_PASSENGER_TYPE 던짐. 미해결
AmadeusCashReceiptService.kt:58// todo: 추후 ticketNumbers 직접 받는 경우를 대비해 매핑하였으나 로직 추가 필요현금영수증의 ticketNumbers를 리트리브 티켓으로 대체 중. 직접 입력 경로 미구현
AmadeusTicketingService.kt:127@Deprecated("API분리 후 제거 예정") (private repricing)발권 내부 repricing이 deprecated인데 여전히 ready() 에서 호출됨 — 제거 전까지 동작 보장 필요
AmadeusTicketingService.kt:386//그지같은 MUMU 항공 엔도스먼트 특수처리(6-E)
AmadeusRefundService.kt:56//stateful을 사용하려면 DocRefund_IgnoreRefund 처리가 필요함.환불계산이 세션을 못 쓰는 이유(6-C)
AmadeusTicketingService.kt:218, AmadeusCancelService.kt:313//locked pnr 방지delay(5000) 근거(7-A)
AmadeusBookingService.kt:135[AMADEUS] carrierTimeLimit is null (로그)TimeLimit null이면 3초 후 재조회하는 우회(1-B)

10. 신규 개발자 체크리스트

Amadeus 코드를 건드리기 전 자문하라

  1. 내가 추가하는 SOAP 호출은 세션 안인가 밖인가? catch에서 signOut을 (Start 직후 예외 포함) 빠짐없이 부르는가? (1-A)
  2. 결제/발권/취소를 손대면 돈의 흐름보상(void/환불) 경로를 다 추적했는가? 비동기 정리는 예외를 삼킨다(2-A, 7-B).
  3. 금액 파싱에 toLong() vs toLongOrNull() 비대칭, “A”/“NO ADC”/“IT” 분기를 깨지 않았는가? (4-A, 4-B)
  4. 캐리어 하드코딩(KE/CZ/MU/PR/AB/MH/SQ) 분기를 신규 캐리어가 우회하지 않는가? (4-D, 6-E, 6-F)
  5. 환불 FOP 역산을 바꿨다면 음수/언더플로/부분실패를 실제 전문으로 검증했는가? (6-A, 6-D)
  6. now()now("UTC") 자리에 쓰지 않았는가? (5-A)
  7. 새 GPS/key-in 에러코드는 GpsError/AmadeusKeyInError 에 등록했는가? (8-A, 8-B)

연습문제로 확인

이 모듈의 함정을 디버깅 시나리오로 풀어보려면 exercises-debugging 의 Amadeus 세션 누수·이중청구 케이스를 참고하라. 오퍼레이션 흐름은 amadeus-operations, 전송/세션 메커니즘은 amadeus-protocol.