서킷 브레이커를 활용한 Fault Tolerance System(FTS) 구성
이전에 Redis 캐시를 활용해 조회 성능을 개선했었습니다. 하지만 기존의 구현 방식은 캐시 서버가 "문제없이 동작하는 경우"만을 고려했습니다. 하지만 캐시 서버 장애 상황에 대해 단순 예외 처리나 로그 기록 방식은 안정적인 서비스 운영을 보장하기에는 어려움이 있을 수 있습니다.
동작 정리
프로젝트에서 레디스는 다음과 같이 구성되어 있습니다.

캐싱 전략: Look-aside (Lazy Loading)
데이터 조회 시 캐시를 먼저 확인하고, 캐시에 없을 경우에만 DB를 조회하는 Look-aside 전략은 조회 빈도가 높은 시스템이 적합합니다. 캐시와 DB가 분리되어 있어 캐시 장애에 대비하면 DB 조회를 통해 가용성을 확보할 수 있지만, 캐시 장애 시 모든 요청이 DB로 쏠리는 문제가 발생할 수 있습니다.
Look aside :
데이터를 조회할 때 캐시에 저장된 데이터가 있는지 확인하는 전략. 없으면 DB 조회
조회가 많은 곳에 적합.
캐시와 DB가 분리되어서 가용됨. 따라서 캐시 장애에 대비를 하면 redis가 다운되더라도 DB에서 조회 할 수 있음.
캐시에 커넥션이 많았으면 redis가 다운되면 DB에 부하가 몰림
@Cacheable의 동작과 한계
@Cacheable의 동작은 "단순히 캐시가 없으면 DB에서 가져와"가 아니라, 캐시 조회 과정에서 예외가 발생하면 그 예외에 따라 "전체 요청이 실패할 수도 있다"는 점입니다.
@Cacheable은 캐시 서버와의 연결 실패를 "캐시 미스"로 간주하지 않고, 시스템의 오류(Error)로 처리합니다.
동작 정리:
@Cacheable의 기본 흐름 (Cache Aside 패턴)
Redis 조회 시도
정상 응답: cache hit -> 결과 리턴
null 또는 cache miss: DB 조회 후 캐시에 저장 -> DB 결과 리턴
Redis 연결이 끊긴 경우
@Cacheable을 통해 호출된 메서드는 내부적으로 Redis 캐시 조회를 먼저 시도합니다.이때 Redis 서버가 죽어 있으면:
Lettuce 클라이언트가 재연결을 시도하며 Timeout을 대기합니다.
기본적으로 Lettuce Client의 Timeout은 60초(RedisURI.DEFAULT_TIMEOUT)입니다.
해당 대기 동안 캐시 조회가 끝나지 않으므로,
다음 단계인 DB 조회는 아예 실행되지 않습니다.
전체 요청이 대기 → 타임아웃 발생 → 예외 발생으로 이어집니다.
즉, Redis 자체의 장애가 전체 로직의 병목을 만드는 셈입니다.
Spring의 @Cacheable은 캐시 접근 시 예외가 발생하면 기본적으로 예외를 던집니다.
@Cacheable은 캐시 조회 실패를 catch해서 무시하지 않습니다.
즉,
캐시 조회 실패 == 전체 실패가 기본 동작입니다.
실제 시나리오
서버가 죽었을 경우:
캐시 서버가 정상적으로 동작하다가 다운되고 API를 호출하면 일정 시간 동안 pending 상태가 됩니다.
클라이언트를 구성하는 LettuceConnectionFactory는 RedisURI 클래스에 존재하는 Default timeout을 사용합니다.
캐시 서버에 장애가 생겨 동작하지 못하게 되고 동시에 100개의 요청이 들어왔을 때 전부 60초 동안 대기하게 된다면, 톰캣 스레드 풀에 자원이 부족하게 되고 이는 정상 동작하는 다른 기능들에까지 영향을 줄 것입니다.
Redis 장애를 견디는 방법
1. Fail Fast 전략을 위한 Command Timeout 설정
command timeout을 짧게 설정하면, 설정한 시간만큼 pending된 후 예외를 발생시킵니다.
해당 설정은 Redis 서버 다운이나 과부화 상황에서 타임아웃 도달 즉시 예외를 발생시켜 애플리케이션이 빠르게 실패 감지(Fail Fast)할 수 있으며 아래와 같은 이점이 있습니다:
Redis 서버 다운/과부하 시 타임아웃 도달 즉시 예외 발생
스레드가 장기간 대기하지 않아 리소스 고갈 방지
이후 요청들은 장애 영향을 받지 않고 처리 가능
하지만 이 설정만으로는 모든 것이 해결되지 않습니다. 아래 상황도 고려해야 합니다.
지나치게 낮은 타임 아웃은 일시적 네트워크 지연 시 거짓 장애(false positive)를 유발하지 않는가?
캐시 서버에 장애가 발생했다고 해서 사용자에게 에러 응답을 반환하는 것이 과연 올바른 선택일까? 사용자 경험을 저하하는 요소는 아닌가?
2. Fallback을 위한 CacheErrorHandler 구현
@Cacheable 어노테이션을 사용할 경우, 내부적으로 Redis에 접근하다 예외가 발생하면 그대로 사용자에게 전파됩니다.
하지만 Spring은 이를 커스터마이징할 수 있는 훅을 제공합니다.
이 CacheErrorHandler를 빈으로 등록(혹은 CacheConfigurer를 구현)하면, Redis에 장애가 발생하더라도 예외를 무시하고 @Cacheable에 감싼 원래의 메서드(DB 조회)를 실행하게 됩니다.
결과적으로 사용자는 Redis가 죽었는지 모른 채 정상 응답을 받게 됩니다.
CacheErrorHandler의 한계
CacheErrorHandler를 구현한 결과, Fail Fast를 만족하면서 사용자에게 예외를 반환하지 않고 DB 결과를 반환합니다.
🤔 그렇다면 모든 것이 해결되었다고 볼 수 있을까요?
표면적으로는 문제가 없어 보입니다. 하지만 내부적으로 모든 요청이 여전히 Redis에 연결을 시도합니다. 이로 인해 발생하는 문제는 다음과 같습니다:
CacheErrorHandler는 예외를 무시하고 DB로 우회할 뿐, Redis 접근 자체를 차단하지는 않습니다. 장애가 지속되는 상황에서 모든 요청은 계속해서 Redis에 접근 시도 -> 실패 -> 타임아웃 -> DB Fallback의 과정을 반복합니다.
즉, 모든 요청이 여전히 Redis에 연결을 시도하게 되고, 이로 인해 발생할 수 있는 문제는 다음과 같습니다.
Redis 서버에 복구를 방해
애플리케이션은 지속적으로 Redis에 연결을 시도하며, 복구 중인 Redis 서버에 여러 건의 실패 요청이 집중됩니다.
애플리케이션 리소스 낭비
각 요청은 Redis Command Timeout 동안 불필요한 대기(pending)를 합니다.
이는 스레드 점유로 이어지며 애플리케이션의 처리 성능을 떨어뜨리는 원인이 될 수 있습니다.
여기까지의 구현은 Fail Fast + Fallback, "빠르게 실패하고 우회"하는 구조입니다. 따라서 CacheErrorHandler는 예외 발생 시 DB fallback을 보장하지만, Redis 접근 자체를 차단하지는 않습니다.
캐시 조회 실패 시마다 Redis에 접근을 시도하고, 실패하고, 타임아웃으로 빠지는 이 흐름은 여전히 계속됩니다. 결과적으로 애플리케이션 리소스는 낭비되고, Redis 복구도 방해받을 수 있습니다.
이러한 한계를 극복하기 위해, Circuit Breaker를 도입해보기로 결정했습니다.
3. 장애 지속 상황 대응을 위한 Circuit Breaker 도입
지금까지 commandTimeout을 활용한 빠른 실패(Fail Fast) 전략과 CacheErrorHandler를 통한 Fallback 로직으로 사용자 경험을 보호하도록 개선했습니다.
하지만 장애가 지속적으로 발생하는 상황에서는 모든 요청이 여전히 Redis에 접근을 시도하고, 실패하고, timeout되는 흐름이 반복됩니다. 이로 인해 Redis의 recovery가 지연될 수 있고, 애플리케이션의 리소스가 낭비될 수 있음을 예측해 볼 수 있었습니다.
Resilience4j 기반 Circuit Breaker는 단순 timeout/fallback 이상의 동적 상태 감지와 차단 기능을 제공합니다.
예외 처리
예외 발생 시 무조건 무시
실패율/횟수 기반으로 "차단"
실패 감지 기준
예외 한 번
통계 기반 (N회 중 M회 실패 시 차단)
회복 처리
없음 (Redis 살아나도 바로 접근)
일정 시간 후 재시도 (Half-Open → Closed)
Redis 호출
❌ 계속 Redis 시도함
✅ 일정 기간 Redis 호출 자체를 막음
애플리케이션에 서킷 적용 시 예상 동작 흐름:
서킷 브레이커로 얻을 수 있는 이점
여기서 소개할 내용은 일정 횟수 혹은 시간 동안 실패가 반복되면, Redis 접근 자체를 완전히 차단하고 바로 DB fallback을 실행하도록 하는 전략입니다.
Redis 호출
계속 시도함 (단, Timeout 설정으로 빠르게 실패)
일정 기간 호출 자체를 막음 (Open 상태)
장애 대응 방식
매번 Redis 접근 → 실패 → DB 우회
실패 감지 → Redis 접근 차단 → 바로 DB 우회
리소스 낭비
Command Timeout 동안 스레드 점유 지속
즉시 차단하여 스레드 및 네트워크 리소스 낭비 최소화
Redis 복구
실패 요청 집중으로 복구 지연 가능성
복구 시도 시까지 요청을 차단하여 복구 지원
Redis가 장애 상태일 때:
CacheErrorHandler는 계속 Redis에 접근 시도 + 실패 무시 + fallback
Circuit Breaker는 연속된 실패 감지 → Redis 접근 자체를 차단 → 바로 fallback로 진입 → Redis 회복 시까지 요청 자체를 보내지 않음
언제 어떤 전략을 선택해야 할까요? 항상 서킷을 선택하는 것이 옳은 방법일까요?
항상 서킷 브레이커가 정답은 아닐 것입니다.
Fast Fail + CacheErrorHandler (단순 fallback만 필요한 경우):
단순 장애 대응만 필요하고, 장애 시 fallback만 잘 되면 되는 경우
트래픽이 적고, Redis 호출 빈도가 낮은 시스템
Redis 장애가 비핵심 기능에 영향을 주는 경우 (잠시 느려져도 괜찮을 때)
단일 서비스 구조여서, 의존성 장애가 다른 곳으로 전파될 위험이 적은 구조
서킷 브레이커 (Redis를 호출하는 것 자체가 비용이라고 판단되는 경우):
하나의 요청이 Redis에 연결을 시도하고 timeout을 기다리는 동안, 스레드, CPU 같은 리소스가 소모됩니다. 요청이 적어서 무시할 수 있다면 괜찮지만, 트래픽이 많은 시스템에서는 작은 비용이 누적되어 전체 성능 저하로 이어질 우려가 있습니다.
또한, 장애가 발생한 Redis 서버에 지속해서 연결 요청을 보내는 것은 불필요한 네트워크 트래픽이 발생할 수 있습니다.
CircuitBreaker란?
서킷 브레이커를 직역하면 회로 차단기로, 불이 나면 차단기가 내려가 더 큰 화재를 막는 상황과 유사하게 서킷 브레이커는 서비스 간 장애 전파를 막는 역할을 한다고 이해했습니다.
예를 들어 서비스 A, B가 존재하고 A가 B를 호출한다고 가정해봅시다. 여기서 서비스 B의 서버가 다운되거나 과부화되어 지속적으로 응답이 오지 않을 경우 이를 감지하여 더 이상 요청을 보내지 않도록 조치해야 합니다.
이처럼 서킷 브레이커는 문제가 발생한 지점을 감지하고 실패하는 요청을 계속하지 않도록 방지하여 시스템의 장애 확산을 막도록 도와줍니다.

