회복탄력성(Resilience4j) — 남의 장애가 내 장애가 되지 않게
MSA 편에서 서킷 브레이커를 한 번 봤다. 하지만 분산 환경에서 장애를 막는 도구는 그것 하나가 아니다. 왜 여러 겹이 필요한지는, 남의 장애가 어떻게 내 장애로 번지는지를 따라가 보면 분명해진다. 그리고 그 도구들을 모은 Resilience4j는 — Netflix Hystrix가 개발을 멈춘 뒤 표준이 된 — 데코레이터 기반의 가벼운 라이브러리라, 호출을 겹겹이 감싸 적용한다는 점도 함께 본다.
장애는 어떻게 전파되는가
내 서비스가 결제 서비스를 호출하는데, 그 결제 서비스가 갑자기 느려졌다고 하자. 죽은 것도 아니고 그냥 응답이 느릴 뿐이다. 그런데 내 쪽에서 무슨 일이 벌어지냐면 — 결제를 호출한 내 스레드들이 응답을 기다리며 거기서 블록된다. 요청이 계속 들어오니 블록된 스레드가 쌓이고, 이내 스레드 풀과 커넥션 풀이 마른다(jcode 사고⑥, 10편 사고④). 그 순간 멀쩡하던 내 서비스까지 함께 쓰러진다.
핵심은 이거다 — 한 의존성의 느려짐이 나를 통째로 끌고 내려간다. 그러니 막으려면 한 겹이 아니라 여러 겹의 방어가 필요하다. 제일 먼저 작동해야 하는 것부터 순서대로 보자.
가장 먼저 — 무한히 기다리지 않기(타임아웃)
전파의 시작점은 "응답을 하염없이 기다린 것"이었다. 그러니 첫 방어는 **타임아웃(TimeLimiter)**이다. "2초 안에 응답이 없으면 끊는다"고 정해 두면 느린 호출이 내 스레드를 오래 붙잡지 못한다. 운영 장애의 가장 흔한 원인이 타임아웃 없는 외부 호출이라, 모든 외부 호출엔 예외 없이 타임아웃이 있어야 한다는 게 이 편 전체를 관통하는 원칙이다.
죽은 의존성은 두드리지 않기 — 서킷 브레이커의 내부
완전히 맛이 간 의존성을 계속 두드리는 건 해롭다. 서킷 브레이커가 그걸 막는데, 어떻게 "실패율이 높다"를 판단하는지가 핵심이다. Resilience4j는 슬라이딩 윈도우에 최근 호출 결과를 모은다 — COUNT_BASED(최근 N개 호출, N칸짜리 원형 배열)나 TIME_BASED(최근 N초) 중 고른다. 그 창에서 *실패율이 임계치(예: 50%)*를 넘으면 — 단, 최소 호출 수가 모인 뒤에야 계산한다(몇 건으로 성급히 판단하지 않게) — 회로를 Open으로 바꿔 호출을 즉시 차단(빠른 실패 + 폴백)한다. 일정 시간 뒤 Half-Open으로 몇 개만 시험 호출해, 회복됐으면 Closed로 돌아온다. *느린 호출 비율(slow call rate)*도 같은 방식으로 차단 근거가 된다. (출처: Resilience4j CircuitBreaker.)
왜 차단이 회복을 돕느냐면 — 장애 서비스에 계속 요청을 퍼부으면 그쪽은 더 죽고 이쪽도 대기 스레드가 쌓여 함께 죽는다. 빨리 끊고 폴백하면 나는 살아남고, 장애 서비스도 부하가 빠져 회복할 틈을 얻는다.
잠깐의 실패는 다시 — 재시도, 단 백오프와 지터로
일시적 실패(네트워크 깜빡임)는 **재시도(Retry)**로 흡수한다. 그런데 함정이 있다 — 장애 순간 모두가 동시에, 즉시 재시도하면 *재시도 폭풍(thundering herd)*으로 서버를 완전히 죽인다. 그래서 두 가지를 곁들인다 — 지수 백오프(간격을 base × 2^n으로 점점 벌림)와 지터(거기에 무작위 가감 ± random을 더해 클라이언트마다 재시도 시점을 흩뜨림). 식으로 쓰면 wait = (base × 2^n) ± random이다. (출처: Resilience4j backoff & jitter.) 이건 네트워크 혼잡 제어·백오프(net)와 똑같은 발상이다. 한 가지 더 — 재시도는 멱등한 연산에만 안전하다. 결제 같은 부작용 있는 호출을 무턱대고 재시도하면 중복 결제가 난다(MSA 멱등성).
한 의존성의 폭주를 가두기 — 벌크헤드
**벌크헤드(Bulkhead)**는 배의 격벽에서 온 이름으로, 한 칸이 침수돼도 배 전체가 가라앉지 않게 한다. 의존성별로 동시 호출 수에 상한을 두는데, Resilience4j는 두 방식을 준다 — SemaphoreBulkhead(세마포어로 동시 호출 수만 제한, 가볍다)와 FixedThreadPoolBulkhead(전용 스레드 풀+큐로 완전히 격리). (출처: Resilience4j Bulkhead.) 그래서 결제 호출이 느려져도 내 스레드 전부가 거기 잠기지 않는다 — OS의 cgroup 자원 격리와 같은 발상으로, 장애를 한 칸 안에 가둔다.
과부하 자체를 막기 — rate limiter
들어오는 부하가 감당 범위를 넘을 때를 위한 rate limiter도 있다. 흔히 토큰 버킷으로 — limit-for-period(주기당 허용 횟수, 예: 100)·limit-refresh-period(주기, 예: 1초)·timeout-duration(토큰 대기 한도)으로 초당 처리율 상한을 둔다(버스트는 버킷만큼 허용). 네트워크 트래픽 셰이핑·API 쿼터의 그 알고리즘이다.
이 다섯을 어떻게 함께 쓰나 — 데코레이터 순서가 동작을 바꾼다
Resilience4j는 이들을 데코레이터로 겹쳐 적용하는데, 순서가 동작을 바꾼다. 스프링 애너테이션을 함께 쓰면 기본 적용 순서는 — Retry( CircuitBreaker( RateLimiter( TimeLimiter( Bulkhead( 함수 ) ) ) ) ) — 곧 Retry가 가장 바깥이다. 이 순서엔 이유가 있다 — 서킷 브레이커가 재시도 안쪽에 있으면 "회로가 열려 있으면 재시도조차 안 나간다"가 성립한다(죽은 서비스에 재시도 폭탄을 안 던진다). 만약 다른 순서가 필요하면 함수형 체이닝을 쓰거나 retryAspectOrder·circuitBreakerAspectOrder 같은 프로퍼티로 명시한다. (출처: Resilience4j Getting Started — aspect order.) 그리고 어떤 방어를 걸든 마지막엔 **폴백(fallback)**을 준비한다 — 차단·실패 시 *기본값·캐시·"잠시 후"*로 우아하게 degrade한다.
정리하면, 회복탄력성은 "의존성은 언젠가 느려지거나 죽는다"는 전제에서 출발한다. 타임아웃으로 오래 안 붙잡히고, 죽은 곳은 서킷 브레이커(슬라이딩 윈도우로 실패율 판단)로 차단하며, 일시적 실패는 백오프+지터 재시도(멱등 연산만)로 흡수하고, 한 의존성의 폭주는 벌크헤드(세마포어/스레드풀)로 격리하고, 과부하는 rate limiter(토큰 버킷)로 막는다. 이 다섯을 데코레이터로 겹쳐 쓰되 순서가 동작을 바꾸니(서킷이 재시도 안쪽) 의도에 맞게 배치하고, 마지막엔 폴백으로 degrade한다 — 그리고 그 무엇보다, 모든 외부 호출에는 타임아웃을 건다.