⚠️ 전체 지뢰요소 마스터

arch-cross-cutting pattern-pitfalls pattern-incident

이 문서의 용도

air-intl-adapter 전체에서 “여기서 사고난다”를 한눈에 보는 지뢰 지도다. 11개 공급사 + 공통 레이어에서 발굴한 모든 함정을 중복제거 → 분류 → severity 우선순위화했다. 각 항목은 영향(왜 위험)·재현(언제 터짐)·예방(어떻게 막나)을 담고, 더 깊은 분석은 출처 노트로 링크한다.

신입은 먼저 onboarding-map을 읽고 자기 모듈로 오기 전에 이 문서의 🔴 high 항목만 훑어라. 시니어는 코드 리뷰/장애 대응 시 체크리스트로 쓴다.


0. 이 시스템에서 “사고”는 어떻게 드러나는가

이 어댑터에는 메시지큐가 없다. 결제/발권/취소가 깨졌을 때 사고가 표면화되는 경로는 4가지뿐이며, 이 사실 자체가 가장 큰 함정이다.

flowchart TD
    INC["사고 발생"]
    INC -->|"경로 1 동기 예외"| H1["RestExceptionHandler"]
    H1 --> R1["응답 code 는 ErrorMessage.name<br/>조건부 Sentry"]
    R1 --> N1["비-ApiException 은 무조건 Sentry<br/>ApiException 은 .capture 있어야 Sentry"]

    INC -->|"경로 2 서킷브레이커 OPEN"| C2["검색만 fallback emptyList<br/>200 OK"]
    C2 --> D2["Datadog span<br/>supplier.circuit-breaker=OPEN<br/>5xx 아님"]

    INC -->|"경로 3 fire-and-forget 코루틴 실패"| F3["보상취소 / 검색키정리 / DSR"]
    F3 --> H3["AdapterCoroutineExceptionHandler"]
    H3 --> S3["Sentry 로깅만<br/>호출자에게 무전파"]

    INC -->|"경로 4 Slack 경보"| K4["SlackService.sendXxxFail"]
    K4 --> M4["서비스가 수동 호출<br/>best-effort<br/>전송 실패도 조용히 사라짐"]
  • 경로 1: 동기 예외 → RestExceptionHandler → 응답 code(ErrorMessage.name) + (조건부)Sentry. 비-ApiException은 무조건 Sentry / ApiException은 .capture()가 있어야 Sentry.
  • 경로 2: 서킷브레이커 OPEN → 검색만 fallback emptyList()(200 OK) → Datadog span supplier.circuit-breaker=OPEN (5xx 아님!).
  • 경로 3: fire-and-forget 코루틴 실패(보상취소/검색키정리/DSR) → AdapterCoroutineExceptionHandler → Sentry 로깅만 (호출자 무전파).
  • 경로 4: Slack 경보(SlackService.sendXxxFail) → 서비스가 수동 호출, best-effort, 전송 실패도 조용히 사라짐.

한 줄 요약

“치명 오류 = 자동 Slack”은 오해다. 자동 경보는 Sentry뿐이고 그조차 ApiException은 .capture() 호출이 있어야 잡힌다. Slack은 서비스가 직접 호출하는 수동 경보이며 전송조차 보장되지 않는다. 자세히는 error-handling, resilience-and-events.


분류별 색인


1. 세션·상태 (stateful) — GDS 세션 누수

🔴 stateful 세션 미종료 → GDS 동시세션 한도 소진 → 전체 예약 마비 (Amadeus/Sabre)

영향: Amadeus(TOPAS)·Sabre는 PNR 트랜잭션을 stateful 세션 토큰 위에서 수행한다. 예외 발생 시 세션을 닫지 않으면 GDS 측 동시세션/라이선스가 고갈되어 신규 예약·발권·취소가 전부 막힌다. 이 어댑터에서 가장 광범위한 장애 원인. 재현: 신규 stateful 오퍼레이션을 추가하면서 catch 블록의 세션 종료 패턴을 누락하거나, 한 StatefulBuilder를 여러 스레드가 공유할 때. 예방:

  • Amadeus는 모든 catch가 if (session?.transactionStatusCode == TransactionStatusCode.InSeries) { end { amadeusClient.signOut(...) } }로 끝나야 한다(AmadeusBookingService.kt:172, :267, :315, :372, :399; AmadeusTicketingService.kt:115/489; AmadeusCancelService.kt:341; AmadeusRefundService.kt:159).
  • Sabre는 getSessionToken → ... → finally { closeSessionToken(token) } 쌍이 필수(SabreClient.kt:310-369, SabrePassengerService.kt:68-70).
  • 두 경우 모두 시그니처 복붙이 누수를 재생산한다. 새 stateful 메서드는 반드시 finally/catch 종료 패턴을 검증하라. 출처: amadeus-pitfalls, amadeus-operations, sabre-pitfalls, sabre-operations, async-coroutines

🔴 Start 직후(InSeries 진입 전) 예외 시 Amadeus 세션 누수

영향: signOut은 transactionStatusCode == InSeries일 때만 호출된다. start{} 안에서 InSeries로 전이되기 전에 예외가 나면 signOut이 안 불려 서버 세션이 열린 채 방치된다. 위 패턴을 정확히 복붙해도 이 코너케이스는 남는다. 재현: 세션 시작 직후 인증 실패/네트워크 오류. 예방: 세션 시작 직후 구간의 예외 처리를 별도로 검토. TOPAS 동시세션 모니터링 지표를 두라. 출처: amadeus-pitfalls

🔴 클라이언트 주도 SequenceNumber + 비-thread-safe Session (Amadeus)

영향: StatefulBuilder.withSession(StatefulBuilder.kt:16)이 서버가 아닌 클라이언트가 직전 응답 seq+1로 SequenceNumber를 매긴다. Session은 data class라 thread-safe하지 않다. 하나의 StatefulBuilder를 여러 스레드가 공유하면 시퀀스 불일치로 세션이 깨진다. 재현: stateful 세션을 코루틴으로 병렬화하거나(book()carrierTimeLimit null → delay(3000) 재조회가 같은 세션 재사용, AmadeusBookingService.kt:134), 세션 객체를 공유할 때. 예방: 세션은 호출 순서 보장 + 미공유가 필수. 같은 세션 안 재조회는 delay 직렬화에만 의존(락 아님). 출처: amadeus-protocol, amadeus-pitfalls

🔴 LCC/REST 좌석점유 토큰(pnrSessionId/PssToken) 만료 (Tway/Jinair/Jejuair)

영향: LCC는 GDS 세션 대신 단명 상태토큰을 쓴다. T’way pnrSessionId(MarkSeats 응답), Jin Air pnrSessionId(markSeat), Jeju Air PssToken(HTTP 응답 헤더로 발급)이 만료되면 검색→예약 사이에 좌석점유가 풀려 매진 처리되거나 인증 실패한다. 재현: markSeat~createBooking 사이 사용자 지연. 또는 pnrSessionId!!/PssToken!! non-null 단언 지점에서 토큰 null이면 즉시 NPE(JinairClient.kt:369, JejuairClient 다수). 예방: Jeju Air 변경계 작업은 반드시 retrieveWithToken(pnr)으로 시작(검색 응답엔 토큰 없음). 토큰 만료는 Jeju OTAUSV900처럼 별도 코드로 위장되니 .retry() + 상위 @Retryable 짝을 깨지 마라. 출처: tway-pitfalls, jinair-pitfalls, jejuair-protocol, jejuair-pitfalls

🟡 NDC/Galileo는 stateless — Version/ID 체인으로 상태 전파 (혼동 주의)

영향: Galileo·NDC 계열에는 GDS PNR 세션이 없다(SessionContext는 코드에 0건). 대신 본문의 PNR/UniversalRecordLocator + Version 속성(Galileo), 또는 검색에서 받은 OfferID/OfferItemID/ShoppingResponseID 3종(Singaporeair NDC)으로 상태를 전파한다. Amadeus의 stateful 모델로 착각하면 디버깅 방향이 틀어진다. 재현: Galileo 수정/취소 시 deleteElements(AIR_PRICING) 후 재조회 없이 호출하면 Version 불일치로 거부(GalileoTicketingService.kt:74). Singaporeair는 ID 하나만 어긋나도 컨텍스트 거부. 예방: Galileo는 매 수정 사이 getBooking으로 Version 갱신. Singaporeair는 OrderID "SQ_{pnr}" 접두사까지 정확해야 함. 출처: galileo-overview, galileo-protocol, singaporeair-protocol

🟡 SSR/OSI 삭제는 반드시 큰 id부터(역순) (Sabre)

영향: Sabre는 SSR/OSI 삭제 시 뒤 id가 앞으로 밀린다. 작은 번호부터 지우면 다른 승객의 여권/연락처가 삭제된다. SabrePassengerService.consecutiveNumbersToRange[1,2,3,5,7,8,9,10][7-10,5,1-3] 역순 범위로 변환해 큰 번호부터 deleteSsr/deleteOsi(SabrePassengerService.kt:73-92). 재현: 이 변환 로직을 단순화/리팩터링하면서 정렬 순서를 바꿀 때. 예방: 삭제 순서 로직을 절대 단순 오름차순으로 바꾸지 마라. SabreQueueService.remove의 무조건 재시도도 이 stateful 순회와 결합돼 위험. 출처: sabre-overview, sabre-operations, sabre-pitfalls

🟡 SQ/NDC 검색 토큰 캐시 6일 TTL — 무효화 시 죽은 토큰 계속 반환 (Sabre)

영향: Sabre 검색 토큰(SABRE-TOKEN/SABRE-ACCESS-TOKEN)은 Redis TTL 6일. Sabre가 만료 전 토큰을 무효화하면 캐시가 만료까지 죽은 토큰을 내줘 검색 전량 실패한다. 401 자동 재발급 없음. 재현: Sabre 측 토큰 정책 변경/장애 복구 직후. 예방: 토큰 캐시 키 수동 무효화 절차를 알아둔다. 출처: sabre-pitfalls


2. 비동기·코루틴·보상취소

🔴 fire-and-forget 보상취소 — "API는 성공인데 PNR/결제는 안 닫힘" (전 공급사)

