⚠️ 전체 지뢰요소 마스터
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 spansupplier.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.
분류별 색인
| # | 카테고리 | 🔴 high | 🟡 med | ⚪ low |
|---|---|---|---|---|
| 1 | 세션·상태(stateful) | 4 | 3 | - |
| 2 | 비동기·코루틴·보상취소 | 4 | 3 | 1 |
| 3 | 프로토콜·전문(SOAP/NDC 직렬화) | 3 | 5 | - |
| 4 | 에러·예외 정규화 | 3 | 4 | 2 |
| 5 | 운임·통화·세금 계산 | 5 | 4 | 1 |
| 6 | 보안(카드/자격증명/로깅) | 4 | 3 | - |
| 7 | 회복탄력성(서킷/리트라이) | 3 | 4 | 2 |
| 8 | 설정·인프라(Redis/프로파일/MDC) | 3 | 5 | 2 |
| 9 | 빌드·배포(CI/CD) | 3 | 2 | 3 |
| 10 | API 계약·DTO 함정 | 2 | 4 | 2 |
| 11 | 날짜·시간대(UTC/KST) | 2 | 4 | 1 |
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 AirpnrSessionId(markSeat), Jeju AirPssToken(HTTP 응답 헤더로 발급)이 만료되면 검색→예약 사이에 좌석점유가 풀려 매진 처리되거나 인증 실패한다. 재현: markSeat~createBooking 사이 사용자 지연. 또는pnrSessionId!!/PssToken!!non-null 단언 지점에서 토큰 null이면 즉시 NPE(JinairClient.kt:369,JejuairClient다수). 예방: Jeju Air 변경계 작업은 반드시retrieveWithToken(pnr)으로 시작(검색 응답엔 토큰 없음). 토큰 만료는 JejuOTAUSV900처럼 별도 코드로 위장되니.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곳 이상. 재현:pnrCancelAsync의delay(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 자동기록이 다르다. 재현: 이 메서드 안 예외는 호출 스레드로 재던져져 MVCRestExceptionHandler가 받는다. 예방:getSessionToken은 반드시finally의closeSessionToken(:69)으로 닫아야 한다(누락 시 세션 고갈). 출처: async-coroutines
🟡
AdapterCoroutineExceptionHandler는 BeanUtils 정적 룩업에 의존영향: 핸들러는
new로 매번 생성되는 컨텍스트 요소라 Spring DI를 못 받는다.handleException(:15-25)이BeanUtils.getBean(RestExceptionHandler)로 런타임 룩업한다.BeanUtils.applicationContext가lateinit이라 컨텍스트 초기화 전 코루틴 예외가 나면 NPE 가능. 재현: 부팅 초기 코루틴 예외, 또는CoroutineExceptionHandler는launch/runBlocking루트에서만 동작하고async엔 무력. 예방: 부팅 시점 코루틴 작업을 피하고, 핸들러 동작 모델(async 무력)을 이해하라. 출처: async-coroutines, support-common
⚪ DSR/검색키 정리 등 부수효과 fire-and-forget — 사후 정합성 조용히 깨짐
영향:
registerDsr(Jin Air 판매보고),removeFlightSearchKey,saveUnexposedFareItinerary도withLaunch로 던져진다. 실패해도 예약/발권은 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 Bodyns1: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)지만 SOAPSabreClient.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/PasswordDigest는now("UTC")로 만든다. 그러나DateExtensions.kt:12의now()기본 인자는 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_OffersAndOrdersMessageNS, 안쪽 자식은IATA_OffersAndOrdersCommonTypesNS. 새 필드에@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 코드 변경 시. 예방: 새 결제 코드는 에러맵에 등록 필요. 위치: AmadeusGpsError.kt(VAN 수천 개,PAYMENT_ETC만 capture),AmadeusKeyInError.kt(KE 완전일치 3개), SabrePaymentError.kt, GalileoKpsPaymentError.kt(인자명message지만 실제errorCode넘김), koreanairKoreanairPaymentClient.kt:49(//TODO 에러 코드 매핑미구현 — 모든 거절이PAYMENT_ETC), jejuairPaymentError(한국어 메시지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()권장. SabregetBooking재시도는 멱등하지 않다(시도마다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
⚪
ErrorMessageenum 이름 = 외부 API 계약 (리네임/삭제는 브레이킹)영향:
RestExceptionView.code = exception.errorMessage.name(:18)이라 enum 상수 이름이 곧 응답 JSON의code가 되어 Triple 예약 시스템이 이 문자열로 분기한다. 상수 리네임/삭제 시 호출자가 깨진다. 추가만 안전. 또 응답message는ResponseMessage타입만 노출하므로 String 부가정보('carrier pnr is null')는 외부에 안 나가고 로그/Sentry에만 남는다. 출처: error-handling
5. 운임·통화·세금 계산
🔴 통화 소수자릿수 미적용 — 외화 운임이 100배 부풀려짐 (lufthansa/singaporeair/amadeusndc/koreanair)
영향: 통화별 소수자릿수(decimals) 처리가 비일관하다. KRW(decimals=0)에선 우연히 맞지만 EUR/USD 등 소수 2자리 통화로 응답이 오면 운임이 망가진다.
- lufthansa:
CurrencyAmountValue.getAmount는movePointLeft(decimals)를 적용하는데 검색(Offer.kt:182)·예약(DataList.kt:163)은toLong()직접 변환으로 decimals 무시 → 100배 부풀림. FareRule 변경수수료만getAmount().- singaporeair:
Amount/CurrencyAmount/ResponseParameter의curCode기본값이 전부 KRW. SQ가 SGD/USD로 응답해도 KRW로 취급(정산 사고). 통화 일치 검증 없음.- amadeusndc:
convertPriceDropLast가 반올림 아닌 truncation(dropLast)이고currencyMap에 통화 없으면 자릿수 0.toBooking(convert)과toReissueBooking(toLong()무변환)이 같은totalPrice를 다른 스케일로 만든다(명백한 버그).- koreanair:
Price.airPrice가equivalentAmount.currencyCode=="KRW"일 때만 그것을 쓰고, 아니면baseAmount.value를 환율 적용 없이 KRW로 합산. 예방: 통화 코드 검증과 decimals 적용을 신규 운임 매핑 시 반드시 확인. KRW funnel만으로 테스트하면 못 잡는다. 출처: lufthansa-pitfalls, singaporeair-pitfalls, amadeusndc-pitfalls, koreanair-pitfalls
🔴
tax에fuelCharge/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.totalgetter는 일관(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 - refundAmount5단계 역산 +differentialAmountDue!!강제언랩 NPE(DeleteOrderItem.kt:23).- lufthansa:
reshopDueMIN/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, CZsaveAdultTicketNumber, MUissueWithEndorsements(소아 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 운임 파싱
toLongvstoLongOrNull비대칭 + 커미션 단위 분산영향:
FareInfo.kt의ticketTotalAmount는toLongOrNull()이라 파싱 실패를 null로 삼켜 가격을 통째로 null로 반환(0원도 아님)하나additionalTotalAmount는toLong()이라 같은 상황에서 예외 — 비대칭. 커미션율/100변환(7→0.07)과 단위 가정(퍼센트 vs 비율)이 코드마다 달라 확인 없이 고치면 100배/1/100배 틀어진다. 예방: 운임 파싱 비대칭과 커미션 단위를 코드별로 확인. 출처: amadeus-pitfalls
⚪ Galileo 커미션 비교가 type 무시하고 Double value만 비교
영향:
convertCommission이CommissionType(NET=정액/GROSS=정률) 무시하고 value(Double)만 비교. 정액 5000과 정률 5%를5000.0 < 5.0로 비교 — 의미 깨짐 + 부동소수 오차. 출처: galileo-pitfalls
6. 보안 (카드/자격증명/로깅)
🔴 로그 마스킹이 message/request_body 컨텍스트의 4개 키로만 한정 — PCI 누출
영향:
EncryptValueMasker는TARGET_CONTEXTS={message, request_body}안의cardNumber/expiryDate/password/userMobile만 마스킹한다(EncryptValueMasker.kt:8).response_body나 다른 JSON 컨텍스트에 실린 카드정보는 평문 로깅된다. 신규 결제 필드 추가 시 이 목록을 갱신하지 않으면 PCI 민감정보가 누출된다. 재현: 결제 응답/다른 컨텍스트에 카드 필드가 실리거나, 새 카드 필드명을 추가할 때. 예방: 결제 필드 추가 시TARGET_NAMES갱신. 평문 카드 바디 로깅 금지. 출처: support-common
🔴 평문 카드정보가 Serializable VO로 Redis 캐시에 직렬화될 수 있음
영향:
PaymentInfo.KeyInCard와ReissueResult.CardInfo가cardNumber/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:
KpsBspCardAuthRQ가CardNumber/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()를 통해서만 생성. 개인카드cardHolderBirthDatenull이면 등록번호 인코딩 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+@SeedEncrypt는 Korean 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) 라이브러리 혼동영향:
@CircuitBreaker는io.github.resilience4j(검색 컨트롤러 11개),@Retryable는org.springframework.retry(클라이언트/서비스)로 완전히 다른 라이브러리다. Resilience4j@Retry는 안 쓰며application.yml의 resilience4j 블록에는 circuitbreaker만 있고 retry 설정이 없다. 재시도는@EnableRetry(AirIntlAdapterApplication.kt:9)로 켜지고 횟수/백오프는 어노테이션에 하드코딩. amadeusndc/koreanair/groupair/jejuair 등은@Bulkhead/@RateLimiter0개. 예방: 두 라이브러리를 구분. 출처: resilience-and-events
🟡
slidingWindowType=TIME_BASED를 COUNT_BASED로 오독영향:
application.yml:43slidingWindowType: 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:927BAGGAGE MISSING만.retry(). 예방: 재시도 대상은.retry()grep으로 추적. 출처: resilience-and-events, error-handling
🟡
@Retryableself-invocation 무효 + 고정 지연 백오프(지터 없음)영향:
@Retryable은 프록시 기반이라 같은 클래스 내부 자기호출 시 재시도가 무시된다. 리팩터링으로 메서드를 인라인하면 재시도가 조용히 사라진다. 또 모든@Retryable이Backoff(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()를 호출하므로CacheSetenum에 없는 캐시명을@Cacheable(value=[...])에 쓰면IllegalArgumentException으로 lazy 생성되지 않고 실패한다. 재현: 새@Cacheable을 추가하면서 CacheSet에 등록을 빠뜨릴 때. 예방: 새 캐시는 반드시support/cache/CacheSet.ktenum+상수에 먼저 등록. 출처: 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.generateSearchRequestKey나CacheSet의 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 < changedDate면 LEGACY 퍼널/PCC로 강제 라우팅(과거 PNR은 다른 Office/계정으로 나감). 재현: MDC 미설정 호출(배치/비동기/재처리), 이관 기간 경계 PNR 디버깅. 예방: 결제/발권 디버깅 시 실제 사용 Office/PCC를 먼저 확인.getApiProperties는val이 아니라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.yml과application-dev.yml모두aws-secretsmanager:dev/air-intl-adapter를 import하고,redisson-local.yml은 dev serverless ElastiCache(air-international-dev-...)를 가리킨다.supplier/*.yml은on-profile: dev,local로 묶여 로컬도 dev AWS 자격증명이 있어야 공급사 호출이 동작한다. 완전 오프라인 개발 불가(VPN/프록시 + dev 자격증명 필요,application-my.yml오버라이드 필요). 예방: 로컬 구동 시 dev 네트워크 접근 준비. 출처: configuration-and-infra, build-deploy-config
⚪ BeanUtils 정적 ApplicationContext 백도어 (전역 가변 상태)
영향:
BeanUtils가lateinit var applicationContext를 static으로 보관하고getBean("objectMapper"/"xmlMapper")로 비-Bean 코드(SoapBodySerializer, JsonBodyDeserializer)에서 직접 꺼낸다. 테스트 격리가 어렵고 컨텍스트 초기화 전 호출 시 lateinit 예외. 출처: support-common
⚪ Redisson은 연결 팩토리로만 — 분산락(RLock) 미사용 + retryAttempts=1
영향:
RedissonClient가 있지만 메인 소스에getLock/tryLock/RLock직접 호출이 없다(분산락 미사용; 동시성은 Resilience4j+예외+Slack).redisson-{env}.yml은retryAttempts: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.yml이release-qa를 force 갱신해 자동 QA 배포된다. 출처: build-deploy-config
🔴 테스트가 CI에서도 Docker 빌드에서도 실행되지 않음
영향:
ci.yml:34와Dockerfile:12모두./gradlew --exclude-task test build로 테스트를 건너뛴다. 자동 파이프라인 어디에서도 테스트가 돌지 않으며, 회귀는 개발자가 로컬에서./gradlew test를 직접 돌려야만 잡힌다. 예방: 변경 후 로컬 테스트 필수. 회귀 안전망이 약함을 전제. 출처: build-deploy-config
🔴 TwaySEED.jar 로컬 파일 의존성 — 누락 시 빌드 깨짐, 재취득 불가
영향:
build.gradle.kts:67implementation(files("libs/TwaySEED.jar"))와Dockerfile:11COPY 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:32는EXPOSE 8080이지만 앱은application.yml:6의server.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.kts의rootProject.name을 바꾸면 Dockerfile도 같이 고쳐야 빌드가 안 깨진다. 출처: build-deploy-config
⚪ cd.yml scheduled-task 배포 블록이 전 환경 비활성(placeholder)
영향:
cd.yml:110-136Update 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:43의PaymentInfoRequest는 sealed class(KeyInCard/TossPay 다형,@JsonTypeInfo DEDUCTION)이고,CancelRequest.kt:43의PaymentInfoRequest는 동일 단순명의 일반 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 TTLREISSUE.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.expirationAt를toUTC()(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 GPSapprovalDate(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 거부), singaporeairsaveSeat/divide/saveAncillary/repricingWithAncillary(‘현재 서비스하지 않음’), jinairsearchSeatmap, koreanaircheckPnr/amadeusndccheckPnr(항상 true 스텁).
연습문제 연결
이 지뢰들로 디버깅 훈련하기
- “검색 결과가 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