CircuitBreaker 상태
"circuit"이 CLOSED이면 요청이 서비스에 도달할 수 있지만, OPEN이면 즉시 실패합니다. OPEN이 일정 시간이 지나면 HALF_OPEN으로 이동하고 오류가 임계값 미만이면 CLOSED로 변경하고 그렇지 않으면 OPEN으로 돌아갑니다.
CircuitBreaker는 아래와 같이 세 가지 State가 있습니다.
상황
정상
장애
Open 상태가 되고 일정 요청 횟수/시간이 지난 상황. Open과 Closed 중 어떤 상태로 변경할지에 대한 판단이 이루어지는 상황
요청에 대한 처리
요청에 대한 처리 수행 정해진 횟수/비율만큼 실패할 경우 Open 상태로 변경
외부 요청을 차단하고 에러를 뱉거나 지정한 callback 메소드를 호출
요청에 대한 처리를 수행하고 실패시 Open 상태로, 성공시 Close 상태로 변경
CircuitBreaker의 상태 변경
CLOSED 상태에서는 정상으로 요청 수행 가능
실패 임계치에 도달하면 CLOSED → OPEN 상태 변경
실패 임계치(failureRateThreshold or slowCallRateThreshold)
OPEN 상태에서 일정 시간이 지나면 HALF_OPEN 상태 변경
waitDurationInOpenState
HALF_OPEN 상태에서 요청 수행
지정 횟수(permittedNumberOfCallsInHalfOpenState)를 기준으로
지정 횟수를 수행하고 성공하면 HALF_OPEN → CLOSED
지정 횟수를 수행하고 실패하면 HALF_OPEN → OPEN
Resilience4j 모듈
서킷 브레이커를 지원하는 모듈은 Netflix Hystrix와 Resilience4j가 있습니다. 현재는 Resilience4j를 권장합니다.
각각의 프로퍼티를 재정의해도 되지만 yml에서도 설정 가능합니다. 임계값을 설정하는 기준에 대해 많은 고민이 있었지만, 명확하게 답변을 낼 수 없었습니다. 그래서 실패의 경우 일부 테크 회사 기술 블로그나 업체들에서 5~20% 정도로 설정하는 경향이 있는 것 같습니다.
서킷 브레이커 적용과 Fallback Method 구현
Fallback Method 구현 시 주의사항:
fallback method는 원래 함수의 파라미터와 return 타입이 같아야 합니다.
Exception을 선언하면 Exception이 다른 fallback method를 사용해서 예외 상황에 따라서 분기 처리가 가능합니다.
CallNotPermittedException 예외는 서킷의 상태가 OPEN일 때 발생합니다.
동작 확인

