[Spring Framework, Spring Data Redis] 캐시 레이어 구성하기

mrcocoball·2025년 2월 9일

Spring Framework

목록 보기
14/20

개요

신규 서비스 프로젝트에서 캐시 레이어 사용을 건의하여 Redis를 활용한 캐시 레이어를 구축하였고 그 내용을 정리해본다.

사실 캐시 레이어에 대해서는 재작년에 알아본 적은 있었으나 직접 구축한 것은 이번이 처음이었다.
아무래도 또 다른 계층이 생기는 만큼 관리 지점이 늘어난다는 단점이 있어서 CTO님, 팀장님과 많은 의견을 나누었었는데 다행히 레퍼런스 조사를 많이 했고 캐시 일관성에 대한 복잡한 로직이 필요하지 않아서 시범적으로 적용하게 되었다.

캐시 레이어

캐시 레이어 개념과 적용하기 좋은 사례

캐시 레이어(Cache Layer)는 자주 조회되는 데이터를 빠르게 제공하기 위해 원본 데이터베이스나 외부 API 앞단에 배치되는 계층이다.
보통 메모리 기반 저장소(Redis, Memcached)를 활용하여 읽기 성능을 향상시키고 시스템 부하를 줄일 때 사용된다.

보통 캐시 레이어는 자주 조회되면서 업데이트가 자주 이뤄지지 않는 데이터를 제공할 때 많이 사용되는데, 적용하기 좋은 사례는 보통 다음과 같다.

  • 빈번한 조회 요청이 있는 경우
  • API 응답 속도 최적화
  • 데이터베이스 부하 경감
  • 세션 및 인증 정보 저장

캐시 레이어의 장/단점

캐시 레이어는 다음과 같은 장점을 가지고 있다.

  • 성능 향상 : 데이터 조회 속도를 크게 개선
  • 부하 감소 : 데이터베이스 및 원본 시스템의 부하를 완화
  • 비용 절감 : 읽기 부하가 높은 경우 비용 절감 효과

보통 데이터베이스에서 성능 문제가 많이 나오는 것은 Read 부분인데 Read하려는 데이터의 업데이트 빈도가 적음에도 조회 시 리소스가 많이 들어간다거나, 사용자의 요청이 아주 빈번한 경우 캐시 레이어를 사용한다면 위와 같은 장점을 얻을 수 있다.

하지만 모든 기술, 솔루션이 반드시 장점만을 가지고 있는 것이 아니듯이, 캐시 레이어에도 분명한 단점이 존재한다.

  • 데이터 일관성 문제 : 원본 데이터 변경 시 변경 사항이 캐시에 즉시 반영되지 않을 수 있음
  • 메모리 비용 : 대용량 데이터 캐싱 시 비용 증가
  • 관리 리소스 추가 : 새로운 계층이 추가되므로 관리 리소스도 추가됨

특히나 많이 다뤄지는 이슈로 데이터 일관성 문제가 있는데, 이로 인해 캐시 전략과 일관성 유지 방법이 반드시 적절하게 결정되어야 한다.

캐시 전략

캐시 전략은 데이터를 캐싱할 때 저장, 갱신, 삭제 방식을 결정하는 여러 가지 방법들을 모아둔 것이다.
요구되는 성능, 데이터 일관성에 따라 적절한 캐시 전략을 선택해야 하는데, 대표적인 캐시 전략들은 다음과 같다.

1. 캐시 읽기 - Cache Aside
데이터를 읽을 때 항상 캐시를 먼저 체크하고 캐시가 없을 경우 원본에서 데이터를 읽어온 후 캐시에 저장하는 방식이다.
필요한 데이터만 캐시에 저장되며, 캐시 미스가 있어도 치명적이지 않다는 장점이 있다.
그러나 캐시가 없을 경우 최초 접근이 느리며 업데이트 주기가 일정하지 않아 캐시가 최신 데이터가 아닐 수 있기에 데이터 일관성 유지가 다소 어렵다.

2. 캐시 읽기 - Read-Through
데이터를 읽을 때 캐시에서만 데이터를 읽어오는 방식으로 데이터 동기화를 캐시 관련 라이브러리나 캐시 저장소에게 위임하기 때문에 라이브러리, 저장소 쪽에 이슈가 발생할 경우 치명적인 문제가 발생할 수 있다.