영향: 발권 실패 시 결제취소/예약취소가 CoroutineScope(Dispatchers.IO).withLaunch{ delay(5000); ... }로 분리 실행된다. 호출자에게 즉시 원 예외가 던져지고 보상은 백그라운드에서 진행되어, 결제는 승인됐는데 항공권 미발권(이중청구) 또는 취소 API 성공 응답인데 실제 PNR 미취소 같은 불일치가 발생한다. Amadeus/Sabre/Galileo/NDC/LCC 전부 동일 패턴. 재현: 보상 코루틴 자체가 실패하거나, delay(5000) 동안 재배포/스케일인되면 보상이 통째로 유실(인메모리, 영속큐 아님). 고아 PNR 잔존. 예방: 백그라운드 예외는 AdapterCoroutineExceptionHandler가 Sentry 로깅만 한다. 유일한 사후 추적 수단은 Slack(sendPaymentCancelFail/sendCancelFail). 신뢰성이 필요하면 PollingUtils.polling처럼 Redis에 상태를 남겨라. 대표 위치: AmadeusTicketingService.kt:217, AmadeusCancelService.kt:311/427, SabreTicketingService.kt:111-168, GalileoTicketingService.kt:235, KoreanairTicketingService.kt:89-155(결제 5초/예약 10초 시간차), LufthansaTicketingService.kt:105, SingaporeairTicketingService.kt:97, TwayTicketingService.kt:48, JinairTicketingService.kt:37, JejuairTicketingService.kt:114, AmadeusndcCancelService.kt:45. 출처: async-coroutines, resilience-and-events, 각 amadeus-pitfalls·sabre-pitfalls·galileo-pitfalls·koreanair-pitfalls·lufthansa-pitfalls·singaporeair-pitfalls·tway-pitfalls·jinair-pitfalls·jejuair-pitfalls·amadeusndc-pitfalls

🔴 withAsync에는 CoroutineExceptionHandler가 없다 — async 예외는 핸들러를 안 탄다

영향: withBlocking(:16)·withLaunch(:25)AdapterCoroutineExceptionHandler()를 주입하지만 withAsync(:33)는 주입하지 않는다(CoroutineExtensions.kt). 코루틴 표준상 async/Deferred의 예외는 핸들러를 무시하고 await() 지점에서 재던져진다. 그래서 pmap(:42-46)이 각 코루틴을 직접 try/catch로 감싸 AsyncFail로 변환한다. 재현: pmap 밖에서 withAsync를 직접 쓰고 await/try-catch를 빠뜨리면 예외가 유실되거나 형제 코루틴을 취소한다. 예방: 병렬 작업은 pmap을 쓰고, 직접 withAsync를 쓸 땐 반드시 await + 예외 수거. SupervisorJob이 한 자식 실패가 형제를 취소하지 않게 격리한다. 출처: async-coroutines, error-handling

🔴 pmap + getOrEmpty()는 실패를 조용히 삼킨다 (검색 무결과 위장)

영향: pmap은 작업 예외를 던지지 않고 AsyncFail로 포착한다. getOrEmpty()(:62)로 받으면 실패가 successes에서 빠진 채 사라지고, getOrThrow()(:64-68)는 첫 예외만 재던진다. 검색(Search)은 getOrEmpty(관대) — OD 조합 일부가 SOAP 타임아웃/에러로 실패해도 성공분이 1건 이상이면 예외 없이 성공분만 반환된다. “왜 일부 항공편이 안 보이지”의 원인. 재현: 멀티-OD/멀티시티 검색에서 일부 조합만 실패할 때. 왕복인데 편도만, 일부 노선 누락 등. 예방: 돈/문서 작업(CashReceipt/Refund/FareRule)은 getOrThrow(엄격), 검색은 getOrEmpty(관대)로 분기한다. 신규 오퍼레이션에서 정책을 잘못 고르면 매출 손실(검색 과실패) 또는 정산 누락(돈 작업 silent swallow). 일부 실패는 warn 로그로만 드러나므로 디버깅 시 로그 확인 필수. 출처: async-coroutines, error-handling, request-flow

🔴 CoroutineScope(Dispatchers.IO).withLaunch 분리 스코프 — 셧다운 시 작업 유실 (60+ 사용처)

영향: 매번 부모 없는 새 CoroutineScope를 만들고 Job을 보관하지 않아 구조화된 동시성 밖이다. 요청/애플리케이션 생명주기에 묶이지 않으므로 graceful shutdown 시 대기되지 않는다. SlackClient, 각 CancelService, PollingUtils 등 60곳 이상. 재현: pnrCancelAsyncdelay(5000) 동안 재배포되면 취소가 영영 실행되지 않아 고아 PNR이 남는다(AmadeusCancelService.kt:311). 예방: 신뢰성이 필요하면 Redis에 상태를 남기는 패턴(PollingUtils.polling)을 써라. 출처: async-coroutines

🟡 디스패처 없는 withBlocking{}는 호출(톰캣 워커) 스레드에서 실행

영향: 검색은 withBlocking(Dispatchers.IO)지만, 다수 booking/cancel/ticketing은 디스패처 없는 withBlocking{}를 쓴다(AmadeusBookingService.kt:134, SabreBookingService.kt:108, GalileoBookingService.kt:82, KoreanairTicketingService.kt:114, LufthansaAncillaryService.kt:33/65 등). runBlocking에 디스패처가 없으면 호출 스레드에서 그대로 돈다. 재현: stateful 세션 내 단일 호출이면 의도적이지만, 병렬 I/O인데 디스패처를 빠뜨리면 직렬에 가깝게 느려진다. 또 withBlocking은 톰캣 워커를 점유한 채 pmap 병렬 호출을 기다린다. 예방: 병렬화 의도면 Dispatchers.IO 명시. MDCContext()를 빠뜨리면 자식 코루틴 로그에서 TraceId/채널 MDC가 사라진다. 출처: async-coroutines, request-flow

🟡 SabrePassengerService는 withBlocking 래퍼 대신 runBlocking 직접 사용

영향: changeApis(:12-71)는 stateful 세션 내 SOAP 호출(삭제는 큰 id부터 역순)을 묶기 위해 runBlocking을 직접 쓴다. AdapterCoroutineExceptionHandler/MDCContext가 안 붙어 코루틴 레벨 추적·Sentry 자동기록이 다르다. 재현: 이 메서드 안 예외는 호출 스레드로 재던져져 MVC RestExceptionHandler가 받는다. 예방: getSessionToken은 반드시 finallycloseSessionToken(:69)으로 닫아야 한다(누락 시 세션 고갈). 출처: async-coroutines

🟡 AdapterCoroutineExceptionHandler는 BeanUtils 정적 룩업에 의존

영향: 핸들러는 new로 매번 생성되는 컨텍스트 요소라 Spring DI를 못 받는다. handleException(:15-25)BeanUtils.getBean(RestExceptionHandler)로 런타임 룩업한다. BeanUtils.applicationContextlateinit이라 컨텍스트 초기화 전 코루틴 예외가 나면 NPE 가능. 재현: 부팅 초기 코루틴 예외, 또는 CoroutineExceptionHandlerlaunch/runBlocking 루트에서만 동작하고 async엔 무력. 예방: 부팅 시점 코루틴 작업을 피하고, 핸들러 동작 모델(async 무력)을 이해하라. 출처: async-coroutines, support-common

⚪ DSR/검색키 정리 등 부수효과 fire-and-forget — 사후 정합성 조용히 깨짐

영향: registerDsr(Jin Air 판매보고), removeFlightSearchKey, saveUnexposedFareItinerarywithLaunch로 던져진다. 실패해도 예약/발권은 200 성공. DSR 누락은 Slack 알림조차 없고 로그만 남으며 dev 프로파일은 아예 skip(통합테스트에서 미발견). 예방: 정산/판매보고 누락 점검은 별도 모니터링이 필요. 출처: jinair-pitfalls, jinair-operations


3. 프로토콜·전문 (SOAP/NDC)

🔴 WSDL 없이 손조립한 SOAP — xmlns="" 제거 문자열 치환 누락 시 Fault (전 SOAP 공급사)

영향: Jackson XML/jakarta.xml.soap이 자식 엘리먼트에 흘리는 빈 기본 네임스페이스(xmlns="")를 .replace(" xmlns=\"\"", "")로 강제 제거한다. WSDL/JAX-WS 스텁이 없어 컴파일타임 검증이 0. 이 한 줄을 빼먹으면 공급사 파서가 거부해 원인불명 SOAP Fault가 발생한다. 재현: 새 전문 DTO를 추가하면서 soapRequestBodyConverter의 후처리 라인을 누락하거나, Jackson/Woodstox 버전 업그레이드로 출력 형식이 바뀔 때. 본문 텍스트값에 우연히 xmlns=""가 들어가면 함께 지워질 원리상 위험도 있다. 예방: 모든 SOAP 공급사 동일 처리. AmadeusClient.kt:1415, GpsClient.kt:56, SabreClient.kt:1105, GalileoClient.kt:901-907(추가로 wstxns\d+ 정규식 제거), AmadeusndcClient.kt:855, KoreanairClient, LufthansaClient.kt:905, SingaporeairClient.kt:894. Galileo는 elementFormDefault=qualified라 더 민감. 출처: amadeus-protocol, sabre-protocol, galileo-protocol, amadeusndc-protocol, koreanair-protocol, lufthansa-protocol, singaporeair-protocol

🔴 "NDC = 순수 REST/JSON"은 오해 — 모듈명이 프로토콜을 거짓말한다

영향: NDC 모듈도 대부분 SOAP 봉투를 쓴다. 잘못 가정하면 전문 구조를 완전히 잘못 읽는다.

  • amadeusndc: 검색은 FMPTBQ_23_1_1A(클래식 1A FareMasterPricer), 큐는 QCSDRQ/QDQLRQ 등 클래식 Amadeus 1A. 가격/예약/발권/조회/취소/재발행만 진짜 NDC(AMA_TravelOrder*RQ). FareRule만 ART JSON REST.
  • koreanair: NDC IATA_*RQ를 SOAP Body ns1:XXTransaction/REQ에 byte로 주입(KoreanairClient.kt:549-593), FLXDM 스크립트로 디스패치. 카드결제는 NDC 밖 NicePay raw TCP 소켓 + SEED/EUC-KR(KoreanairPaymentClient.kt).
  • lufthansa: NDC EDIST를 SOAP <iden> 자격증명 봉투로 감쌈(LufthansaClient.kt:866).
  • singaporeair: NDC EDIST 18.1을 SOAP + WS-Security UsernameToken으로 Amadeus 인프라 경유(SOAPAction이 webservices.amadeus.com/NDC_*_18.1). 예방: 인증 디버깅 시 채널별 토큰 종류(세션 vs Bearer vs WS-Security)와 직렬화기(xmlMapper vs objectMapper)를 분리해서 봐라. 출처: amadeusndc-overview, amadeusndc-protocol, koreanair-overview, koreanair-protocol, lufthansa-overview, singaporeair-overview, singaporeair-protocol