Redis 서버에 장애가 발생하고 fallback method를 호출합니다. 아직은 CLOSED 상태입니다.

또한 특정 임계치가 넘어가게 되면 서킷의 상태가 OPEN으로 변경됩니다. 이 시점에서 캐시 서버가 복구되고 client가 커넥션을 생성하면 HALF_OPEN 상태로 돌아가게 됩니다.
Redis pub/sub을 활용한 Circuit State 동기화
분산 환경에서의 서킷 브레이커 상태 동기화 필요성
일반적으로 서킷 브레이커는 각 서버 인스턴스에 독립적으로 존재하는 자원입니다. 그래서 로드밸런싱이 적용된 분산 시스템에서는 별도의 장치 없이 모든 애플리케이션 서버의 서킷 상태가 동시에 OPEN되지 않을 것입니다.
만약 어떤 서버 인스턴스의 서킷이 OPEN 상태가 되더라도, 다른 인스턴스들은 여전히 요청을 처리할 수 있습니다. 또한, 각 인스턴스가 독립적으로 서킷 상태를 관리하면 전체 시스템의 일관된 장애 대응이 힘들어질 수 있습니다.
따라서 분산 환경인 경우, 이러한 문제를 차단하기 위해서 State 동기화 과정이 필요합니다.
서킷 브레이커의 상태 동기화 방법
서킷 브레이커의 상태 동기화를 구성하는 방법을 고민해봤습니다.
메시지 브로커를 활용한 이벤트 전파
Kafka나 RabbitMQ와 같은 메시지 브로커를 사용하여 서킷의 상태 변경을 이벤트로 발행하고, 다른 서버들이 해당 이벤트를 구독하여 상태를 업데이트
분산 캐시 활용
Redis와 같은 분산 캐시를 활용하여 각 서버 인스턴스의 서킷의 상태를 중앙화된 캐시에 저장하여 공유하고 동기화
기타
유레카나 구성 관리 서비스를 도입
구현 전략: Redis pub/sub 활용
프로젝트에서는 사용자에게 알림을 전송하기 위해 이미 Redis pub/sub을 적용하고 있었습니다. 따라서 별도의 외부 시스템 도입 대신, 기존의 pub/sub 구조를 활용하여 서킷 상태를 전파하는 방식을 선택했습니다.
상태를 전파하는 publisher와 subscriber의 구현은 복잡하지 않습니다.
상태 변경이 감지되면 해당 상태를 TOPIC에 전달하고, subscriber는 구독한 TOPIC의 정보를 기반으로 현재 서킷의 상태를 업데이트합니다.
테스트

