회복탄력성 & 상태/이벤트 전파 (Resilience4j·Slack)

arch-cross-cutting pattern-resilience pattern-circuit-breaker config-resilience

한 줄 요약

이 시스템에는 Kafka/RabbitMQ 같은 메시지 큐가 없다. “이벤트/상태 전파”는 곧 ① Resilience4j 서킷브레이커의 상태전이(CLOSED→OPEN→HALF_OPEN), ② Spring Retry(@Retryable) 의 재시도/백오프, ③ 예외 전파(error-handlingApiException + .retry()/.capture()), ④ Slack 경보(SlackServiceSlackClient, 코루틴 fire-and-forget) 이 네 가지의 조합으로 구현된다. 운영자에게 “사고가 났다”는 사실이 도달하는 유일한 실시간 경로가 Slack이다.


0. 두 개의 서로 다른 라이브러리를 헷갈리지 마라

신입이 가장 먼저 빠지는 함정. 이 코드베이스에는 이름이 비슷한 두 메커니즘이 공존한다.

메커니즘라이브러리어노테이션임포트적용 위치단위
서킷브레이커Resilience4j@CircuitBreakerio.github.resilience4j.circuitbreaker.annotation.CircuitBreaker검색 컨트롤러 11개 (interfaces 레이어)공급사별 1개
재시도Spring Retry@Retryable + @EnableRetryorg.springframework.retry.annotation.Retryable클라이언트/서비스 (infrastructure·application 레이어)메서드별
// AmadeusSearchController.kt:13  → Resilience4j
import 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.ymlresilience4j: 블록에는 retry: 설정이 없다(circuitbreaker만 있음). 재시도 횟수/백오프는 각 @Retryable 어노테이션 인자에 하드코딩되어 있다.


1. 서킷브레이커: 검색 경로만 보호한다 (Resilience4j)

1.1 어디에 붙어 있나 — 11개 검색 컨트롤러

@CircuitBreaker오직 {Name}SearchController.search() 에만 붙는다. 예약/발권/취소/환불에는 서킷브레이커가 없다.

공급사컨트롤러서킷 인스턴스명fallback
amadeusAmadeusSearchController.kt:24amadeusSearchsearchFallback
amadeusndcAmadeusndcSearchController.kt:25amadeusndcSearchsearchFallback
sabreSabreSearchController.kt:24sabreSearchsearchFallback
galileoGalileoSearchController.kt:24galileoSearchsearchFallback
groupairGroupairSearchController.kt:25groupairSearchsearchFallback
twayTwaySearchController.kt:27twaySearchsearchFallback
jinairJinairSearchController.kt:26jinairSearchsearchFallback
jejuairJejuairSearchController.kt:27jejuairSearchsearchFallback
koreanairKoreanairSearchController.kt:28koreanairSearchsearchFallback
lufthansaLufthansaSearchController.kt:28lufthansaSearchsearchFallback
singaporeairSingaporeairSearchController.kt:25singaporeSearchsearchFallback

인스턴스명 vs 공급사명 미스매치

singaporeair만 서킷 인스턴스명이 singaporeSearch다(singaporeairSearch가 아님). application.yml:59의 instance 키와 SingaporeairSearchController.kt:25name="singaporeSearch"정확히 일치해야 설정이 매핑된다. 이름이 어긋나면 default config로 떨어져 조용히 다르게 동작한다.

1.2 설정 — application.yml에 단 한 벌

# src/main/resources/application.yml:37
resilience4j:
  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라는 점이 핵심):

파라미터의미흔한 오해
slidingWindowTypeTIME_BASED윈도우 단위가 시간(초)COUNT_BASED로 착각 → “180번 호출”로 오독
slidingWindowSize180최근 180초(3분)의 호출 통계로 실패율 계산”최근 180건”이 아님
minimumNumberOfCalls30180초 안에 최소 30건이 쌓여야 실패율 평가 시작트래픽 적은 공급사는 영원히 평가 안 될 수 있음
failureRateThreshold35 (%)실패율 ≥ 35%면 CLOSED→OPEN35건이 아니라 35**%**
waitDurationInOpenState120sOPEN 유지 시간(2분), 이후 HALF_OPEN짧다고 오해 금지, 검색 차단 2분은 길다
permittedNumberOfCallsInHalfOpenState10HALF_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"
}

폴백 시그니처에서 배울 점:

