entries 006 · #0006
#0001entry

외부 마케팅 채널 호출 정리: TimeLimiter 데코레이터를 들어낸 이유

Retry 와 TimeLimiter 를 같이 묶지 않기로 한 후, 클라이언트 두 개·설정 두 개·환경 yml 다섯 개에서 같은 패턴을 일괄 정리했다.

이 글에 대해

Spring Boot + Resilience4j로 외부 마케팅 채널(Amplitude, Braze) 두 곳에 이벤트를 전송하는 콜백 경로에서, Retry와 TimeLimiter를 같이 묶어 쓰던 구성을 들어냈다.

두 클라이언트와 두 설정 클래스에서 TimeLimiter 데코레이터, 전용 Executor, CompletableFuture 래핑을 함께 제거하고 Retry 단일 정책으로 통일했다.

코드의 도메인 명사는 일반화했고, 패턴 자체는 어느 도메인에서도 동일하게 적용된다.


§1풀세트로 묶여 있던 호출

콜백 경로에서 외부 마케팅 채널 두 곳을 부르는 클라이언트는 처음부터 Retry, TimeLimiter, 전용 Executor 세 가지가 같이 묶여 있었다.

@Component
public class B2bCallbackAmplitudeResilienceClient {
 
    private final B2bCallbackAmplitudeRestClient restClient;
    private final Retry retry;
    private final TimeLimiter timeLimiter;
    private final Executor executor;
 
    public ResponseEntity<String> send(GenerateAmplitudeRequest request) {
 
        Supplier<ResponseEntity<String>> protectedSupplier =
                Retry.decorateSupplier(retry, () -> restClient.send(request));
 
        Callable<ResponseEntity<String>> timeoutProtectedCall =
                TimeLimiter.decorateFutureSupplier(
                        timeLimiter,
                        () -> CompletableFuture.supplyAsync(protectedSupplier, executor)
                );
 
        try {
            return timeoutProtectedCall.call();
        } catch (Exception ex) {
            throw toAmplitudeException(ex);
        }
    }
}

설정은 두 채널마다 같은 모양의 짝을 갖고 있었다.

  • TimeLimiter: timeoutMillis = 1000, cancelRunningFuture(true)
  • Retry: maxAttempts = 3, retryIntervalMillis = 500
  • Executor: 채널 전용 fixed thread pool, 크기 8
@Bean(name = "amplitudeTimeLimiter")
public TimeLimiter amplitudeTimeLimiter(Properties.Amplitude amplitude) {
 
    TimeLimiterConfig config = TimeLimiterConfig.custom()
            .timeoutDuration(Duration.ofMillis(amplitude.timeoutMillis()))
            .cancelRunningFuture(true)
            .build();
 
    return TimeLimiter.of("amplitudeTimeLimiter", config);
}
 
@Bean(name = "amplitudeExecutor", destroyMethod = "shutdown")
public ExecutorService amplitudeExecutor() {
    return Executors.newFixedThreadPool(8);
}

Braze 쪽도 같은 패턴이었다. 두 채널, 두 설정 클래스, 두 클라이언트, 두 Executor, 두 TimeLimiter Bean. 정리 시점에는 이 중복이 오히려 유리하게 작용했다.


§2두 정책의 시간 단위가 어긋날 때

문제는 Retry의 backoff와 TimeLimiter의 timeout이 같은 호출에 함께 걸려 있다는 점이었다.

설정값으로 보면 TimeLimiter timeout이 1.0s, Retry 한 사이클의 총 소요 시간이 maxAttempts × retryIntervalMillis = 3 × 500 = 1.5s였다. retry가 끝까지 돌기 전에 바깥쪽 Future가 통째로 cancel된다.

retry 횟수의 일부만 쓰고 호출이 끊긴다. cancelRunningFuture(true) 때문에 inflight HTTP thread도 interrupt 대상이라, cause chain의 최상단에는 CancellationException 또는 InterruptedException이 박혔다.