3. 캐시 쓰기 - Write-Through
데이터를 쓸 때 항상 캐시를 업데이트하여 최신 상태를 유지하는 방식이다.
캐시가 항상 동기화되어 있기 때문에 데이터 일관성 유지에 용이하지만 데이터를 업데이트 할 때마다 캐시도 업데이트해야 하기 때문에 쓰기 지연이 증가한다.

4. 캐시 쓰기 - Write-Around
데이터를 쓸 때 캐시에 저장하지 않고 원본에만 저장하는 방식으로, 캐시가 오염되는 것을 방지하지만 읽기 요청 시 캐시가 없을 경우가 있기 때문에 데이터 일관성이 많이 떨어지게 된다.

5. 캐시 쓰기 - Write-Back
데이터를 쓸 때 캐시에만 저장하는 방식으로, 캐시 읽기 전략과 결합될 경우 캐시 조회 및 쓰기 성능이 아주 좋아지지만, 실제 원본에는 데이터가 반영이 되지 않기 때문에 원본의 데이터 일관성이 많이 떨어지게 된다.

위처럼 캐시를 읽는 방법과 쓰는 방법 모두 다양한 전략이 있고 이에 따른 성능, 데이터 일관성의 Trade-Off가 존재하기 때문에 제공하려는 데이터의 성격과 비즈니스 성격 등을 고려하여 전략을 잘 선택해야 할 것이다.

또한, 캐시 읽기 쓰기 전략 뿐만 아니라 TTL 설정이나 캐시 무효화 등으로 데이터 일관성 유지를 강화하는 방안도 고려해야 한다.

Spring의 캐시 추상화

Spring Framework는 어플리케이션에서 데이터 조회 속도를 개선하고 DB 부하를 줄이기 위한 캐싱을 캐시 추상화(Cache Abstraction)를 통해 제공하고 있다.

https://docs.spring.io/spring-framework/reference/integration/cache.html

캐시 추상화에는 다음과 같은 주요 기능이 존재한다.

1. 일관된 캐싱 API 제공
여러 캐시 라이브러리를 동일한 방식으로 사용할 수 있음

2. 선언적 캐싱 지원
어노테이션 기반 캐싱(@Cacheable, @CachePut, @CacheEvict 등)을 제공하여 비즈니스 로직을 변경하지 않고도 캐싱 적용 가능

3. 캐싱 자동 설정 지원
@EnableCaching 어노테이션만 추가하면 기본 캐시 매니저가 자동으로 설정됨

4. 캐시 저장소 선택 가능
메모리 기반 캐시, 분산 캐시 등을 지원

5. 캐시 만료 및 갱신 전략 설정 가능
TTL, 캐시 갱신 등을 설정할 수 있음.

Spring Framework에서 제공하는 캐시 추상화는 캐시 저장소의 선택을 유연하게 하고, 비즈니스 로직을 크게 해치지 않으면서도 캐싱을 할 수 있도록 도와준다.

이러한 특징은 내가 담당했던 웹 영역 뿐만 아니라, 어드민 영역에서도 비즈니스 로직을 건드리지 않으면서 캐싱을 구현할 수 있었어서 큰 수정 없이 캐시 레이어를 도입하는데에 많은 도움이 되었다.

캐시 레이어 적용 예시 (Spring Data Redis)

의존성 추가

캐시 추상화 자체는 의존성을 추가할 필요는 없으나, 캐시 저장소 및 매니저를 Redis로 할 경우 Spring Data Redis 의존성의 추가가 필요하다.

@Configuration 적용

캐시 매니저와 캐시 설정을 담당할 Configuration 클래스를 작성한다. 또한 @EnableCaching 을 적용하여 캐싱 기능이 활성화되도록 한다.
캐시 매니저는 RedisCacheManager를 Bean으로 등록하며, 캐싱에 대한 전역적인 설정 (직렬화/역직렬화 Mapper, TTL 설정 등) 을 RedisCacheConfiguration으로 지정한다.