🔴 SOAP/REST 하이브리드 — 오퍼레이션마다 클라이언트가 다르다 (Sabre/Galileo)

영향: 같은 모듈에 두 스택이 평행 존재한다. “Sabre 검색 = SOAP”로 단정하면 엉뚱한 클라이언트를 디버깅한다.

  • Sabre: 검색은 SabreRestClient(BargainFinderMax v3, OAuth Bearer)지만 SOAP SabreClient.search도 별도 존재. 예약/발권/큐/현금영수증은 SOAP, revalidate는 SOAP(SabreFlightSearchService.kt:185). 결제는 SabrePaymentClient(PaymentService WSDL).
  • Galileo: 검색만 REST(Travelport JSON API v11), Pricing/Booking/Ticketing/Void/FareRule/Divide는 SOAP Universal API. 결제는 KpsPaymentClient(.aspx), 운임규정 한글번역은 KrtClient(.aspx). 4개 외부 시스템을 한 모듈이 오케스트레이션. 예방: 어느 경로를 타는지는 application 서비스가 결정한다. 토큰 종류·타임아웃을 클라이언트별로 확인. 출처: sabre-overview, sabre-operations, sabre-protocol, galileo-overview, galileo-operations

🟡 단일 메시지/SOAPAction이 여러 오퍼레이션을 담당 (NDC OrderChange/OrderReshop)

영향: 오퍼레이션 구분이 경로/SOAPAction이 아니라 본문 내용에 있다.

  • koreanair IATA_OrderChangeRQ 하나가 발권/취소(발권전·후)/연락처변경/재발행/분리 6개를 ofIssue/ofIssuedCancel/... 팩토리로 분기.
  • lufthansa OrderChangeRQ(8개 팩토리)/OrderReshopRQ(환불계산/재발행검색).
  • amadeusndc Travel_OrderReshop_1.6 단일 action이 취소료계산/재발행검색/재발행가격 3용도.
  • jinair는 SOAP-in-JSON 봉투라 경로(/reservation)가 아닌 service(=body 타입)로 10여 개 분기(JinairRequest.kt:18-32). 예방: 디버깅 시 로그에서 어떤 팩토리/ActionType/body 타입이 호출됐는지 먼저 확인. tway도 발권/재발행/부가결제가 /book/ModifyBooking 단일 경로를 ActionType으로 공유. 출처: koreanair-protocol, lufthansa-protocol, amadeusndc-operations, jinair-protocol, tway-protocol

🟡 WS-Security Created 타임스탬프는 반드시 UTC — now() 기본은 Asia/Seoul

영향: SOAP 인증 Created/PasswordDigestnow("UTC")로 만든다. 그러나 DateExtensions.kt:12now() 기본 인자는 Asia/Seoul이다. UTC를 빠뜨리면 KST(+9h)가 SHA-1 digest에 들어가 인증이 깨진다. 컨테이너 NTP 드리프트도 곧 인증 장애. 재현: now("UTC")now()로 무심코 바꾸거나, 노드 시계가 틀어질 때. Singaporeair는 토큰 Created+5분 만료(병렬 검색 백프레셔로 5분 초과 시 거부). 예방: 인증 타임스탬프 코드는 항상 now("UTC"). NTP 동기화 운영 필수. 또 Security 헤더는 세션 Start에만 붙어야 한다(AmadeusClient.kt:1349, Amadeus). SHA-1은 약하지만 스펙 요구라 변경 불가. 출처: amadeus-pitfalls, amadeusndc-protocol, singaporeair-protocol

🟡 반복 엘리먼트 @JacksonXmlElementWrapper(useWrapping=false) / @JsonPropertyOrder 필수

영향: 스키마가 엘리먼트 순서·래핑에 엄격하다. Galileo AirPriceRQ의 리스트 필드는 useWrapping=false 없으면 래퍼가 생겨 스키마 불일치(AirPriceRQ.kt:39). amadeusndc FMPTBQ는 @JsonPropertyOrder로 순서를 못 박는다(FareMasterPricerTravelBoardSearch.kt:18). 재현: 필드를 추가/재정렬하면서 어노테이션을 갱신하지 않을 때. 예방: 새 필드 추가 시 순서/래핑 어노테이션 동기화. 출처: galileo-protocol, amadeusndc-protocol

🟡 koreanair 한 DTO 안에 네임스페이스가 둘 (Message vs CommonTypes)

영향: 루트는 IATA_OffersAndOrdersMessage NS, 안쪽 자식은 IATA_OffersAndOrdersCommonTypes NS. 새 필드에 @JacksonXmlProperty(namespace=...COMMON_TYPES)를 빠뜨리면 KE가 element를 무시하거나 검증 오류. 예방: koreanair 새 NDC 필드는 NS를 반드시 명시. 출처: koreanair-protocol

🟡 koreanair NicePay 고정폭 EUC-KR 바이트 전문 — 한글 경계 절단 위험

영향: @ByteRange 고정폭 EUC-KR 전문, raw Socket 송수신, 응답 종료 판정이 '\r' 단일 문자. serializeToLiteralTextByByte가 byte 길이 초과분을 copyOf(targetSize)로 자르므로(ReflectionUtils.kt:147) 2바이트 한글이 경계에서 잘리면 전문이 깨지고, 응답 파싱도 1바이트 어긋나면 이후 전 필드가 밀린다. 예방: 한글 포함 필드의 바이트 오프셋을 신중히. 응답에 \r 없으면 5초 타임아웃에만 의존. 출처: koreanair-protocol, koreanair-pitfalls


4. 에러·예외 정규화

🔴 SOAP fault 파싱 실패 시 raw 예외 그대로 전파 → 분류 정보 손실 + 무조건 Sentry

영향: ClientSupport.handleSoapFaultException(:62)errorData가 null이거나 SOAP 형식이 아니면 catch(_:Exception){this.exception} / ?:this.exception 폴백으로 원본 예외(IOException 등)를 던진다. 타임아웃(OkHttpError.isTimeout, ClientSupport.kt:206)도 errorData가 없어 이 경로로 빠진다. 결과적으로 응답 code가 INTERNAL_SERVER_ERROR로만 나가 분류를 잃고, 비-ApiException은 capturable 체크를 우회해 무조건 Sentry로 전송된다(노이즈). 재현: 외부 공급사가 비-SOAP 에러/타임아웃을 줄 때. 예방: 정규화 실패 케이스를 모니터링. INTERNAL_SERVER_ERROR 빈발 시 이 경로 의심. 출처: error-handling, configuration-and-infra

🔴 검색/발권 성패 판정이 영문 메시지 문자열 매칭에 의존 (전 공급사)

영향: 공급사가 문구를 바꾸면 분기가 즉시 붕괴된다. 정상 무결과를 장애로, 또는 장애를 빈결과로 둔갑시킨다.

  • Amadeus: search 코드 866/931/977/996/118/950 침묵 무시, ticketing은 'NO ALLOCATION FOR NN'/'Time Out'/'BAGGAGE ALLOWANCE MISSING' 문자열로 재시도/캡처 분기(AmadeusClient.kt:920-934). 288은 한시적 SOLD_OUT 처리 TODO 미제거 — 진짜 288이 영구 매진으로 둔갑.
  • Sabre: 'NO AVAILABILITY' 등 11개 화이트리스트, 'INTERNAL ERROR'까지 정상 무재고로 취급해 실제 장애를 빈결과로 삼키고 서킷 실패율 집계를 왜곡(SabreClient.kt:190-211, SabreRestClient.kt:134-156 복붙).
  • Galileo: SOLD_OUT(are not bookable/NOT AVAIL), CHECK CONNECTION, has already been cancelled 등 faultString contains.
  • amadeusndc: 'ORDER ALREADY CANCELLED' contains로 ALREADY_CANCELED_PNR + noRetry() 판정 — 문구 변경 시 이미 취소된 PNR을 3번 재시도.
  • koreanair: 검색 325(재고없음)/719(운임없음)만 정상 흡수.
  • lufthansa/singaporeair: 'Checked In status not valid', 'RESERVATION PREVIOUSLY CANCELLED', 'MAXIMUM TICKET LIMIT REACHED' 등 substring/완전일치. 예방: 이 화이트리스트/문자열 매칭은 운영지식이니 함부로 지우지 마라. 코드/문구가 바뀌면 한쪽만 깨지므로 회귀 테스트 필수. 출처: amadeus-pitfalls, sabre-pitfalls, galileo-pitfalls, amadeusndc-pitfalls, koreanair-pitfalls, lufthansa-pitfalls, singaporeair-pitfalls

🔴 결제 에러코드 미등록 → 전부 PAYMENT_ETC, 분류된 거절은 Sentry 미보고

