Redis를 사용해 응답 데이터를 캐싱했다고 가정해 보자. Redis의 TTL 동안 오는 모든 요청은 Redis와 통신해야 한다.
Look - Aside 패턴을 사용해 캐싱했다고 가정해 보자.

요청을 받은 애플리케이션은 우선 Redis에 해당 데이터가 존재하는지 확인해야 한다.
존재한다면 Redis의 데이터를 반환할 것이고, 존재하지 않는다면 DB에서 직접 값을 읽은 후, Redis에 삽입한 후, 반환할 것이다.
그렇다면.. 만약 Redis에서 장애가 발생해 Redis와 연결이 끊기면 어떻게 될까?

Redis에 연결할 수 없으므로, 별다른 처리가 없다면 500 Internal Server Error 가 발생할 것이다.
이는 모놀리식 아키텍처에서도 문제이지만, MSA에서는 더 큰 문제이다.
왜 MSA에서는 더 큰 문제일까?
MSA는 여러 애플리케이션이 서로 통신하며 동작한다.
만약, A --(요청)--> B --(요청)--> C --(응답)--> B --(응답)--> A 식으로 통신하는 아키텍처가 있다고 가정해 보자.

C에서 장애가 발생하면 어떻게 될까?
A는 B에게 요청을 보내기 위해 할당한 자원들(쓰레드, 메모리, CPU 등) 은 점유된 상태에서 계속해서 B의 응답을 기다릴 것이고, B도 마찬가지로 자신의 자원이 점유된 채로 C의 응답을 기다릴 것이다.
Timeout 등을 설정해 일정 시간이 지나면 에러를 응답하도록 할 수 있지만, 이는 좋은 해결책이라고 하기엔 문제가 있다. C에서 문제가 발생해도, B는 계속해서 C로 요청을 보낼 것이고, Timeout 동안의 자원을 지속적으로 점유당해야 하기 때문이다.
결국, C의 장애가 지속될수록, C에게 요청을 보내는 서버의 자원은 고갈되기 시작한다. B에서 가장 먼저 문제가 생길 것이고, A가 그 다음이 될 것이다.
이를 Cascading failure 이라 한다.
이번 포스팅에서는 모놀리식 아키텍처 환경에서 Redis를 사용해 응답을 캐싱했을 때, Redis 장애 시 Circuit Breaker 패턴을 알아볼 것이다.
Circuit Breaker
Circuit Breaker는 회로 차단기라는 뜻으로, 특정 작업에서 장애 발생 시 계속해서 해당 작업을 수행하려고 시도하는 것을 방지하는 패턴이다.
Circuit Breaker는 아래 세 가지 상태를 가지고 있다.
Circuit Breaker의 세 가지 상태