Redis 캐시 서버를 중단한 후, 하나의 서버 인스턴스에 대해 실패율이 설정된 임계값(백분율)에 도달할 때까지 요청을 전송하여 해당 서버의 서킷 브레이커를 OPEN 상태로 변경했습니다.
OPEN 상태로 변경된 서버는 서킷 브레이커의 상태 변경 이벤트를 발행하고, 이 이벤트를 구독하는 4개의 서버 인스턴스는 발행된 이벤트를 수신하여 자신의 서킷 상태를 OPEN으로 업데이트했습니다.

Actuator를 활용해 각 서버 인스턴스의 상태를 확인해보면, 구독을 통해 상태가 변경된 모든 인스턴스의 서킷이 OPEN 상태로 전환된 것을 알 수 있습니다.
상태 전파 고려 사항

publisher의 코드를 살펴보면, "OPEN이 아닌 상태 → OPEN"이면서 "OPEN → OPEN이 아닌 경우"에만 이벤트를 발생시키도록 구현되어 있는데, 이렇게 구현한 이유에는 2가지 고민이 있었습니다.
왜
OPEN → HALF-OPEN, HALF-OPEN → CLOSED와 같은 경우의 상태는 전파하지 않아도 될까?
서킷 브레이커의 자동 복구 메커니즘은 아래와 같이 동작합니다.
OPEN → HALF-OPEN: 타임아웃(일정 시간) 이후에 자동으로 시스템이 회복을 시도HALF-OPEN → CLOSED or OPEN: 사용자 요청이 성공/실패에 따라 결정
따라서 이와 같은 불필요한 이벤트를 방지하는 것이 복잡도를 줄이고, 오류 추적 및 로깅을 단순화해서 중요한 상태 변화에 집중할 수 있다고 판단했습니다.
왜
OPEN -> OPEN경우에는 상태 전파를 제어 해야할까요?
서킷이 OPEN 상태로 전환되고 회복되기 전까지는 계속해서 OPEN 이벤트를 발생시키고 중복된 이벤트들이 전달될 것입니다. 이는 리소스 효율성을 저하하고, 실제로 의미 없는 변화를 전달하는 것이라고 생각했습니다.
Summary
실제 서비스를 경험해 보지는 못했지만 각 서비스는 서로 호출하는 관계도 존재하는 경우가 있을 것입니다. 그로 인해 장애 전파 방지의 필요성에 대해서 고민해 보는 시간이었습니다.
이번 구현을 통해 단순한 캐시 적용에서 시작하여 실제 운영 환경에서 발생할 수 있는 다양한 장애 상황을 고려하여 시스템을 구축할 수 있었습니다. 또한 상황에 맞게 Fail Fast, Fallback, 그리고 Circuit Breaker를 조합하여 적절한 장애 대응 전략을 수립하는 것이 좋겠습니다.
Last updated