Lufthansa — 지뢰요소

module-lufthansa pattern-error-handling api-ndc config-resilience

이 노트의 사용법

Lufthansa(LH) 모듈을 만지기 전에 반드시 한 번 통독하라. 여기 정리된 함정들은 대부분 “정상 응답처럼 보이지만 값이 틀리거나, 특정 캐리어/시나리오에서만 터지는” 종류라 코드 리뷰나 단위 테스트로 잘 안 잡힌다. 운임/세금/환불 계산 관련 항목은 운영 정산 사고로 직결되므로 [!danger] 표시를 우선 본다.

연계: 오퍼레이션별 동작 · SOAP 프로토콜 · 에러 처리 공통 · Resilience4j와 이벤트 전파 · 전체 지뢰 색인

LH는 Amadeus ART(NDC V17.2)를 SOAP로 감싼 구조다. 즉 “NDC 표준 메시지(AirShoppingRQ/OfferPriceRQ/OrderCreateRQ/OrderChangeRQ/OrderReshopRQ)“를 SOAP 봉투에 넣어 Amadeus가 운영하는 ART 게이트웨이로 보낸다. 그래서 함정도 두 층위에서 온다: (A) NDC 메시지 자체의 데이터 함정, (B) SOAP 헤더/인증/세션 함정.

flowchart TD
    T["Triple 예약"] --> C["LufthansaXxxController<br/>REST, /internals/LUFTHANSA/..."]
    C --> S["LufthansaXxxService<br/>application, 코루틴 일부"]
    S --> CL["LufthansaClient<br/>infrastructure - 모든 외부호출 단일 진입점"]
    CL -->|"soapRequestBodyConverter 로 NDC-RQ 를 SOAP 봉투로 감쌈"| ART["Amadeus ART<br/>Ocp-Apim-Subscription-Key 게이트웨이"]
    ART --> NDC["LH NDC"]

0. 한눈에 보는 지뢰 지도