1. CLOSE
- CLOSE 상태일 때, 정상적으로 요청이 수행된다.
- 만약, CLOSE 상태일 때, 오류가 발생하면, 이를 기록하고 Count 한다.
- Count 한 오류의 수가 특정 임계점을 넘으면 OPEN 상태로 전환된다.
2. OPEN
- OPEN 상태가 되면, 실제 작업을 수행하지 않고, 지정해 놓은 Fallback 작업을 수행한다.
- OPEN 은 지정해 놓은 시간 동안 지속된다.
3. HALF OPEN
- OPEN 상태의 지속시간이 끝나면, 바로 CLOSE 상태에 전환되지 않고, HALF OPEN 상태로 전환된다.
- HALF OPEN 상태에서는 제한된 수의 테스트 요청만 실제 작업을 수행하도록 한다.
- 만약 테스트 요청이 실제 작업을 수행하는 데에 성공하면 CLOSE 상태로 전환된다.
- 만약 테스트 요청이 실제 작업을 수행하는 데에 실패하면 다시 OPEN 상태로 전환된다.
Redis를 사용해 응답 캐싱을 하는 상황에 대입해 보겠다.
임계 Count는 3
HALF OPEN으로 테스트할 요청 수는 4
OPEN 지속 시간은 10분
Fallback 은 DB를 사용해 작업을 수행한다고 가정.
1. 요청을 보냈는데, 3번 실패했다.
2. 이때, OPEN 상태로 전환된다.
3. 10분 동안, DB를 통해 작업을 수행한다.
4. 10분 후 HALF OPEN 상태로 돌입한 후, 이후 들어오는 요청 중 4 개를 Redis를 사용하게 테스트해 본다.
5. 요청이 실패한다.
6. 다시 OPEN 상태로 전환된다.
7. 10분 동안, DB를 통해 작업을 수행한다.
8. 10분 후 HALF OPEN 상태로 돌입한 후, 이후 들어오는 요청 중 4 개를 Redis를 사용하게 테스트해 본다.
9. 요청이 성공한다.
10. CLOSE 상태로 전환되고, 다시 Redis를 사용하게 된다.
Resilience4j 라이브러리를 통해 Circuit Breaker를 적용해 보겠다.
Resilience4j
Resilience4j는 개수 기반 슬라이딩 윈도우, 시간 기반 슬라이딩 윈도우 방식을 사용해 Circuit Breaker 를 구현한다.
개수 기반 슬라이딩 윈도우 방식의 동작은 다음과 같다.
슬라이딩 윈도우의 크기를 N이라 한다.
최근 N 개의 요청 중, 실패한 요청의 비율을 계산하여 회로 차단 의사결정을 수행한다.
만약 N = 10이고, 회로 차단 임계 비율이 40% 이면, 가장 최근 10 개의 요청 중 4 개의 요청이 실패했으면 OPEN 상태에 진입하게 된다.
시간 기반 슬라이딩 윈도우 방식의 동작은 다음과 같다.
슬라이딩 윈도우의 기간을 T 라 한다.
최근 T 동안의 요청 중, 실패한 요청의 비율을 계산하여 회로 차단 의사결정을 수행한다.
만약, T = 30초 이고, 회로 차단 임계 비율이 40% 이면, 최근 30초 동안의 요청 중 40% 이상의 요청이 실패했으면 OPEN 상태에 진입하게 된다.
이제 코드로 구현해 보겠다.
Resilience4j 의존성을 추가한다.
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.0.2'
config 클래스를 작성한다. (application.properties 로 대체 가능)
@Configuration
public class Resilience4jConfig {
public static final String REDIS_CIRCUIT_BREAKER = "redis";
@Bean
public CircuitBreakerRegistry redisCircuitBreakerRegistry() {
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
.failureRateThreshold(80)
.slidingWindowSize(10)
.permittedNumberOfCallsInHalfOpenState(5)
.waitDurationInOpenState(Duration.ofHours(1))
.recordExceptions(RedisException.class, RedisSystemException.class,
RedisConnectionFailureException.class)
.ignoreExceptions(CustomException.class)
.build();
CircuitBreakerRegistry circuitBreakerRegistry = CircuitBreakerRegistry.of(circuitBreakerConfig);
circuitBreakerRegistry.circuitBreaker(REDIS_CIRCUIT_BREAKER, circuitBreakerConfig);
return circuitBreakerRegistry;
}
}
.failureRateThreshold(80)
- 실패율 임계값이다. 80%의 요청이 실패하면 OPEN 상태로 진입한다는 것이다.
.slidingWindowSize(10)
- 개수 기반 슬라이딩 윈도우 방식을 사용한다는 것이며, 슬라이딩 윈도우의 크기는 10이라는 것이다. 가장 최근 10 개의 요청으로 OPEN 진입 여부를 판단한다.
.permittedNumberOfCallsInHalfOpenState(5)
- HALF OPEN 상태일 때, 테스트로 몇 개의 요청을 사용할 것인지를 지정한다.
.waitDurationInOpenState(Duration.ofHours(1))
- Circuit Breaker 가 OPEN 상태를 얼마나 지속할지를 지정한다.
.recordExceptions(.....)
- 실패로 기록할 예외를 지정한다. 해당 예외가 발생하면, 실패로 기록된다.
.ignoreExceptions(......)
- 실패로 기록하지 않을 예외를 지정한다. 해당 예외가 발생하면, 실패로 기록되지 않는다. (주의: 예외로 기록하지는 않지만, Fallback 메서드는 실행될 수 있다. 아래에서 계속)
- RuntimeException을 상속받는 CustomException을 사용 중이라 등록했다.(CustomException 이 발생했다는 것은, 의도된 예외이기 때문)
@Service
@RequiredArgsConstructor
public class CircuitBreakerTestService {
private final UserJpaRepository userJpaRepository;
@Cacheable(value = "user", key = "#id")
@CircuitBreaker(name = REDIS_CIRCUIT_BREAKER, fallbackMethod = "findByIdFallback")
public User findById(final UUID id) {
return findByIdLogic(id);
}
public User findByIdFallback(final UUID id, final RuntimeException e) {
return findByIdLogic(id);
}
private User findByIdLogic(final UUID id) {
return userJpaRepository.findById(id).orElseThrow(() -> new NotFoundException(
ErrorType.USER_NOT_FOUND_ERROR));
}
}
위와 같이 사용할 수 있다.
@CircuitBreaker의 fallbackMethod는 Fallback을 수행할 메서드명을 적어 주면 된다.
이제, 위에서 적은 recordExceptions() 중 하나가 발생하면, fail로 기록되고, threshold에 도달하면 계속해서 Fallback 메서드를 수행한다.
여기서 주의할 점이 있다. 아까 위에서 CustomException을 ignoreExceptions에 등록했다.
하지만, CustomException의 자식 예외인 NotFoundException 이 발생하면 Fallback 메서드가 수행된다.
처음엔 이해가 잘 되지 않았는데, 깃허브에서 이슈를 하나 찾았다.
https://github.com/resilience4j/resilience4j/issues/780
Fallback 메서드는 config에서 등록한 예외들과는 상관없이, 파라미터에 해당하는 예외를 잡아 수행된다는 것을 알았다.
CustomException 은 threshold count에서 무시된다. 이건 팩트이다.
하지만, Fallback 메서드인 findByIdFallback에서 RuntimeException을 매개변수로 받고 있기 때문에 CustomException이 발생해도 findByIdFallback은 수행된다.
그렇게 되면, 로직이 두 번 수행된다. 이를 해결하기 위해 코드를 수정할 수 있다.
@Service
@RequiredArgsConstructor
public class CircuitBreakerTestService {
private final UserJpaRepository userJpaRepository;
@Cacheable(value = "user", key = "#id")
@CircuitBreaker(name = REDIS_CIRCUIT_BREAKER, fallbackMethod = "findByIdFallback")
public User findById(final UUID id) {
return findByIdLogic(id);
}
public User findByIdFallback(final UUID id, final RuntimeException e) {
if (e instanceof CustomException) {
throw e;
}
return findByIdLogic(id);
}
private User findByIdLogic(final UUID id) {
return userJpaRepository.findById(id).orElseThrow(() -> new NotFoundException(
ErrorType.USER_NOT_FOUND_ERROR));
}
}
'Spring Boot' 카테고리의 다른 글
RestInterceptor 개발기 - 패턴 매칭 이슈 (1) | 2024.12.02 |
---|---|
Spring 라이브러리 개발기 - RestInterceptor v0.1 (0) | 2024.11.29 |
Spring Boot에서 Redis를 활용해 데이터 캐싱하기(feat. Look Aside) (1) | 2024.06.17 |
Interceptor를 활용한 JWT 토큰 검증 (1) | 2024.06.07 |
Dto Validation 실패 시 예외처리 (0) | 2024.03.15 |