이 때, RedisCacheConfiguration에서 캐시의 Value를 직렬화하는 Serializer를 어떤 것으로 지정하느냐에 대한 이슈가 자주 언급이 되는데, 대표적인 Serializer의 종류와 특징에 대해 간단히 요약하자면 다음과 같다.

1. GenericJackson2JsonRedisSerializer
JSON으로 직렬화하며, 타입 정보를 포함하여 직렬화한다.

  • 장점 : 가독성이 높고, 다형성을 지원
  • 단점 : 타입 정보 포함으로 데이터 크기가 증가하며, List<>와 같은 제네릭 타입을 직렬화할 경우 오류가 발생하는 경우가 많음

2. Jackson2JsonRedisSerializer<T>
JSON으로 직렬화하며 특정 타입을 지정하여 직렬화한다.

  • 장점 : 가독성이 높고 데이터 크기가 작음
  • 단점 : 특정 클래스 타입을 지정해야 함

3. StringRedisSerializer
UTF-8 문자열 형태로 직렬화

  • 장점 : 빠르고 가벼움
  • 단점 : String만 저장 가능, 객체 직렬화가 불가능하며 JSON처럼 구조화된 데이터 저장 시 별도의 비즈니스 로직으로 변환해야 함

이에 대해 굉장히 잘 정리된 내용의 링크도 같이 첨부해둔다.
https://github.com/binghe819/TIL/blob/master/Spring/Redis/redis%20serializer/serializer.md

어쨌든 위의 내용처럼 각 Serializer에 대한 트레이드 오프가 뚜렷하다보니 캐시를 사용하는 어플리케이션의 아키텍처와 비즈니스 로직 복잡도 등에 따라 Serializer를 선택하는 것이 필요하다.

필자의 경우, 캐시를 사용하는 부분이 워낙 뚜렷했고 마이크로서비스 아키텍처와 같이 어플리케이션이 다양하게 분산되어 있는 환경도 아니었으며 무엇보다 캐싱과 관련된 비즈니스 로직을 별도로 구현하면서 코드가 변경되는 사이드 이펙트를 줄이고자 Jackson2JsonRedisSerializer 를 사용했다.

아래의 예제는 특정 타입(TestDTO.class)에 대해서만 캐싱을 하는 예제로, 실제로 여러 타입에 대한 캐싱 설정을 별도로 지정하려면 cacheManager 설정 시 withInitialCacheConfigurations(), Map<String, RedisCacheConfiguration> 으로 RedisCacheConfiguration 여러 벌을 등록해야 한다.

@EnableCaching
@Configuration
public class CacheConfig {
    
    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {

        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(cacheConfiguration())
                .build();
    }

    private RedisCacheConfiguration cacheConfiguration() {

        ObjectMapper objectMapper = new ObjectMapper()
                .registerModule(new JavaTimeModule())
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
                .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);

        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(20))
                .serializeKeysWith(fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(fromSerializer(new Jackson2JsonRedisSerializer<>(objectMapper, TestDTO.class)))
                .disableCachingNullValues();
    }

}

Jackson2JsonRedisSerializer 를 사용할 경우, 직렬화 대상 클래스에는 다음과 같이 직렬화 관련 설정을 추가해야 한다.
UID를 지정하지 않을 경우, 어플리케이션 재기동 후 저장된 캐시를 로드할 때 이전에 저장해둔 정보와 일치하지 않는 문제로 역직렬화가 되지 않을 수 있다.

@Builder
@Getter
public class TestDTO implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    private Long id;
    private String title;
    private String description;

    public static TestDTO from(TestEntity entity) {
        return TestDTO.builder()
                .id(entity.getId())
                .title(entity.getTitle())
                .description(entity.getDescription())
                .build();
    }

}

비즈니스 로직에 적용

비즈니스 로직에 지정하는 방식은 상당히 간단한데, 다음과 같이 비즈니스 로직에 어노테이션을 추가하면 된다.

@RequiredArgsConstructor
@Service
public class TestService {

    private final TestRepository repository;

    @Cacheable(value = "testCache", unless = "#result == null || #result.isEmpty()")
    public List<TestDTO> getTestEntities() {
        return repository.findAll().stream().map(TestDTO::from).toList();
    }

}