#영역심각도한 줄 요약
1운임/통화high검색·발권 운임은 통화 소수자릿수(decimals)를 적용하지 않고 toLong() 직접 변환 — FareRule만 getAmount() 사용
2취소/환불highcancel() 반환값은 항상 0L (수수료 합산 로직 주석처리). 환불수수료 계산은 refundCalculate()의 MIN/MAX 구조 가정에 의존
3세션/오퍼highOfferPrice 오퍼ID 만료(325 Invalid or Expired Offer500 No fares found가 정상흐름에서 빈번. 검색→가격→예약 사이 시간초과 주의
4발권 실패 보상highissue() 실패 시 cancelAsync()로 5초 delay 후 비동기 취소 — 결과를 호출자가 기다리지 않아 미취소 PNR 잔존 가능
5validatingCarriermed"LH" 하드코딩 폴백, owner.take(2), "LX"→"LXA" 변환, retrieve 기본값 "LHG"가 코드마다 제각각
6시간대med모든 일정 시각을 timezone 없는 LocalDateTime으로 파싱. validateTimeGap 3시간 환승 판정, voidable today() 비교가 KST 기준
7dividemedLH는 divide(주문 분할) 미지원. 컨트롤러·클라이언트 코드는 살아있어 호출하면 325 에러
8인코딩/인증medSOAP 헤더에 ID/PW/대리점 비밀번호가 평문. xmlns="" 강제 제거 문자열 치환
9NPE/단언medresponse!!, first(), !! 다수. 응답 구조가 예상과 다르면 500
10부가서비스lowpurchaseAncillaries 결제는 항상 현금(CA) 0원, HN(대기) 상태 폴링, Po로 시작하는 orderItem 제외

1. 운임·통화·세금·수수료 계산 함정 (가장 위험)

검색/발권 운임은 통화 소수자릿수를 적용하지 않는다

LH 응답의 금액은 <CurrencyAmountValue Code="EUR">9000</CurrencyAmountValue> + 별도 <CurrencyMetadata MetadataKey="EUR"><Decimals>2</Decimals> 형태로, 9000은 실제 90.00 EUR를 의미한다(소수점 2자리). 이 변환을 담당하는 함수는 CurrencyAmountValue.getAmount(currencyInfos)이다 (infrastructure/response/CurrencyAmount.kt:22):

fun getAmount(currencyInfos: List<CurrencyMetadata>?): Long {
    return value.movePointLeft(currencyInfos?.find { it.metadataKey == code }?.decimals ?: 0).toLong()
}

그런데 검색/가격산정 운임은 이 함수를 거치지 않는다. infrastructure/response/Offer.kt:182-184toPassengerFare, DataList.kt:163-167의 booking fare는 모두 fareDetail.farePrice.toLong() / baseAmount.value.toLong()소수자릿수를 무시한 채 변환한다.

// Offer.kt:182  (검색·가격산정)
val price = fareDetail.farePrice.toLong()   // decimals 미적용
val tax   = fareDetail.fareTax.toLong()

반면 FareRule의 변경수수료(getChangeFeeRule)만 it.getAmount(currencyInfos)로 올바르게 적용한다 (Offer.kt:354, 405).

이 비대칭은 다음을 의미한다:

  • LH 정산 통화가 KRW(decimals=0)인 funnel에서는 우연히 맞는다 (movePointLeft(0) == 그대로). 그래서 한국 판매에서는 문제가 드러나지 않을 수 있다.
  • 만약 응답이 EUR/USD 등 소수 2자리 통화로 내려오는 funnel/시나리오가 생기면 운임이 100배 부풀려진다. 검색·발권 가격과 FareRule 변경수수료가 서로 다른 스케일로 표시되는 모순이 발생한다.

연료할증료(YQ)는 세금에서 코드로 골라낸다

FareDetail.fareFuelCharge (Detail.kt이 아니라 FareDetail.kt:25)는 taxes.taxes.filter { it.taxCode == "YQ" }로만 추출한다. LH가 유류할증을 YQ가 아닌 다른 코드(YR 등)로 보내면 fuelCharge=0이 되고 그 금액은 세금(tax) 총액에는 포함되지만 fuelCharge 항목에선 누락된다.

qCharge는 항상 0으로 하드코딩

Offer.kt:202, DataList.kt:169, Fare/PassengerFare 변환 전반에서 qCharge = 0. LH는 Q차지를 baseAmount에 녹여 보내므로 의도된 동작이지만, Q차지를 별도 항목으로 기대하는 다운스트림 정산 로직이 있으면 0으로 비어 보인다. 단언하지 말고 운임 합계(total = airPrice + tax)로만 검증하라.


2. 취소·환불·부분취소 엣지케이스

cancel()의 반환값은 의미가 없다 — 항상 0L

LufthansaClient.cancel() (infrastructure/LufthansaClient.kt:370-379)에 주석 그대로 인용:

// TODO 확인필요
// amount 의 데이터클래스 구조가 잘못된거 같아 확인해보려 했으나, 발권하고 하루 뒤에 취소를해도 orderCancelRS.response.changeFees 는 내려오지 않는다.
// amount 도 MIN, MAX 로 수수료의 최소/최대가 내려오는듯 하여 둘을 더하면 안될것 같다.
// 일단 이 리턴값은 사용하는 곳이 없어 0 을 리턴하도록 변경한다.
0L

즉 취소 수수료를 이 경로에서 절대 신뢰하지 마라. 환불 금액 산정은 별도 경로인 refundCalculate()가 담당한다.

예상 환불금액은 OrderReshopRQ로 미리 계산하고, 보낼 때는 비운다

취소 플로우는 두 단계다 (application/LufthansaCancelService.kt):

  1. expectedCancel()retrieve()로 booking 조회 후 refundCalculate()(OrderReshopRQ, autoExchRequestInd=false)로 환불액·수수료 계산.
  2. cancel()(OrderCancelRQ)로 실제 취소.

그런데 OrderCancelRQ.of()expectedRefundAmount = null로 강제한다 (infrastructure/request/OrderCancelRQ.kt:45):

// TODO 예상 환불 금액을 보내지 말라고 서티 응답 받음
expectedRefundAmount = null,

환불액을 요청에 실어 보내면 LH가 거부한다. ExpectedRefundAmount DTO는 정의돼 있지만 항상 null로 보내야 한다.

void 가능 여부 판정이 두 가지 응답 위치에 걸쳐 있다

refundCalculate() (LufthansaClient.kt:489-493)의 voidable 판정:

response.dataList?.disclosures?.any { it.listKey == "VOID" }
    ?: response.reshopOfferGroup?.reshopOffers?.any { it.disclosureRef == "VOID" }
    ?: false

LH가 VOID 정보를 DataLists.Disclosures 또는 ReshopOffer.DisclosureRef 중 어디에 담는지가 케이스마다 다르다. 둘 다 없으면 false(환불 처리)로 떨어진다. 발권 당일 취소인데 REFUND로 분류되는 회귀가 생기면 이 분기부터 의심하라.

환불 금액 계산은 MIN/MAX·reshopDue 구조 가정에 강하게 의존

refundCalculate() (LufthansaClient.kt:508-527)는 passenger별로 deleteOfferItem.reshopDifferential에서 다음을 뽑아 계산한다:

val originAirPrice = originalOrderItem?.total?.amount?.value ?: ZERO
val originTax      = originalOrderItem?.taxes?.total?.value ?: ZERO
val unUsedTax      = reshopDue?.taxes?.total?.value ?: ZERO
val unUsedAirPrice = reshopDue?.byAirline?.total?.amount?.value?.minus(unUsedTax) ?: ZERO
val penaltyPrice   = reshopDifferential?.penaltyAmount?.total?.amount?.value ?: ZERO
// expectedRefundAmount = unUsedAirPrice + unUsedTax
// usedTax            = originTax - unUsedTax
// usedAirPrice       = originAirPrice - unUsedAirPrice - penaltyPrice

가정: ① “남은(미사용) 금액 = reshopDue”, ② “byAirline.total에 세금 포함되어 있어 빼야 함”. 이 가정이 깨지면 음수 환불액이나 잘못된 수수료가 나온다. 모두 .value ?: ZERO필드 누락 시 조용히 0으로 처리되어 환불액이 과소·과대 계산될 수 있다. 여기서도 decimals 미적용이다(BigDecimal을 그대로 .toLong()).

발권 안 된 PNR을 reshop으로 취소하려 하면 막힌다

PricingParameters.autoExchRequestInd (infrastructure/request/OrderReshopRQ.kt:171-175)의 주석:

If the indicator is set to "True" and no tickets have been issued for the Order, an error message will be returned.
If no tickets have been issued for the Order, this indicator must be sent with the value "False".

코드는 환불계산/재발권검색 모두 false로 고정. 발권 전/후 상태를 잘못 가정해 True로 바꾸면 미발권 PNR에서 에러가 난다.

"이미 체크인" 환불 불가 메시지는 문자열 매칭으로 분기

cancel()refundCalculate() 모두 응답 메시지에 "Checked In status not valid for Auto-Refund"가 들어있으면 CANCEL_UNABLE_BY_ALREADY_CHECK_IN을 던진다 (LufthansaClient.kt:358, 474). LH가 이 영문 문구를 바꾸면 분기가 깨져 일반 CANCEL_FAILED로 떨어진다. 에러코드가 아니라 메시지 본문 substring에 의존하는 취약한 매칭이다.


3. 재발행(Reissue) 엣지케이스

재발행 시 차액 결제를 보내지 않는다 (정책 미확정 상태로 박제)

OrderChangeRQ.ofReissue() (infrastructure/request/OrderChangeRQ.kt:185-200)는 차액 결제 코드를 주석으로 남긴 채 Payment.ofCache()(현금 0원)만 보낸다:

// LH 돌려줘야 하는 환불금액이 생길 경우에 대한 처리방법 수정 필요
/**
 * 써티 진행 중 금액에 대한 정보는 넘길필요 없다고 피드백 옴.
 * 스케줄 변경 시 발생한 차액에 대해서 추가결제를 할 필요가 없는건지?
 * 트랙스페이스 문의 진행중
 */
Payment.ofCache()

스케줄 변경으로 차액(추가요금/환불)이 생기는 재발행 케이스의 결제 처리가 미정의 상태다. 차액이 발생하는 노선에서 재발행을 붙이기 전 반드시 정책 확인.

재발행 검색은 다중좌석/다중운임 불가, 첫 cabin·첫 공항만 사용

OrderReshopRQ.ofReissueSearch() (OrderReshopRQ.kt:122-135):

  • 주석 “루프트한자 TrueReshop 다중 좌석 요청 불가” — 좌석 수를 못 넘김. OrderReshopRS.toReshopFareItineraries는 그래서 seatCount = 9로 임의 설정(OrderReshopRS.kt:103, 주석 “어드민 호출 응답으로 9 설정”).
  • cabin은 cabins.first().lufthansa 하나만, 출/도착 공항은 airports.first()만 사용 → 멀티공항 도시(예: 런던 LHR/LGW)에서 의도와 다른 공항으로 검색될 수 있다.
  • delete 대상 orderItem의 serviceRetainRequestIds는 “변경되지 않는 구간”의 서비스ID를 보존하기 위해 계산되는데, originDestinationServices!! non-null 단언이 있어 구조가 다르면 NPE.

재발행 결과 티켓 선택 로직

LufthansaTicketingService.reissue() (application/LufthansaTicketingService.kt:95-101)는 passenger별로 reissueTicketStatus == ISSUE인 티켓을 우선 고르고, 없으면 tickets.first()를 쓴다. 재발행 후 여러 티켓 문서가 섞여 내려올 때 잘못된 티켓을 노출할 위험이 있어, 다운스트림에서 티켓번호를 다시 검증하는 게 안전하다.


4. @CircuitBreaker / Resilience4j 동작과 폴백

LH는 @Retry/@Bulkhead가 없다 — 검색에 @CircuitBreaker 하나뿐

lufthansa 패키지 전체에서 Resilience4j 어노테이션은 검색 컨트롤러의 @CircuitBreaker 단 한 곳이다 (interfaces/controller/internals/LufthansaSearchController.kt:28):

@CircuitBreaker(name = "lufthansaSearch", fallbackMethod = "searchFallback")
@PostMapping
fun search(...): ResponseEntity<List<FareItineraryView>> { ... }

설정은 공유 search baseConfig를 상속한다 (application.yml:42-47, 61-62):

search:
  slidingWindowSize: 180
  slidingWindowType: TIME_BASED
  permittedNumberOfCallsInHalfOpenState: 10
  minimumNumberOfCalls: 30
  waitDurationInOpenState: 120s
  failureRateThreshold: 35
lufthansaSearch: { baseConfig: search }

함의:

  • 발권/예약/취소/환불/부가서비스에는 서킷브레이커가 없다. ART 게이트웨이가 느려지면 이 경로들은 보호 없이 그대로 타임아웃을 맞는다.
  • 서킷이 OPEN이면 searchFallback(CallNotPermittedException)이 호출되어 빈 리스트를 반환한다 (검색결과 없음으로 보임, 에러로 안 보임). Datadog span에 supplier.circuit-breaker=OPEN 태그만 남긴다. “검색결과 0건”이 실제 매진인지 서킷 OPEN인지 구분하려면 span 태그/로그를 봐야 한다.

검색 타임아웃은 "실패"가 아니라 "빈 결과"로 흡수된다

LufthansaClient.search() (LufthansaClient.kt:138-144)는 실패 분기에서 failure.isTimeout이면 예외를 던지지 않고 emptyList()를 반환한다. 그래서 검색 타임아웃은 서킷브레이커 실패율에 잡히지 않는다(예외가 fold success로 흡수되므로). 검색이 느리게 매진처럼 보이는 현상의 원인이 될 수 있다.

타임아웃 클라이언트 분리

LufthansaClientClientSupport(searchTimeout=15000, defaultTimeout=60000)로 초기화 (LufthansaClient.kt:44-45). 검색만 .client(searchClient)(15초)를 명시적으로 쓰고, 나머지(발권/예약/취소 등)는 기본 60초 클라이언트다. 발권 같은 무거운 작업이 60초 안에 못 끝나면 타임아웃 → 보상 로직(아래) 발동.

이벤트/상태 전파 전반은 resilience-and-events 참고.


5. 발권 실패 보상 — 비동기 취소의 함정

issue() 실패 시 5초 뒤 비동기 취소 — 결과를 기다리지 않는다

LufthansaTicketingService.issue() (application/LufthansaTicketingService.kt:42-62):

val booking = try {
    lufthansaClient.savePayment(supplierIdentificationKey, timeoutCallback = { slackService.sendTicketingTimeout(...) })
} catch (e: Exception) {
    cancelAsync(pnr, supplierIdentificationKey, validatingCarrier)  // fire-and-forget
    throw e
}

cancelAsync (:105-123)는 CoroutineScope(Dispatchers.IO).withLaunch { delay(5000); lufthansaClient.cancel(...) }별도 스코프에서 5초 지연 후 취소한다. 위험요소:

  • 호출자는 이 취소 완료를 기다리지 않는다(throw e가 먼저 반환). 비동기 취소가 또 실패하면 Slack(sendCancelFail)만 울리고 PNR이 미취소 상태로 잔존한다.
  • delay(5000)은 LH가 발권 직후 곧바로 취소를 거부하는 것을 피하려는 경험적 대기다. 이 대기 동안 프로세스가 종료/재배포되면 취소가 유실된다.
  • savePayment 자체의 타임아웃 시에는 timeoutCallback으로 별도 Slack(sendTicketingTimeout)을 보내지만, 그 후에도 동일하게 throwcancelAsync 경로를 탄다. 타임아웃인데 실제로는 발권 성공했을 수 있는 상태(LH 응답만 늦음)에서 비동기 취소가 정상 발권을 취소할 위험. 코루틴 예외 처리는 async-coroutines · error-handling 참고.

cancel 타임아웃은 Slack으로만 알림

LufthansaClient.cancel() (:382-388)의 실패 분기에서 failure.isTimeout이면 slackService.sendCancelFailTimeout(...) 호출 후 예외를 던진다. 취소 타임아웃 = 취소 성공 여부 불확실 상태이므로, Slack 알림을 받으면 LH에서 PNR 상태를 수동 확인해야 한다.


6. validatingCarrier / owner 코드 함정

validatingCarrier 처리가 코드마다 다르다

위치처리비고
OrderViewRS.kt:67this.order.owner.take(2)”LX의 경우 owner가 LXA로 와서 앞 2글자만” (주석)
AirShoppingRS.kt:142,198marketingCarrier.airlineId ?: "LH"”검색 응답에 validatingCarrier 없음” → “LH” 하드코딩 폴백
OrderReshopRS.kt:95offer.validatingCarrier ?: "LH"동일 폴백
LufthansaBookingService.kt:32validatingCarrier: String = "LHG"// TODO 확인 필요 기본값
LufthansaAncillaryService.kt:156validatingCarrier.takeIf { it != "LX" } ?: "LXA"부가서비스에선 LX→LXA 반대 변환

즉 검색에서는 owner를 못 받아 “LH”로 가정하고, 예약 후 retrieve에서는 owner를 take(2)로 깎고, 부가서비스에서는 다시 “LXA”로 부풀린다. Swiss(LX), Austrian, Brussels 등 LH 그룹 캐리어를 다룰 때 이 변환들이 일관되지 않으면 retrieve/cancel이 “주문 없음”으로 떨어진다. LX 그룹 캐리어 회귀가 생기면 이 표를 먼저 본다.

검색 validatingCarrier가 "LH" 폴백이라는 점의 의미

검색결과의 validatingCarrier가 실제 발권 캐리어와 다를 수 있다(첫 마케팅 캐리어로 추정). 가격산정(OfferPriceRS)에서 pricedOffer.validatingCarrier로 보정되긴 하지만, 검색단계 표시값을 정산 키로 쓰면 안 된다.


7. 시간대(timezone)·날짜 함정

모든 항공편 시각을 timezone 없는 LocalDateTime으로 파싱

LH 응답의 출발/도착 시각은 각 공항의 현지시각이다. 코드는 이를 timezone 정보 없이 "날짜T시각:00".toLocalDateTime()으로 파싱한다 (DataList.kt:85-87, 525, 558-559). 그래서:

  • validateTimeGap(DataList.kt:76-89) 은 2개 구간 자유여행 결합 시 firstArrival.plusHours(3) <= secondDeparture로 환승 가능 여부를 판정하는데, 도착지/출발지 timezone이 다르면 3시간 갭이 실제 갭과 어긋난다. 예: 프랑크푸르트 도착(CET) → 인천 출발(KST, +8h)을 단순 LocalDateTime 비교하면 갭이 8시간 부풀거나 줄어든다.
  • 환승시간(connectingTimeoverNightStay·addDay 계산도 모두 현지시각 기준 단순 차이라, 날짜경계를 넘는 노선에서 ±1일 오차가 날 수 있다.

voidable(당일 발권 취소 가능) 판정이 KST 오늘과 LH 발권일을 비교

OrderViewRS.toBooking()voidable (infrastructure/response/OrderViewRS.kt:99-107):

ticketDocument.dateOfIssue!!.toLocalDate().isEqual(today()) && ...

today()Asia/Seoul 기준이다 (support/util/DateExtensions.kt:9). 그런데 dateOfIssue는 LH/발권 시스템의 날짜다. 두 timezone이 다른 시각대에 발권하면(예: 한국 자정 직후) “당일 발권”인데도 날짜가 어긋나 voidable=false가 되어 void 대신 환불로 처리될 수 있다. 또한 dateOfIssue!! non-null 단언이라 누락 시 NPE.

paymentTimeLimit / carrierTimeLimit 파싱

OrderViewRS.kt:51-53은 paymentTimeLimit만 ZonedDateTime.parse(it.timestamp).toLocalDateTime()timezone을 반영해 KST 변환(toLocalDateTime은 KST로 환산)한다. 반면 발권시한 carrierTimeLimit(:91-93)은 it.timestamp.toLocalDateTime()timezone 없이 파싱한다. 같은 응답 안에서도 timezone 처리가 섞여 있어, 두 시한을 함께 비교/표시할 때 기준이 다르다.


8. divide(주문 분할) — 미지원 기능이 살아있다

LH는 divide를 지원하지 않는다. 호출하면 325 에러

LufthansaClient.divide() 위 주석 (LufthansaClient.kt:425-431) 그대로:

LH 루프트한자는 divide API 기능을 지원하지 않습니다. (주문 분할 사용 불가)
Controller 영역까지 구현되어 있으나 호출 시 아래와 같은 오류메세지가 응답됩니다.
<Error Type="DME" ShortText="230000119" Code="325">
    The request cannot be processed at this time because this functionality has not been enabled for this carrier.
</Error>

그런데 LufthansaBookingController.divide(:131-147)와 LufthansaBookingService.divide(:73-95)는 정상 라우팅된다. 분할 후 it.pnr!!로 재조회까지 시도하므로, 운영에서 분할 시나리오를 LH에 태우지 않도록 상위(Triple)에서 막아야 한다. divide 진입 시 validate()로 승객 일치/유아쌍 검증은 통과시킨 뒤 LH가 325로 거부한다 → 사용자에겐 DIVIDE_FAILED로 보임.


9. 인코딩·인증·SOAP 봉투 함정

SOAP 헤더에 ID/비밀번호/대리점 비밀번호가 평문으로 들어간다

soapRequestBodyConverter (LufthansaClient.kt:866-907)는 SOAP 헤더 iden 엘리먼트에 인증정보를 평문 속성으로 박는다:

childElement("iden") {
    attribute("u") { lufthansaApiProperties.userName }
    attribute("p") { lufthansaApiProperties.password }       // 평문 비밀번호
    attribute("agtpwd") { lufthansaApiProperties.agencyPassword } // 평문 대리점 비밀번호
    ...
}

따라서 요청 본문 전체 로깅(.log(true))을 켜면 자격증명이 로그로 샌다. 검색은 enableSearchLog.or(logging)(:106)로 로깅이 켜질 수 있으니, 운영에서 LH 요청 로깅을 켤 때는 마스킹 여부를 반드시 확인. 자격증명은 AWS Secrets Manager(resources/supplier/lufthansa.yml)에서 주입된다.

xmlns="" 강제 문자열 치환

봉투 마지막에 .replace(" xmlns=\"\"", "") (LufthansaClient.kt:905). Jackson XML이 자식 엘리먼트에 빈 네임스페이스를 붙이는 것을 제거하는 문자열 후처리 해킹이다. 새 DTO를 추가했는데 LH가 “잘못된 XML/네임스페이스” 에러를 주면, 이 치환이 모든 케이스를 못 잡는다는 점을 의심하라.

TransactionIdentifier는 매 요청 랜덤 UUID

모든 RQ가 UUID.randomUUID().toString().replace("-", "")로 trace id를 만든다 (AirShoppingRQ.kt:47 등). 주석 // TODO: LH 난수값 말고 트레이스 ID 값 넣을까?(AirShoppingRQ.kt:46)대로, 분산 추적(Datadog) trace id와 연결돼 있지 않다. LH에 장애 문의 시 우리 trace와 LH TransactionIdentifier 매칭이 안 된다.

검색 요청의 미구현 옵션들

AirShoppingRQ.of()에 주석처리된 미지원/미구현 옵션이 있다 (AirShoppingRQ.kt:89-100): “LH 지정 제외”(특정항공사 필터), “LH 다중 좌석선택 불가”(cabin 다중 지정 불가). 검색 요청에 cabin을 못 실으므로, cabin 필터는 응답 후 checkedCabins로 클라이언트 측 필터링한다(AirShoppingRS.kt:59). 즉 cabin 필터가 LH가 아닌 우리 코드 책임이다.


10. 공급사 에러코드 ↔ ErrorMessage 매핑 (전수)

LH 응답 에러는 <Errors><Error Code=".." Type=".." Status="..">메시지</Error></Errors> 형태이며, 각 RS의 checkError { code, message -> ... }첫 번째 에러만(errors.first()) 본다 (OrderViewRS.kt:26-31 등 모든 RS 동일). 다중 에러 시 두 번째 이후는 무시된다.

오퍼레이션 (메서드)코드/메시지 조건매핑 ErrorMessage비고
search325, 719(무시, 정상으로 처리)325=재고없음, 719=운임없음 (LufthansaClient.kt:111-114)
search그 외 코드SEARCH_FAILEDOD 구간정보 부착
search (transport)timeoutemptyList() 반환실패로 안 잡음 (:139-141)
getFareRule메시지에 No fares found for booking classSOLD_OUT (StatusInvalidException)매진 → 검색키 제거+미노출 저장 (LufthansaFareRuleService.kt:44-49)
getFareRule그 외FETCH_FARE_RULES_FAILEDtoFareRules null이면 동일
pricing모든 에러PRICING_FAILED325 Invalid or Expired Offer 자주 발생 (DTO 주석 OfferPriceRS.kt:32-34)
booking모든 에러BOOKING_FAILED.capture() 없음(주의)
retrieveorderItems 비었음ALREADY_CANCELED_PNR(:286-291)
retrieve그 외 에러RETRIEVE_FAILED
changeApisorderItems 비었음ALREADY_CANCELED_PNR실패 분기는 RETRIEVE_FAILED (:333)
changeApis그 외PASSENGER_CHANGE_FAILED
cancel메시지 Checked In status not valid for Auto-RefundCANCEL_UNABLE_BY_ALREADY_CHECK_IN
cancel그 외CANCEL_FAILEDtimeout 시 Slack
savePayment(발권)모든 에러TICKETING_FAILEDtimeout 시 timeoutCallback
divide모든 에러DIVIDE_FAILEDLH 미지원→항상 325
refundCalculateChecked In...CANCEL_UNABLE_BY_ALREADY_CHECK_IN
refundCalculate그 외CALCULATE_CANCEL_FEE_FAILED
reissueSearch(errors 리스트)REISSUE_SEARCH_FAILED공급사 메시지 join 노출, 실패분기는 RETICKETING_FAILED (:566-584)
reissue모든 에러RETICKETING_FAILED
searchExtraBaggages에러SEARCH_ANCILLARY_FAILED / transport SEARCH_BAGGAGE_ANCILLARY_FAILED
searchSeats에러SEARCH_SEAT_ANCILLARY_FAILED
bookAncillaries에러BOOKING_ANCILLARY_FAILED
retrieveForAncillary325(pnr being updated)/그 외RETRIEVE_FAILED두 분기 모두 RETRIEVE_FAILED (의미상 분기만 있고 동작 동일, :805-818)
retrieveForAncillaryservice.status == HNANCILLARY_BOOKING_PENDING부가서비스 대기 (:821-822)
purchaseAncillaries에러RETRIEVE_FAILED(!) / transport PURCHASE_ANCILLARY_FAILEDsuccess분기 에러를 RETRIEVE_FAILED로 매핑 — 라벨 오류 의심 (:853)

알려진 에러코드/메시지 카탈로그 (DTO 주석에 박제된 실측값)

  • OfferPriceRS.kt:32-34: Code="500" No fares found for booking class., Code="325" Invalid or Expired Offer ..., <Error>Socket recv failed. Error 10054</Error>(소켓 단절).
  • LufthansaClient.kt:111-112: 325 No inventory available, 719 No fares available.
  • retrieveForAncillary 325 pnr being updated(:806) — PNR 갱신 중 동시성 충돌.

325는 컨텍스트에 따라 “재고없음 / 오퍼만료 / divide미지원 / PNR갱신중” 등 전혀 다른 의미를 갖는다. 코드만 보고 분기하면 안 되고 메시지를 함께 봐야 한다. 공통 에러 처리 규칙은 error-handling 참조.

.capture() 호출 유무의 비일관성

대부분의 예외는 .capture()(Sentry 캡처, support/exception/Exceptions.kt)를 붙인다. 그러나 booking(:253), changeApis(:321), retrieve(:283), searchExtraBaggages(:671), searchSeats(:735), bookAncillaries(:771)의 일부 에러 분기에는 .capture()가 빠져 있다. 이 경로 실패는 Sentry에 안 잡힐 수 있다 — 운영 모니터링 사각지대.


11. NPE·강제단언(!!first() 위험 지점

응답 구조가 예상과 다르면 500으로 죽는다

LH 모듈은 NDC 응답을 신뢰하는 !!·.first()가 매우 많다. 대표적 위험 지점:

  • OrderViewRS.toBooking(): orderViewRS.response!!, dataList.flightSegments.first { ... }(:70), couponInfos!!(:102), dateOfIssue!!(:101).
  • AirShoppingRS: offersGroup!!(:51), shoppingResponse!!.id(:138,194), firstOffer.offerItems!!, fareDetails!!.first()(:122 등).
  • DataList.toPassenger: individual!!(:127), individual.birthdate.toLocalDate()(:156 — 유아 생일 누락 시), Gender.valueOf(...uppercase())(:130 — 미지원 gender 값 시 IllegalArgument), Title.valueOf(nameTitle)(:133 — enum에 없는 title이면 예외).
  • OfferPriceRS.toPricingInfo: pricedOffer!!.parameter!!.passengerTypeCodePrices!!(:53), offerItemPassengerTypeMap[it.id]!!(:64) — passengerType 매핑 누락 시 NPE.
  • refundCalculate: response.reshopOfferGroup!!(:495) — VOID 케이스에서 reshopOfferGroup이 없으면 NPE (voidable=true인데도 passenger 매핑에서 죽을 수 있음).

NDC는 캐리어/운임마다 응답 형태 편차가 크므로, 새 캐리어/노선/운임을 붙일 때 위 단언들이 1순위 깨짐 후보다.


12. 부가서비스(Ancillary) 함정

부가서비스 결제는 항상 현금 0원

OrderChangeRQ.ofPurchaseAncillaries()(OrderChangeRQ.kt:281-287)와 ofBookAncillariesPayment(type="CA", Cash, amount=0.00)만 보낸다. 코드 주석 // cardInfo: ... Cash 결제에서 Card 결제로 변경(LufthansaAncillaryService.kt:154)대로 카드결제 미구현. 실제 정산은 후속(현금/대리점) 처리에 의존한다.

구매 대상 orderItem 필터가 prefix "Po"에 의존

LufthansaAncillaryService.purchaseAncillaries(:188-190)는 retrieve 후 orderItem 중 id.startsWith("Po").not()인 것만 구매 대상으로 보낸다. “Po”로 시작하는 orderItem = (이미 결제된/항공권) 항목이라는 LH ID 컨벤션 가정이다. LH가 ID 규칙을 바꾸면 잘못된 항목을 결제하거나 누락한다.

부가서비스 대기(HN) 상태 처리

retrieveForAncillary(:821)는 첫 service 상태가 HN(Holding/대기)이면 ANCILLARY_BOOKING_PENDING을 던진다. bookAncillariesretrieveForAncillarypurchaseAncillaries 순서에서 LH가 즉시 확정하지 않으면 PENDING으로 빠진다. 순차 호출 사이 상태 전이를 기다리는 명시적 폴링은 없다 — PENDING이면 그 자체로 실패 반환.

avail 조회는 SEAT/EXTRA_BAGGAGE만 병렬(pmap) 조회

searchAvailAncillary(:43-55)는 AncillaryType.availOf(LUFTHANSA)pmap(병렬)으로 돌며 SEAT/EXTRA_BAGGAGE만 처리, 나머지는 null. 좌석/수하물 둘 다 호출되므로 가격산정(pricing) → seats/baggages 검색이 한 요청에 묶여 ART 게이트웨이 부하가 크다(서킷브레이커 없음, §4 참고).


13. 캐시·검색키 함정

FareItinerary는 Gzip+JSON으로 Redis에 캐시

LufthansaRedisConfiguration(configuration/RedisConfiguration.kt)은 GzipRedisSerializer<FareItinerary>(Jackson2JsonRedisSerializer)로 직렬화한다. FareItinerary는 Serializable이며 내부 모델(Schedule/Fare 등)이 모두 serialVersionUID=1L. FareItinerary 구조를 바꾸면 기존 캐시 역직렬화가 깨질 수 있다(JSON 기반이라 필드 추가엔 관대하지만, 타입 변경/제거는 위험). 캐시 키는 CacheKeyGenerator.generateFareItineraryKey(LUFTHANSA).

매진 발견 시 검색키 제거 + 미노출 등록이 비동기

FareRule 조회에서 SOLD_OUT이 뜨면(LufthansaFareRuleService.kt:44-49) removeFlightSearchKeysaveUnexposedFareItinerary각각 별도 CoroutineScope(Dispatchers.IO).withLaunch 로 fire-and-forget 실행한다. 이 비동기가 실패해도 사용자에겐 SOLD_OUT 예외만 전달되고, 다음 검색에서 같은 매진 운임이 다시 노출될 수 있다(미노출 등록 유실 시). 코루틴 예외는 async-coroutines 참고.


14. APIS(승객정보) 변경 함정

null 값을 보내면 기존 값이 지워진다 (GDS와 반대)

LufthansaPassengerService.changeApis(application/LufthansaPassengerService.kt:21-26) 주석:

// GDS와 다르게 null 값이 생기면 기존 값이 지워진다. (email은 DB 컬럼이 없어서 리트리브 것을 덮는다.)
passenger.copy(email = passenger.email ?: originPassengers.first { ... }.email)

NDC OrderChange는 부분 업데이트가 아니라 보낸 그대로 덮어쓴다. email은 retrieve 값으로 보존하지만, passport/stayInfo/mobile은 변경 대상이 아니면 요청에서 제외(needChange 필터) 하는 방식으로 우회한다. 새 필드를 APIS 변경에 추가할 때 이 “보내면 덮어쓴다” 시맨틱을 반드시 고려하라.

APIS 변경은 변경 전/후 데이터를 모두 보내야 한다

OrderChangeRQ.ofApis(:93-99) 주석 “apis 변경시 변경 전/후 데이터를 모두 보내야한다”. ContactInformation을 변경후(idPostfix=“N”)·변경전(idPostfix="") 두 벌로 만들고, contactInfoReferenceCI{key}N / CI{key} 규칙으로 매칭한다(PassengerChange.ofContactInfo, :424-430). 이 ID 접미사 규칙이 틀리면 LH가 변경 전 레코드를 못 찾아 실패한다.


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

파일:라인주석의미
LufthansaBookingService.kt:32// TODO 확인 필요 (retrieve 기본 validatingCarrier=“LHG”)기본 캐리어코드 미확정
LufthansaClient.kt:370-379// TODO 확인필요 (취소 수수료 0L 반환)§2 — 수수료 합산 불가, 데이터구조 의심
OrderCancelRQ.kt:45// TODO 예상 환불 금액을 보내지 말라고 서티 응답 받음§2 — expectedRefundAmount=null 강제
OrderChangeRQ.kt:186-198(재발행 차액 결제 미정, 트랙스페이스 문의 중)§3 — 차액 결제 정책 미확정
OrderViewRS.kt:56,58// TODO: LH F1 PNR, // TODO: LH Airline PNRpnr/subPnr 식별 규칙 임시
TicketDocInfo.kt:91// TODO: 서티 당시 연결 항공편 체크용...현재 PrimaryDocInd 값이옴연결편 식별 필드가 바뀜(InConnectionDocNbr→PrimaryDocInd)
AirShoppingRQ.kt:46// TODO: LH 난수값 말고 트레이스 ID 값?§9 — trace 미연동
AirShoppingRQ.kt:89,95// TODO: LH 지정 제외, // TODO: LH 다중 좌석선택 불가§9 — 항공사필터/cabin 다중지정 불가
TicketStatus.kt:113// TODO KOREANAIR 발권 작업시 확인 (LH는 매핑 있음)LH는 ETC=hashSetOf("ETC") 외 명시 매핑 (참고)

자가 점검 퀴즈

Q1. 검색에서 EUR 운임 9000이 왔다. 화면에 표시되는 가격은?

Q2. 발권 호출이 60초 타임아웃됐다. 시스템은 무엇을 하나? 위험은?

Q3. 검색 결과가 0건이다. 매진인지 장애인지 어떻게 구분하나?

Q4. Swiss(LX) 예약을 retrieve하니 "주문 없음"이 뜬다. 어디를 보나?


관련 노트