두 정책을 같이 쓰려면 "TimeLimiter timeout이 Retry 총 소요 시간보다 길어야 한다"는 invariant를 항상 유지해야 한다. 이 invariant는 코드에 드러나 있지 않다. 어느 한쪽 값이 바뀌면 즉시 깨진다.

TimeLimiter timeout이 Retry attempts × backoff 합보다 짧을 때 마지막 attempt가 cancel되는 메커니즘


§3정리 범위

두 정책의 충돌을 어떻게 잡을지

두 정책 유지하되 invariant 를 코드 가까이 명시
TimeLimiter 풀세트 제거, timeout 을 HTTP 클라이언트 레이어에 위임

결정은 TimeLimiter 풀세트를 들어내는 쪽이었다. timeout은 HTTP 클라이언트 레이어에 위임한다. JdkClientHttpRequestFactory의 connect/read timeout이 안전판으로 충분하다고 봤고, 애플리케이션 레이어의 TimeLimiter는 모두 빠졌다.

정리한 항목은 다음과 같다.

  • 클라이언트 두 개에서 TimeLimiter 데코레이터, 전용 Executor 주입, CompletableFuture 래핑 제거
  • 설정 클래스 두 개에서 TimeLimiter Bean과 fixed thread pool Executor Bean 일괄 삭제
  • Properties에서 timeout-millis 키 제거. 환경별 설정 파일 다섯 곳에서도 같은 키를 일괄 정리
  • 두 클라이언트 단위 테스트의 stub 셋업이 TimeLimiter 의존성 제거로 절반 이하로 짧아짐

같은 모양의 코드가 두 채널에 걸쳐 있던 점은 정리에 유리했다. 한 채널에서 얻은 결론을 다른 채널에 그대로 옮겼다.

변경 단위는 두 모듈을 합친 일괄 정리로 묶였다. 클라이언트 2 + 설정 2 + Properties 1 + 환경 yml 5.

정리 후 클라이언트는 한 줄짜리 호출로 단순해진다.

@Component
public class B2bCallbackAmplitudeResilienceClient {
 
    private final B2bCallbackAmplitudeRestClient restClient;
    private final Retry retry;
 
    public ResponseEntity<String> send(GenerateAmplitudeRequest request) {
 
        Supplier<ResponseEntity<String>> protectedSupplier =
                Retry.decorateSupplier(retry, () -> restClient.send(request));
 
        try {
            return protectedSupplier.get();
        } catch (Exception ex) {
            throw toAmplitudeException(ex);
        }
    }
}

TimeLimiter+Executor+CompletableFuture 3겹 래핑이 Retry 단일 호출로 단순해진 모습


§4운영 관찰

정리 직후 한 주 동안 같은 트래픽 구간에서 비교했다. 사전 설정값(TimeLimiter timeout 1.0s, Retry maxAttempts 3, interval 0.5s)이 그대로 적용되던 시점과의 차이다.

  • 호출 실패율이 떨어졌다.
    외부 API의 P95 응답이 약 1.2s였고, TimeLimiter timeout 1.0s가 P95 응답을 끊고 있었다. 실패율은 약 4.1%대를 유지했다. TimeLimiter를 빼고 retry가 끝까지 도는 구성으로 바꾼 뒤, 실패율은 약 0.7%로 떨어졌다.
  • retry attempts 분포가 정상으로 돌아왔다.
    정리 전 평균 attempt는 1.4회였다. 1.5s 사이클을 1.0s에서 cancel당해 두 번째, 세 번째 시도를 거의 쓰지 못했다. 정리 후 평균 attempt는 2.1회로 올라왔고, 응답이 늦은 호출이 두세 번째 시도에서 성공해 끝나는 구간이 정상적으로 관측된다.
  • 예외 cause chain이 짧아졌다.
    정리 전 호출 실패의 최상단은 CancellationException 또는 InterruptedException이었고, 그 안에 진짜 원인이 두세 단계 wrapping되어 있었다. 정리 후에는 HTTP 클라이언트 레이어의 SocketTimeoutException 또는 IOException이 그대로 retry predicate에 도달한다. 로그에서 원인을 찾는 cause 깊이가 평균 4단계에서 1단계로 줄었다.
  • 모니터링 항목이 줄었다.
    채널별로 떠 있던 fixed thread pool 두 개(각 8 thread)가 사라졌다. 풀 사이즈, active thread, queue depth를 채널별로 보던 대시보드 패널 여섯 개가 함께 정리됐다.
  • 설정 드리프트 표면이 좁아졌다.
    Properties와 환경별 yml 다섯 곳에서 timeout-millis 키가 빠졌다. 환경 사이에서 timeout 값이 다르게 흘러갈 표면이 없어졌다.
  • 단위 테스트 셋업이 짧아졌다.
    TimeLimiter와 Executor를 stub하던 코드가 사라지면서 클라이언트 두 개의 테스트 셋업이 평균 22라인에서 9라인으로 짧아졌다. 새 케이스를 추가할 때의 진입 비용도 같이 줄었다.