영향: 결제 VAN/PG 에러를 사람이 분류한 해시셋으로 매핑하고, set에 없으면 PAYMENT_ETC로 뭉뚱그린다. 더 위험한 건 분류된 거절(한도초과 등)은 .capture() 없이 Sentry에 안 잡히고 미분류만 보인다는 것. PG가 신규 코드를 추가하면 PAYMENT_ETC 빈발(운영 중 단골 원인)이고 거절 사유 구분이 안 된다. 재현: 카드사/VAN/PG 코드 변경 시. 예방: 새 결제 코드는 에러맵에 등록 필요. 위치: Amadeus GpsError.kt(VAN 수천 개, PAYMENT_ETC만 capture), AmadeusKeyInError.kt(KE 완전일치 3개), Sabre PaymentError.kt, Galileo KpsPaymentError.kt(인자명 message지만 실제 errorCode 넘김), koreanair KoreanairPaymentClient.kt:49(//TODO 에러 코드 매핑 미구현 — 모든 거절이 PAYMENT_ETC), jejuair PaymentError(한국어 메시지 contains), jinair(capture(silence=true)로 Slack도 꺼짐), tway/singaporeair(TODO). 출처: amadeus-pitfalls, sabre-pitfalls, galileo-pitfalls, koreanair-pitfalls, jejuair-pitfalls, jinair-pitfalls, error-handling

🟡 retryable 미지정(null) 시 기본 재시도가 클라이언트마다 정반대

영향: shouldXxxRetryable(e as? ApiException)?.retryable ?: <기본값>인데 기본값이 통일돼 있지 않다. Amadeus/Jeju/Jin/Tway는 false(재시도 안 함)지만 AmadeusndcClient.shouldRetrieveRetryable(:371)SabreClient.shouldRetrieveRetryable(:735)true(재시도 함). 명시적 .noRetry() 없는 예외나 raw 예외(as? null)가 sabre/amadeusndc retrieve에 닿으면 자동 재시도되어 중복 예약/세션 꼬임 위험. amadeusndc는 NPE까지 3번 재시도된다. 재현: .retry()/.noRetry() 미명시 예외를 retrieve 경로에 흘릴 때. 예방: 예외 throw 시 명시적 .noRetry() 권장. Sabre getBooking 재시도는 멱등하지 않다(시도마다 ignore+retrieve 부수효과). 출처: error-handling, resilience-and-events, sabre-pitfalls, amadeusndc-pitfalls

🟡 findRootCause()가 cause 래핑된 ApiException의 분류를 로그/Sentry에서 버린다

영향: RestExceptionHandler.findRootCause()(:108)는 가장 안쪽 cause를 로그/Sentry 대상으로 삼는다. InternationalAdapterException(BOOKING_FAILED, cause=ioException)처럼 던지면 root cause인 ioException이 Sentry로 가고 BOOKING_FAILED 분류가 사라진다. 게다가 ioException은 capturable 체크를 우회해 무조건 Sentry. 응답 변환은 원본 e를 쓰므로(RestExceptionView.of(exception=e)) 응답 code는 정상 — 로그/Sentry와 응답이 서로 다른 예외를 본다. 예방: cause 래핑 시 분류 손실을 인지. 출처: error-handling

🟡 ApiException.capture()를 호출해야만 Sentry에 잡힌다 (capturable 기본 false)

영향: ApiException.capturable 기본값 false(Exceptions.kt:67). sentryLog()(:82)this is SentryAlertHandler && capturable.not()면 전송 스킵. 따라서 .capture() 없이 던진 ApiException은 Sentry에 안 잡힌다(예상된 비즈니스 실패는 의도적으로 조용히). 반대로 비-ApiException은 무조건 잡혀 정규화 실패 시 노이즈가 증가한다. .capture(silence=true)는 notifiable 태그만 끈다. 예방: 모니터링하고 싶은 비즈니스 실패는 .capture()를 붙여라. 일부 lufthansa 분기(booking:253, changeApis:321 등)는 .capture() 누락으로 Sentry 사각지대. 출처: error-handling, lufthansa-pitfalls

@Recover 메서드가 전혀 없음 + Slack은 수동 호출

영향: 코드 전체에 Spring Retry @Recover가 0개라 @Retryable 소진 시 마지막 예외가 그대로 @ControllerAdvice까지 전파된다. RestExceptionHandler/AdapterCoroutineExceptionHandler는 Slack을 호출하지 않는다 — 자동 경보는 Sentry뿐. Slack은 수동 개입 지점(AmadeusCancelService.pnrCancelRepeat:249 등)에서 서비스가 직접 호출 후 rethrow. 출처: error-handling, resilience-and-events

ErrorMessage enum 이름 = 외부 API 계약 (리네임/삭제는 브레이킹)

영향: RestExceptionView.code = exception.errorMessage.name(:18)이라 enum 상수 이름이 곧 응답 JSON의 code가 되어 Triple 예약 시스템이 이 문자열로 분기한다. 상수 리네임/삭제 시 호출자가 깨진다. 추가만 안전. 또 응답 messageResponseMessage 타입만 노출하므로 String 부가정보('carrier pnr is null')는 외부에 안 나가고 로그/Sentry에만 남는다. 출처: error-handling


5. 운임·통화·세금 계산

🔴 통화 소수자릿수 미적용 — 외화 운임이 100배 부풀려짐 (lufthansa/singaporeair/amadeusndc/koreanair)

영향: 통화별 소수자릿수(decimals) 처리가 비일관하다. KRW(decimals=0)에선 우연히 맞지만 EUR/USD 등 소수 2자리 통화로 응답이 오면 운임이 망가진다.

  • lufthansa: CurrencyAmountValue.getAmountmovePointLeft(decimals)를 적용하는데 검색(Offer.kt:182)·예약(DataList.kt:163)은 toLong() 직접 변환으로 decimals 무시 → 100배 부풀림. FareRule 변경수수료만 getAmount().
  • singaporeair: Amount/CurrencyAmount/ResponseParametercurCode 기본값이 전부 KRW. SQ가 SGD/USD로 응답해도 KRW로 취급(정산 사고). 통화 일치 검증 없음.
  • amadeusndc: convertPriceDropLast가 반올림 아닌 truncation(dropLast)이고 currencyMap에 통화 없으면 자릿수 0. toBooking(convert)과 toReissueBooking(toLong() 무변환)이 같은 totalPrice를 다른 스케일로 만든다(명백한 버그).
  • koreanair: Price.airPriceequivalentAmount.currencyCode=="KRW"일 때만 그것을 쓰고, 아니면 baseAmount.value를 환율 적용 없이 KRW로 합산. 예방: 통화 코드 검증과 decimals 적용을 신규 운임 매핑 시 반드시 확인. KRW funnel만으로 테스트하면 못 잡는다. 출처: lufthansa-pitfalls, singaporeair-pitfalls, amadeusndc-pitfalls, koreanair-pitfalls

🔴 taxfuelCharge/surcharge가 합산되어 유류할증료 이중계상 위험 (전 LCC + amadeusndc/groupair)

영향: 다수 공급사가 Fare.tax에 유류할증을 합산하면서 fuelCharge/surcharge를 별도로도 보관한다. Fare.total = airPrice + tax라 유류할증은 이미 포함이다. 규칙을 모르는 신입이 airPrice + tax + fuelCharge로 합하면 즉시 이중계상 금액 오류. 재현: tway(Fare.kt:20), jinair(AirAvailabilityRS.kt:384), groupair(toPassengerFare:144, toFare:208), amadeusndc(Passenger.kt:63)에서 +fuelCharge 추가 시. 예방: 세금만 분리 표시하려면 tax - fuelCharge. Fare.total getter는 일관(airPrice+tax)되니 가급적 getter를 쓰고 수동 합산을 피하라. 출처: tway-pitfalls, jinair-pitfalls, groupair-pitfalls, amadeusndc-pitfalls

🔴 환불/재발행 금액 역산에 음수·언더플로·통화불일치 가드 없음 (Amadeus/amadeusndc/lufthansa/koreanair/singaporeair)

영향: 환불액을 직접 받지 못하고 차액으로 역산한다. 입력 하나만 어긋나면 음수/과대/과소 환불이 검증 없이 통과한다.

  • Amadeus: 복합결제 noneRefundAmount(usedAirPrice+usedTax+refundFee) 역산에서 cashAmount-(noneRefundAmount-cardAmount)가 음수 가능(AmadeusRefundService.kt:271). 부분환불은 전부성공-아니면-전부실패 강제하나 환불은 비가역이라 다승객 PNR 중간 실패 시 불일치 상태 잔존.
  • amadeusndc: usedSurcharge = originalTotal - penalty - usedTax - refundAmount 5단계 역산 + differentialAmountDue!! 강제언랩 NPE(DeleteOrderItem.kt:23).
  • lufthansa: reshopDue MIN/MAX·byAirline 세금포함 가정, 필드 누락 시 조용히 0 + decimals 미적용.
  • koreanair: usedAirPrice = origin - unused - penalty 음수 미가드, passenger.fare!! NPE, VOID·REFUND 혼재 시 거부.
  • singaporeair: expectedRefundAmount = abs(...)로 부호 손실(추가징수가 환불처럼 보임), refundFee==0L을 계산 실패로 간주해 무료 환불 차단. 예방: 복합결제/부분사용 환불은 반드시 실제 전문으로 검증. 음수 가드 추가. 출처: amadeus-pitfalls, amadeus-operations, amadeusndc-pitfalls, lufthansa-pitfalls, koreanair-pitfalls, singaporeair-pitfalls

🔴 Galileo removeCurrencyCode가 통화 3글자 + KRW 정수만 가정

영향: FormatUtils.removeCurrencyCode = substring(3).toLong()모든 금액 파싱에 쓰인다. 통화 항상 3글자, 소수점 없음, 천단위 구분자 없음을 전제. 주석 예시 GBP253.20 같은 소수 금액이 오면 NumberFormatException, 통화 없거나 null이면 StringIndexOutOfBounds/NPE. 재현: 비-KRW 통화 응답. 예방: Galileo 금액 파싱 경로 전반이 KRW 정수에 강결합돼 있음을 인지. equivalentBasePrice ?: basePrice 폴백은 통화 비교 없이 환산/원본을 혼용. 출처: galileo-pitfalls

🔴 항공사 코드 하드코딩 운임/세금/발권 분기가 흩어져 있다 (Amadeus 등)

영향: PR/MU/KE/CZ/AB/SQ/MH 등 캐리어별 특수처리가 곳곳에 박혀 있어 신규 항공사 추가 시 누락된다.

  • Amadeus: PR/MU endorsement 주입, KE approveByKeyIn, CZ saveAdultTicketNumber, MU issueWithEndorsements(소아 endorsement 매직 인덱스 tickets[index++ / 3], ‘성인 1명당 티켓 3장’ 가정 — 구조 다르면 IndexOutOfBounds), SQ는 Q차지를 세금에 합산, MH 코드쉐어는 검색서 통째 필터.
  • amadeusndc: SQ는 fuelCharge에 YQ만(나머지 YQ+YR).
  • YQ/YR 등 유류할증 코드 필터가 공급사마다 다름(koreanair YR만, lufthansa YQ만, singaporeair YQ만) — 다른 코드로 오면 fuelCharge 0. 예방: 캐리어 추가/수정 시 이 분기들을 grep으로 전수 점검. 출처: amadeus-operations, amadeus-pitfalls, amadeusndc-pitfalls, koreanair-pitfalls, lufthansa-pitfalls, singaporeair-pitfalls

🟡 enum 역매핑의 조용한 기본값 / TODO() 폭탄

영향: 대부분의 getXxxBySupplier(value, supplier)가 매칭 실패 시 예외 없이 ?: ECONOMY/?: ADULT/?: WEIGHT_KG조용한 기본값을 반환해 잘못된 공급사 코드가 버그로 숨는다(소아/유아 환불이 성인으로 오분류 등). 더 위험한 건 BaggageUnit.getBaggageUnitBySupplier(:54-63)의 미지원 공급사 분기 else -> TODO()(NotImplementedError) — Amadeus/Sabre/Lufthansa 등은 else로 빠져 런타임에 터진다. WEIGHT_LB.groupair=""라 파운드 표현이 kg로 오표기되기도. 재현: 공급사 추가/수정 후 양방향 매핑 누락. 예방: 공급사 추가 시 양방향 enum 매핑 테스트 필수. 출처: support-common, groupair-pitfalls

🟡 fareBasis 변동 = SOLD_OUT 강제 차단 (재가격 가드, 중복 구현)

영향: 재가격 전후 승객별 fareBasis가 달라지면 PNR 비동기취소 후 StatusInvalidException(SOLD_OUT)을 던진다(고객이 보던 운임≠실제 발권가 방지). Amadeus/Sabre/Galileo의 validateFareBasisChange가 Booking/Ticketing 양쪽에 거의 동일하게 복붙돼 있어 한쪽만 고치면 동작이 갈린다. Sabre는 joinToString { "_" } 오용으로 값 대신 언더스코어만 비교(로그용 잠재 버그). 예방: 재가격 가드 수정 시 양 경로 동시 반영. 출처: amadeus-operations, sabre-operations, galileo-operations, sabre-pitfalls

🟡 Sabre compareWithFareItinerary는 운임 불일치를 throw 없이 로그만

영향: 예약 후 총운임/fareBasis/bookingClass가 검색 시점과 달라도 logger.error만 찍고 예약을 그대로 진행한다(SabreBookingService.kt:201-251). 실제 차단은 revalidate와 validateFareBasisChange(repricing)에서만. 단계별 검증 강도가 달라 오해하기 쉽다. 예방: 단계별 가드 위치를 정확히 파악. 출처: sabre-operations, sabre-pitfalls

🟡 Amadeus 운임 파싱 toLong vs toLongOrNull 비대칭 + 커미션 단위 분산

영향: FareInfo.ktticketTotalAmounttoLongOrNull()이라 파싱 실패를 null로 삼켜 가격을 통째로 null로 반환(0원도 아님)하나 additionalTotalAmounttoLong()이라 같은 상황에서 예외 — 비대칭. 커미션율 /100 변환(7→0.07)과 단위 가정(퍼센트 vs 비율)이 코드마다 달라 확인 없이 고치면 100배/1/100배 틀어진다. 예방: 운임 파싱 비대칭과 커미션 단위를 코드별로 확인. 출처: amadeus-pitfalls

⚪ Galileo 커미션 비교가 type 무시하고 Double value만 비교

영향: convertCommissionCommissionType(NET=정액/GROSS=정률) 무시하고 value(Double)만 비교. 정액 5000과 정률 5%를 5000.0 < 5.0로 비교 — 의미 깨짐 + 부동소수 오차. 출처: galileo-pitfalls


6. 보안 (카드/자격증명/로깅)

🔴 로그 마스킹이 message/request_body 컨텍스트의 4개 키로만 한정 — PCI 누출

영향: EncryptValueMaskerTARGET_CONTEXTS={message, request_body} 안의 cardNumber/expiryDate/password/userMobile만 마스킹한다(EncryptValueMasker.kt:8). response_body나 다른 JSON 컨텍스트에 실린 카드정보는 평문 로깅된다. 신규 결제 필드 추가 시 이 목록을 갱신하지 않으면 PCI 민감정보가 누출된다. 재현: 결제 응답/다른 컨텍스트에 카드 필드가 실리거나, 새 카드 필드명을 추가할 때. 예방: 결제 필드 추가 시 TARGET_NAMES 갱신. 평문 카드 바디 로깅 금지. 출처: support-common

🔴 평문 카드정보가 Serializable VO로 Redis 캐시에 직렬화될 수 있음

영향: PaymentInfo.KeyInCardReissueResult.CardInfocardNumber/password를 평문 String으로 보유하고 Serializable이라 REISSUE 등 Redis 캐시에 직렬화될 수 있다(PaymentInfo.kt:32). 로그 마스킹과 별개로 캐시에 평문 카드정보가 남을 위험. 예방: 캐시되는 객체에 평문 카드 필드 포함 여부 점검. 출처: support-common

🔴 SOAP <iden> / KPS / 진에어 결제 본문에 자격증명·카드 PAN 평문 + 로깅 기본 on

영향: 여러 공급사가 자격증명/카드정보를 평문 본문에 싣고, 검색은 .log(enableSearchLog.or(logging))로 요청 로깅이 켜질 수 있어 로그로 샌다.

  • lufthansa/singaporeair: SOAP iden 속성 u/p/agtpwd 평문(인증 샘플 XML에도 실제 패스워드 박힘).
  • galileo KPS: KpsBspCardAuthRQCardNumber/AgtPWD/AgtID 평문 JSON, 토큰 발급 본문에 client_secret/password 평문(로깅 .log 미지정 → 기본 on).
  • koreanair: isLocalProfile()이면 RQ/RS XML을 디스크에 평문 저장(certi/yyMMdd/, //certification 까지만 유지 임시코드) + NicePay 전문 통째 logger.info. 예방: 결제/인증 본문 로깅을 끄고, 프로파일 오설정(local 활성)을 방지. 자격증명은 Secrets Manager 주입. 출처: lufthansa-protocol, lufthansa-pitfalls, galileo-protocol, koreanair-pitfalls, support-common

🔴 SEED 키가 TwaySEED.jar에 평문 하드코딩 (T'way)

영향: SEED 키 'twayBookingAPI@014'com.twayair.security.seed.SeedUtil의 정적 필드로 jar 안에 박혀 있다. encoding/decoding이 양방향이라 SEED 문자열이 로그에 남으면 복호 가능 — 카드번호/비밀번호 든 /book/ModifyBooking 평문 바디 로깅 금지. 키 로테이션은 어댑터가 아니라 T’way가 새 jar를 배포해야만 가능. 재현: OfflineAuthInfo(...)를 생성자로 직접 만들면 SEED 암호화를 건너뛰어 평문 카드정보가 와이어에 나간다(암호화는 CardInfo.of()가 수행). 예방: CardInfo.of()를 통해서만 생성. 개인카드 cardHolderBirthDate null이면 등록번호 인코딩 NPE. 출처: tway-protocol, tway-pitfalls

🟡 LCC 카드 암호화 구현 혼동 — SEED(Tway/KE) vs RSA(Jinair/Jejuair)

영향: 암호화 방식을 공급사별로 혼동하면 잘못된 경로를 디버깅한다.

  • Jin Air: SEED가 아니라 RSA+AES 하이브리드(JinairCipher: RSA로 nonce 암호화 → SHA-256 앞 16바이트 AES 키). decrypt()@Deprecated로 예외(단방향). 공개키 맵이 lateinit companion이라 인증서 부재/초기화 전 호출 시 부팅 실패.
  • Jeju Air: RSA Cipher.getInstance("RSA") 패딩 미지정, 공개키 소스 하드코딩, 출력이 Base64가 아닌 Hex(공통 CryptoUtils는 Base64라 혼동). decrypt 미지원.
  • 어댑터 내부 support/util/SeedEncryptor.kt + @SeedEncryptKorean Air NicePay 전용(T’way SEED와 별개). 예방: 모듈별 cipher 구현을 확인. 키 회전 시 소스 수정/재배포 필요. 출처: jinair-overview, jinair-pitfalls, jejuair-overview, jejuair-pitfalls, tway-overview

🟡 CVV2 더미 '999' 전송 + 더미 여권 전송 위험

영향: Jin Air는 cvv2Number='999' 고정 전송(PG가 키인결제에서 CVV 무시 전제 — PG가 검증 켜면 전 건 결제 실패). Passport.ofFake()(KR/X1234567/만료 10년 후)는 여권 없는 승객용인데 실제 발권 전 그대로 전송되면 거부/데이터 오염(Galileo도 FOID_PASSPORT_CARRIERS 미입력 시 fake 주입). 예방: isFake()/FAKE_PASSPORT_NUMBER로 흐름 추적. 출처: jinair-pitfalls, support-common, galileo-pitfalls

🟡 Sabre 발권 에러 메시지에 카드번호 앞 8자리(BIN) 노출

영향: ticketing checkError가 'INCORRECT CARD NUMBER' 발견 시 payment.cardNumber.take(8)과 cardCode를 예외 objs에 담는다(SabreClient.kt:565-596). 마스킹이 풀리면 카드 BIN+일부 노출. 출처: sabre-pitfalls


7. 회복탄력성 (서킷/리트라이)

🔴 서킷브레이커는 검색에만 — 예약/발권/취소는 무방비 (전 공급사 공통 철학)

영향: @CircuitBreaker는 11개 공급사의 검색 컨트롤러에만 붙는다. 돈·재고 정합성 때문에 예약/발권/취소엔 의도적으로 없다(멱등성 없는 작업에 서킷/리트라이는 위험). “서킷브레이커가 모든 호출을 보호한다”고 가정하면 오류. 비검색 오퍼레이션의 안정성은 코드레벨 보상 트랜잭션 + 수동 for 재시도 루프 + Slack 경보로만 구현. 예방: 발권/취소 경로는 서킷이 없다는 전제로 장애 대응. 출처: resilience-and-events, 각 *-pitfalls

🔴 서킷 OPEN 시 검색은 200 빈배열(에러 아님) — 매진과 장애 구분 불가

영향: searchFallback(exception: CallNotPermittedException)는 OPEN 상태로 차단된 호출만 ResponseEntity.ok(emptyList())로 흡수하고 Datadog span에 supplier.circuit-breaker=OPEN 태그만 단다. RestExceptionHandler를 안 타므로 모니터링은 5xx가 아니라 결과 0건/Datadog 태그로 해야 한다. 호출자는 ‘좌석 없음’과 ‘Amadeus 장애’를 응답만으로 구분 못 한다. 재현: 서킷 OPEN 동안(waitDurationInOpenState 120s) 검색이 2분간 빈 결과를 반환하는 사이 운영자가 모를 수 있다. 예방: 서킷 상태전이를 사람이 직접 알 방법이 없다(onStateTransition/이벤트 컨슈머 0건). Datadog 태그 알람을 걸어라. 또 fallback은 CallNotPermittedException만 흡수하므로 검색 로직 내부의 타임아웃/비즈니스 예외는 폴백을 안 타고 그대로 500 전파 — “왜 어떤 검색 실패는 빈 결과인데 어떤 건 500이냐”의 원인. 11개 컨트롤러 동일. 출처: resilience-and-events, request-flow, 각 *-pitfalls

🔴 발권(ISSUE)·재발행은 멱등하지 않음 — 리트라이 시 중복 발권/결제

영향: POST /ticketing(ISSUE)는 결제+발권이 발생하는 비멱등 작업이다. Resilience4j retry를 무심코 적용하면 중복 결제 위험. SEARCH만 서킷 fallback으로 emptyList()(가짜 성공)를 반환하고 발권은 그런 패턴을 쓰지 않는다. 재발행은 아예 비동기 폴링으로 분리. Amadeus ticketing @Retryable(maxAttempts=3)retryable==true인 예외만 재시도하므로 사실상 거의 재시도 안 됨(‘발권 3회 재시도’는 오해). 재현: 발권 타임아웃을 단순 실패로 보고 재시도하면 항공사 측은 발권 완료인데 응답만 못 받은 케이스에서 이중발권. 예방: 발권 타임아웃은 후속 retrieve로 실제 성공 여부 확인 후 처리(timeoutCallback). 자동 재발권 금지. 출처: common-operations, resilience-and-events, galileo-protocol

🟡 @CircuitBreaker(Resilience4j) ≠ @Retryable(Spring Retry) 라이브러리 혼동

영향: @CircuitBreakerio.github.resilience4j(검색 컨트롤러 11개), @Retryableorg.springframework.retry(클라이언트/서비스)로 완전히 다른 라이브러리다. Resilience4j @Retry는 안 쓰며 application.yml의 resilience4j 블록에는 circuitbreaker만 있고 retry 설정이 없다. 재시도는 @EnableRetry(AirIntlAdapterApplication.kt:9)로 켜지고 횟수/백오프는 어노테이션에 하드코딩. amadeusndc/koreanair/groupair/jejuair 등은 @Bulkhead/@RateLimiter 0개. 예방: 두 라이브러리를 구분. 출처: resilience-and-events

🟡 slidingWindowType=TIME_BASED를 COUNT_BASED로 오독

영향: application.yml:43 slidingWindowType: TIME_BASED. slidingWindowSize:180은 ‘180건’이 아니라 ‘최근 180초’, failureRateThreshold:35는 35건이 아니라 35%. minimumNumberOfCalls:30이라 트래픽 적은 공급사는 평가 자체가 안 될 수 있다. 임계값 변경 시 단위를 오해하면 전 공급사에 동시 영향(전 프로파일 공통, 개별 오버라이드 없음). 예방: 단위를 정확히 인지. 출처: resilience-and-events, configuration-and-infra

🟡 재시도 정책이 예외 객체의 retryable 플래그에 숨어 있어 추적 난이도 높음

영향: @Retryable(exceptionExpression="@bean.shouldXxxRetryable(#root)")(e as? ApiException)?.retryable를 본다. retryable는 비즈니스 로직 깊은 곳에서 .retry()/.noRetry() 확장함수로 설정(기본 null=재시도 안 함). 재시도 여부가 어노테이션이 아닌 throw 지점에 분산되어 어떤 실패가 재시도되는지 코드 한곳에서 알 수 없다. 예: AmadeusClient.kt:927 BAGGAGE MISSING만 .retry(). 예방: 재시도 대상은 .retry() grep으로 추적. 출처: resilience-and-events, error-handling

🟡 @Retryable self-invocation 무효 + 고정 지연 백오프(지터 없음)

영향: @Retryable은 프록시 기반이라 같은 클래스 내부 자기호출 시 재시도가 무시된다. 리팩터링으로 메서드를 인라인하면 재시도가 조용히 사라진다. 또 모든 @RetryableBackoff(delay=N) 고정 지연만 써서 지수/maxDelay/지터가 없어 외부 API 동시 실패 시 재시도가 동기화되어 부하 스파이크(delay 5000ms 다수: SabreQueueService.remove, QueueService, Galileo, Tway.cancel). 예방: 재시도 메서드는 다른 빈을 통해 외부 호출. 출처: resilience-and-events

⚪ 서킷 인스턴스명과 공급사명 미스매치 (singaporeSearch)

영향: singaporeair의 서킷 인스턴스명은 singaporeSearch(application.yml:59)이며 컨트롤러 @CircuitBreaker(name="singaporeSearch")(SingaporeairSearchController.kt:25)와 정확히 일치해야 baseConfig:search가 매핑된다. 어긋나면 default config로 조용히 다른 임계값으로 동작. 예방: 신규 공급사 추가 시 instance 키와 어노테이션 name 일치 확인. 출처: resilience-and-events

circuit-breaker-aspect-order: 1의 의미

영향: application.yml:39 낮을수록 바깥. 서킷이 바깥이면 안쪽 재시도 소진 후 최종 실패 1건만 서킷 통계에 잡힌다. 검색 경로엔 보통 @Retryable이 없어 겹침은 드물지만 전역 설정이라 인지 필요. 출처: resilience-and-events


8. 설정·인프라 (Redis/프로파일/MDC)

🔴 disableCreateOnMissingCache() — CacheSet 미등록 캐시명은 런타임 예외

영향: RedisCacheConfiguration.redisCacheManager()disableCreateOnMissingCache()를 호출하므로 CacheSet enum에 없는 캐시명을 @Cacheable(value=[...])에 쓰면 IllegalArgumentException으로 lazy 생성되지 않고 실패한다. 재현: 새 @Cacheable을 추가하면서 CacheSet에 등록을 빠뜨릴 때. 예방: 새 캐시는 반드시 support/cache/CacheSet.kt enum+상수에 먼저 등록. 출처: configuration-and-infra

🔴 Redis 직렬화 경로가 둘로 갈림 (JDK @Cacheable vs Gzip/Snappy+JSON RedisTemplate)

영향: @Cacheable 경로는 RedisCacheManager의 JDK 직렬화(객체가 Serializable이어야 함; City/Airport가 그래서 Serializable)이고, 공급사 RedisTemplate 경로는 GzipRedisSerializer로 감싼 Jackson2Json이다. 같은 키를 두 경로로 읽으면 역직렬화가 깨진다. 11개 공급사가 각자 configuration/RedisConfiguration.kt를 가짐. 재현: 캐시 키 규칙을 공유하면서 직렬화 경로를 섞을 때. 예방: 키별로 직렬화 경로를 일관 유지. 롤링배포 중 FareItinerary DTO 변경 시 이전 버전 캐시 JSON 역직렬화 실패. 해시 파생 키(toSha3) 로직 변경 시 INVALID_CACHE_KEY. 출처: configuration-and-infra, galileo-overview, tway-pitfalls, groupair-pitfalls

🔴 공유 Redis 캐시 키/TTL 변경 시 검색→예약 전 공급사 동시 장애

영향: FlightSearchKeyRepository(약 25곳 주입), UnexposedFareItineraryRepository(약 18곳), FareRuleRepository(약 11곳)는 @Component로 전 공급사가 직접 주입. CacheKeyGenerator.generateSearchRequestKeyCacheSet의 cacheName/ttl을 바꾸면 검색이 저장한 키를 예약이 못 찾아 ‘운임 만료’로 전 공급사 예약이 막힌다. 시그니처 불변·의미만 변경되는 위험이라 컴파일러가 못 잡는다. 검색→예약 사이 사용자 지연으로 TTL 만료돼도 동일 증상. 예방: 공유 캐시 키/TTL 변경은 전 공급사 영향으로 간주. 출처: caller-callee-map, configuration-and-infra

🟡 채널/퍼널/PNR생성일 기반 동적 자격증명 라우팅 — MDC 유실 시 광범위 실패

영향: getApiProperties()가 인자 없이 호출되면 MDCHolder.SalesChannel/SalesFunnel을 읽어 PCC/Office/엔드포인트를 동적 선택한다. MDC가 비면 NOT_SUPPORTED_SALES_CHANNEL/FUNNEL 예외. Galileo/Sabre/Koreanair/Tway/Jejuair/Groupair의 거의 모든 메서드가 의존하므로, 코루틴(withBlocking/pmap) 경계에서 MDC 전파가 깨지면 엉뚱한 채널/광범위 실패. 또 Amadeus/Sabre는 MDCHolder.PnrCreatedAt < changedDateLEGACY 퍼널/PCC로 강제 라우팅(과거 PNR은 다른 Office/계정으로 나감). 재현: MDC 미설정 호출(배치/비동기/재처리), 이관 기간 경계 PNR 디버깅. 예방: 결제/발권 디버깅 시 실제 사용 Office/PCC를 먼저 확인. getApiPropertiesval이 아니라 get() 프로퍼티(매 호출 재계산). 출처: configuration-and-infra, amadeus-protocol, amadeusndc-protocol, galileo-overview, sabre-overview, koreanair-overview, jejuair-protocol, groupair-protocol

🟡 OkhttpClientConfiguration.kt에는 OkHttpClient 빈이 없다 + 클라이언트마다 2개씩 생성

영향: 파일명과 달리 configuration/OkhttpClientConfiguration.kt에는 LoggingAndCompressionInterceptor만 있고 @Bean이 없다. 실제 OkHttpClient(searchClient/defaultClient) 생성과 타임아웃(searchTimeout=30000/defaultTimeout=60000)은 support/web/ClientSupport.kt 추상 클래스에 있으며, 인스턴스 필드라 공급사 클라이언트 빈마다 OkHttpClient 2개가 새로 생성된다(연결풀/디스패처 비공유, OkHttp 권장과 다름). 예방: HTTP 설정을 찾을 땐 ClientSupport를 보라. 신규 클라이언트 작성 시 인지. 출처: configuration-and-infra

🟡 ClientSupport/공유 base 변경 = 전 23개 클라이언트 동시 영향

영향: support/web/ClientSupport.kt(추상 클래스)를 23개 클라이언트가 모두 상속. execute<RES>() 응답 파싱, OkHttpError.isTimeout 판정(서킷/리트라이와 직결), 타임아웃 기본값, LoggingAndCompressionInterceptor 부착을 바꾸면 SOAP/NDC/JSON 전 공급사 외부호출이 동시 영향. 시그니처 외 ‘동작만’ 바뀌면 컴파일러가 못 잡는다. execute()는 예외 대신 자체 Result.failure(OkHttpError)를 반환하므로(kotlin.Result 아님) .fold 없이 성공만 가정하면 에러를 놓친다. isTimeout이 IOException까지 포함해 광범위(오탐 알림). 예방: ClientSupport 변경은 전 공급사 회귀 테스트. 출처: caller-callee-map, support-common, configuration-and-infra, amadeusndc-pitfalls

🟡 @ConfigurationProperties 추가 시 WebMvcConfig 등록 필수 + 필터 순서 의존

영향: Properties.kt@ConfigurationProperties 클래스만 만들면 빈이 안 된다. WebMvcConfiguration@EnableConfigurationProperties 배열에 등록해야 한다. 또 필터는 @Order(1) ContentCaching → @Order(2) MDC → @Order(3) Logging 고정(WebMvcConfiguration.kt:103-113). ContentCaching이 먼저 body를 래핑 안 하면 LoggingFilter가 body를 소비해 컨트롤러가 빈 body를 받는다. MDCFilter는 finally의 MDCHolder.clear()(MDCFilter.kt:21)가 ThreadLocal 누수(스레드풀 재사용 시 이전 PNR/채널 오염)의 유일한 방어선. 예방: 새 Properties는 등록, 필터 순서/clear 패턴 유지. 출처: configuration-and-infra, support-common, request-flow

🟡 local 프로파일이 dev Secrets Manager/dev ElastiCache를 공유 (오프라인 개발 불가)

영향: application-local.ymlapplication-dev.yml 모두 aws-secretsmanager:dev/air-intl-adapter를 import하고, redisson-local.yml은 dev serverless ElastiCache(air-international-dev-...)를 가리킨다. supplier/*.ymlon-profile: dev,local로 묶여 로컬도 dev AWS 자격증명이 있어야 공급사 호출이 동작한다. 완전 오프라인 개발 불가(VPN/프록시 + dev 자격증명 필요, application-my.yml 오버라이드 필요). 예방: 로컬 구동 시 dev 네트워크 접근 준비. 출처: configuration-and-infra, build-deploy-config

⚪ BeanUtils 정적 ApplicationContext 백도어 (전역 가변 상태)

영향: BeanUtilslateinit var applicationContext를 static으로 보관하고 getBean("objectMapper"/"xmlMapper")로 비-Bean 코드(SoapBodySerializer, JsonBodyDeserializer)에서 직접 꺼낸다. 테스트 격리가 어렵고 컨텍스트 초기화 전 호출 시 lateinit 예외. 출처: support-common

⚪ Redisson은 연결 팩토리로만 — 분산락(RLock) 미사용 + retryAttempts=1

영향: RedissonClient가 있지만 메인 소스에 getLock/tryLock/RLock 직접 호출이 없다(분산락 미사용; 동시성은 Resilience4j+예외+Slack). redisson-{env}.ymlretryAttempts:1로 Redis 장애 시 빠르게 실패해 원본 호출로 폴백(best-effort). S3 빈은 등록되나 런타임 사용처 없음(Region 서울 하드코딩). 출처: configuration-and-infra


9. 빌드·배포 (CI/CD)

🔴 release-dev/staging는 PROD 계정, release-qa만 DEV 계정 — 배포 사고 단골

영향: cd.yml:55-71의 환경→AWS계정 매핑이 직관과 비대칭이다. release-prod/staging/dev 모두 PROD 계정(107243714588)에 배포되고, **release-qa만 DEV 계정(927056181394)**에 배포된다. 환경 이름과 AWS 계정이 1:1이 아니다. 재현: 태그명만 보고 배포 대상 계정을 가정할 때. 예방: 배포 전 cd.yml 매핑을 확인. 통합 브랜치가 main이 아닌 master이고, master push에 tag.ymlrelease-qa를 force 갱신해 자동 QA 배포된다. 출처: build-deploy-config

🔴 테스트가 CI에서도 Docker 빌드에서도 실행되지 않음

영향: ci.yml:34Dockerfile:12 모두 ./gradlew --exclude-task test build로 테스트를 건너뛴다. 자동 파이프라인 어디에서도 테스트가 돌지 않으며, 회귀는 개발자가 로컬에서 ./gradlew test를 직접 돌려야만 잡힌다. 예방: 변경 후 로컬 테스트 필수. 회귀 안전망이 약함을 전제. 출처: build-deploy-config

🔴 TwaySEED.jar 로컬 파일 의존성 — 누락 시 빌드 깨짐, 재취득 불가

영향: build.gradle.kts:67 implementation(files("libs/TwaySEED.jar"))Dockerfile:11 COPY libs ./libs 중 하나라도 빠지면 com.twayair.security.seed.SeedUtil 미해결로 컴파일 실패. T’way 제공 jar로 Maven Central에 없어 삭제하면 재취득 불가. Maven 좌표가 아니라 의존성 감사 도구가 추적 못 함. 예방: clean 체크아웃/CI/Docker 빌드에 jar가 따라가는지 확인. BouncyCastle도 직접 의존이 아니라 wss4j/xmlsec 전이 의존으로만 클래스패스에 들어오므로 상위 업그레이드 시 bcprov가 빠지면 SEED 암호화가 런타임에 깨질 수 있다. 출처: build-deploy-config, tway-protocol

🟡 Dockerfile EXPOSE 8080 vs 앱 실제 포트 8000 불일치

영향: Dockerfile:32EXPOSE 8080이지만 앱은 application.yml:6server.port=8000을 리슨한다. EXPOSE는 문서용 메타데이터일 뿐이라 실제 ALB target group/ECS containerPort는 8000을 가리켜야 트래픽이 통한다. 예방: 컨테이너 포트 설정은 8000 기준. 출처: build-deploy-config

🟡 Jin Air 결제 인증서가 git 레포에 직접 커밋

영향: aiRES_PYM_Cert.cer, payment.dev.cer가 Secrets Manager가 아니라 클래스패스 리소스로 레포에 포함되어 jar에 패키징된다. 민감 파일이 버전관리에 노출. 출처: build-deploy-config

⚪ 프로젝트 version 미선언 — 아티팩트명이 rootProject.name에 종속

영향: build.gradle.kts에 version 선언이 없어 bootJar가 air-intl-adapter.jar를 생성하고 Dockerfile:30이 이 이름을 하드코딩해 COPY한다. settings.gradle.ktsrootProject.name을 바꾸면 Dockerfile도 같이 고쳐야 빌드가 안 깨진다. 출처: build-deploy-config

⚪ cd.yml scheduled-task 배포 블록이 전 환경 비활성(placeholder)

영향: cd.yml:110-136 Update scheduled-tasks는 모든 환경에서 SCHEDULE_RULES= 빈 값이라 아무 동작도 안 한다. 향후 배치/스케줄 연동 자리. 출처: build-deploy-config

⚪ T'way XSD/mockData 부재, 통합테스트는 실서버 직접 호출

영향: src/test/schema/에 tway 디렉터리 없음, mockData/tway 없음. 전문 계약은 Kotlin DTO Jackson 애너테이션이 유일한 명세. TwayClientTest는 mock 없이 @SpringBootTest local로 실서버 직접 호출 — XML 직렬화 골든파일 회귀 검증이 없다(galileo/amadeusndc/singaporeair mockData도 부재/빈약). 출처: tway-protocol, galileo-protocol, amadeusndc-protocol, singaporeair-protocol


10. API 계약·DTO 함정

🔴 PaymentInfoRequest가 두 개 — 발권용(sealed) vs 취소용(data class)

영향: TicketingRequest.kt:43PaymentInfoRequest는 sealed class(KeyInCard/TossPay 다형, @JsonTypeInfo DEDUCTION)이고, CancelRequest.kt:43PaymentInfoRequest는 동일 단순명의 일반 data class(가격/승인/카드 필드)다. import 시 같은 이름이라 혼동·오참조 위험. 재현: 결제 처리 코드에서 잘못된 PaymentInfoRequest를 import할 때. 예방: import 경로를 정확히 확인. 출처: interfaces-dtos

🔴 같은 URL이라도 공급사마다 필수 파라미터가 다르다 (PNR vs supplierIdentificationKey)

영향: GET /bookings/{pnr} 등 같은 경로라도 Amadeus/Sabre는 pnr만으로 조회되지만 NDC 계열(KoreanAir/Galileo/Groupair/Singaporeair)은 @RequestParam supplierIdentificationKey(=NDC OrderID/UniversalRecordPnr/콘솔리데이터 sequence)를 필수로 받는다. 한 공급사 호출 코드를 다른 공급사에 복붙하면 400/누락 오류. Galileo는 PNR이 3종(providerPnr/reservationPnr/universalRecordPnr)이고 SOAP 호출마다 다른 키를 쓴다. Groupair는 {pnr} 경로변수가 실제 키가 아니라 쿼리의 supplierIdentificationKey가 키다. 재현: 공급사 간 호출 코드 복붙. 예방: 공급사별 식별자 규칙을 확인. 출처: common-operations, koreanair-overview, galileo-operations, groupair-overview

🟡 repricing/cancel voided 등 같은 응답 타입인데 내부 동작이 공급사마다 다름

영향: 동일 View 응답이라도 내부 의미가 갈린다 — 복붙 금지.

  • repricing: RepricingView 동일하지만 Amadeus/Sabre는 별도 repricing(pnr) 호출, Tway는 confirmPrice(pnr), Singaporeair/Jejuair/Jinair/koreanair/lufthansa/amadeusndc는 retrieve(pnr) 결과를 그대로 변환(실제 재계산 없음). 비용·부작용·재계산 여부가 다르다.
  • cancel voided: Amadeus는 action==VOID, Sabre는 refunds.isEmpty() 역산, Singaporeair는 구조분해, Groupair는 voided=false 하드코딩. 예방: View가 같아도 서비스 동작을 확인. 출처: common-operations, amadeusndc-operations, lufthansa-operations, groupair-operations

🟡 재발행(addition)은 202+폴링키 — Pending을 실패로 오해 금지

영향: POST /ticketing/addition은 202 ACCEPTED + DeferredKeyView(폴링키)를 즉시 반환하고, GET /ticketing/addition/{reissueKey}DeferredView(Pending/Complete)를 폴링해야 한다(Redis polling/poller). DeferredStatus.ERROR면 저장된 throwable 재던짐. Pending은 정상 진행 상태. tway/jinair/jejuair/koreanair/singaporeair 동일. 동기 응답을 기대하면 결과 누락(Redis TTL REISSUE.ttl 후 소멸). 예방: 재발행은 폴링 흐름임을 인지. 출처: common-operations, tway-operations, singaporeair-overview, koreanair-overview

🟡 공급사별 검색 가능 조건(isSearchable)·당일출발 컷오프를 모르면 '결과 없음'이 정상

영향: SearchRequest.isSearchable(supplier)(:26-34)가 제약을 코드로 강제: TWAY/JINAIR는 여정≤2 & 항공사코드 포함, SINGAPOREAIR는 SQ 포함, GROUPAIR는 왕복만. 조건 불충족 시 해당 공급사는 검색에서 스스로 빠진다. 특히 Jin Air는 departureDate < today().plusDays(2)면 무조건 emptyList(예약=결제 동시처리 모델, KST 기준) — “왜 진에어만 검색이 안 되지?”의 단골 원인. 예방: 검색 0건이 정상 동작일 수 있음을 인지. 출처: common-operations, jinair-overview, jinair-pitfalls

⚪ 파일명 ≠ 클래스명 / 존재하지 않는 클래스

영향: grep으로 못 찾는 함정.

  • AncillaryRequest.kt 안에 AncillaryRequest 클래스는 없다(BaggageRequest 등만).
  • singaporeair SingaporeairFlightSearch.kt 안 핵심 클래스는 FareItinerary, RedisConfiguration.kt 안은 SingaporeairRedisConfiguration.
  • tway 예약 봉투는 CreatBookingRQmsg(오타, e 없음).
  • 비즈니스 컨트롤러는 interfaces/controller에 없고(HealthController뿐) supplier/{name}/interfaces/controller/internals/*에 분산. 출처: interfaces-dtos, singaporeair-overview, tway-protocol

⚪ 응답 변환의 조용한 부분 실패 / non-null 단언 NPE

영향: AirportService.codes.mapNotNull{...}는 공항 한 곳 실패 시 그 공항만 결과에서 빠진다(‘일부 공항명 비어보임’). StructuredFareRuleView 대부분이 취소/변경 정책을 비워둠(수하물만). PassengerView.order는 identificationKey 숫자 추출 → 숫자 없으면 NumberFormatException. NDC/Galileo/Groupair 응답 매핑은 !!/first() 남용으로 필드 누락 시 의미 없는 NPE(오프라인/분리 PNR에서 자주). 출처: interfaces-dtos, galileo-pitfalls, groupair-pitfalls, jinair-pitfalls


11. 날짜·시간대 (UTC/KST)

🔴 JVM 전역 시간대를 UTC로 강제 — 모든 시간 값이 UTC 가정

영향: AirIntlAdapterApplication.init()@PostConstruct에서 TimeZone.setDefault(UTC) 호출(AirIntlAdapterApplication.kt:13). 국제선 다중 시간대 환경에서 코드 전반의 LocalDate/LocalDateTime이 암묵적으로 UTC 기준이라 가정해야 한다. 로컬에서 KST를 기대하면 디버깅이 어긋난다. 단 DateExtensions.now()의 기본 인자는 Asia/Seoul, today()도 Asia/Seoul — JVM 디폴트(UTC)와 라이브러리 디폴트(KST)가 다른 이중 기준이 함정의 근원. 재현: now() 기반 시각과 LocalDateTime 기반 시각을 섞어 비교할 때. 예방: 시간대 변환은 application/CalculateTimezoneService.kt가 담당(IATA→타임존). 인증 타임스탬프는 항상 now("UTC"). 출처: system-architecture, configuration-and-infra

🔴 CalculateTimezoneService 동작 변경 시 발권시한·취소위약 금액 오류

영향: calculateToUTC()(:14)는 CityClient로 IATA→타임존 조회 후 TTL(발권시한)·취소위약 시각을 계산한다. Amadeus(Booking/Cancel/Ticketing)·Galileo(Cancel)·Sabre(Cancel)가 공유. 시그니처는 그대로 두고 로직(국내공항 판정, ZZZ/UTC 처리, fallback Asia/Seoul)만 바뀌면 컴파일러가 못 잡고 결제/환불 금액 오류로 이어진다. 예방: 시간대 계산 변경은 전 의존 공급사 영향. 출처: caller-callee-map

🟡 void(무료취소) 판정이 KST today()와 발권일 직접 비교 — 자정 경계에서 VOID↔REFUND 전환

영향: 거의 모든 공급사가 발권일 == today()(Asia/Seoul)로 void 가능 여부를 판정한다. 발권일이 다른 시각대(UTC/BSP 정산시각)면 ‘당일’인데도 날짜가 어긋나 void 대신 환불로 처리되고, 자정 직전 발권 후 자정 넘으면 void 불가. tway/jinair/galileo/lufthansa/singaporeair/jejuair 공통. 미발권 승객 1명이라도 있으면 전체 void 불가(?:false). 예방: 시각이 아닌 날짜 동일성만 본다는 점, KST 기준임을 인지. 출처: galileo-pitfalls, lufthansa-pitfalls, tway-pitfalls, jinair-pitfalls, jejuair-pitfalls

🟡 같은 응답 안에서 시각 파싱 전략이 필드마다 다름 (오프셋 소실 vs KST 환산)

영향: 한 응답 DTO 안에서도 ZonedDateTime.parse().toLocalDateTime()(오프셋 버림)과 .toLocalDateTime(ZoneId.of("Asia/Seoul"))(KST 환산)이 섞여 두 시한의 기준이 달라진다.

  • koreanair: OrderView는 오프셋 미환산, Reshop은 KST 환산 — paymentTimeLimit이 9시간 어긋남.
  • lufthansa: paymentTimeLimit은 KST 변환, carrierTimeLimit은 timezone 무시.
  • singaporeair: paymentTimeLimit 오프셋 폐기, pnrCreatedAt은 서버 로컬 now().
  • jejuair: 여정/발권시한은 LocalDateTime(공항 zoneId로 별도 toUTC), createdDate는 Z 붙은 ZonedDateTime, FarePenaltyFeeInfo.createDate만 Z 없으면 +09:00 강제.
  • jinair: originalTicketIssueDateKST 변수에 Asia/Seoul, issuedDateTimeKST(이름KST) 변수에 UTC가 들어가 변수명과 실제 ZoneId가 뒤바뀜 → 재발권 판정 최대 9시간 오차. 예방: 시한 비교/표시 시 각 필드의 파싱 전략을 확인. 출처: koreanair-pitfalls, lufthansa-pitfalls, singaporeair-pitfalls, jejuair-protocol, jinair-pitfalls

🟡 jejuair는 KST(+09:00) 하드코딩 — 해외 출발편 발권시한 오계산

영향: ZonedDateTimeDeserializer가 Z로 끝나지 않는 날짜에 무조건 +09:00 부착, toUTC/today 기본 zoneId가 Asia/Seoul. RetrieveRS.expirationAttoUTC()(KST)로 변환해 carrierTimeLimit으로 쓰므로 괌/사이판 등 해외 출발편 발권시한이 KST 가정으로 어긋난다(단 세그먼트 출도착은 공항 zoneId 사용 — 비대칭). 예방: 해외 노선 시한 계산 주의. 출처: jejuair-pitfalls

🟡 groupair carrierTimeLimit이 now().plusHours(1) 하드코딩 (issueTimeLimit 무시)

영향: ReservationDetailResponse.toBooking(:59)이 응답의 issueTimeLimit을 무시하고 carrierTimeLimit = now().plusHours(1), voidable=true, segment status "HK" 등을 하드코딩한다. 조회할 때마다 발권시한이 지금+1h로 갱신되어 실제 공급사 시한과 무관 → 발권마감 알림/자동취소 로직이 오판한다. pnrCreatedAt도 무조건 KST 가정 toUTC(). 예방: groupair Booking의 시한/voidable 필드는 신뢰 금지. 출처: groupair-overview, groupair-pitfalls

⚪ Amadeus KE 결제 now(UTC) vs GPS approvalDate(KST 간주) 기준 불일치

영향: KE GDS key-in 결제는 approvedAt=now("UTC"), GPS VAN은 approvalDate(TZ 정보 없어 KST 간주)를 파싱해 toUTC(). 결제 취소 시 당일 void 일자 비교에서 자정 전후 경계가 어긋날 수 있고, VAN이 다른 TZ면 9시간 틀어진다. 출처: amadeus-pitfalls


부록: 모듈별 “처음 열면 압도되는” 함정 (탐색 가이드)

코드 탐색 시 알아둘 것

  • 파일 수 폭발: GDS/NDC 자동생성 스키마 DTO 때문에 amadeus(873)·sabre(882)·galileo(525)·amadeusndc(357)가 모듈당 수백~900개. 흐름은 Controller → Service → Client 3개만 추적하면 동일. 골격 학습은 groupair(34개)가 최적 (onboarding-map 참고).
  • 중앙 디스패처 부재: supplier 분기형 단일 엔드포인트가 없다. /internals/{SUPPLIER}/{resource} 경로별 독립 컨트롤러. ‘공통 search 엔드포인트’를 찾으면 존재하지 않는다.
  • test/schema/amadeus에 GDS·NDC 스키마 혼재: AMA_Travel*는 전부 amadeusndc만 참조. ‘Amadeus도 NDC SOAP을 한다’는 오인 주의.
  • XSD ≠ 실제 전송 버전: amadeus 검색 XSD는 v23인데 실제 전송은 v18(FareMasterPricerTravelBoardSearch.kt). 진실의 원천은 코드(DTO + converter).
  • 데드코드/미지원 기능이 살아있음: lufthansa divide(컨트롤러까지 있으나 NDC Code 325 거부), singaporeair saveSeat/divide/saveAncillary/repricingWithAncillary(‘현재 서비스하지 않음’), jinair searchSeatmap, koreanair checkPnr/amadeusndc checkPnr(항상 true 스텁).

더 자세히: onboarding-map, quick-reference, system-architecture


연습문제 연결

이 지뢰들로 디버깅 훈련하기

  • “검색 결과가 0건인데 장애일까 정상일까?” → 서킷 OPEN(Datadog 태그) vs isSearchable 제약 vs pmap 부분실패 vs Jin Air 당일컷오프 구분. exercises-debugging
  • “발권은 됐는데 결제 취소가 안 됐다” → fire-and-forget 보상취소 + Slack best-effort 추적. exercises-debugging
  • “신규 공급사 추가 시 빠뜨리기 쉬운 것” → 서킷 instance명 일치, enum 양방향 매핑, signOut catch 패턴, 캐시키 등록. exercises-suppliers
  • “통화 1원 안 맞는다” → decimals 미적용, tax+fuelCharge 이중계상, removeCurrencyCode KRW 가정. exercises-architecture