Redis 장애 처리하기

Hyuk·2023년 10월 13일
1

HappyScrolls 개발기

목록 보기
19/24
post-thumbnail

문제

레디스를 이용하여 캐시서버를 구성하고 있는 상황인데,

만약 레디스 서버가 다운되면, 레디스 캐시를 이용하는 api를 호출했을 때 오류가 나는 상황이 발생했다.

레디스는 캐시이기 때문에, 레디스에서 오류가 나면 원래 실행되어야 할 쿼리를 날려 DB에서 값을 받아와야 한다고 생각했다.

따라서 레디스에서 장애가 발생해도 정상적으로 api가 동작하도록 구성해보았다.

해결법1

기존 레디스 설정 파일은 다음과 같다.

@Configuration
@EnableCaching
@RequiredArgsConstructor
public class RedisCacheConfig{

    private final RedisConnectionFactory cf;

	public CacheManager cacheManager() {
        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(3L));

        return RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(cf)
                .cacheDefaults(redisCacheConfiguration)
                .build();
    }
}

위 파일을 다음과 같이 CachingConfigurerSupport를 상속하도록 하고,

public class RedisCacheConfig extends CachingConfigurerSupport {

    private final RedisConnectionFactory cf;
    @Override
    public CacheManager cacheManager() {
		...생략
    }


    @Override
    public CacheErrorHandler errorHandler() {
        return new CustomCacheErrorHandler();
    }
}

CustomCacheErrorHandler를 구현했다.

public class CustomCacheErrorHandler implements CacheErrorHandler {

    @Override
    public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
	//아직 아무것도 구현하지 않음
    }
	...생략

}

이렇게 되면 레디스 캐시를 조회할 때 오류가 생기더라도 "아무것도 안함"으로 예외처리를 하고 기존 코드를 실행하게 되어 db에 접근하여 값을 받아오게 된다.

해결법2

application.yml

resilience4j:
  circuitbreaker:
    instances:
      circuitbreaker_test:
        failure-rate-threshold: 60
        recordExceptions:
          - java.net.SocketTimeoutException
          - java.net.ConnectException
        ignoreExceptions:
          - java.lang.IllegalStateException

Service 코드


	레디스 사용 부분
    @CircuitBreaker(name = "circuitbreaker_test", fallbackMethod = "fallBack")
    @Cacheable(cacheNames = "zeropagingarticles", key = "#root.target + #root.methodName",  cacheManager = "cacheManager" )
    @Transactional(readOnly = true)
    public List<Article> articleRetrievePagingWithZeroOffset(Long lastindex, Integer limit) {

        List<Article> articles = articleRepository.zeroOffsetPaging(lastindex, limit);
        if(articles.isEmpty()){
            throw  new NoResultException(String.format("게시글 조회 결과가 비었습니다. lastid: [%s]",lastindex));
        }
        return articles;
    }
	
    FallBack 함수
    private List<Article> fallBack(Long lastindex, Integer limit,Throwable e) {
       List<Article> articles = articleRepository.zeroOffsetPaging(lastindex, limit);
        if(articles.isEmpty()){
            throw  new NoResultException(String.format("게시글 조회 결과가 비었습니다. lastid: [%s]",lastindex));
        }
        return articles;
    }

위와 같이 resilience4j를 이용하여 구성할 수도 있다.
서킷브레이커 패턴을 이용하여 레디스에 접근이 실패하면 Fallback을 실행하게 된다. 그리고 failure-rate-threshold: 60로 두어서 최근 100번의 호출동안 60%가 실패하면 서킷을 오픈하게 된다.(100번이라는 횟수는 resilience4j의 기본 slidingWindowSize 값이다) 서킷을 오픈하면 호출은 바로 실패하도록 설정된다.

위 코드는 fallback 함수로 기존 코드와 완벽하게 동일한 코드를 작성해야 한다는 단점이 있다.

각 해결법의 문제점

CacheErrorHandler

error 핸들러를 사용하면 레디스에서 발생하는 모든 예외를 각각 처리해서 대응할 수 있다는 장점이 있다. 하지만 레디스가 긴 시간동안 장애상태에 빠졌을 때, 이를 알아채지 못하고 계속해서 접근을 시도하고 예외를 처리해야하는것이 단점이다.

서킷 브레이커 패턴

특정 횟수 이상으로 오류가 생기면 당분간 아예 시도를 안하는 과정이 있기 때문에, 장애를 알아차리고 계속해서 무의미하게 접근을 시도하는 비용을 줄일 수 있다는 장점이 있다.
단점은 폴백 함수를 구현하는 과정에서 코드가 중복된다는 문제가 있었다.

두 방법 모두 단점이 있으나, 1번 방법(CacheErrorHandler)은 계속해서 레디스에 접근을 시도하고 때문에 비용이 계속 발생한다. 2번 방법(서킷 브레이커 패턴)은 장애가 발생하면 당분간 시도를 안하기 때문에 비용이 들지 않는다. 따라서 2번 방법(서킷 브레이커 패턴)이 조금 더 올바른 방향이이라 생각되어서 서킷브레이커의 단점을 보완하여 사용하는것으로 결정하였다.

서킷 브레이커 패턴 단점 해결(어댑터의 적용)

public class ArticleAdaptor {
    @Autowired
    private  ArticleRepository articleRepository;

    public List<Article> retrieveByPaging(Long lastId, Integer limitPage){
        List<Article> result =  articleRepository.zeroOffsetPaging(lastId, limitPage);
        if(result.isEmpty()) throw new NoResultException(String.format("게시글 조회 결과가 비었습니다. lastId:[%s]", lastId));

        return result;
    }

	...생략
}

Service 코드

    @CircuitBreaker(name = "circuitbreaker_test", fallbackMethod = "retrieveAllPagingFallBack")
    @Cacheable(cacheNames = "zeropagingarticles", key = "#root.target + #root.methodName",  cacheManager = "cacheManager" )
    @Transactional(readOnly = true)
    public List<Article> retrieveAllPaging(Long lastId, Integer limit) {
        return articleAdaptor.retrieveByPaging(lastId, limit);
    }

    private List<Article> retrieveAllPagingFallBack(Long lastId, Integer limit,Throwable e) {
        return articleAdaptor.retrieveByPaging(lastId, limit);
    }

위와 같이 리포지토리 메소드를 어댑터로 감싸고, 서비스에서 이전에는 리포지토리 메소드를 직접 호출했다면, 이제는 어댑터의 메소드를 호출하는 형태로 변경했다.

FallBack함수에서 retrieveAllPaging과 같은 코드를 호출한다는 중복은 존재하지만, 기존에 retrieveAllPaging가 갖고 있던 예외처리 부분을 어댑터에서 처리하기 때문에 FallBack함수에서 예외처리를 해줄 필요가 없어서 중복이 줄어드는 결과를 얻을 수 있었다.

또한 어댑터 패턴은 다른 문제의 해결방법으로도 사용하면 좋을 것 같다.

profile
🙂 🙃 🙂 🙃

0개의 댓글