Amadeus NDC — 지뢰요소
module-amadeusndc arch-application pattern-resilience api-ndc config-secrets
한 줄 요약
Amadeus NDC 모듈에서 가장 위험한 지뢰는 “돈”과 “재시도”다. 운임은
convertPriceDropLast로 통화 소수점을 문자열 조작으로 깎아내고(반올림 없음), 세금에 surcharge를 합산해tax한 필드에 욱여넣으며, 환불 수수료는 5단계 산수로 역산한다.retrieveByPnr만@Retryable이 붙어 있고 나머지 발권/예약/취소는 한 번 실패하면 끝이다. 예약 실패 시 보상취소(cancelAsync)는 코루틴으로 fire-and-forget 되어 호출자는 성공 여부를 모른다. 이 문서는 그 함정들을 전수한다. 통신 골격은 amadeusndc-protocol, 각 오퍼레이션 흐름은 amadeusndc-operations, 공통 예외 모델은 error-handling, 서킷/리트라이 철학은 resilience-and-events 참고.
0. 지뢰 지도 (먼저 큰 그림)
flowchart TD MAP["Amadeus NDC 지뢰 지도"] MAP --> MONEY MAP --> RETRY MAP --> STATE MAP --> COMP MAP --> PARSE subgraph 가장치명적["가장 치명적"] MONEY["돈 계산 지뢰<br/>convertPriceDropLast 문자열 절삭<br/>tax = tax + surcharge 합산<br/>calculateRefundFare 5단계 역산<br/>YQ/YR fuelCharge 캐리어 분기 (SQ 예외)"] end RETRY["재시도/서킷 지뢰<br/>retrieve만 @Retryable<br/>noRetry() 분기"] STATE["상태/스케줄 지뢰<br/>UN/TK 상태 retrieve 중단<br/>INVOLUNTARY CHANGE 경고는 예외로<br/>이미 발권/체크인 시 취소 불가"] COMP["보상취소 지뢰<br/>cancelAsync는 fire and forget<br/>delay(5000) 후 백그라운드 취소"] PARSE["파싱/인코딩 지뢰<br/>PasswordDigest SHA-1 / UTC 5분<br/>PhoneNormalizer null은 PARSE_FAILED<br/>ISO3에서 ISO2 국가코드 변환 분기"]
1. 공급사 고유 예외·에러코드와 ErrorMessage 매핑
NDC와 클래식 1A 전문이 섞여 있어 에러 추출 경로가 전문마다 다르다. 같은 checkError() 이름이지만 시그니처와 의미가 제각각이라는 게 첫 번째 함정이다.
1.1 전문별 checkError 시그니처가 전부 다름
| 전문(파일) | checkError 형태 | 에러 위치 | 던지는 ErrorMessage |
|---|---|---|---|
FareMasterPricerTravelBoardSearchReply | (code, message)? 콜백 | errorMessage.applicationError… (단수) | SEARCH_FAILED |
OfferPriceRS | 인자 없음(내부 분기) | errors: List<Error> | SOLD_OUT / PRICING_FAILED |
OrderViewRS | (List<Pair<code,desc>>) 콜백 | errors: List<Error> | 호출처가 결정 |
OrderReshopRS | 오버로드 2개: (code,msg) / (List<Error>) | errors: List<Error> | 호출처가 결정 |
ArtResponse(운임규정) | (message) 콜백 | 별도 | FETCH_FARE_RULES_FAILED |
검색 에러는 "단수", 그 외는 "복수"
FareMasterPricerTravelBoardSearchReply.checkError(FareMasterPricerTravelBoardSearchReply.kt:79)는errorMessage가 단일 객체라 첫 에러만 본다. 게다가textSubjectQualifier == "WRN"이면 경고로 보고 통과시킨다. 반면 OrderView/Reshop은errors: List라 여러 에러가 올 수 있고, 호출처가joinToString으로 전부 이어붙인다. 신입이 “검색도 에러 리스트겠지”라고 가정하면 틀린다.
1.2 검색 응답에서 “무시하는 에러코드” 화이트리스트
AmadeusndcClient.search() (AmadeusndcClient.kt:118-128)는 특정 코드를 조용히 통과시킨다.
fareMasterPricerTravelBoardSearchReply.checkError { code, message ->
when (code) {
"866", "931", "977", "996", "118", "950", "9212", "9211" -> Unit // 무시
else -> throw InternationalAdapterException(ErrorMessage.SEARCH_FAILED, ...).capture()
}
}매직 넘버 8개 — 문서가 없다
이 8개 코드(866/931/977/996/118/950/9212/9211)가 무엇을 의미하는지 코드 어디에도 주석이 없다. “결과 없음/일부 구간 매진” 류로 추정되지만 확인되지 않은 매직 넘버다. 새 코드가 추가되면 검색 전체가
SEARCH_FAILED로 죽으므로, 운영 중 “특정 노선만 검색 실패”가 보이면 이 리스트부터 의심해야 한다. cartesianProduct로 여러 OD를 병렬 조회하므로 한 조각만 실패해도AmadeusndcFlightSearchService.search()의onFailure(AmadeusndcFlightSearchService.kt:70-74)에서 “성공이 하나라도 있으면 통과, 전부 실패해야 예외”로 처리된다 — 즉 일부 노선 누락이 조용히 발생할 수 있다.
1.3 SOLD_OUT 매핑 — 비노출 운임 처리의 트리거
OfferPriceRS.checkError (OfferPriceRS.kt:23-45)에서 코드 65010(외부 공급사 오류) / 490(좌석 소진)을 ErrorMessage.SOLD_OUT으로 매핑한다. 이게 단순 에러가 아니라 부수효과의 방아쇠다.
// AmadeusndcBookingService.kt:148-150
private fun isUnexposedFareItinerary(e: Exception): Boolean {
return e is StatusInvalidException && e.errorMessage == ErrorMessage.SOLD_OUT
}SOLD_OUT은 StatusInvalidException이어야만 비노출 처리됨
book()의 catch(AmadeusndcBookingService.kt:49-56)는isUnexposedFareItinerary(e)가 true일 때만 캐시 키를 제거하고 비노출 운임으로 저장한다. 그런데OfferPriceRS.checkError가 던지는 건InternationalAdapterException이지StatusInvalidException이 아니다(OfferPriceRS.kt:29). 즉 현재 pricing 경로의 SOLD_OUT은 비노출 처리 분기를 타지 못한다. 비노출 처리가 동작하려면 어딘가에서StatusInvalidException(SOLD_OUT)로 다시 감싸야 하는데 그 연결고리를 코드에서 확인해야 한다(잠재적 버그/누수).
1.4 “ORDER ALREADY CANCELLED” — 메시지 문자열 매칭
AmadeusndcClient.retrieve() (AmadeusndcClient.kt:343-347)는 에러 메시지 본문 문자열을 보고 분기한다.
if (errorMessages.any { (_, messages) -> messages.contains("ORDER ALREADY CANCELLED") }) {
throw InternationalAdapterException(ErrorMessage.ALREADY_CANCELED_PNR, ...).noRetry()
}영문 메시지 문자열 의존 — 가장 깨지기 쉬운 매칭
코드값이 아니라
"ORDER ALREADY CANCELLED"라는 영어 대문자 문자열에 의존한다. Amadeus가 메시지 표현을 바꾸거나(대소문자, 띄어쓰기, “CANCELED” vs “CANCELLED”), 응답 언어가 바뀌면 이 분기는 즉시 무력화되어ALREADY_CANCELED_PNR대신 일반RETRIEVE_FAILED로 떨어지고, 그러면noRetry()가 안 붙어 이미 취소된 PNR을 3번 재시도하게 된다(1.6 참고).
1.5 reissueSearch의 “41913” 특수 처리
AmadeusndcClient.reissueSearch() (AmadeusndcClient.kt:528-534)는 reshop 에러 중 41913이면 “검색 가능한 스케줄이 없습니다”, 아니면 “검색 오류”로 사용자 메시지를 만든다. 또 다른 코드 의존 분기.
1.6 NO_QUOTA — 발권 실패 시 Slack 분기
AmadeusndcTicketingService.issue()의 catch(AmadeusndcTicketingService.kt:72-78)는 e is ApiException && e.errorMessage == NO_QUOTA이면 항공사 쿼터 소진 Slack 알람을 보낸다. 그 후 무조건 cancelAsync. 자세한 알람 메커니즘은 resilience-and-events 참고.
2. 상태·세션 함정
2.1 Session DTO는 파싱만 하고 안 쓴다 (NDC는 stateless 추정)
AmadeusndcResponse (AmadeusndcResponse.kt:5-8)는 session: Session?을 들고 있고, Session(Session.kt)은 TransactionStatusCode(Start/InSeries/End), sessionId, sequenceNumber, securityToken을 파싱한다. 하지만 모듈 어디에서도 이 session을 다음 요청에 재사용하지 않는다.
amadeus(GDS, stateful PNR 세션) vs amadeusndc(stateless) 비교
형제 모듈
amadeus는 TOPAS PNR 세션을 유지하지만,amadeusndc는 매 요청마다soapRequestBodyConverter(AmadeusndcClient.kt:793-857)에서 새UUID.randomUUID()MessageID와 새 Nonce/PasswordDigest를 생성한다 — 즉 요청마다 독립 인증. Session을 파싱하는 코드는 클래식 1A 큐 전문 호환용 잔재로 보인다. 신입이 “세션 만료 핸들링이 있겠지”라고 찾으면 없다. 단, 인증 토큰(아래 2.2)에는 시간 제약이 있다.
2.2 PasswordDigest의 UTC Created 타임스탬프 — 서버 시계 동기화 의존
AmadeusndcClient.kt:812-835에서 매 요청마다:
val nonce = PasswordDigest.genNonce()
val formattedCreated = PasswordDigest.getFormattedTime(now("UTC"))
...
PasswordDigest.getPasswordDigestFromClearTextPW(nonce, formattedCreated, password)서버 시계가 틀어지면 전 오퍼레이션이 401로 죽는다
WS-Security UsernameToken은
Created시각이 서버 허용 윈도우(보통 수 분)를 벗어나면 인증을 거부한다.now("UTC")(DateExtensions.kt:12)는 컨테이너의 시스템 시계를 쓰므로 NTP가 어긋난 노드에 배포되면 검색·예약·발권 전부 인증 실패한다. 이건 코드 버그가 아니라 인프라 함정이라 디버깅이 어렵다 — 응답이 SOAP Fault로 와서handleSoapFaultException을 거쳐 일반 에러로 보인다.PasswordDigest.getExpiredTime()(현재시각+5분)도 정의돼 있다(PasswordDigest.kt:57).
2.3 SHA-1 PasswordDigest — 레거시 암호 알고리즘
PasswordDigest.kt:91은 MessageDigest.getInstance("SHA-1")을 쓴다. Amadeus WSS 스펙 강제 사항이라 바꿀 수 없지만, 보안 스캐너가 SHA-1 사용을 플래그할 수 있음을 알아둘 것. nonce는 SecureRandom("SHA1PRNG") 32바이트(PasswordDigest.kt:42-44).
2.4 큐 OID는 “TRIPLE/TRIPLE” 하드코딩 + first()
AmadeusndcQueueService.getOfflineOid() (AmadeusndcQueueService.kt:77-80):
return amadeusProperties.channels.first { it.channel == "TRIPLE" }
.funnels.first { it.funnel == "TRIPLE" }.offlineOid설정에 TRIPLE 채널/퍼널이 없으면 NoSuchElementException
first { }는 매칭이 없으면 던진다. 설정 구조가 바뀌거나 환경별 프로파일에 TRIPLE이 빠지면 큐 조회 자체가 부팅/호출 시 깨진다. 큐 번호는"7"하드코딩(TARGET_ORIGIN_QUEUE_NUMBER,AmadeusndcQueueService.kt:20), 큐당 최대 250건 조회 제한(주석AmadeusndcQueueService.kt:36).
3. @Retry / @CircuitBreaker 동작과 주의·폴백
이 모듈에는 Resilience4j
@CircuitBreaker/@Bulkhead가 없다amadeusndc 패키지 전체에
@CircuitBreaker/@Bulkhead어노테이션이 하나도 없다(grep 확인). 재시도는 오직 Spring Retry@Retryable만 쓴다. 전사 서킷브레이커 철학은 resilience-and-events에 있지만 이 모듈은 그 적용 대상이 아니다 — “서킷이 막아주겠지”라는 가정은 틀린다.
3.1 @Retryable이 붙은 곳은 단 두 군데
| 메서드 | 위치 | 정책 |
|---|---|---|
AmadeusndcClient.retrieveByPnr | AmadeusndcClient.kt:279-283 | maxAttempts=3, backoff 2000ms, exceptionExpression 조건부 |
NdcArtClient.getFareRules | NdcArtClient.kt:31 | maxAttempts=2, 조건 없음(전부 재시도) |
@Retryable(
maxAttempts = 3,
backoff = Backoff(delay = 2000),
exceptionExpression = "@amadeusndcClient.shouldRetrieveRetryable(#root)",
)
fun retrieveByPnr(...)3.2 재시도 가부는 retryable 3-상태(true/false/null)로 결정
shouldRetrieveRetryable (AmadeusndcClient.kt:370-372):
fun shouldRetrieveRetryable(exception: Exception): Boolean {
return (exception as? ApiException)?.retryable ?: true
}ApiException.retryable은 Boolean?이며 기본값 null(Exceptions.kt:69). 따라서:
| retryable 값 | 의미 | 설정 방법 |
|---|---|---|
null(기본) | 재시도함 (?: true) | 아무것도 안 함 |
false | 재시도 안 함 | .noRetry() 호출 |
true | 재시도함 | .retry() 호출 |
"기본이 재시도"라는 게 양날의 검
ApiException이 아닌 일반 예외(NPE 등)나 retryable=null인 예외는 전부 3번 재시도된다.
noRetry()를 명시적으로 붙인 곳만 1회로 끝난다:
Booking.validateSchedulesCancellation(Booking.kt:30-37): UN/TK 상태 →noRetry()Response.validateInvoluntaryScheduleChange(OrderViewRS.kt:167-172): 비자발 변경 경고 →noRetry()retrieve()의 ALREADY_CANCELLED 분기 (AmadeusndcClient.kt:346): →noRetry()이 세 곳이
noRetry()를 빼먹으면, 결항된 PNR이나 이미 취소된 PNR을 2초 간격으로 3번 조회하게 된다. 반대로 1.4의 문자열 매칭이 깨지면 자동으로 이 안전장치가 풀린다.
3.3 발권·예약·취소·재발행에는 재시도가 없다
book/savePayment/cancel/divide/reissue/pricing/getCancelableTypeDetail는 @Retryable이 없다. 한 번 SOAP Fault나 타임아웃이 나면 즉시 호출처로 예외가 던져진다.
돈이 오가는 호출은 재시도하면 위험하므로 의도적
savePayment(발권 결제)를 자동 재시도하면 이중 발권 위험이 있으니 재시도가 없는 게 맞다. 대신 타임아웃은 Slack으로 알리고 보상취소를 건다(아래 3.4). 신입이 “왜 발권은 재시도 안 하지?”라고 retry를 추가하면 안 된다.
3.4 타임아웃 폴백 = timeoutCallback + 보상취소(코루틴 fire-and-forget)
savePayment/cancel은 failure 블록에서 it.isTimeout이면 timeoutCallback()(Slack 알림)을 호출하고 예외를 던진다(AmadeusndcClient.kt:271-274, 454-456). isTimeout 정의(ClientSupport.kt:206-210)는 매우 넓다:
val isTimeout: Boolean
get() = exception is SocketTimeoutException
|| exception is SocketException
|| exception is IOException // ← 거의 모든 IO 오류
|| exception.message?.contains("timeout", ignoreCase = true) == trueisTimeout이 IOException까지 포함 → "타임아웃 아님"도 타임아웃 알림이 갈 수 있다
IOException은 연결 거부, EOF, 응답 파싱 전 끊김 등 광범위하다. 실제 결제가 안 갔는데 “발권 타임아웃” Slack이 오거나, 그 반대도 가능. 운영자는 이 알림만 보고 “결제됐을 수도 있으니 수동 확인”을 해야 한다.
그리고 발권/예약 실패 시 공통 보상로직 cancelAsync(AmadeusndcCancelService.kt:45-88):
fun cancelAsync(...) {
CoroutineScope(Dispatchers.IO).withLaunch {
delay(5000) // ← 5초 대기 후
try {
val booking = amadeusndcClient.retrieveByPnr(pnr)
... 취소 ...
} catch (e: Exception) {
slackService.sendCancelFail(...) // 실패해도 Slack만
throw e // ← 코루틴 안에서 던져봐야 호출자엔 안 감
}
}
}보상취소는 fire-and-forget — 성공 보장이 없다
cancelAsync는CoroutineScope(Dispatchers.IO).withLaunch로 즉시 반환하고, 5초 뒤 백그라운드에서 취소를 시도한다. 호출자(book/issue)는 이미 원래 예외를 던지고 끝났으므로 취소가 성공했는지 모른다. 취소가 실패하면sendCancelFailSlack만 가고throw e는 코루틴 컨텍스트에서 소실된다(async-coroutines의AdapterCoroutineExceptionHandler로만 잡힘). 결과적으로 “발권 실패 + 취소도 실패”면 미정리 예약(orphan PNR)이 남고 운영자가 Slack 보고 손으로 치워야 한다. delay(5000)은 Amadeus 측 상태 전파 대기용으로 추정.
3.5 발권 후 티켓 폴링 — 최대 3회 2초 간격
AmadeusndcTicketingService.issue() (AmadeusndcTicketingService.kt:57-67)는 결제 후 티켓이 즉시 안 붙을 수 있어 withBlocking으로 최대 3번 retrieveByPnr를 다시 친다. 3번 후에도 티켓이 없으면 TICKETING_FAILED_PASSENGER_HAS_NO_TICKET → catch에서 cancelAsync.
retrieveByPnr 자체가 또 3번 재시도 → 폴링×재시도 = 최대 9회
폴링 루프 안의
retrieveByPnr는 3.1의@Retryable(maxAttempts=3)이다. 최악의 경우 발권 후 조회가 3(폴링)×3(재시도)=9회까지 호출될 수 있고, 각 재시도 backoff 2초 + 폴링 delay 2초가 누적되어 응답이 수십 초로 늘어날 수 있다.@Retryable은 프록시 기반이므로 같은 빈 내부 호출에선 안 먹지만, 여기선 서비스→클라이언트 빈 경계를 넘으므로 적용된다.
4. 운임·통화·세금·수수료 계산 함정 (가장 치명적)
4.1 convertPriceDropLast — 문자열 절삭으로 통화 소수점 처리 (반올림 없음)
CurrencyConvertible.convertPriceDropLast (CurrencyAmount.kt:8-22):
fun convertPriceDropLast(currencyMap: Map<String, Int>): Long {
val convertedPrice = value.toPlainString().replace(".", "") // "1234.56" → "123456"
val digitsFromEnd = currencyMap[code] ?: 0 // 통화별 소수 자릿수
return if (digitsFromEnd >= convertedPrice.length) {
0 // ← 전부 깎이면 0
} else {
convertedPrice.dropLast(digitsFromEnd).toLong() // 끝에서 N자리 제거
}
}소수점을 "지운 뒤 뒤를 자른다" — 반올림이 없어 절사된다
BigDecimal을 정수로 만들 때 점을 제거하고 통화 소수자릿수만큼 뒤를 버린다. 예: KRW(0자리)는 그대로, USD/EUR(2자리)는 끝 2자리 절사. 문제는 반올림이 아니라 절사(truncation)라는 점. 또한value.toPlainString()이"100.5"(소수 1자리)인데 currencyMap[code]=2면"1005".dropLast(2)="10"이 되어 자리수 불일치 시 값이 망가진다.code가 currencyMap에 없으면digitsFromEnd=0→ 소수점만 제거한 거대한 정수가 나온다.
currencyMap은 응답 metadata에서 옴 — null이면 NPE
곳곳에서
this.response.metadata!!.currencyMap(OrderViewRS.kt:83,91,OfferPriceRS.kt:55등)으로 강제 언랩(!!)한다. NDC 응답에 Metadata가 없으면 NPE. 이 NPE는 ApiException이 아니라 retryable=null이므로retrieveByPnr경로에선 3번 재시도된다(3.2).
4.2 toBooking의 totalPrice 변환이 두 메서드에서 다르다 (일관성 버그 의심)
같은 파일 OrderViewRS.kt인데:
// toBooking (정상조회) — 라인 91
totalPrice = order.totalPrice?.totalAmount?.convertPriceDropLast(currencyMap = ...)
// toReissueBooking (재발행) — 라인 144
totalPrice = order.totalPrice?.totalAmount?.value?.toLong() // ← convert 안 함!toReissueBooking은 통화 변환 없이
.value.toLong()— BigDecimal 그대로 절사
toBooking은 통화 소수자릿수를 고려해 변환하지만,toReissueBooking(OrderViewRS.kt:144)은value.toLong()로 BigDecimal을 그냥 정수화한다(소수부 버림). 두 메서드가 만드는Booking.totalPrice의 스케일이 다르다. 재발행 예약의 totalPrice를 다른 곳과 비교하면 단위가 어긋날 수 있다. 같은 필드인데 변환 규칙이 다른 건 명백한 잠재 버그/불일치.
4.3 tax 필드에 surcharge를 합산해서 넣는다
Passenger.toPassenger의 Fare 생성(Passenger.kt:63-90):
val tax = fareDetail.price.tax?.totalAmount?.convertPriceDropLast(...) ?: 0
val surcharge = fareDetail.price.totalAmount?.detailCurrencyPrice?.fees
?.sumOf { it.totalAmount.convertPriceDropLast(...) }
?: offerPriceInfo?.offerItems?...?.surcharge // ← 폴백: pricing 결과에서 매칭
?: 0
Fare(
airPrice = fareDetail.price.baseAmount.convertPriceDropLast(...),
tax = tax + surcharge, // ← tax에 surcharge 합산
...
surcharge = surcharge, // surcharge는 따로도 보관
)tax = 실제세금 + surcharge — 더블카운트/혼동 위험
Fare.tax에는 세금 + surcharge가 들어가는데Fare.surcharge에도 같은 surcharge가 별도로 들어간다.Fare.totalPrice = airPrice + tax(Fare.kt:20-21)이므로 totalPrice에는 surcharge가 (tax를 통해) 포함된다. 그런데 누군가airPrice + tax + surcharge로 합산하면 surcharge가 두 번 계산된다. surcharge 폴백 매칭(Passenger.kt:70-73)은identityCode && airPrice && tax가 모두 같은 OfferItem을 찾는데, 동일 운임 승객이 여러 명이면 엉뚱한 surcharge가 붙을 수 있다.
4.4 fuelCharge 추출이 validatingCarrier로 분기 (SQ 예외)
Passenger.kt:79-88:
fuelCharge = fareDetail.price.tax?.taxInfos
?.filter {
if (validatingCarrier == "SQ") { it.taxCode == "YQ" } // SQ는 YQ만
else { it.taxCode == "YQ" || it.taxCode == "YR" } // 나머지는 YQ+YR
}
?.sumOf { it.amount.convertPriceDropLast(...) } ?: 0싱가포르항공(SQ)만 YR 제외 — 항공사별 세금코드 특수처리
일반적으로 YQ/YR이 유류할증료지만 SQ는 YR을 유류할증으로 보지 않는다. PaxFareProduct.toPassengerFare(
PaxFareProduct.kt:41-65)에도 같은 SQ 분기가 있어 SQ는tax = tax + qCharge,fuelCharge = qCharge로 별도 계산한다. 항공사가 추가될 때마다 이런 하드코딩 분기를 사람이 확인해야 한다. singaporeair NDC 분석은 singaporeair-ndc 참고.
4.5 qCharge는 그냥 0 — 미확인 항목
Passenger.kt:90:
qCharge = 0, // qCharge 항목은 0원으로 들어가는지 체크가 필요하다.코드 주석이 "확인 필요"라고 적어둔 미해결 항목
orderview 경로에서 qCharge를 항상 0으로 넣는다. 주석 그대로 “0원이 맞는지 확인 안 됨”. 만약 NDC 응답에 별도 qCharge가 있는데 무시되고 있다면 운임 누락이다. (참고: PaxFareProduct 경로에선 qCharge를 monetaryDetails에서 추출한다 — 경로별 불일치.)
4.6 환불 수수료 역산 — 5단계 산수 (calculateRefundFare)
DeleteOrderItem.calculateRefundFare (DeleteOrderItem.kt:23-49):
val originalAirPrice = originalItem.amount.convertPriceDropLast(...) ?: 0
val originalTax = originalItem.taxSummary?.totalTaxAmount?.convertPriceDropLast(...) ?: 0
val originalSurcharge = fare.surcharge ?: 0
val unusedTax = diffItem.taxSummary?.totalTaxAmount?.convertPriceDropLast(...) ?: 0
val refundAmount = abs(diffItem.amount.convertPriceDropLast(...)!!) // ← !! 강제
val penaltyPrice = penaltyDifferential?.amount?.convertPriceDropLast(...) ?: 0
val originalTotal = originalAirPrice + originalTax + originalSurcharge
val usedTax = originalTax - unusedTax
val usedSurcharge = originalTotal - penaltyPrice - usedTax - refundAmount // ← 나머지를 surcharge로
return fare.withRefundInfo(
refundFee = penaltyPrice.takeIf { it > 0 },
expectedRefundAmount = refundAmount,
usedTax = usedTax + usedSurcharge
)usedSurcharge를 "잔여 = 전체 - 페널티 - usedTax - 환불액"으로 역산한다
환불 금액을 직접 받지 못하고 차액(Differential)으로 역산하는 구조라, 위 5개 값 중 하나라도 통화 변환이 어긋나면(4.1) usedSurcharge가 음수가 되거나 환불액이 틀어진다.
differentialAmountDue!!,originalOrderItemDifferential!!(DeleteOrderItem.kt:25,31)는 강제 언랩이라 응답에 해당 노드가 없으면 NPE.refundAmount = abs(...)!!도 마찬가지.
4.7 환불 후 sanity check — refundFee ≤ airPrice가 아니면 전체 폐기
getCancelableTypeDetail의 REFUND 분기(AmadeusndcClient.kt:411-413):
.takeIf { refundPassengers ->
refundPassengers.all { (it.fare?.refundFee ?: 0) <= (it.fare?.airPrice ?: 0) }
}한 승객이라도 페널티 > 항공운임이면 refunds 전체가 null이 된다
takeIf가 false면refunds = null이 되어CancelableTypeDetail(action=REFUND, refunds=null)이 된다. 비정상 페널티(역산 오류 포함)를 거르는 안전장치지만, 거른 후 호출자에게 “왜 null인지” 정보가 없다. 환불 가능인데 금액이 안 나오는 미스터리 상황이 될 수 있다.
4.8 reissue 가격 검증 — prepaidPrice 일치 강제
AmadeusndcTicketingService.reissue() (AmadeusndcTicketingService.kt:105-115):
val pricedTotal = passengerFares.sumOf { (it.total + (it.carrierFee ?: 0)) * it.travellerIds.size }
if (prepaidPrice != null && cardInfo == null && prepaidPrice != pricedTotal) {
throw InternationalAdapterException(RETICKETING_FAILED_BY_MISMATCH_PRICE, ...)
}선결제 금액과 재산정 금액이 1원이라도 다르면 재발행 실패
total = airPrice + tax에 carrierFee를 더하고 승객수를 곱한다. 통화 절사(4.1)로 1원 단위가 어긋나면 정당한 재발행이 막힐 수 있다. cardInfo가 있으면(즉시 카드결제) 이 검증을 건너뛴다.
4.9 reissueDetail — 금액 감소 시 재발행 차단 + 스케줄 미변경 차단
AmadeusndcFlightSearchService.reissueDetail() (AmadeusndcFlightSearchService.kt:176-251):
- 음수 airPrice/tax가 있으면(
< 0)REISSUE_NON_CHANGEABLE_FARE_SCHEDULE로 “결제금액 감소 → 재발행 불가, 항공사 사이트 이용” 메시지(:179-208). - 출/도착지가 같고 편명·날짜·등급이 동일하면
NON_CHANGEABLE_SCHEDULES(:228-243).
재발행은 "출/도착지 변경 불가"가 전제 → 출도착지로 기존 스케줄을 매칭
주석(
:229)대로 출/도착지가 안 바뀐다는 전제 하에departure==... && arrival==...로 원 스케줄을 찾는다. 만약 동일 구간을 여러 번 왕복하는 복잡한 여정이면 이 매칭이 잘못된 세그먼트를 집을 수 있다.remainServiceIds로 유지 스케줄을 빼는 로직(:213-225)도 serviceId 집합 비교라 정밀해야 한다.
5. 인코딩·암호화·날짜·시간대 함정
5.1 PhoneNormalizer — 정규화 실패 시 예외, 한국번호 전용
PhoneNormalizer.toStandard (FormatUtils.kt:7-37)는 +82/82/0 시작 한국 번호만 처리한다. 그 외는 null 반환. 그런데 Passenger.toPassenger(Passenger.kt:107-114):
mobile = contactInfo?.phones?.firstOrNull()?.phoneNumber?.let {
PhoneNormalizer.toStandard(it) ?: throw InternationalAdapterException(PARSE_FAILED, "invalid phone number format: $it")
}외국 전화번호가 들어오면 조회/예약이 PARSE_FAILED로 죽는다
NDC 예약에 외국 연락처(예:
+1...,+65...)가 있으면toStandard가 null → 예외. 즉 한국 번호가 아닌 PNR은 retrieve/toBooking 단계에서 통째로 실패한다.format()(:30-36)도 10자리→앞에 0, 11자리(0시작)만 허용이라 매우 빡빡하다. 이 예외는 retryable=null → 3번 재시도되지만 번호는 변하지 않으니 무의미하게 3번 죽는다.
5.2 국가코드 ISO3 → ISO2 변환 분기 (길이 3 체크)
Passenger.kt:118-128:
issueNationality = if (it.issuingCountryCode?.length == 3)
CountryUtils.iso3CodeToIso2Code(it.issuingCountryCode) else it.issuingCountryCode
nationality = when {
it.citizenshipCountryCode == null -> requestPassenger?.passport?.nationality
it.citizenshipCountryCode.length == 3 -> CountryUtils.iso3CodeToIso2Code(...)
else -> it.citizenshipCountryCode
}길이 3이면 무조건 ISO3로 간주 — 변환 실패는 CountryUtils에 위임
길이로만 판단하므로 잘못된 코드가 길이 3이면 변환을 시도한다. citizenship이 null이면 요청 시 받은 승객 정보로 폴백(
requestPassenger)하는데, retrieve 단독 호출(passengers=null)에선 폴백 소스가 없어 nationality가 null이 될 수 있다.
5.3 날짜 파싱 — ZonedDateTime vs LocalDateTime 혼용
paymentTimeLimit은ZonedDateTime.parse(it).toLocalDateTime()(OrderViewRS.kt:54,99) — 오프셋을 버리고 LocalDateTime화 → 타임존 정보 소실.carrierTimeLimit과paymentTimeLimit에 같은 값을 넣는다(OrderViewRS.kt:87-88). 둘이 의미상 다를 수 있는데 동일 처리.pnrCreatedAt = LocalDateTime.now()(OrderViewRS.kt:89) — 응답 파싱 시점의 서버 로컬 시각(타임존 미지정). PNR 실제 생성시각이 아니다.- 생년월일/만료일은
String.toLocalDate()(Passenger.kt:103,129), 포맷yyyy-MM-dd가정. 다른 포맷이면 예외.
now()가 두 종류 — 헷갈리지 말 것
DateExtensions.now()는 기본Asia/Seoul(DateExtensions.kt:12)이지만LocalDateTime.now()(OrderViewRS)는 시스템 기본 존이다. 또 PasswordDigest는now("UTC"). 시간대 기준이 코드 위치마다 다르므로 시각 비교/로그 해석 시 주의.
5.4 reshop 응답에 shoppingResponseId 없으면 “DUMMYVALUE”
OrderReshopRS.kt:100,138:
ndcShoppingId = this.shoppingResponse?.shoppingResponseId ?: "DUMMYVALUE",누락 시 "DUMMYVALUE"라는 가짜 ID로 진행 → 후속 호출 실패 가능
NDC offer는 shoppingResponseId로 다음 단계를 묶는데, 누락 시 문자열
"DUMMYVALUE"를 넣고 계속 진행한다. 이게 후속 OrderChange/Pay에 그대로 쓰이면 공급사가 거부할 가능성이 높다. 조용히 넘어가서 나중에 터지는 전형적 지뢰.
5.5 SOAP 직렬화 후 xmlns="" 강제 제거
AmadeusndcClient.kt:855, GpsClient.kt:56: .replace(" xmlns=\"\"", ""). Jackson XML이 자식 요소에 빈 네임스페이스를 붙이는 걸 문자열 치환으로 제거한다.
문자열 치환 기반 XML 후처리 — 깨지기 쉽다
정당한
xmlns=""가 본문 텍스트에 있으면(가능성 낮지만) 같이 지워진다. Jackson 버전이 바뀌어 빈 네임스페이스 출력 형태가 달라지면 SOAP가 invalid해질 수 있다.
6. 재발행·환불·부분취소 엣지케이스
6.1 취소 가능 여부 판정 — 체크인/발권 상태 게이트
AmadeusndcCancelService.cancelable() (AmadeusndcCancelService.kt:90-111):
- 한 명이라도
isCheckIn→CANCEL_UNABLE_BY_ALREADY_CHECK_IN. unissued(전원 무티켓) →VOID.- 그 외 → reshop으로 VOID/REFUND 산정.
- VOID인데
NonVoidableAirline.contains(validatingCarrier)→CANCEL_UNABLE.
unissued 판정은 "모든 승객이 티켓 없음"
Booking.unissued = passengers.all { it.tickets.isNullOrEmpty() }(Booking.kt:44-45). 일부만 발권된 부분발권 상태는unissued=false라 REFUND 경로로 간다. 부분발권/부분취소 시나리오는 reshop deleteOrderItem 매칭(identificationKey기준,AmadeusndcClient.kt:405)에 의존하므로 키 불일치 시 누락된다.
6.2 VOID + NonVoidableAirline 조합 차단 (두 곳에 중복)
같은 검사가 cancelable()(:102-108)과 cancelAsync()(:58-65) 두 군데 있다. 동기 취소와 보상취소 경로 모두 막지만, 두 곳을 따로 유지보수해야 하는 중복.
6.3 retrieveAndCancelIfNeed — 검증 실패 시 보상취소
AmadeusndcBookingService.retrieveAndCancelIfNeed() (AmadeusndcBookingService.kt:184-200)는 validateScheduleStatus=false로 조회 후 validateSchedulesCancellation()을 따로 부른다. 실패하면(UN/TK) booking이 null이 아닐 때만 cancelAsync. 컨트롤러의 /confirm, /repricing이 이 경로(AmadeusndcBookingController.kt:74-96).
confirm/repricing이 결항을 만나면 자동으로 취소를 건다
이름이 “retrieveAndCancelIfNeed”인 만큼, 단순 조회처럼 보이는
/confirm·/repricingAPI가 결항 스케줄 발견 시 부수효과로 보상취소를 트리거한다. 신입이 “조회 API인데 왜 취소가 나가지?”라고 당황할 수 있는 지점. cancelAsync는 fire-and-forget(3.4)이라 응답엔 취소 결과가 안 나온다.
6.4 예약 직후 총액 검증 불일치 시 즉시 취소
AmadeusndcBookingService.validateMismatchedTotalPrice() (AmadeusndcBookingService.kt:69-86):
val totalPrice = this.totalPrice ?: return // ← totalPrice null이면 검증 스킵
val totalPassengerPrice = this.passengers.sumOf { it.fare?.totalPrice ?: 0 }
if (totalPrice != totalPassengerPrice) {
cancelService.cancelAsync(...) // 불일치 → 취소
throw InternationalAdapterException(BOOKING_FAILED, "Price mismatch: ...")
}totalPrice가 null이면 검증을 건너뛴다 + 통화 스케일 불일치 위험
order.totalPrice가 없으면(OrderViewRS.kt:91) totalPrice=null →?: return으로 검증 자체를 통과시킨다. 또한totalPrice(4.1 변환)와passengers.fare.totalPrice(airPrice+tax, surcharge 포함 4.3)가 다른 경로로 계산되므로, 통화/surcharge 처리가 어긋나면 정상 예약이 “price mismatch”로 취소될 수 있다. 검증 통과(취소) 모두 위험.
6.5 reissueBooking 티켓 필터 — 신규 티켓만 추출
toReissueBooking (OrderViewRS.kt:122-128)은 원 예약에 없던 티켓만 새 티켓으로 본다:
ticketDocumentInfo.passengerReference == passenger.id
&& originTickets?.none { ticketDocumentInfo.hasTicket(it.ticketNumber) } == trueoriginTickets가 null이면
== true가 false → 신규 티켓이 0건이 될 수 있다
originTickets?.none {...} == true는 originTickets가 null일 때null == true→ false라 해당 승객의 티켓이 전부 필터링돼 빈 리스트가 된다. 원 예약 조회가 티켓 없이 왔다면 재발행 결과에 티켓이 안 붙는다.
6.6 divide(분리발권) — 성인-유아 짝 검증
AmadeusndcBookingService.divide()/validate() (AmadeusndcBookingService.kt:88-146)는 요청 승객이 실제 예약에 있는지, 유아-성인 짝이 함께 분리되는지 검증한다. first { }(:123,129)를 쓰므로 매칭 실패 시 NoSuchElementException(ApiException 아님 → 재시도 대상).
6.7 reissueSearch — 유지 스케줄 그룹 계산
AmadeusndcClient.reissueSearch() (AmadeusndcClient.kt:508-510)는 booking.schedules!!(강제 언랩)를 groupSequence로 묶어 변경 OD가 아닌 그룹을 remainSchedules로 남긴다. schedules가 null이면 NPE.
7. 검색 결과 필터링 함정 (조용한 누락)
검색은 여러 단계 필터로 결과를 깎는데, 각 단계가 조용히 운임을 버린다.
| 필터 | 위치 | 버리는 것 |
|---|---|---|
isNonAir() | AmadeusndcClient.kt:156-162 | NonAirEquipment(버스/기차 등) 포함 구간 |
hasNonTicketableCarrier() | AmadeusndcClient.kt:164-170 | MH(말레이시아항공) + 마케팅캐리어≠MH 조합 |
filterByNotNullFlightTimes() | AmadeusndcFlightSearchService.kt:124-134 | flightTime이 null인 세그먼트 포함 운임 |
filterByUnexposedFareItinerary() | AmadeusndcFlightSearchService.kt:114-122 | 비노출(SOLD_OUT 이력) 운임 |
| OD 일치 필터 | AmadeusndcFlightSearchService.kt:77-84 | 첫 출발/마지막 도착이 요청 OD와 불일치 |
MH 하드코딩 + 비행시간 누락 로그만 남기고 버림
hasNonTicketableCarrier는validatingCarrier == "MH"만 검사하는 항공사별 하드코딩(AmadeusndcClient.kt:165).filterByNotNullFlightTimes는 버린 건수를 INFO 로그(:131)로만 남긴다 — 운영 중 “검색 결과가 적다”면 이 로그를 봐야 한다. 비행시간 null은 데이터 품질 문제라 공급사 응답을 의심해야 한다.
8. 코드 내 TODO / FIXME / 주석 경고 전수 인용
코드에 박힌 미해결 표시들 (grep 전수)
신입은 이 주석들이 “끝나지 않은 작업”임을 인지하고, 관련 버그를 만나면 여기부터 의심하라.
| 파일:라인 | 주석 | 의미 |
|---|---|---|
Passenger.kt:90 | qCharge = 0, // qCharge 항목은 0원으로 들어가는지 체크가 필요하다. | qCharge 항상 0 (4.5) |
Fare.kt:16 | val usedAirPrice: Long? = null, //todo: 추후 usedAirPrice 케이스 확인후 새로 정의 | usedAirPrice 미정의 |
PaxFareProduct.kt:86 | // TODO: 멀티페어 조회를 병렬조회 하지 않고 additional 객체로 받아 같이 조회 할 때 이부분에서 무조건 예외 처리되는데 확인 필요! | 멀티페어+additional 시 NOT_FOUND_PASSENGER_TYPE 예외 |
OrderReshopRS.kt:100,138 | ?: "DUMMYVALUE" | shoppingResponseId 폴백 가짜값 (5.4) |
FareOptionType.kt:9 | PSB(...) //…문서 최종본 받아서 확인 필요하다. -> Farebasis가 성인과 다른 유아운임 조회 옵션 | 옵션 의미 미확정 |
FareOptionType.kt:10-12 | RF/MNR/MST //…확인 필요 | 미사용/미확정 운임옵션 3종 |
PaxFareProduct.getPassengerTypeIdentificationCode의 TODO가 실제 예외를 던진다
PaxFareProduct.kt:82-98에서 ptc(승객타입코드)가 1개가 아니면 INF/CH/CHD 또는 supplier별 식별타입을 찾고, 못 찾으면NOT_FOUND_PASSENGER_TYPE을 던진다(:93-96). 주석(:86)이 “additional 객체로 멀티페어를 같이 조회하면 무조건 예외 난다”고 경고. 즉 멀티페어 병렬조회를 단일조회로 바꾸면 이 지점이 터진다. 멀티페어 구조를 건드리기 전 반드시 확인.
9. 자가진단 퀴즈
Q1.
retrieveByPnr로 이미 취소된 PNR을 조회하면 몇 번 호출될까? "ORDER ALREADY CANCELLED" 문자열 매칭이 깨졌다고 가정하라.정답 보기
ALREADY_CANCELED_PNR에.noRetry()(AmadeusndcClient.kt:346)가 붙어 1번에 끝나지만, 문자열 매칭이 실패하면 일반RETRIEVE_FAILED(retryable=null)로 떨어져@Retryable(maxAttempts=3)(3.2)에 따라 2초 간격 3번 재시도된다. → 1.4, 3.2 참고.3번. 정상 동작 시엔
Q2. 발권 중
savePayment가 SocketTimeout으로 실패했다. 사용자 결제는 실제로 됐는지 알 수 있나? 시스템은 어떻게 동작하나?정답 보기
isTimeout이면 SlacksendTicketingTimeout알림이 가고(AmadeusndcClient.kt:271-274), 예외가 던져진 뒤issue()catch에서cancelAsync(5초 후 백그라운드 취소)가 걸린다(AmadeusndcTicketingService.kt:79-83). 취소도 실패하면sendCancelFailSlack만 남고 orphan PNR이 된다. 운영자가 수동 확인해야 한다. → 3.4 참고.알 수 없다.
Q3. KRW가 아닌 통화(USD, 소수 2자리)에서 운임이 미세하게 틀어진다. 어디를 봐야 하나?
정답 보기
CurrencyConvertible.convertPriceDropLast(CurrencyAmount.kt:8-22). 반올림 없이 소수점 제거 후 통화 자릿수만큼 뒤를 절사(truncation)한다. currencyMap(응답 metadata)에 통화 코드가 없으면 0자리로 처리돼 값이 폭증하고,toReissueBooking(OrderViewRS.kt:144)은 아예 변환 없이value.toLong()을 쓴다. → 4.1, 4.2 참고.
10. 교차 참조
- Amadeus NDC — 오퍼레이션 : 각 함정이 어느 흐름에서 발동하는지
- Amadeus NDC — 프로토콜·전문 : SOAP/WS-Security/통화·세금 전문 매핑
- 에러 처리 : ApiException / retryable / capture·noRetry 공통 모델
- 회복탄력성과 이벤트 : 서킷·리트라이·Slack 경보 철학(이 모듈은 서킷 미적용)
- 비동기·코루틴 : cancelAsync/withLaunch fire-and-forget, 코루틴 예외 소실
- 전역 지뢰 지도 : 11개 공급사 공통 지뢰와의 비교