이 시스템에는 Kafka/RabbitMQ 같은 메시지 큐가 없다. “이벤트/상태 전파”는 곧
① Resilience4j 서킷브레이커의 상태전이(CLOSED→OPEN→HALF_OPEN),
② Spring Retry(@Retryable) 의 재시도/백오프,
③ 예외 전파(error-handling의 ApiException + .retry()/.capture()),
④ Slack 경보(SlackService→SlackClient, 코루틴 fire-and-forget)
이 네 가지의 조합으로 구현된다. 운영자에게 “사고가 났다”는 사실이 도달하는 유일한 실시간 경로가 Slack이다.
// AmadeusSearchController.kt:13 → Resilience4jimport io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker// AmadeusRetrieveService.kt:17 → Spring Retry (전혀 다른 라이브러리!)import org.springframework.retry.annotation.Retryable
@Retryable은 Resilience4j의 @Retry가 아니다
Resilience4j도 @Retry 어노테이션을 제공하지만, 이 프로젝트는 그것을 쓰지 않는다.
재시도는 전부 Spring Retry(org.springframework.retry)이며, AirIntlAdapterApplication.kt:9의
@EnableRetry로 활성화된다. 따라서 application.yml의 resilience4j: 블록에는 retry: 설정이 없다(circuitbreaker만 있음).
재시도 횟수/백오프는 각 @Retryable 어노테이션 인자에 하드코딩되어 있다.
1. 서킷브레이커: 검색 경로만 보호한다 (Resilience4j)
1.1 어디에 붙어 있나 — 11개 검색 컨트롤러
@CircuitBreaker는 오직 {Name}SearchController.search() 에만 붙는다. 예약/발권/취소/환불에는 서킷브레이커가 없다.
공급사
컨트롤러
서킷 인스턴스명
fallback
amadeus
AmadeusSearchController.kt:24
amadeusSearch
searchFallback
amadeusndc
AmadeusndcSearchController.kt:25
amadeusndcSearch
searchFallback
sabre
SabreSearchController.kt:24
sabreSearch
searchFallback
galileo
GalileoSearchController.kt:24
galileoSearch
searchFallback
groupair
GroupairSearchController.kt:25
groupairSearch
searchFallback
tway
TwaySearchController.kt:27
twaySearch
searchFallback
jinair
JinairSearchController.kt:26
jinairSearch
searchFallback
jejuair
JejuairSearchController.kt:27
jejuairSearch
searchFallback
koreanair
KoreanairSearchController.kt:28
koreanairSearch
searchFallback
lufthansa
LufthansaSearchController.kt:28
lufthansaSearch
searchFallback
singaporeair
SingaporeairSearchController.kt:25
singaporeSearch
searchFallback
인스턴스명 vs 공급사명 미스매치
singaporeair만 서킷 인스턴스명이 singaporeSearch다(singaporeairSearch가 아님).
application.yml:59의 instance 키와 SingaporeairSearchController.kt:25의 name="singaporeSearch"가
정확히 일치해야 설정이 매핑된다. 이름이 어긋나면 default config로 떨어져 조용히 다르게 동작한다.
1.2 설정 — application.yml에 단 한 벌
# src/main/resources/application.yml:37resilience4j: circuitbreaker: circuit-breaker-aspect-order: 1 # AOP 적용 순서(아래 1.5 참고) configs: search: # 모든 공급사가 공유하는 baseConfig slidingWindowSize: 180 slidingWindowType: TIME_BASED # 호출 "건수"가 아니라 "초" 단위! permittedNumberOfCallsInHalfOpenState: 10 minimumNumberOfCalls: 30 waitDurationInOpenState: 120s failureRateThreshold: 35 # 실패율 35% 이상이면 OPEN instances: amadeusSearch: { baseConfig: search } # ... 11개 모두 baseConfig: search 만 참조 (개별 오버라이드 없음)
11개 공급사가 동일 설정을 공유하지만 서킷은 인스턴스별로 독립이다
configs.search는 “템플릿”일 뿐, 실제 서킷브레이커 상태(CLOSED/OPEN/HALF_OPEN)는
amadeusSearch, sabreSearch… 인스턴스마다 따로 관리된다. 아마데우스가 OPEN이어도 세이버는 정상일 수 있다.
이 파일은 프로파일 무관 공통이다 — application-prod.yml/-staging.yml 등 어디에도
resilience4j 오버라이드가 없으므로 dev=qa=prod 전부 동일 임계값으로 돌아간다.
설정 값의 의미를 정확히 이해하라 (TIME_BASED라는 점이 핵심):
파라미터
값
의미
흔한 오해
slidingWindowType
TIME_BASED
윈도우 단위가 시간(초)
COUNT_BASED로 착각 → “180번 호출”로 오독
slidingWindowSize
180
최근 180초(3분)의 호출 통계로 실패율 계산
”최근 180건”이 아님
minimumNumberOfCalls
30
180초 안에 최소 30건이 쌓여야 실패율 평가 시작
트래픽 적은 공급사는 영원히 평가 안 될 수 있음
failureRateThreshold
35 (%)
실패율 ≥ 35%면 CLOSED→OPEN
35건이 아니라 35**%**
waitDurationInOpenState
120s
OPEN 유지 시간(2분), 이후 HALF_OPEN
짧다고 오해 금지, 검색 차단 2분은 길다
permittedNumberOfCallsInHalfOpenState
10
HALF_OPEN에서 시험 호출 10건
10건의 실패율로 재판정
1.3 상태 전이 다이어그램
stateDiagram-v2
CLOSED: CLOSED 정상 호출통과
OPEN: OPEN 차단 모든 search 호출 즉시 차단 CallNotPermittedException
HALF_OPEN: HALF_OPEN 시험 10건만 통과
CLOSED --> OPEN: 실패율 35퍼센트 이상 180초 윈도우 최소 30건
OPEN --> HALF_OPEN: waitDurationInOpenState 120초 경과
HALF_OPEN --> CLOSED: 시험호출 10건 실패율 35퍼센트 미만
HALF_OPEN --> OPEN: 시험호출 10건 실패율 35퍼센트 이상
OPEN to HALF_OPEN 전이는 Resilience4j 내부 스케줄에 의한 자동 경과이며 별도 트리거 코드가 없다.
CLOSED → OPEN: 최근 180초 동안 30건 이상 호출 중 실패율이 35% 넘으면 차단 시작.
OPEN → HALF_OPEN: 120초 자동 경과. 별도 트리거(이벤트/타이머 코드) 없음 — Resilience4j 내부 스케줄.
HALF_OPEN → CLOSED/OPEN: 시험 호출 10건의 실패율로 복귀/재차단 결정.
"이것이 이 시스템의 이벤트 전파다"
OPEN으로 전이되는 순간이 곧 “이 공급사 검색이 죽었다”는 이벤트의 발생이다.
단, 이 이벤트는 메시지로 발행되지 않는다. 대신 두 가지 부수효과로 전파된다:
① fallback이 실행되어 supplier.circuit-breaker = "OPEN" Datadog span 태그가 찍힌다(아래 1.4),
② 호출자(Triple 예약 시스템)는 빈 리스트(emptyList())를 받아 “해당 공급사 결과 없음”으로 인지한다.
Resilience4j의 onStateTransition 이벤트 컨슈머는 등록되어 있지 않다(grep 결과 0건). 즉 상태전이 자체는
로그/Slack으로 직접 알리지 않는다 — 관측은 전적으로 Datadog 태그에 의존한다.
1.4 폴백(fallback) 메서드 — 실패를 “빈 결과”로 흡수
모든 11개 컨트롤러의 fallback은 사실상 동일하다(AmadeusSearchController.kt:72).
@Suppress("UNUSED_PARAMETER")private fun searchFallback(exception: CallNotPermittedException): ResponseEntity<List<FareItineraryView>> { val span = GlobalTracer.get().activeSpan() if (span != null) { span.setTag("supplier.circuit-breaker", "OPEN") (span as? MutableSpan)?.localRootSpan?.setTag("supplier.circuit-breaker", "OPEN") } return ResponseEntity.ok(emptyList()) // ← 200 OK + 빈 결과로 "graceful degradation"}
폴백 시그니처에서 배울 점:
포인트
설명
파라미터 타입 = CallNotPermittedException
OPEN 상태에서 차단된 호출만 이 폴백으로 온다. 일반 검색 예외(타임아웃 등)는 폴백을 타지 않고 그대로 전파됨
반환 = 200 OK + emptyList()
호출자에게 에러를 던지지 않고 “결과 없음”으로 위장 → 한 공급사 장애가 전체 검색을 죽이지 않음
Datadog 태그
localRootSpan에도 태그를 박아 트레이스 루트에서 검색 가능
fallback 시그니처 규칙 (Resilience4j)
fallbackMethod는 원본 메서드와 같은 반환타입 + 마지막에 던져진 예외 타입을 받는 파라미터여야 매칭된다.
여기서는 CallNotPermittedException만 받으므로, 검색 로직 내부에서 던진 다른 예외는 fallback으로 흡수되지 않고
그대로 호출자에게 500으로 전파된다. “왜 어떤 검색 실패는 빈 리스트인데 어떤 건 500이지?”의 답이 여기 있다.
일부 컨트롤러는 ResponseEntity<List<...>>, 일부는 List<...>(jejuair는 @ResponseStatus(OK) + 생 List)로
반환 형태가 갈리므로 폴백 반환 타입도 그에 맞춰져 있다.
1.5 circuit-breaker-aspect-order: 1이 왜 중요한가
검색 컨트롤러에는 서킷브레이커만 붙지만, 재시도(@Retryable)는 그 아래 클라이언트 메서드에 붙는다. 두 AOP가 같은 호출 스택에서 겹칠 때 누가 바깥에 있느냐가 동작을 바꾼다.
바깥/안쪽 순서의 의미
circuit-breaker-aspect-order: 1 (낮을수록 바깥). 서킷브레이커가 바깥(outer) 에 위치하면,
안쪽 재시도가 전부 소진된 최종 실패 1건만 서킷브레이커 통계에 잡힌다(재시도 중간 실패는 카운트 안 됨).
만약 순서가 반대였다면 재시도 각각이 실패로 집계되어 서킷이 훨씬 빨리 OPEN될 것이다.
단, 검색 경로의 클라이언트에는 대체로 @Retryable이 없고(재시도는 주로 예약/취소/조회 경로),
검색 컨트롤러와 같은 빈 메서드에 둘이 동시에 걸리는 경우는 드물다 — 그래도 전역 order 설정이라 알아둘 것.
2. 재시도: 예약/취소/조회 경로의 자가 치유 (Spring Retry)
@Retryable은 @EnableRetry(AirIntlAdapterApplication.kt:9)로 켜진 Spring Retry다. 전수 조사 결과:
서킷브레이커가 검색(읽기 대량 트래픽)을 보호한다면, 재시도는 발권·취소·PNR조회·운임규정 같은
멱등하거나 “잠깐 기다리면 풀리는” 작업에 집중된다. 두 메커니즘의 보호 대상이 거의 겹치지 않는다는 점이 설계 의도다.
2.1 재시도 조건의 핵심 패턴: SpEL + ApiException.retryable
이 시스템의 가장 영리하면서 가장 헷갈리는 패턴. 재시도 여부를 예외 객체 자신이 들고 다닌다.
// support/exception/Exceptions.kt:21abstract class ApiException : RuntimeException, SentryAlertHandler { override var capturable: Boolean = false // ← Sentry 캡처 여부 (error-handling) override var notifiable: Boolean = true var retryable: Boolean? = null // ← null=미지정, true=재시도, false=재시도금지}fun <T : ApiException> T.retry(): T { this.retryable = true; return this } // line 72fun <T : ApiException> T.noRetry(): T { this.retryable = false; return this } // line 77
// 예: AmadeusClient.kt:951 / AmadeusRetrieveService.kt:60 / JejuairCancelService.kt:81 …// 전부 동일한 한 줄 패턴fun shouldTicketingRetryable(exception: Exception): Boolean { return (exception as? ApiException)?.retryable ?: false // ← retryable==true 일 때만 재시도}
흐름:
flowchart TD
A["비즈니스 로직이 예외를 던질 때 재시도 의도를 마킹<br/>throw InternationalAdapterException TICKETING_FAILED msg .retry<br/>retryable=true 로 설정해서 던짐"]
B["@Retryable<br/>exceptionExpression 은 @bean.shouldXxxRetryable #root"]
C["shouldXxxRetryable e<br/>e as ApiException 의 retryable 또는 false"]
D["백오프 후 재시도"]
E["즉시 전파 재시도 안 함"]
A -->|"예외 전파"| B
B -->|"SpEL이 빈 메서드 호출"| C
C -->|"true"| D
C -->|"false 또는 null"| E
throw ... .retry() 위치: AmadeusClient.kt:927
재시도 조건 평가식: @Retryable(exceptionExpression="@bean.shouldXxxRetryable(#root)")
빈 메서드 구현: shouldXxxRetryable(e) → (e as? ApiException)?.retryable ?: false
#root의 정체
exceptionExpression의 #root는 던져진 예외 객체 자체를 가리킨다(Spring Retry가 SpEL root로 예외를 바인딩).
@amadeusClient.shouldTicketingRetryable(#root)는 “amadeusClient 빈의 메서드에 예외를 넘겨 boolean을 받는다”는 뜻.
이 패턴 덕에 재시도 정책을 비즈니스 로직 쪽에서 동적으로 결정할 수 있다. 단점은 추적이 어렵다는 것(아래 지뢰).
.retry()가 실제로 의미를 갖는 자리 (실코드)
// AmadeusClient.kt:925 발권 응답에 BAGGAGE ALLOWANCE MISSING 경고가 오면throw InternationalAdapterException(ErrorMessage.TICKETING_FAILED, errorMessage).retry() // 재시도 O// line 930 Time Out 이면 timeoutCallback() 호출 후throw InternationalAdapterException(ErrorMessage.TICKETING_FAILED, errorMessage).capture() // 재시도 X, Sentry 캡처
// JejuairClient.kt:485 특정 에러코드(OTAUSV900)만 토큰 재발급 후 재시도"OTAUSV900" -> throw InternationalAdapterException(...).retry() // 주석: "호출 서비스 단에 @Retryable 처리하여 token 재발급 후 재시도"
2.2 백오프는 모두 “고정 지연(fixed delay)”
표의 backoff는 전부 Backoff(delay = N) 형태다. multiplier(지수 백오프)나 maxDelay가 없다. 즉 매 재시도마다 동일 간격으로 쉰다(예: 2초, 2초). 지터(jitter)도 없어 동시 다발 실패 시 재시도가 동기화되어 외부 API에 부하 스파이크를 줄 수 있다(지뢰 참조).
@Retryable은 프록시 기반이라 같은 클래스 안에서 자기 메서드를 직접 호출하면 무시된다.
반드시 다른 빈(@Service/@Component)을 통해 외부에서 호출되어야 재시도가 걸린다. 그래서 jejuair는
JejuairCancelService(application)가 JejuairClient(infrastructure)를 호출하는 식으로 빈 경계를 넘긴다.
검증 테스트: src/test/.../support/util/SpringElTest.kt가 RetryTestService로 SpEL 재시도 동작을 실증한다.
3. Slack 경보: 운영자에게 도달하는 유일한 실시간 채널
@CircuitBreaker/@Retryable이 기술적 자가복구라면, 사람의 개입이 필요한 사건은 Slack으로 전파된다.
application/SlackService.kt는 24개의 sendXxx 함수로 사고 유형별 메시지를 만든다.
“지금 당장 사람이 수동 취소/환불을 해야 하는가?”가 emergency, “기록해두되 자동 흐름은 계속”이 operation.
예: sendCancelFail(취소 실패→수동취소 필요)은 emergency, sendTicketingTimeout(발권 타임아웃)은 operation.
단 sendTicketingTimeoutEmergencyChannel(line 421)처럼 같은 사건을 emergency로 보내는 변종도 있으니
호출부가 어떤 함수를 쓰는지 봐야 한다.
3.2 메시지 구성 — MDC 컨텍스트가 핵심
모든 메시지는 Slack BlockKit의 title_section_block + description_section_block 2블록 구조이며,
공통적으로 MDCHolder에서 요청 컨텍스트를 끌어와 박는다.
모든 경보에 MDCHolder.DatadogTraceId가 박힌다. 운영자는 Slack에서 TraceId를 복사해 Datadog에서
전체 트레이스(서킷브레이커 span 태그 supplier.circuit-breaker=OPEN 포함)를 역추적할 수 있다.
즉 “빈 결과 흡수(fallback) → Datadog 태그 → Slack TraceId → 트레이스 복원” 이 사실상의 이벤트 추적 파이프라인이다.
getAdminOrderPageLink는 https://air.{profile}.admin.triple-corp.com/order/international/orders/{id}로
어드민 주문 페이지 딥링크를 만든다(SlackService.kt:727, profile은 active profile에서 추출).
3.3 전송은 fire-and-forget 코루틴 — 절대 호출자를 막지 않는다
// infrastructure/slack/SlackClient.kt:25fun send(channel: String, blocks: List<LayoutBlock>) { if (active.not()) return // slack.active=false면 즉시 무시(local 등) CoroutineScope(Dispatchers.IO).withLaunch { // ← 새 스코프에 띄우고 즉시 반환 val title = (blocks.find { it.blockId == "title_section_block" } as SectionBlock?)?.fields?.get(0)?.text slack.chatPostMessage { req -> req.channel(channel).blocks(blocks).text(title ?: "fallback message").also { if (profile != "prod") it.username("항공($profile)") // 비-prod는 봇 이름에 환경 표시 } } }}
Slack 전송 실패는 조용히 사라진다 — 그리고 책임 추적이 끊긴다
CoroutineScope(Dispatchers.IO).withLaunch{}는 새 루트 스코프다. withLaunch는 내부적으로
SupervisorJob() + AdapterCoroutineExceptionHandler() + MDCContext()를 붙인다(async-coroutines 참조).
Slack API 호출이 실패하면 예외는 AdapterCoroutineExceptionHandler로 가서 로깅만 되고,
원래 비즈니스 흐름은 영향을 받지 않는다(의도된 fire-and-forget). 장점은 “Slack 장애가 발권을 막지 않음”,
단점은 “경보가 안 갔는데 아무도 모름”. 경보의 도달 여부 자체가 보장되지 않는 단일 실패 지점이다.
slack.active 프로파일별 차이:
프로파일
slack.active
동작
local
false (application-local.yml:30)
전송 안 함
dev/qa
(base application.yml:80 = true, 채널은 모두 C0766644BPZ 항공팀_테스트로그)
테스트 채널로
staging/prod
true (infrastructure.slack.active)
실 채널
4. 네 가지가 어떻게 “이벤트 시스템”을 이루나 (통합도)
flowchart TD
Caller["Triple 예약 시스템 은 호출자"]
CB["@CircuitBreaker name 은 supplier Search 인 Resilience4j<br/>CLOSED 실패율35퍼센트 180초 후 OPEN 120초 후 HALF_OPEN"]
App["application 서비스"]
FB["searchFallback<br/>Datadog span tag supplier.circuit-breaker=OPEN 이벤트1<br/>return emptyList graceful degradation"]
Infra["infrastructure 클라이언트<br/>@Retryable maxAttempts Backoff 인 Spring Retry"]
Retry["실패 시 SpEL 은 @bean.shouldXxxRetryable #root<br/>e as ApiException 의 retryable 예외가 재시도여부를 운반 이벤트2"]
Throw["throw ApiException .capture 또는 .retry<br/>error-handling 전역 핸들러<br/>Sentry 캡처 capturable 및 HTTP 에러 응답"]
Slack["SlackService.sendXxx 이벤트3<br/>emergency 또는 operation 사고 심각도 분기"]
Send["SlackClient.send fire-and-forget 코루틴 Slack 채널<br/>TraceId 포함 Datadog 역추적 가능 이벤트4"]
Caller -->|"POST internals SUPPLIER search"| CB
CB -->|"정상 통과"| App
CB -->|"OPEN 차단 CallNotPermittedException"| FB
App --> Infra
Infra --> Retry
Retry -->|"재시도 소진 또는 retryable false"| Throw
Throw -->|"비즈니스 로직이 사람 개입 필요 판단"| Slack
Slack --> Send
호출자 진입 경로: POST /internals/{SUPPLIER}/search
이벤트1 = searchFallback()의 Datadog span tag supplier.circuit-breaker=OPEN + return emptyList()
이벤트2 = @Retryable의 SpEL @bean.shouldXxxRetryable(#root) → (e as? ApiException)?.retryable 가 재시도 여부를 운반
이벤트4 = SlackClient.send() fire-and-forget 코루틴, TraceId 포함으로 Datadog 역추적 가능
시니어로 가는 통찰
메시지 큐가 없다는 것은 **“상태가 한 요청-응답 스택 안에서만 산다”**는 뜻이다. 비동기 이벤트가 필요한 곳은
오직 Slack 알림 한 곳뿐이고, 그것마저 best-effort다. 그래서 이 시스템의 신뢰성은
① 서킷브레이커가 검색 폭주를 막고, ② 재시도가 일시 장애를 흡수하고, ③ 실패가 예외로 동기 전파되며,
④ 사람이 봐야 할 것만 Slack으로 새어나가는 “동기 + best-effort 알림” 모델에 의존한다.
신규 기능을 추가할 때 “이 실패를 누가, 어떻게 알게 되는가?”를 항상 물어라 — 자동 복구(재시도) /
차단(서킷) / 사람 호출(Slack) 중 무엇이 맞는지가 설계의 핵심이다.
5. 빠른 점검 (Quick Reference)
질문
답
근거
서킷브레이커는 어떤 동작을 보호하나?
검색(search)만, 공급사별 11개 인스턴스
*SearchController.kt@CircuitBreaker
서킷 임계값을 바꾸려면?
application.yml:41configs.search 한 곳 (전 공급사 공유, 전 프로파일 공통)