이 문서는 air-intl-adapter에서 실제로 사고가 나는 지점(landmines)을 “장애 시나리오”로 재구성한 디버깅 훈련 모음이다. 전 문항이 debug 유형이며, 운영에서 마주칠 법한 증상을 먼저 주고 신입이 직접 조사 순서를 세워 보게 한다.
푸는 법: ① 증상만 보고 “내가 콜이라면 어디부터 로그를 깔까”를 적는다. ② 조사 순서 → 근본원인 → 예방을 스스로 정리한다. ③ [!answer]- 정답 보기를 펼쳐 파일경로·라인·예외 타입·전파 경로까지 맞췄는지 대조한다.
이 시스템의 사고는 메시지큐가 아니라 4개 경로로만 표면화된다(중요): ①동기 예외→RestExceptionHandler, ②서킷 OPEN→검색 fallback 200 [], ③fire-and-forget 코루틴→AdapterCoroutineExceptionHandler(Sentry 로깅만), ④Slack 수동 경보(best-effort). → landmines §0, error-handling, async-coroutines, resilience-and-events
증상을 받으면 가장 먼저 4개 경로 중 어디로 드러난 사고인지 분류하라. 5xx면 ①(동기예외), 200 []이면 ②(서킷 OPEN) 또는 검색 부분실패, “API는 성공인데 뒤가 안 맞음”이면 ③(fire-and-forget), Sentry/Slack은 자동이 아니라 조건부/수동이다. 이 분류만으로 조사 범위가 1/4로 줄어든다.
문제 1 — [debug] “오후부터 Amadeus 예약·발권·취소가 전부 막혔다”
오후 2시경부터 Amadeus(/internals/AMADEUS/booking, /ticketing, /cancel)가 전부 실패하기 시작했다. 검색(/search)은 정상이다. 에러 메시지는 GDS 측 “no more sessions available / session limit exceeded” 류다. 재배포하면 잠깐 풀렸다가 다시 막힌다.
근본원인: stateful 세션 누수로 TOPAS 동시세션/라이선스 한도 소진. Amadeus(TOPAS·1A)는 PNR 트랜잭션을 stateful 세션 토큰 위에서 수행하고, 예외 시 세션을 닫지 않으면 GDS 측 세션이 열린 채 쌓여 한도가 고갈된다. 검색은 stateless라 멀쩡하다 → “검색 정상 + 예약/발권/취소 전멸”이 결정적 단서.
조사 순서:
검색이 정상인지 확인 → 정상이면 네트워크/공통 인프라가 아니라 stateful 경로 한정 문제로 좁힌다.
최근 배포 diff에서 새 stateful 오퍼레이션 추가/리팩터링 여부 확인. 누수는 거의 항상 “세션 종료 패턴 누락”에서 온다.
AmadeusBookingService.kt의 catch 블록이 전부 종료 패턴으로 끝나는지 점검:
} catch (e: Exception) { if (session?.transactionStatusCode == TransactionStatusCode.InSeries) { end { amadeusClient.signOut(statefulBuilder = this) } } throw e}
이 패턴이 AmadeusBookingService.kt:172, :268, :316, :373, :400 등 모든 catch에 있어야 한다(누락 시 누수 재생산). Sabre는 finally { closeSessionToken(token) } 쌍 — SabreTicketingService.kt:47-48, SabreCashReceiptService.kt:54-55, SabreQueueService.kt:74-75/130-131, SabrePassengerService.kt:68-69.
4. 코너케이스: signOut은 transactionStatusCode == InSeries일 때만 호출된다. start{} 직후 InSeries 전이 전에 인증 실패/네트워크 오류가 나면 패턴을 정확히 복붙해도 세션이 닫히지 않는다(landmines §1 “Start 직후 예외”).
재배포로 일시 회복하는 이유: 프로세스가 죽으면 어댑터 측 커넥션이 끊겨 GDS가 일부 좀비 세션을 정리하기 때문. 즉 재배포는 증상 완화일 뿐 근본 치료가 아니다.
이 CoroutineScope(Dispatchers.IO)는 부모가 없는 분리 스코프다(Job을 보관하지 않음 → 구조화된 동시성 밖, 60+ 사용처). 인메모리이고 영속큐가 아니다.
조사 순서:
“동기 성공 + 비동기 보상” 패턴인지 확인 → withLaunch/pnrCancelAsync/pnrCancelRepeat(AmadeusCancelService.kt:237)를 grep.
불일치 발생 시각에 재배포/스케일인이 있었는지 확인. delay(5000) 만료 전 프로세스가 죽으면 취소가 영영 실행되지 않는다(고아 PNR).
보상 코루틴 자체가 던진 예외 추적: withLaunch는 AdapterCoroutineExceptionHandler()를 컨텍스트에 주입(CoroutineExtensions.kt:20-25)하므로 Sentry 로깅은 남는다. 단 호출자에게는 전파 안 됨.
사후 추적의 유일한 능동 신호는 Slack 수동 경보: pnrCancelRepeat가 최종 실패 시 slackService.sendCancelFail(...)(AmadeusCancelService.kt:249), 결제취소 실패는 sendPaymentCancelFail(...)(:435) 후 rethrow. 이건 best-effort라 전송 실패도 조용히 사라진다.
"치명 오류 = 자동 Slack"은 오해 RestExceptionHandler/AdapterCoroutineExceptionHandler는 Slack을 호출하지 않는다. 자동 경보는 Sentry뿐이고, Slack은 서비스 코드가 수동으로 부르는 지점에서만 나간다. → error-handling §5.3
예방: 신뢰성이 필요한 보상은 support/util/PollingUtils.kt의 polling처럼 Redis에 상태를 남기는 패턴으로 바꿔야 한다(DeferredResult PENDING/COMPLETE/ERROR를 TTL과 함께 저장, PollingUtils.kt:12-40). 전 공급사 동일 패턴 — AmadeusTicketingService.kt:217, SabreTicketingService.kt, KoreanairTicketingService.kt(결제 5초/예약 10초 시간차) 등.
출처: async-coroutines, landmines §2, resilience-and-events, amadeus-pitfalls
문제 3 — [debug] “특정 공급사만 검색 결과가 0건이다 — 장애인가 정상인가?”
대시보드에서 어떤 공급사의 검색 결과가 한동안 0건으로 보인다. 다른 공급사는 정상. 5xx는 없고 응답코드는 전부 200이다. 같은 0건이라도 원인이 4가지나 된다고 한다. 어떻게 구분하나?
공급사가 검색에서 스스로 빠짐. TWAY/JINAIR는 여정≤2 & 항공사코드 포함, SINGAPOREAIR는 SQ 포함, GROUPAIR는 왕복만. 조건 불충족이면 정상 동작으로 0건
(c) Jin Air 당일 컷오프
JinairFlightSearchService.kt:54return emptyList()
departureDate < today().plusDays(2)(KST) 류 제약/필터로 출발 임박편은 무조건 0건. “왜 진에어만 안 되지?”의 단골
(d) pmap 부분/전체 실패 swallow
getOrEmpty()(CoroutineExtensions.kt:62)
OD 조합 일부가 타임아웃/에러여도 성공분만 반환, 전부 실패면 0건. 로그 warn에만 흔적
조사 순서:
Datadog에서 supplier.circuit-breaker=OPEN 태그 확인 → 있으면 (a) 확정. (a)는 5xx가 아니라 태그/0건으로만 드러나므로 알람이 없으면 운영자가 모른다(landmines §7).
요청 본문의 항공사코드/여정수/왕복여부를 isSearchable 규칙과 대조 → (b).
출발일이 임박했나? → (c).
위가 다 아니면 검색 로그의 warn(AsyncFail)을 본다 → (d). getOrEmpty가 실패를 삼키므로 로그 확인이 필수.
서킷 fallback은 CallNotPermittedException만 받는다
searchFallback 시그니처가 CallNotPermittedException만 받으므로(AmadeusSearchController.kt:73), 검색 로직 내부의 타임아웃/비즈니스 예외는 fallback을 안 타고 그대로 500 전파된다. "왜 어떤 검색 실패는 빈 결과인데 어떤 건 500이냐"의 답. → resilience-and-events, exercises-architecture 문제5
근본원인 두 갈래. “KRW만 정상”이 결정적 단서다 — KRW는 decimals=0이라 버그가 우연히 가려진다.
(1) 통화 소수자릿수(decimals) 미적용 → 100배 부풀림
lufthansa: CurrencyAmountValue.getAmount는 movePointLeft(decimals)를 적용하지만, 검색(Offer.kt:182)·예약(DataList.kt:163)은 toLong() 직접 변환으로 decimals를 무시한다. EUR(소수 2자리) 응답이면 12345가 123.45가 아니라 12345로 들어가 100배. FareRule 변경수수료만 올바른 getAmount() 사용 — 같은 모듈 안에서도 비일관.
amadeusndc: convertPriceDropLast가 반올림이 아닌 truncation, currencyMap에 통화 없으면 자릿수 0. toBooking(convert)과 toReissueBooking(toLong() 무변환)이 같은 totalPrice를 다른 스케일로 — 명백한 버그.
(2) tax에 fuelCharge가 합산되어 유류할증 이중계상
다수 공급사가 Fare.tax에 유류할증을 이미 합산하면서 fuelCharge/surcharge를 별도로도 보관한다. Fare.total = airPrice + tax라 유류할증은 이미 포함. 신입이 airPrice + tax + fuelCharge로 다시 합하면 즉시 이중계상. tway(Fare.kt), jinair(AirAvailabilityRS.kt), groupair, amadeusndc(Passenger.kt).
조사 순서:
신고 노선의 통화 코드 확인 → 비-KRW면 (1) 강력 의심.
부풀림 배율이 정확히 100배/10배면 decimals 누락(toLong() vs getAmount()) 확인.
OfflineAuthInfo(...)를 생성자로 직접 만들면 SEED 암호화를 건너뛰어 평문 카드정보가 와이어에 나가고 T’way가 거부한다. 반드시 CardInfo.of()를 통해서만 생성. SEED 키 'twayBookingAPI@014'는 jar 안 정적 필드에 하드코딩 — 키 로테이션은 T’way가 새 jar를 배포해야만 가능.
근본원인: 재발행(addition/reissue)은 동기가 아니라 202 ACCEPTED + 폴링키 모델이다. Pending은 정상 진행 상태다.
흐름(KoreanairTicketingController.kt):
POST .../addition (reissue) ── 202 ACCEPTED + DeferredKeyView(pollingKey) (:53~66)
│ polling(key, ttl, redisTemplate) { 실제 재발행 } → 백그라운드 실행, 즉시 키 반환
▼
GET .../addition/{reissueKey} ── DeferredView<...> (:69~)
DeferredStatus.PENDING -> DeferredView.Pending (:77) ← 정상 진행!
DeferredStatus.ERROR -> throw throwable!! (:78) ← 저장된 예외 재던짐
DeferredStatus.COMPLETE -> DeferredView.Complete(...) (:79~)
서버측 polling(PollingUtils.kt:12-40)은 Redis에 DeferredResult.pending()을 TTL과 함께 저장한 뒤 CoroutineScope(Dispatchers.IO).withLaunch로 실제 작업을 돌리고, 성공/실패에 따라 complete/error로 갱신한다(:21-37). 조회측 poller(:43-49)는 키가 없으면 CacheKeyInvalidException(INVALID_CACHE_KEY), throwable != null이면 재던진다.
조사 순서:
컨트롤러 응답코드 확인 → 202이고 body가 DeferredKeyView면 폴링 모델 확정.
Triple이 GET .../{reissueKey}로 폴링하고 있는지 확인. 한 번만 조회하고 Pending이면 실패로 치는 게 진짜 버그.
ERROR 누락이면: 폴링 결과 DeferredStatus.ERROR일 때 throw throwable!!(:78)로 저장된 예외를 재던지는데, 호출자가 이를 못 잡고 흘리는지 본다.
(a) 분류된 실패가 안 잡힘 → ApiException.capturable 기본값 falseApiException은 .capture()를 호출해야만 Sentry에 잡힌다(기본 capturable=false, Exceptions.kt). sentryLog()는 this is SentryAlertHandler && capturable.not()이면 전송 스킵. 즉 .capture() 없이 던진 비즈니스 예외(예상된 실패)는 의도적으로 조용하다. 결제 거절도 분류된 코드는 .capture() 없이 안 잡히고, set에 없는 미분류만 PAYMENT_ETC로 capture되는 모듈도 있다(landmines §4 “결제 에러코드 미등록”).
(b) 타임아웃이 과보고 → SOAP fault 파싱 실패 시 raw 예외 전파ClientSupport.handleSoapFaultException(ClientSupport.kt:62)은 errorData가 null이거나 비-SOAP 형식이면 ?: this.exception 폴백으로 원본 예외(IOException 등)를 던진다. 타임아웃(OkHttpError.isTimeout, ClientSupport.kt:206)도 errorData가 없어 이 경로로 빠진다. 비-ApiException은 capturable 체크를 우회해 무조건 Sentry로 전송되고 응답 code는 분류를 잃고 INTERNAL_SERVER_ERROR로만 나간다.
(응답/로그 불일치) → findRootCause()가 cause 래핑의 분류를 버린다RestExceptionHandler.findRootCause()는 가장 안쪽 cause를 로그/Sentry 대상으로 삼는다. InternationalAdapterException(BOOKING_FAILED, cause=ioException)처럼 던지면 root cause인 ioException이 Sentry로 가고 BOOKING_FAILED 분류가 사라진다. 게다가 응답 변환은 원본 e를 쓰므로(RestExceptionView.of(exception=e)) 응답 code는 정상 — 로그/Sentry와 응답이 서로 다른 예외를 본다.
조사 순서:
안 잡히는 실패가 ApiException(비즈니스)인지 확인 → .capture() 호출 여부 grep. 모니터링하고 싶으면 .capture()를 붙인다.
응답 code와 Sentry 예외가 다르면 cause 래핑(findRootCause) 분류 손실을 의심.
HTTP status는 ErrorMessage(코드)가 아니라 예외 "타입"이 정한다 @ControllerAdvice(RestExceptionHandler)가 MethodArgumentInvalidException→400, CacheKeyInvalidException→410, InternationalAdapterException/그 외→500으로 분기. 응답 code는 exception.errorMessage.name이라 enum 상수명이 곧 외부 API 계약 — 추가만 안전, 리네임/삭제는 브레이킹. → error-handling, exercises-architecture 문제5
예방: 모니터링 대상 비즈니스 실패는 .capture(), 정규화 실패 경로 모니터링, cause 래핑 시 분류 손실 인지. 일부 lufthansa 분기(booking, changeApis)는 .capture() 누락으로 Sentry 사각지대.
출처: error-handling, landmines §4, lufthansa-pitfalls