외부 API 한 번 부르는 데 계층이 여섯 개입니다 (커넥션 풀부터 Circuit Breaker까지)
타임아웃, 커넥션 풀, Bulkhead, Retry, Circuit Breaker, Rate Limiter. 각 계층이 무엇을 막고 무엇을 못 막는지 정리하다가, 제가 실측으로 고른 중첩 순서와 Resilience4j 애노테이션의 기본 순서가 반대라는 걸 알았습니다.
[배경 - 따로 쓴 두 글이 사실 같은 그림의 일부였다]
외부 API 호출에 대해 두 번 글을 썼습니다.
1번 글에서는 Circuit Breaker와 Retry의 중첩 순서를 실측으로 비교했어요. CB를 바깥에 두는 쪽을 골랐습니다. 33번 글에서는 커넥션 풀이 없는 줄 알았는데 JDK가 keep-alive로 재사용하고 있더라는 걸 재봤고요.
두 글을 쓸 때는 별개 주제라고 생각했습니다. 그런데 면접에서 “외부 API 호출할 때 어떤 걸 신경 쓰시나요”라는 넓은 질문을 받고 답하다 보니, 이게 하나로 이어진 계층 구조라는 걸 알았어요. 그리고 계층 사이의 상호작용에서 나오는 문제들을 제가 놓치고 있었습니다.
정리하면서 하나 발견한 게 있어요. 1번 글에서 실측으로 고른 순서가, Resilience4j 애노테이션을 쓸 때의 기본 순서와 정반대입니다. 저는 프로그래매틱 API로 직접 감쌌기 때문에 제가 고른 순서가 적용됐는데, @CircuitBreaker 와 @Retry 를 나란히 붙였다면 반대로 동작했을 거예요. 이건 뒤에서 자세히 씁니다.
미리 밝혀둘게요. 이 글의 수치는 라이브러리 기본값이고 제가 잰 값이 아닙니다. 1번과 33번 글에 있는 측정값만 제 것이에요.
[문제 상황 분석 - 호출 하나에 겹쳐 있는 계층들]
외부 호출을 감싸는 것들을 바깥에서 안으로 늘어놓으면 이렇습니다.
각 계층은 다른 실패를 막습니다. 하나로 여러 개를 대신할 수 없어요. 아래에서부터 올라가면서 각각을 봅니다.
[계층 1. 타임아웃 - 가장 아래이고 가장 중요하다]
타임아웃이 없으면 나머지가 전부 무의미합니다
외부 호출에서 최악은 실패가 아니라 끝나지 않는 것입니다. 실패는 처리하면 되는데, 안 끝나면 스레드가 영원히 잡혀 있어요.
그리고 기본값이 대개 “무제한”입니다. JDK의 HttpURLConnection 도, 여러 HTTP 클라이언트도 명시하지 않으면 0, 그러니까 무한 대기예요. 33번 글에서 확인한 SimpleClientHttpRequestFactory 도 타임아웃을 직접 설정해야 했습니다.
타임아웃은 한 종류가 아닙니다
여기서 자주 헷갈립니다. 흔히 두 개를 설정하는데, 그 둘이 보장해주는 게 생각보다 좁아요.
| 종류 | 재는 것 | 못 막는 것 |
|---|---|---|
| connect timeout | TCP 연결 수립까지 | 연결 후의 모든 것 |
| read (socket) timeout | 패킷과 패킷 사이 간격 | 전체 소요 시간 |
| 전체 타임아웃 | 요청 시작부터 끝까지 | (별도로 설정해야 존재한다) |
read timeout이 전체 응답 시간이 아니라는 게 핵심입니다. 소켓에서 데이터가 오지 않고 흐른 시간을 재요. 서버가 1초마다 1바이트씩 보내면 read timeout이 3초여도 영원히 안 끝납니다. 응답 본문이 100KB면 100,000초까지 갈 수 있어요.
이게 Slowloris 류 공격이 성립하는 원리이고, 정상적인 상황에서도 상대 서버가 아주 느려졌을 때 그대로 재현됩니다. 그래서 전체 요청에 상한을 거는 계층이 따로 필요해요. Resilience4j의 TimeLimiter 나, Apache HttpClient 5의 responseTimeout 같은 것들입니다.
DNS는 타임아웃 바깥에 있습니다
덜 알려진 함정이에요. connect timeout 은 TCP 연결에만 걸립니다. 그 앞에 있는 DNS 조회에는 안 걸려요. InetAddress.getByName 이 걸리면 OS 리졸버가 포기할 때까지 기다립니다.
JVM에는 DNS 캐시도 있습니다. 보안 매니저가 있으면 성공 결과를 무기한 캐싱하는 게 기본이라, DNS로 페일오버하는 인프라를 쓸 때 문제가 됩니다. networkaddress.cache.ttl 로 조정해야 해요. 실패 결과 캐시(networkaddress.cache.negative.ttl)도 기본이 10초라, 일시적인 DNS 실패가 10초 동안 고착됩니다.
[계층 2. 커넥션 풀 - 재사용이 아니라 제한이 본질이다]
33번 글에서 얻은 구분
그 글의 결론이 이거였어요. keep-alive 캐시와 커넥션 풀은 다르다. 캐시는 재사용을 하고 풀은 제한을 한다.
JDK의 keep-alive 캐시는 요청이 끝난 커넥션을 보관했다가 다음에 씁니다. 재사용은 되지만 동시에 몇 개까지 열 수 있는지는 제한하지 않아요. 요청이 100개 동시에 오면 소켓 100개가 열립니다.
풀은 반대입니다. 상한이 있고, 상한에 걸리면 기다립니다. 이 기다림이 backpressure예요. 외부 API가 느려지면 풀이 마르고, 풀이 마르면 요청이 대기하고, 그 신호가 상류로 전달됩니다.
그래서 풀의 역할은 셋입니다.
- 커넥션 재사용 (핸드셰이크 비용 절약)
- 동시 호출 수 상한 (상대 서버를 밀어붙이지 않기)
- 획득 대기를 통한 backpressure
기본값이 아주 작습니다
Apache HttpClient의 PoolingHttpClientConnectionManager 기본값이 실무의 대표적인 함정이에요.
maxTotal = 25 // 전체 커넥션
defaultMaxPerRoute = 5 // 목적지 호스트 하나당
호스트 하나당 5개입니다. 외부 API를 한 곳만 부른다면 동시 호출이 5개로 묶여요. 서블릿 스레드가 200개여도 그중 5개만 실제로 호출하고 195개는 풀을 기다립니다.
의도적으로 걸어둔 제한이라면 훌륭한 backpressure지만, 모르고 쓰면 원인 모를 지연의 근원이 됩니다. 33번 글에서 저는 반대 상황이었어요. 풀이 아예 없어서 제한이 없었습니다.
풀 획득에도 타임아웃이 필요합니다
이게 진짜 사고를 만드는 지점입니다. 풀에서 커넥션을 얻는 대기에 타임아웃이 없으면, 커넥션이 없는 동안 스레드가 무한정 쌓입니다.
외부 API 가 느려짐
→ 커넥션이 오래 반납되지 않음
→ 풀이 마름
→ 새 요청 스레드들이 풀 앞에 줄을 섬 ← 여기에 타임아웃이 없으면
→ 서블릿 스레드 풀이 고갈됨
→ 이 API 와 무관한 기능까지 멈춤
1번 글에서 다룬 cascading failure의 전형적인 경로예요. 그 글에서는 Circuit Breaker로 처방했는데, 여기 타임아웃 하나만 있어도 상당 부분 막힙니다. Apache HttpClient의 connectionRequestTimeout 이 이 값이에요.
오래된 커넥션 문제
풀에 있는 커넥션이 항상 살아 있는 건 아닙니다. 서버가 idle 타임아웃으로 먼저 끊었는데 클라이언트는 모를 수 있어요. TCP FIN이 왔어도 풀에서 꺼내 쓰기 전까지는 확인하지 않으니까요.
그 커넥션으로 요청을 보내면 NoHttpResponseException 같은 게 뜹니다. 재현이 잘 안 되고, 트래픽이 뜸한 시간대에 몰려서 나오는 그 오류예요.
처방은 셋입니다.
- 주기적으로 idle 커넥션을 걷어냅니다.
evictIdleConnections - 꺼낼 때 검증합니다.
validateAfterInactivity. 마지막 사용 후 일정 시간이 지났으면 살아 있는지 확인하고 씁니다 - 클라이언트 idle 타임아웃을 서버보다 짧게 잡습니다. 이게 가장 확실해요. 상대가 60초에 끊는다면 우리는 30초에 버립니다
세 번째가 근본적인데, 상대 서버의 keep-alive 타임아웃을 알아야 합니다. 응답 헤더의 Keep-Alive: timeout=... 를 보거나 문서를 찾아야 해요.
풀 크기는 어떻게 정하나
Little’s Law가 출발점입니다.
필요한 동시 커넥션 수 ≈ 초당 요청 수 × 평균 응답 시간(초)
초당 100건이고 평균 200ms면 20개가 필요해요. 여기에 여유를 얹습니다.
다만 이 계산에는 함정이 있습니다. 응답 시간이 나빠지면 필요한 수가 같이 늘어납니다. 평균이 200ms에서 2초로 나빠지면 필요한 커넥션이 20개에서 200개가 돼요. 그런데 풀을 200개로 키우면 이번엔 우리가 상대 서버를 200개 동시 호출로 밀어붙입니다. 이미 힘들어하는 서버를 더 밀어요.
그래서 풀을 넉넉하게 잡는 게 항상 좋은 게 아닙니다. 풀 크기는 “정상 상황에서 필요한 만큼”으로 잡고, 나빠졌을 때는 대기와 실패로 신호를 보내는 쪽이 맞습니다.
HTTP/2를 쓴다면 이야기가 좀 달라져요. 커넥션 하나에 여러 스트림이 다중화되니 커넥션 수보다 동시 스트림 수 제한이 중요해집니다.
[계층 3. Bulkhead - 1번 글에서 미뤄둔 숙제]
1번 글의 결론에서 이렇게 적었습니다.
원인이 공통 서블릿 스레드 풀 고갈이라면 Bulkhead로 풀을 격리하는 쪽이 더 직접적이에요. CB는 빠른 실패로 점유 시간을 줄여 간접적으로 같은 효과를 낼 뿐입니다.
그때는 이름만 알고 미뤘는데, 파보니 Bulkhead에도 두 종류가 있고 성질이 꽤 다릅니다.
| SemaphoreBulkhead | ThreadPoolBulkhead | |
|---|---|---|
| 실행 스레드 | 호출한 스레드 그대로 | 별도 스레드 풀 |
| 제한 방식 | 세마포어로 동시 진입 수 제한 | 풀 크기와 큐 크기 |
| 호출 스레드 | 끝날 때까지 붙잡힌다 | 즉시 돌려받는다 |
| 오버헤드 | 거의 없음 | 컨텍스트 스위치가 있다 |
| 타임아웃으로 끊기 | 안 된다 | 된다 |
Resilience4j의 기본은 세마포어입니다. 가볍고 단순해요. 그런데 세마포어 방식은 이미 시작된 호출을 끊지 못합니다. 호출한 스레드가 소켓에서 블로킹돼 있으면, 그 스레드를 인터럽트해도 소켓 I/O는 인터럽트에 반응하지 않아요.
그래서 TimeLimiter 를 세마포어 Bulkhead와 같이 써도 실제 스레드는 안 풀립니다. 호출자에게 타임아웃 예외를 돌려주는 것뿐이고, 뒤에서는 여전히 소켓 타임아웃까지 기다려요.
Hystrix가 스레드 풀 격리를 기본으로 삼았던 이유가 이겁니다. 별도 풀에서 실행하면 호출 스레드는 즉시 돌아오고, 느려진 호출은 그 전용 풀만 채워요. 대신 스레드 사이를 넘나드는 비용이 붙고, ThreadLocal 이 안 넘어갑니다. 3번 글에서 MDC를 비동기 경계 너머로 넘긴 것과 같은 문제가 여기서도 나와요.
그러니 진짜 격리가 필요하면 ThreadPoolBulkhead이고, 동시 호출 수만 제한하면 되면 세마포어입니다. 그리고 커넥션 풀에 상한이 있다면 그 자체가 이미 세마포어 Bulkhead 역할을 합니다. 계층이 겹치는 부분이에요.
[계층 4. Retry - 무엇을 다시 부를 것인가]
멱등성이 전제입니다
1번 글에서 GET에만 재시도를 허용하고 나머지는 전부 default-off 로 뒀습니다. VM 생성 요청을 재시도하면 VM이 두 대 생길 수 있으니까요.
그 판단은 지금도 맞다고 봅니다. 다만 더 정확한 기준을 하나 알게 됐어요. 실패한 지점에 따라 재시도의 안전성이 다릅니다.
| 실패 지점 | 서버가 처리했을 가능성 | 재시도 |
|---|---|---|
| DNS 조회 실패 | 없음 | 안전하다 |
| connect timeout | 거의 없음 | 비교적 안전하다 |
| 요청 전송 중 끊김 | 낮다 | 조심 |
| read timeout | 높다 | 위험하다 |
| 5xx 응답 수신 | 서버가 알려준 것이므로 상태에 달렸다 | 상태 코드로 판단 |
read timeout이 가장 위험합니다. 요청은 갔고 응답만 못 받은 상황이니, 서버는 이미 처리했을 수 있어요. 여기서 재시도하면 중복 실행입니다.
그래서 POST를 재시도해야 한다면 답은 “재시도하지 않는다”가 아니라 “멱등 키를 붙인다” 예요. 40번 글과 11번 글에서 다룬 그것입니다. 상대 API가 멱등 키를 지원하면 재시도가 안전해지고, 지원 안 하면 재시도하면 안 됩니다.
HTTP 상태 코드 기준도 정리해두면 이렇습니다.
- 재시도 가능: 408, 429(
Retry-After를 존중), 502, 503, 504 - 재시도 무의미: 400, 401, 403, 404, 422. 다시 불러도 같은 답이 온다
- 500: 애매하다. 서버 버그면 무의미하고 일시적 오류면 유효하다
지수 백오프와 jitter
1번 글의 한계 첫 번째로 이걸 적었습니다.
재시도 간격에 jitter가 없습니다. 고정 200ms라서 과부하 상황에서 여러 요청이 같은 시점에 재시도하면 부하가 몰릴 수 있어요.
이게 왜 문제인지 좀 더 정확히 하면요. 서버가 잠깐 죽어서 100개의 요청이 동시에 실패하면, 고정 간격에서는 100개가 정확히 200ms 뒤에 다시 옵니다. 회복하려던 서버가 다시 맞아요. 그리고 또 실패해서 또 200ms 뒤에 동시에 옵니다. 이걸 thundering herd라고 부릅니다.
지수 백오프는 간격을 늘려서 완화하지만, 간격이 결정적이면 무리는 여전히 같이 움직입니다. 무작위를 섞어야 흩어져요.
full jitter : sleep = random(0, min(cap, base × 2^n))
Resilience4j에는 IntervalFunction.ofExponentialRandomBackoff(...) 가 있습니다. 초기 간격, 배수, 무작위 비율을 받아요. 1번 글의 설정을 지금 고친다면 여기를 먼저 바꿀 겁니다.
재시도 증폭
계층이 겹칠 때 나오는 문제입니다. 재시도는 곱해집니다.
API 게이트웨이 : 3회 재시도
서비스 A : 3회 재시도
서비스 B : 3회 재시도
─────────────
최악의 경우 : 3 × 3 × 3 = 27회
이미 무너지고 있는 하류 시스템에 부하를 27배로 늘려서 보냅니다. 장애를 회복시키려던 장치가 장애를 확대해요.
그래서 규칙은 재시도는 한 계층에서만 입니다. 보통 가장 하류, 그러니까 실제로 외부를 부르는 곳에 둡니다. 상류는 실패를 그대로 올려요.
더 나아가면 재시도 예산이라는 개념이 있습니다. “전체 요청의 10%를 넘게 재시도하지 않는다”처럼 총량을 제한하는 방식이에요. 개별 호출이 아니라 전체를 보는 거라, 장애가 광범위할 때 Circuit Breaker보다 부드럽게 동작합니다. 다만 Resilience4j에 기본 제공되지는 않아서 직접 만들어야 해요.
[계층 5. Circuit Breaker - 그리고 순서 문제]
상태 머신과 통계 창
1번 글에서 다룬 내용이니 요약만 하고, 그때 안 쓴 것들을 봅니다.
CLOSED에서 실패율이 임계값을 넘으면 OPEN, 일정 시간 뒤 HALF_OPEN, 거기서 시험 호출이 성공하면 다시 CLOSED입니다. Resilience4j에는 DISABLED 와 FORCED_OPEN 도 있어요. 운영 중에 수동으로 열고 닫을 수 있습니다.
통계 창은 두 종류예요.
| COUNT_BASED | TIME_BASED | |
|---|---|---|
| 세는 단위 | 최근 N건 | 최근 N초 |
| 트래픽이 적을 때 | 오래된 결과가 남는다 | 자연스럽게 잊는다 |
| 트래픽이 많을 때 | 짧은 구간만 본다 | 메모리를 더 쓴다 |
1번 글에서는 COUNT_BASED 에 slidingWindowSize=50 을 썼습니다. 트래픽이 아주 적은 시간대에는 몇 시간 전 실패가 창에 남아 있을 수 있어요. minimumNumberOfCalls=20 으로 일부 완화했지만, 지금 다시 고른다면 TIME_BASED 도 검토했을 것 같습니다.
그때 안 쓴 것 하나. 느린 호출
Resilience4j에는 slowCallRateThreshold 와 slowCallDurationThreshold 가 있습니다. 실패하지 않았어도 느리면 실패로 세는 설정이에요.
이게 중요한 이유가 있습니다. 장애는 대개 실패로 시작하지 않고 느려지는 것으로 시작해요. 응답은 오는데 3초씩 걸립니다. 실패율 기준만 있으면 차단기가 안 열리고, 그동안 스레드가 계속 잡혀요. 앞에서 본 cascading failure가 정확히 이 구간에서 벌어집니다.
1번 글의 설정에는 이게 없었습니다. 실패율만 봤어요. 지금 보면 그게 빈 곳입니다.
가장 자주 틀리는 것. 무엇을 실패로 셀 것인가
recordExceptions 와 ignoreExceptions 입니다.
4xx는 실패로 세면 안 됩니다. 400이나 404는 우리 요청이 잘못됐다는 뜻이지 상대 서버가 아프다는 뜻이 아니에요. 클라이언트가 잘못된 요청을 반복해서 보내면 멀쩡한 서버로 가는 회로가 열립니다. 버그 하나가 정상 기능을 차단하는 결과가 나와요.
그런데 HTTP 클라이언트가 4xx도 예외로 던지는 경우가 많아서, 아무 설정 없이 쓰면 이렇게 됩니다. 반드시 걸러야 해요.
circuitbreaker:
configs:
default-config:
ignoreExceptions:
- com.example.error.BadRequestException
- com.example.error.NotFoundException
그리고 순서. 1번 글과 애노테이션의 기본이 반대입니다
여기가 이번에 정리하면서 가장 크게 놀란 부분이에요.
1번 글에서 저는 이렇게 감쌌습니다.
Supplier<T> decorated = CircuitBreaker.decorateSupplier(
cb,
Retry.decorateSupplier(retry, supplier)
);
CB가 바깥, Retry가 안쪽입니다. 실험 3에서 “재시도로 살아나는 일시적 오류 50건” 중 Retry 바깥 배치가 32건을 실패시킨 걸 보고 고른 순서예요.
그런데 Resilience4j의 Spring Boot 스타터에서 애노테이션을 쓰면 기본 중첩이 이렇습니다.
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( 실제 호출 ) ) ) ) )
Retry가 바깥이고 CircuitBreaker가 안쪽입니다. 제가 실측으로 버린 순서예요.
// 이렇게 붙이면 기본 순서는 Retry 가 바깥이다
@Retry(name = "keystone")
@CircuitBreaker(name = "keystone")
public Result call() { ... }
프로그래매틱 API로 직접 감싼 저는 이 기본값의 영향을 받지 않았습니다. 그래서 몰랐어요. 애노테이션으로 갔다면 반대로 동작했을 거고, 실험 3의 32건 실패를 운영에서 만났을 겁니다.
바꾸는 방법은 있습니다. 각 애스펙트의 순서를 지정하는 속성이 있어요.
resilience4j:
circuitbreaker:
circuitBreakerAspectOrder: ...
retry:
retryAspectOrder: ...
다만 값이 클수록 바깥인지 안쪽인지는 버전에 따라 확인이 필요합니다. 저라면 값을 넣은 뒤 실제로 로그를 찍어 순서를 확인하겠어요. 이건 문서를 믿고 넘어갈 부분이 아니라고 봅니다.
그리고 순서를 못 바꾸는 상황이라면 차선책이 있습니다. 1번 글에서 이렇게 적었어요.
정확히는 예외 타입을 걸러내면 막을 수 있어요. 다만 그건 별도 설정을 얹어야 성립하는 이야기이고
그 별도 설정이 이겁니다. Retry가 CallNotPermittedException 을 재시도하지 않게 막는 거예요.
resilience4j:
retry:
configs:
default-get:
maxAttempts: 3
waitDuration: 200ms
ignoreExceptions:
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
이러면 OPEN 이후의 낭비 재시도는 사라집니다. 다만 실험 3의 문제, 그러니까 회복 가능한 오류를 CB가 실패로 세는 건 그대로 남아요. 순서 자체가 바뀌지 않으니까요. 부분적인 처방입니다.
Circuit Breaker의 한계
하나 더 적어둘 게 있어요. Resilience4j의 Circuit Breaker는 인스턴스 로컬입니다.
서버가 10대면 차단기가 10개 있고 각자 자기가 본 것만으로 판단해요. 어떤 인스턴스는 열려 있고 어떤 인스턴스는 닫혀 있는 상태가 정상적으로 생깁니다. 트래픽이 적으면 각 인스턴스가 minimumNumberOfCalls 를 못 채워서 아무도 안 열릴 수도 있어요.
이건 버그가 아니라 설계 선택입니다. 상태를 공유하려면 중앙 저장소가 필요하고, 그러면 그 저장소가 새로운 단일 장애점이 돼요. 다만 인스턴스 수가 많고 인스턴스당 트래픽이 적은 환경에서는 차단기가 기대만큼 동작하지 않는다는 걸 알고 있어야 합니다.
[계층 6. Rate Limiter - 맞기 전에 멈추기]
Circuit Breaker가 “상대가 아프다”에 반응한다면, Rate Limiter는 “상대가 정한 한도를 내가 지킨다”입니다. 방향이 반대예요.
27번 글에서 Gmail API의 429를 맞고 나서야 쿼터를 세기 시작한 이야기를 썼습니다. 그 글에서 Redis Lua 토큰 버킷으로 간 이유가 여기서 분명해져요.
Resilience4j의 RateLimiter 는 인스턴스 로컬입니다. 서버가 3대인데 각자 초당 10건으로 제한하면 실제로는 초당 30건이 나갑니다. 상대가 정한 쿼터는 우리 인스턴스 수와 무관하니, 로컬 제한으로는 지킬 수 없어요.
그래서 분산 환경에서 외부 쿼터를 지키려면 상태를 공유하는 저장소가 필요합니다. Redis에 토큰 버킷을 두는 게 그 답이었어요.
Circuit Breaker는 로컬이어도 어느 정도 동작하는데(각 인스턴스가 자기가 본 장애에 반응하면 됨) Rate Limiter는 로컬이면 목적 자체를 달성하지 못합니다. 같은 라이브러리 안의 두 기능인데 분산 환경에서의 성질이 다릅니다.
[계층 밖 - 실패한 다음에 무엇을 할 것인가]
계층을 다 쌓아도 결국 실패는 옵니다. 그때의 선택이 남아요.
폴백을 줄 것인가. 1번 글에서는 캐시된 데이터 대신 503을 던지기로 했습니다. “오래된 상태를 보여주는 것보다 솔직한 실패가 낫다”고 판단했어요. 지금도 그 판단은 유지합니다. 다만 이건 도메인마다 다릅니다. 목록 조회라면 오래된 캐시가 나을 수 있어요. 잔액이라면 절대 아니고요.
타임아웃 예산을 전달할 것인가. 클라이언트가 3초 안에 응답을 원하는데, 우리가 하류를 5초 타임아웃으로 부르면 의미가 없습니다. 이미 클라이언트는 끊었어요. 상류에서 남은 시간을 하류로 전달하는 걸 deadline propagation이라고 부릅니다. gRPC에는 프로토콜에 들어 있고, HTTP에서는 직접 헤더로 실어야 해요.
무엇을 관측할 것인가. Circuit Breaker의 상태 전이는 그 자체가 최고의 알림입니다. OPEN으로 바뀌는 이벤트를 지표로 내보내면 장애를 사람보다 먼저 압니다. 1번 글에서 [CB-OPEN] 로그를 남긴 게 그 시작이었는데, 로그보다는 지표가 나아요.
[결론]
두 글을 따로 썼는데 사실 같은 그림의 아래층과 중간층이었습니다. 정리하면서 얻은 게 세 가지예요.
계층마다 막는 실패가 다릅니다. Circuit Breaker가 있으면 타임아웃이 필요 없는 게 아니고, 커넥션 풀이 있으면 Bulkhead가 필요 없는 게 아니에요. 각각이 다른 지점을 지킵니다. 그리고 가장 아래의 타임아웃이 없으면 위의 다섯 개가 전부 무의미합니다.
계층 사이의 순서가 동작을 바꿉니다. 1번 글의 결론이었는데, 그게 애노테이션 기본값과 반대라는 걸 이번에 알았어요. 라이브러리가 정해둔 기본 순서가 내 판단과 같은지 확인하지 않으면, 실측으로 고른 설계가 코드에는 반영되지 않습니다.
같은 라이브러리 안에서도 분산 환경에서의 성질이 다릅니다. Circuit Breaker는 로컬이어도 되고 Rate Limiter는 안 됩니다. 27번 글에서 Redis로 간 이유가 그때는 감이었는데 지금은 설명이 돼요.
한계를 적어둘게요.
첫째, 애스펙트 순서의 방향을 실제로 확인하지 않았습니다. 문서에 적힌 기본 중첩은 확인했지만, circuitBreakerAspectOrder 값을 어느 쪽으로 줘야 CB가 바깥이 되는지는 코드를 돌려봐야 압니다. 이건 다음에 확인해서 따로 적을 생각이에요.
둘째, Bulkhead를 실제로 붙여본 적이 없습니다. 1번 글에서 미뤘고 지금도 미뤄져 있어요. 세마포어와 스레드 풀의 차이를 문서로 정리했을 뿐입니다.
셋째, slowCallRateThreshold 도 안 써봤습니다. 이 글을 쓰면서 1번 글 설정의 빈 곳으로 발견한 건데, 실제로 임계값을 얼마로 잡아야 하는지는 응답 시간 분포를 재봐야 알아요.
넷째, 재시도 예산은 개념만 압니다. 구현해본 적이 없고 Resilience4j에 없어서 직접 만들어야 합니다.
그리고 마지막으로, 애노테이션 이야기가 나온 김에 짚어둘 게 있어요. @CircuitBreaker 와 @Retry 는 프록시로 동작합니다. 같은 클래스 안에서 메서드를 직접 부르면 아무 일도 안 일어나요. 이 계층 전체가 조용히 사라지는 실패가 실무에서 꽤 자주 나옵니다. 그 이야기는 46번 글에 따로 썼습니다.