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:91MessageDigest.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.retrieveByPnrAmadeusndcClient.kt:279-283maxAttempts=3, backoff 2000ms, exceptionExpression 조건부
NdcArtClient.getFareRulesNdcArtClient.kt:31maxAttempts=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.retryableBoolean?이며 기본값 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/cancelfailure 블록에서 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) == true

isTimeout이 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 — 성공 보장이 없다

cancelAsyncCoroutineScope(Dispatchers.IO).withLaunch즉시 반환하고, 5초 뒤 백그라운드에서 취소를 시도한다. 호출자(book/issue)는 이미 원래 예외를 던지고 끝났으므로 취소가 성공했는지 모른다. 취소가 실패하면 sendCancelFail Slack만 가고 throw e는 코루틴 컨텍스트에서 소실된다(async-coroutinesAdapterCoroutineExceptionHandler로만 잡힘). 결과적으로 “발권 실패 + 취소도 실패”면 미정리 예약(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 혼용

  • paymentTimeLimitZonedDateTime.parse(it).toLocalDateTime()(OrderViewRS.kt:54,99) — 오프셋을 버리고 LocalDateTime화 → 타임존 정보 소실.
  • carrierTimeLimitpaymentTimeLimit같은 값을 넣는다(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):

  1. 한 명이라도 isCheckInCANCEL_UNABLE_BY_ALREADY_CHECK_IN.
  2. unissued(전원 무티켓) → VOID.
  3. 그 외 → reshop으로 VOID/REFUND 산정.
  4. 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·/repricing API가 결항 스케줄 발견 시 부수효과로 보상취소를 트리거한다. 신입이 “조회 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) } == true

originTickets가 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-162NonAirEquipment(버스/기차 등) 포함 구간
hasNonTicketableCarrier()AmadeusndcClient.kt:164-170MH(말레이시아항공) + 마케팅캐리어≠MH 조합
filterByNotNullFlightTimes()AmadeusndcFlightSearchService.kt:124-134flightTime이 null인 세그먼트 포함 운임
filterByUnexposedFareItinerary()AmadeusndcFlightSearchService.kt:114-122비노출(SOLD_OUT 이력) 운임
OD 일치 필터AmadeusndcFlightSearchService.kt:77-84첫 출발/마지막 도착이 요청 OD와 불일치

MH 하드코딩 + 비행시간 누락 로그만 남기고 버림

hasNonTicketableCarriervalidatingCarrier == "MH"만 검사하는 항공사별 하드코딩(AmadeusndcClient.kt:165). filterByNotNullFlightTimes는 버린 건수를 INFO 로그(:131)로만 남긴다 — 운영 중 “검색 결과가 적다”면 이 로그를 봐야 한다. 비행시간 null은 데이터 품질 문제라 공급사 응답을 의심해야 한다.


8. 코드 내 TODO / FIXME / 주석 경고 전수 인용

코드에 박힌 미해결 표시들 (grep 전수)

신입은 이 주석들이 “끝나지 않은 작업”임을 인지하고, 관련 버그를 만나면 여기부터 의심하라.

파일:라인주석의미
Passenger.kt:90qCharge = 0, // qCharge 항목은 0원으로 들어가는지 체크가 필요하다.qCharge 항상 0 (4.5)
Fare.kt:16val 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:9PSB(...) //…문서 최종본 받아서 확인 필요하다. -> Farebasis가 성인과 다른 유아운임 조회 옵션옵션 의미 미확정
FareOptionType.kt:10-12RF/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" 문자열 매칭이 깨졌다고 가정하라.

Q2. 발권 중 savePayment가 SocketTimeout으로 실패했다. 사용자 결제는 실제로 됐는지 알 수 있나? 시스템은 어떻게 동작하나?

Q3. KRW가 아닌 통화(USD, 소수 2자리)에서 운임이 미세하게 틀어진다. 어디를 봐야 하나?


10. 교차 참조