정리 전후의 운영 표면 변화 요약: cause chain 깊이, 실패율, retry attempts, 대시보드 패널 수, 설정 키, 테스트 셋업 라인 수

한 가지 점검 항목이 더 따라왔다. retry predicate가 그것이다.

TimeLimiter가 사라지면서 predicate가 보던 예외 타입의 분포가 바뀐다. 기존 predicate가 TimeoutException 계열만 재시도 대상으로 잡고 있었다면, 클라이언트 레이어의 타임아웃 타입을 명시적으로 추가해야 retry가 의도대로 동작한다.


§5한 줄로 압축하면

이 정리는 결국 도구 셋의 결합 방식을 한 단계 깊이 들여다본 것이 전부다. Retry가 어떻게 backoff를 깔고 가는지, TimeLimiter가 어떤 시점에 인터럽트를 던지는지, 전용 Executor가 그 사이에서 어떤 cancel 신호를 흘려보내는지. 세 도구의 동작이 결합되는 방식을 한 번 따라가면, 풀세트로 묶지 않는 편이 합리적이라는 결론은 자연스럽게 따라온다.

도구가 무엇을 cancel하고, swallow하고, null로 처리하는지 한 단계 깊이 보지 않으면 운영에서 깨진다.

같은 클래스 안에서 Retry / TimeLimiter / 전용 Executor 세 요소가 같이 등장하는 코드를 만나면, 일단 이 함정의 신호로 의심한다. 의도해서 둔 거라면 timeout과 retry 총 소요 시간의 관계가 코드 가까이 적혀 있어야 한다.


§6짧은 메모 세 가지

  • 새 외부 API 클라이언트를 추가할 때 timeout 레이어를 먼저 정한다.
    HTTP 클라이언트 레이어에 둘지, 애플리케이션 레이어에 둘지를 ADR이나 PR 템플릿에 적어 둔다. 같은 외부 채널이 또 추가되기 전에 결정 자체가 코드 가까이 있어야, "두 시간 단위를 맞춘다" 같은 invariant가 다시 코드 밖으로 빠져나가지 않는다.

  • 코드 리뷰에서 같은 패턴을 잡는 시그널 세 가지.
    TimeLimiter.decorateFutureSupplier, CompletableFuture.supplyAsync(..., executor), 그리고 그 옆에 주입되는 전용 Executor Bean. 이 셋이 한 클래스 안에서 같이 보이면 같은 함정일 가능성이 매우 높다. 의도해서 둔 거라면 timeout과 retry 총 소요 시간의 관계를 주석으로라도 적어 둬야 한다.

  • 로그 최상단 예외가 CancellationException / InterruptedException이면 한 단계 의심한다.
    진짜 원인이 두세 단계 wrapping되어 있을 가능성이 높다. 비즈니스 예외처럼 보이는 호출 실패의 분류가 실제로는 retry와 timeout 배선의 문제일 수 있다. cause chain을 끝까지 펴 보기 전에는 원인 라벨링을 확정하지 않는다.