포인트설명
파라미터 타입 = CallNotPermittedExceptionOPEN 상태에서 차단된 호출만 이 폴백으로 온다. 일반 검색 예외(타임아웃 등)는 폴백을 타지 않고 그대로 전파됨
반환 = 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다. 전수 조사 결과:

#위치 (file:line)maxAttemptsbackoff재시도 조건비고
1AmadeusClient.kt:894 ticketing()32000ms 고정exceptionExpression="@amadeusClient.shouldTicketingRetryable(#root)"발권 — BAGGAGE MISSING 등에서 .retry()
2AmadeusClient.kt:1173 removePnrsInQueue()35000ms 고정조건 없음(모든 예외 재시도)큐 정리
3AmadeusRetrieveService.kt:28 getPnrInfoAndCheckInfantSoldOut()55000ms 고정@amadeusRetrieveService.shouldGetPnrInfoAndCheckInfantSoldOutRetryable(#root)유아 좌석 HN 대기 폴링
4ArtClient.kt:29 findFareRules()2(기본=1000ms)조건 없음TOPAS ART 운임규정
5NdcArtClient.kt:31 getFareRules()2(기본)조건 없음NDC ART 운임규정
6AmadeusndcClient.kt:279 retrieveByPnr()32000ms@amadeusndcClient.shouldRetrieveRetryable(#root)PNR 재조회
7GalileoClient.kt:87135000ms(해당 메서드 참조)1G
8JejuairCancelService.kt:20 cancelable()23000msinclude=[InternationalAdapterException] + @jejuairCancelService.shouldRetry(#root)취소 가능 판정
9JejuairCancelService.kt:40 cancel()23000ms위와 동일취소
10JejuairClient.kt:216/324/3592(각 메서드)조건토큰 재발급 후 재시도(주석 line 489)
11JinairClient.kt:585 getCancelInfo()35000ms@jinairClient.shouldCancelRetryable(#root)취소 정보
12JinairClient.kt:639 cancelBooking()25000ms@jinairClient.shouldCancelRetryable(#root)취소 실행
13SabreClient.kt:310 getSessionToken()21000msinclude=[IOException]세션 토큰(별도 timeout)
14SabreClient.kt:704 getBooking()32000ms@sabreClient.shouldRetrieveRetryable(#root)PNR 재조회
15SabreQueueService.kt:85 remove()35000ms조건 없음큐 제거
16TwayClient.kt:450 cancel()35000ms@twayClient.shouldCancelRetryable(#root)취소

검색이 아니라 "쓰기/조회"가 재시도된다

서킷브레이커가 검색(읽기 대량 트래픽)을 보호한다면, 재시도는 발권·취소·PNR조회·운임규정 같은 멱등하거나 “잠깐 기다리면 풀리는” 작업에 집중된다. 두 메커니즘의 보호 대상이 거의 겹치지 않는다는 점이 설계 의도다.

2.1 재시도 조건의 핵심 패턴: SpEL + ApiException.retryable

이 시스템의 가장 영리하면서 가장 헷갈리는 패턴. 재시도 여부를 예외 객체 자신이 들고 다닌다.

// support/exception/Exceptions.kt:21
abstract 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 72
fun <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은 self-invocation에서 동작하지 않는다 (Spring AOP 한계)

@Retryable은 프록시 기반이라 같은 클래스 안에서 자기 메서드를 직접 호출하면 무시된다. 반드시 다른 빈(@Service/@Component)을 통해 외부에서 호출되어야 재시도가 걸린다. 그래서 jejuair는 JejuairCancelService(application)가 JejuairClient(infrastructure)를 호출하는 식으로 빈 경계를 넘긴다. 검증 테스트: src/test/.../support/util/SpringElTest.ktRetryTestService로 SpEL 재시도 동작을 실증한다.


3. Slack 경보: 운영자에게 도달하는 유일한 실시간 채널

@CircuitBreaker/@Retryable이 기술적 자가복구라면, 사람의 개입이 필요한 사건은 Slack으로 전파된다. application/SlackService.kt는 24개의 sendXxx 함수로 사고 유형별 메시지를 만든다.

3.1 두 종류의 채널 — emergency vs operation

// configuration/Properties.kt:547
fun getSlackEmergencyChannelProperties(salesChannel: String = MDCHolder.SalesChannel.get()): String =
    channels.emergency[salesChannel.lowercase()] ?: channels.emergency.getValue("default")  // ← 판매채널별 라우팅!
 
fun getSlackOperationChannelProperties(): String =
    channels.operation.getValue("default")  // ← 항상 default
채널용도prod 채널ID(application-prod.yml)판매채널 라우팅
emergency수동 개입이 시급한 사고 (취소/환불/결제취소 실패, QUOTA 부족, VOID 일부실패…)C05FJBD1HPC(항공팀_이머전시), interpark→C06NB02KPT5Ointerpark 채널은 별도 방으로 분기
operation모니터링성 경고 (티켓체크 조회 실패, 타임아웃, EmptyLeg, Warnings, BAGGAGE MISSING, 결제 타임아웃, PNR히스토리 실패)C05FSAESXR9(항공팀_운영로그)X — 항상 default

사고 분류 = 채널 선택

“지금 당장 사람이 수동 취소/환불을 해야 하는가?”가 emergency, “기록해두되 자동 흐름은 계속”이 operation. 예: sendCancelFail(취소 실패→수동취소 필요)은 emergency, sendTicketingTimeout(발권 타임아웃)은 operation. 단 sendTicketingTimeoutEmergencyChannel(line 421)처럼 같은 사건을 emergency로 보내는 변종도 있으니 호출부가 어떤 함수를 쓰는지 봐야 한다.

3.2 메시지 구성 — MDC 컨텍스트가 핵심

모든 메시지는 Slack BlockKit의 title_section_block + description_section_block 2블록 구조이며, 공통적으로 MDCHolder에서 요청 컨텍스트를 끌어와 박는다.

// SlackService.kt 내부 공통 패턴
markdownText("*채널/경로:* :${MDCHolder.SalesChannel.get().lowercase()}: :${MDCHolder.SalesFunnel.get().lowercase()}:")
markdownText("*공급사:* ${getSupplierName(supplier.name)}")          // :amadeus: 아마데우스 식 이모지+한글
markdownText("*TraceId*: ${MDCHolder.DatadogTraceId.getOrNull() ?: ""}")  // ← Datadog 연결고리
findOrderId()?.let { markdownText("*예약번호*: ${getAdminOrderPageLink(it)}") }  // 어드민 딥링크

TraceId가 Slack과 Datadog을 잇는다

모든 경보에 MDCHolder.DatadogTraceId가 박힌다. 운영자는 Slack에서 TraceId를 복사해 Datadog에서 전체 트레이스(서킷브레이커 span 태그 supplier.circuit-breaker=OPEN 포함)를 역추적할 수 있다. 즉 “빈 결과 흡수(fallback) → Datadog 태그 → Slack TraceId → 트레이스 복원” 이 사실상의 이벤트 추적 파이프라인이다. getAdminOrderPageLinkhttps://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:25
fun 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동작
localfalse (application-local.yml:30)전송 안 함
dev/qa(base application.yml:80 = true, 채널은 모두 C0766644BPZ 항공팀_테스트로그)테스트 채널로
staging/prodtrue (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 가 재시도 여부를 운반
  • 이벤트3 = SlackService.sendXxx() emergency/operation 심각도 분기
  • 이벤트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:41 configs.search 한 곳 (전 공급사 공유, 전 프로파일 공통)application.yml
재시도는 어떤 라이브러리?Spring Retry (@EnableRetry), Resilience4j 아님AirIntlAdapterApplication.kt:9
재시도 여부는 어떻게 결정?예외의 ApiException.retryable 플래그 + exceptionExpression SpELExceptions.kt:69, .retry():72
재시도 횟수/지연은 어디?@Retryable 인자에 하드코딩 (yml에 없음)위 표 §2
백오프 종류?고정 지연(fixed), 지수/지터 없음모든 Backoff(delay=N)
OPEN 상태를 사람이 알 수 있나?자동 Slack 없음. Datadog span 태그뿐searchFallback()
긴급 vs 일반 경보 구분?emergency(수동개입) / operation(모니터링)SlackService 채널 선택
Slack 실패 시?코루틴에서 로깅 후 무시, 비즈니스 무영향SlackClient.kt:27
local에서 Slack 안 가는 이유?slack.active=falseapplication-local.yml:30

더 읽기

  • error-handlingApiException/.capture()/.retry()/SentryAlertHandler의 전체 예외 전파 체계와 전역 핸들러
  • async-coroutineswithLaunch, SupervisorJob, AdapterCoroutineExceptionHandler (Slack fire-and-forget의 기반)
  • configuration-and-infraapplication*.yml 프로파일 구조, SlackProperties/Channels 바인딩, 공급사별 supplier/*.yml
  • landmines — 아래 §지뢰 요소를 한곳에 모은 운영 주의 목록