Amadeus는 11개 공급사 중 가장 크고 가장 위험한 모듈이다. stateful PNR 세션, 결제-발권의 비대칭, 환불 FOP 역산, 카드사별 하드코딩 에러코드, 비동기 정리(async cleanup)까지 — 한 줄만 잘못 건드려도 돈이 새거나 고객 좌석이 사라진다. 운영 장애의 대부분은 여기 적힌 함정에서 나온다. 코드 수정 전에 반드시 통독할 것.
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.kt 와 infrastructure/Session.kt 에 구현돼 있고, 서비스 계층은 stateful { start{} inSeries{} end{} } DSL로 감싼다.
모든 서비스의 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). withLaunch 는 AdapterCoroutineExceptionHandler 를 달고 있어 예외가 호출자에게 전파되지 않고 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 여부 등)에서 경계 케이스(자정 전후)가 어긋날 수 있다. GpsResponse 의 toLocalDateTime("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가 단 한 곳, 검색 컨트롤러에만 있다. 예약/발권/취소/환불에는 서킷브레이커가 없다.
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() :894
maxAttempts=3, delay=2000ms
shouldTicketingRetryable → ApiException.retryable == true 일 때만
발권 재시도는 세션 안에서 돈다. retry 시 동일 세션/시퀀스로 재호출 → 세션이 이미 오염됐으면 같은 실패 반복
최대 25초 대기. 유아 좌석 대기(HN)가 안 풀리면 5번 다 소진 후 INFANT_SOLD_OUT
AmadeusClient.removePnrsInQueue() :1173
maxAttempts=3, delay=5000ms
무조건(예외 타입 무관)
큐 제거는 멱등이 아닐 수 있음 — 중복 제거 호출 주의
ArtClient.findFareRules() :29
maxAttempts=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 가 파싱한다. 여기엔 통화·금액 파싱 함정이 밀집해 있다.
Amadeus는 금액 필드에 문자(A 접미사, NO ADC, IT=Inclusive Tour 비공개운임)를 섞어 보낸다. 이 분기를 타지 못한 새 포맷이 오면 바로 아래 .toLong() 에서 NumberFormatException이 터진다.
지뢰 4-B: toLongOrNull() 이 파싱 실패를 조용히 null로 삼킨다
// FareInfo.kt:18private 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() 이라 같은 상황에서 예외를 던진다 — 두 필드의 처리가 비대칭이다.)
Amadeus는 커미션율을 정수 퍼센트(예 7)로 주는데 코드는 /100 해서 비율(0.07)로 바꾼다. ticketprocessedocreply/FareInfo.kt:87 와 별개로 pnrreply/DataElementsIndiv.kt:310 에도 value.toDouble() 류 변환이 있어 단위 가정이 흩어져 있다. 단위(퍼센트 vs 비율, 원 vs 100분율) 를 코드마다 확인하지 않으면 커미션이 100배/1/100배 틀어진다.
SQ만 Q차지를 세금에 합산하고 qCharge를 0으로 비운다. 항공사별 세금 구성 가정이 코드에 하드코딩돼 있으니, 신규 캐리어 추가 시 이 분기를 반드시 검토하라.
환율/통화 가정
결제(approve/approveByKeyIn)의 price: Long 은 사실상 KRW(원) 정수 가정이다. 통화 코드를 별도로 들고 다니지 않는다. 외화 정산 시나리오는 이 코드 경로가 가정하지 않으므로 주의.
5. 인코딩 / 암호화 / 날짜·시간대 함정
SOAP 인증은 WS-Security UsernameToken + PasswordDigest(SHA-1) 방식이다.
// support/util/PasswordDigest.ktsha1.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 헤더의 Created 는 PasswordDigest.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.refundFeeval 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:227isWaiverRefund && initRefund.refundFee > ZERO -> trueinitRefund.fopGroups.size > 1 && initRefund.noneRefundAmount > ZERO -> trueelse -> false
이 조건을 만족하지 않으면 updateRefund 를 건너뛰고 Amadeus가 계산한 기본 환불액으로 processRefund 한다. 즉 무료환불(waiver)인데 수수료가 0이면 override 없이 진행 — 정상이지만, “분명 override 했는데 금액이 그대로”라면 이 분기를 먼저 의심.
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) 등 캐리어별 하드코딩 분기가 곳곳에 있어, 신규 캐리어가 같은 특성을 가져도 자동 적용되지 않는다.
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/55 를 LOCKED_PNR 로 매핑하고, voidRepeat() 는 최대 3회 재시도하지만 각 시도 사이엔 추가 지연이 없다.
지뢰 7-B: 비동기 정리의 예외는 전부 삼켜진다
pnrCancelAsync, paymentCancelAsync, saveUnexposedFareItinerary, removeFlightSearchKey, handleIssuanceFailure 등은 전부 CoroutineScope(Dispatchers.IO).withLaunch {} 다. withLaunch 의 AdapterCoroutineExceptionHandler 가 예외를 로깅/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:98val (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)으로 수렴한다.
지뢰 8-A: GPS 결제 에러코드 4000+개가 하드코딩, 매칭 실패 시 전부 PAYMENT_ETC
GpsError.kt 는 hashSetOf("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 결제 실패 사유가 항상 “기타”로 보이면 이 매핑부터 의심.