@Cacheable 어노테이션은 Cache Aside 전략을 사용하여 어노테이션이 부착된 메서드 호출 시 캐시를 먼저 조회하고, 캐시가 존재하면 캐시를 반환하고 존재하지 않을 경우 메서드 내부 로직을 호출하여 결과를 캐시에 저장시킨다.

이 때 저장될 캐시의 키 이름과 키 Prefix, 캐시가 존재하지 않는 경우를 판별하는 조건 등을 지정할 수 있다.

@CacheEvict 어노테이션은 캐시를 삭제할 때 사용하며, 키 이름을 지정하여 해당 키로 저장된 캐시를 저장소에서 삭제시킨다.

그리고 @Caching 어노테이션은 여러 개의 캐싱 관련 어노테이션을 전부 적용할 때 사용한다.

캐시 레이어 장애 시 방어 로직

Spring Framework에서 제공하는 캐시 추상화를 사용할 경우, 만약 캐시 저장소에 문제가 생기거나 캐시를 읽어들이는 과정에서 문제가 발생할 경우 원본 메서드를 호출하지 않고 예외가 발생해버린다.

따라서 캐시 레이어 관련 로직에 장애가 발생할 경우 방어 로직을 구상해야 하는데, 이에 대해서는 다음과 같은 방법들이 존재한다.

  • ErrorHandler를 구현, 추가하여 캐시 추상화 관련 예외 발생 시 로깅 등의 부가적인 처리 진행 (이 경우, 부가적인 작업 처리 후 원본이 호출됨)
  • Spring Retry / Recovery를 사용하여 재시도 및 실패 시 Fallback 처리
  • Spring Cloud Circuit Breaker를 사용하여 재시도 및 실패 시 Fallback 처리

가장 간단한 방법은 ErrorHandler를 구현하는 방식이며, 다른 방식들의 경우 Fallback 처리 시 원본과 똑같은 메서드를 중복으로 구현해야 하거나 외부 종속성이 생기기 때문에 필자는 ErrorHandler를 구현하는 방식을 사용하였다.

ErrorHandler를 적용하려면, Configuration 클래스가 CacheConfigurer 를 구현해야 하며, CacheErrorHandler 의 구현체를 빈으로 등록해야 한다.

// CacheConfig
@EnableCaching
@Configuration
public class CacheConfig implements CacheConfigurer {

	...
    
    @Override
    @Bean
    public CacheErrorHander errorHander() {
    	return new CustomCacheErrorHandler();
    }

}

// CacheErrorHandler 구현체
public class CustomCacheErrorHandler implements CacheErrorHandler {
    
    @Override
    public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
        // 에러 핸들링 로직 구현
    }

    @Override
    public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
		// 에러 핸들링 로직 구현
    }

    @Override
    public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
		// 에러 핸들링 로직 구현
    }

    @Override
    public void handleCacheClearError(RuntimeException exception, Cache cache) {
		// 에러 핸들링 로직 구현
    }
}

기타 고려할 점

이번에 캐시 레이어를 도입한 프로젝트는 분산 환경이 아니었고 캐시로 저장할 데이터의 크기나 종류가 크지 않아서 Redis 하나를 캐시 저장소로 두고 캐시 레이어를 간단하게 구축하였다.

그러나 대규모 트래픽이 발생하는 경우, 캐시 레이어를 여러 곳에 두고 (글로벌 캐시, 1차, 2차, n차 캐시 등...) 관리하는 경우가 많은데 캐시 레이어가 많아질 수록 캐시 레이어 간의 데이터 정합성 문제가 심해지고 장애 발생 시 대응 로직도 복잡해지는 경향이 보이는 것 같다.

그리고 캐시를 꼭 서버와 연결된 캐시 레이어에서 관리하지 않고 CDN이나 Cloudflare에서 제공하는 캐싱 기능 등을 활용하는 경우도 있는 것 같은데 캐시 레이어 적용 시 생기는 이슈들을 좀 더 추적하면서 고도화할 수 있는 방안이 무엇이 있는지 좀 더 찾아보고 개선을 해보고 싶다.

profile
Backend Developer

0개의 댓글