Redis 캐싱 적용 가이드

운영하던 게시판 서비스에 Redis 캐싱을 적용하면서 직렬화 실패와 캐싱 대상 선정 문제로 적지 않게 시간을 썼다. 처음에는 단순히 @Cacheable만 붙이면 될 줄 알았는데, 캐싱 대상으로 어떤 객체를 넣느냐에 따라 직렬화가 깨지거나 역직렬화가 실패하는 경우가 많았다. 그래서 적용 과정에서 부딪힌 이슈와 정리한 설계 기준을 한곳에 모아 두었다.

프로젝트 설계 원칙

Redis 캐싱을 적용할 프로젝트라면 처음부터 다음 기준을 잡고 설계하는 편이 낫다.

1. ObjectMapper 설정

Redis 직렬화에 쓸 ObjectMapper는 API 응답용과 분리해서 별도로 설정한다.

@Configuration
public class RedisConfig {
    @Bean("redisObjectMapper")
    public ObjectMapper redisObjectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule()); // 날짜/시간 타입 지원
        mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
        mapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
        mapper.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT);
        BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
            .allowIfSubType("com.example.")
            .allowIfSubType("java.util.")
            .build();
        mapper.activateDefaultTyping(
            ptv,
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
        );
        return mapper;
    }

    @Bean
    RedisCacheConfiguration defaultRedisCacheConfiguration(
            @Qualifier("redisObjectMapper") ObjectMapper redisObjectMapper) {
        return RedisCacheConfiguration.defaultCacheConfig()
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer(redisObjectMapper)))
            .disableCachingNullValues()
            .entryTtl(Duration.ofMinutes(1));
    }
}

각 설정의 역할은 이렇다.

  • JavaTimeModule: LocalDateTime 등 날짜/시간 타입 직렬화
  • activateDefaultTyping: 타입 정보 포함 (다형성 지원)
  • disableCachingNullValues: null 값 캐싱 방지

activateDefaultTyping을 쓸 때 allowIfBaseType(Object.class)처럼 너무 넓게 열어두면, 신뢰할 수 없는 타입 정보까지 역직렬화 대상이 될 수 있다. Redis가 내부망에 있더라도 가능하면 캐싱 DTO가 있는 패키지와 필요한 JDK 컬렉션 정도만 allowlist로 제한한다.

Redis 전용 ObjectMapper를 굳이 별도로 만든 이유는 Redis 직렬화와 REST API 응답이 요구사항이 다르기 때문이다.

구분 Redis ObjectMapper API ObjectMapper
타입 정보 포함 필요 (역직렬화를 위해) 불필요 (클라이언트에 노출 안 함)
null 처리 캐싱 안 함 응답에 포함 가능
날짜 형식 타임스탬프 가능 ISO-8601 형식 선호

전역 ObjectMapper를 그대로 Redis에 쓰면 activateDefaultTyping이 API 응답에도 적용되어 응답 JSON에 불필요한 타입 정보(@class)가 같이 나가게 된다.

// 잘못된 예시: 전역 ObjectMapper 사용
@Bean
public ObjectMapper objectMapper() {
    ObjectMapper mapper = new ObjectMapper();
    mapper.activateDefaultTyping(...); // API 응답에도 타입 정보 포함됨
    return mapper;
}

// API 응답 예시 (타입 정보 노출)
{
  "@class": "com.example.UserDto",  // 불필요한 정보
  "id": 1,
  "name": "홍길동"
}

// 올바른 예시: Redis 전용 ObjectMapper
@Bean("redisObjectMapper")
public ObjectMapper redisObjectMapper() {
    // Redis 직렬화에만 사용
}

@Bean
public ObjectMapper objectMapper() {
    // API 응답에만 사용
}

// API 응답 예시 (깔끔함)
{
  "id": 1,
  "name": "홍길동"
}

그래서 @Qualifier("redisObjectMapper")로 Redis 설정에만 이 매퍼를 주입한다.

2. 캐싱 메서드는 데이터 DTO를 반환하기

Redis에 저장되는 값은 가능한 한 순수 데이터 DTO로 제한한다. ResponseEntity, HttpHeaders, HttpStatus 같은 HTTP 응답 객체는 Redis 직렬화 대상에 넣지 않는 편이 낫다.

// 올바른 예시
@Cacheable("dataCache")
public DataDto getData() {
    return repository.findData()
        .map(this::toDto)
        .orElseThrow();
}

// 잘못된 예시
public ResponseEntity<DataDto> getData() {
    DataDto data = repository.findData().map(this::toDto).orElseThrow();
    return new ResponseEntity<>(data, HttpStatus.OK); // HTTP 객체 사용 - 잘못됨
}

3. Entity를 직접 반환하지 않기

// 잘못된 예시
@Cacheable("userCache")
public User getUser(Long id) {
    return userRepository.findById(id).orElseThrow(); // Entity 직접 반환
}

Entity를 직접 캐싱하면 발생하는 문제:

  • JPA 프록시 객체 직렬화 실패
  • Lazy Loading 관계 직렬화 실패
  • 양방향 연관관계로 인한 순환 참조
  • 불필요한 데이터까지 캐싱되어 메모리 낭비
  • Entity 변경 시 캐시 데이터 구조도 변경됨

해결 방법

// 올바른 예시
@Cacheable("userCache")
public UserDto getUser(Long id) {
    User user = userRepository.findById(id).orElseThrow();
    return UserDto.from(user); // DTO로 변환 후 반환
}

// DTO 정의
public class UserDto {
    private Long id;
    private String name;
    private String email;
    
    public static UserDto from(User user) {
        UserDto dto = new UserDto();
        dto.id = user.getId();
        dto.name = user.getName();
        dto.email = user.getEmail();
        return dto;
    }
    
    // getter/setter
}

4. 직렬화 가능한 DTO 설계

Jackson JSON 직렬화 기준 요구사항

GenericJackson2JsonRedisSerializer를 쓴다면 캐싱 대상 DTO는 다음 중 하나를 만족해야 한다.

// 방법 1: 기본 생성자 + Getter/Setter (가장 안전)
public class DataDto {
    private String field1;
    private int field2;
    
    // 기본 생성자 필수
    public DataDto() {}
    
    // 모든 필드에 대한 getter/setter
    public String getField1() { return field1; }
    public void setField1(String field1) { this.field1 = field1; }
    // ...
}

// 방법 2: @JsonCreator 사용 (불변 객체)
public class DataDto {
    private final String field1;
    private final int field2;
    
    @JsonCreator
    public DataDto(@JsonProperty("field1") String field1,
                   @JsonProperty("field2") int field2) {
        this.field1 = field1;
        this.field2 = field2;
    }
    
    // getter만 있어도 됨
    public String getField1() { return field1; }
    public int getField2() { return field2; }
}

Serializable은 언제 필요한가

GenericJackson2JsonRedisSerializer처럼 Jackson JSON 기반 직렬화기를 쓰는 경우 Serializable 구현은 필수가 아니다. Jackson은 기본 생성자/@JsonCreator, getter/setter 또는 생성자 파라미터 정보를 보고 JSON으로 직렬화·역직렬화한다.

SerializableserialVersionUID가 의미 있는 경우는 JDK 직렬화 기반의 JdkSerializationRedisSerializer를 쓸 때다. JSON 직렬화에서는 serialVersionUID가 역직렬화 호환성을 보장하지 않으므로, DTO 필드 변경 시에는 기존 캐시 무효화나 버전이 다른 캐시 키를 사용하는 식으로 대응해야 한다.

피해야 할 패턴

// 잘못된 예시 1: 기본 생성자 없음
public class DataDto {
    private final String field;
    
    public DataDto(String field) { // @JsonCreator 없음
        this.field = field;
    }
}

// 잘못된 예시 2: Getter 없음
public class DataDto {
    private String field;
    
    public DataDto() {}
    // 기본 설정에서는 Jackson이 속성으로 인식하지 못할 수 있음
}

// 잘못된 예시 3: 복잡한 객체 포함
public class DataDto {
    private InputStream stream; // 직렬화 불가능한 타입
    private Connection connection; // 리소스 객체
}

// 주의 예시 4: JDK 직렬화기를 쓰는데 serialVersionUID 누락
public class DataDto implements Serializable {
    // JdkSerializationRedisSerializer 사용 시 클래스 변경 후 호환성 문제가 생길 수 있음
    private String field;
}

5. 컬렉션 타입 처리

목록 응답도 Entity 컬렉션을 그대로 캐싱하지 말고, DTO 컬렉션이나 목록 응답 DTO로 변환해서 저장한다. 캐시에서 꺼낸 뒤에도 타입을 예측할 수 있도록 반환 타입은 구체적으로 둔다.

@Cacheable("listCache")
public List<DataDto> getData() {
    return repository.findAll().stream()
        .map(DataDto::from)
        .collect(Collectors.toList());
}

public class DataListResponse {
    private List<DataDto> items;
    private int totalCount;
    
    public DataListResponse() {} // 기본 생성자
    
    // getter/setter
}

@Cacheable("listCache")
public DataListResponse getData() {
    List<DataDto> items = repository.findAll().stream()
        .map(DataDto::from)
        .collect(Collectors.toList());
    
    DataListResponse response = new DataListResponse();
    response.setItems(items);
    response.setTotalCount(items.size());
    return response;
}

6. 페이징 처리

문제 상황

// 잘못된 예시
@Cacheable("pageCache")
public Page<Entity> getPage(Pageable pageable) {
    return repository.findAll(pageable); // Page 객체 직렬화 문제
}

해결 방법

// 페이징 응답 DTO 정의
public class PageResponse<T> {
    private List<T> content;
    private int pageNumber;
    private int pageSize;
    private long totalElements;
    private int totalPages;
    
    public PageResponse() {} // 기본 생성자
    
    public static <T> PageResponse<T> from(Page<T> page) {
        PageResponse<T> response = new PageResponse<>();
        response.setContent(page.getContent());
        response.setPageNumber(page.getNumber());
        response.setPageSize(page.getSize());
        response.setTotalElements(page.getTotalElements());
        response.setTotalPages(page.getTotalPages());
        return response;
    }
    
    // getter/setter
}

// 올바른 사용
@Cacheable("pageCache")
public PageResponse<DataDto> getPage(int page, int size) {
    Page<Entity> entityPage = repository.findAll(PageRequest.of(page, size));
    Page<DataDto> dtoPage = entityPage.map(DataDto::from);
    return PageResponse.from(dtoPage);
}

7. 캐싱 조건 처리

결과나 파라미터에 따라 캐싱에서 제외해야 할 경우

// 예외 발생 시 캐싱하지 않기
@Cacheable(value = "dataCache", unless = "#result == null")
public DataDto getData(Long id) {
    return repository.findById(id)
        .map(DataDto::from)
        .orElse(null);
}

// 특정 조건에서만 캐싱
@Cacheable(value = "dataCache", condition = "#id > 0")
public DataDto getData(Long id) {
    return repository.findById(id)
        .map(DataDto::from)
        .orElseThrow();
}

8. 날짜/시간 타입 처리

문제 상황

// LocalDateTime, ZonedDateTime 등의 직렬화 이슈
public class DataDto {
    private LocalDateTime createdAt; // 직렬화 형식 주의
}

해결 방법

// 방법 1: Redis 전용 ObjectMapper에 JavaTimeModule 추가
// 위 RedisConfig의 redisObjectMapper()처럼 registerModule(new JavaTimeModule())을 적용한다.

// 방법 2: 문자열로 변환
public class DataDto {
    private String createdAt; // ISO-8601 형식 문자열
    
    public static DataDto from(Entity entity) {
        DataDto dto = new DataDto();
        dto.createdAt = entity.getCreatedAt().toString();
        return dto;
    }
}

// 방법 3: 어노테이션 사용
public class DataDto {
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createdAt;
}

발생한 주요 이슈

1. CustomResponseEntity 역직렬화 실패

문제 상황

org.springframework.data.redis.serializer.SerializationException: 
Could not read JSON: Cannot construct instance of `com.example.bbs.common.CustomResponseEntity` 
(although at least one Creator exists): cannot deserialize from Object value 
(no delegate- or property-based Creator)

원인

  • CustomResponseEntity 클래스가 Jackson 역직렬화를 위한 적절한 생성자가 없음
  • Redis에서 캐시된 데이터를 읽을 때 GenericJackson2JsonRedisSerializer가 객체를 생성할 수 없음

단기 조치

당장 구조를 바꾸기 어렵다면 @JsonCreator 생성자를 추가해 역직렬화 실패를 막을 수 있다. 다만 이 방법은 HTTP 응답 객체를 계속 캐싱한다는 한계가 있으므로 최종 해결책으로 두지는 않는다.

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;

public class CustomResponseEntity<T> extends ResponseEntity<T> {

    @JsonCreator
    public CustomResponseEntity(@JsonProperty("body") T body,
                                @JsonProperty("headers") MultiValueMap<String, String> headers,
                                @JsonProperty("statusCode") HttpStatusCode status) {
        super(body, headers, status);
    }
    
    // 기타 생성자들...
}

최종 해결

캐싱 대상에서 ResponseEntity와 커스텀 HTTP 래퍼를 제거하고, Service는 DTO만 반환하게 바꾼다. HTTP 상태 코드와 헤더는 Controller에서만 만든다.

2. HTTP 응답 객체가 캐싱 대상에 섞인 문제

문제 상황

@Cacheable이 붙은 메서드가 ResponseEntity를 반환하는 구조:

// 문제가 있는 구조
public ResponseEntity<OnairCommentArticleListDto> getCacheableOnairCommentArticle(...) {
    OnairCommentArticleListDto result = new OnairCommentArticleListDto();
    // 비즈니스 로직
    return new CustomResponseEntity<>(result, HttpStatus.OK);
}

Redis 캐싱 관점의 문제점

  • ResponseEntity, HttpStatus, HttpHeaders까지 Redis 직렬화 대상에 포함됨
  • 응답 래퍼 생성자나 내부 필드 구조에 따라 역직렬화 실패 가능성이 커짐
  • 캐시 값의 스키마가 API 응답 포맷에 묶여, 응답 포맷 변경이 캐시 호환성 문제로 이어질 수 있음

해결 방법

Service는 DTO만 반환하고, Controller에서 ResponseEntity 생성:

// Service
@Cacheable(value = "getCacheableOnairCommentArticle", 
           key = "'getCacheableOnairCommentArticle_' + #commentId + #prevSeq + #size")
public OnairCommentArticleListDto getCacheableOnairCommentArticle(...) {
    OnairCommentArticleListDto result = new OnairCommentArticleListDto();
    // 비즈니스 로직
    return result;
}

// Controller
private ResponseEntity<OnairCommentArticleListDto> getOnairComment(...) {
    OnairCommentArticleListDto result;
    if (user != null)
        result = commentService.getOnairCommentArticle(user, prevSeq, commentId, size);
    else
        result = commentService.getCacheableOnairCommentArticle(user, prevSeq, commentId, size);
    
    return new CustomResponseEntity<>(result, HttpStatus.OK);
}

3. 폭주 트래픽과 Cache Stampede — “짧은 TTL” 기조와 지터 사이의 고민

게시판 목록이나 실시간 댓글처럼 순간적으로 트래픽이 몰리는 API가 있었다. 여기에 캐시를 적용하면서 “캐시는 최대한 짧게(최소 5초) 가져간다”는 기조를 세웠는데, 짧은 TTL이 오히려 Cache Stampede를 자주 유발한다는 게 문제였다. 처음엔 지터를 주고 싶어도 “수명을 늘리는” 방향이라 기조와 충돌한다고 생각했다.

정리하다 보니 Stampede를 두 종류로 나눠서 봐야 했다. 둘은 원인도 해법도 다르다.

유형 상황 대응
핫키 동시 미스 인기 게시판 한 키에 수천 요청 → 만료 순간 전부 DB로 분산 락, stale-while-revalidate 등
동시 대량 만료 여러 키가 같은 순간 생성 → 동시에 만료 TTL 지터

@Cacheable(sync = true)도 단일 인스턴스 안에서는 같은 키의 동시 miss를 줄일 수 있지만, 서버가 2대 이상이면 인스턴스마다 DB 조회가 발생할 수 있다. 실제 프로젝트도 이중화된 환경이었기 때문에, 전체 클러스터 기준의 해결책으로 보기는 어려웠다.

해결: 대량 만료는 음수 지터 — 단, TTL을 늘리지 않는 방향으로

지터를 흔히 TTL = base + random으로 떠올리는데, 이러면 수명이 늘어 “짧게” 기조와 충돌한다. 그래서 빼는 방향(음수)으로만 적용했다. 실제 TTL을 [base × (1 - ratio), base] 범위로 무작위화하면, 최대 수명은 base를 절대 넘지 않으면서 만료 시점만 흩어진다. 기조와 충돌하지 않는다.

Spring Data Redis 3.2부터는 RedisCacheConfiguration.entryTtl(TtlFunction)으로 캐시 쓰기 시점마다 TTL을 계산할 수 있다. 별도 RedisCacheWriter 데코레이터를 만들 필요 없이 설정에서 바로 지터를 줄 수 있다.

@Bean
RedisCacheConfiguration defaultRedisCacheConfiguration() {
    Duration baseTtl = Duration.ofSeconds(5);
    double jitterRatio = 0.2;

    return RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl((key, value) -> {
            long ttlMillis = baseTtl.toMillis();
            long maxJitter = (long) (ttlMillis * jitterRatio);
            long jitter = ThreadLocalRandom.current().nextLong(maxJitter + 1);
            return Duration.ofMillis(ttlMillis - jitter);
        });
}

ratio 0.2 기준으로 5초 → [4.0s, 5.0s], 1분 → [48s, 60s]가 된다. 캐시별로 base TTL이 다르다면 캐시 이름별 RedisCacheConfiguration을 나눠서 같은 방식으로 적용하면 된다. 비율은 redis.cache.jitter-ratio 같은 프로퍼티로 빼두면 프로파일별 튜닝도 쉽다.

정리하면 “짧게 유지”와 stampede 방지는 지터의 방향(음수)만 잡으면 상충하지 않는다. 음수 지터로 대량 만료를 흩고, 핫키 동시 미스까지 더 줄여야 하면 분산 락이나 stale-while-revalidate로 넘어가는 단계별 설계가 가능하다.

운영 기준

앞에서 다룬 직렬화, 캐싱 DTO 분리 원칙을 지켰다면 운영 단계에서는 캐시 키, TTL, 무효화 전략을 명확히 정하는 것이 중요하다.

1. 캐시 키 설계

권장 패턴

@Cacheable(value = "cacheName", 
           key = "'prefix_' + #param1 + '_' + #param2")
public DataDto getData(String param1, String param2) {
    // ...
}

주의사항

  • 캐시 키는 고유해야 함
  • 사용자별로 다른 데이터인 경우 userId를 키에 포함
  • null 파라미터 처리 고려
  • 키는 짧게 유지 (Redis 키는 최대 512MB까지 가능하지만, 성능을 위해 가능한 짧게 — 보통 수백 byte 이내로 권장)

2. 캐시 만료 시간 설정

기본 TTL은 다음처럼 설정한다.

spring:
  cache:
    redis:
      time-to-live: 600000  # 10분 (밀리초)

TTL은 데이터 변경 빈도에 맞춰 정하면 된다. 실시간성이 중요한 데이터는 짧게 가져가거나 캐싱에서 빼고, 메모리 사용량과 성능 사이에서 적당히 타협한다.

최소 TTL 권장

TTL을 1~2초처럼 너무 짧게 잡으면 캐시 히트율이 낮아 캐싱을 안 한 것과 큰 차이가 없고, 만료 순간에 요청이 DB로 몰리는 Cache Stampede가 생기기 쉽다. Redis와의 통신과 직렬화/역직렬화도 계속 반복되어 오히려 손해다. 실시간성이 필요한 데이터라도 최소 5~10초 정도는 두는 편이 낫다고 본다.

TTL 설정 예시

# 정적 데이터 (거의 변경되지 않음)
spring.cache.redis.time-to-live: 3600000  # 1시간

# 준정적 데이터 (하루 1~2회 변경)
spring.cache.redis.time-to-live: 600000   # 10분

# 동적 데이터 (자주 변경되지만 실시간성 불필요)
spring.cache.redis.time-to-live: 60000    # 1분

# 실시간성 필요 데이터 (최소 권장)
spring.cache.redis.time-to-live: 10000    # 10초

# 실시간 데이터
# 캐싱하지 않거나 Cache-Aside 패턴으로 수동 관리

짧은 TTL을 써야 한다면 스케줄러로 미리 캐시를 갱신해 두는 방법도 있다.

// 스케줄러로 주기적으로 캐시 갱신
@Scheduled(fixedDelay = 8000) // 8초마다 실행
public void warmUpCache() {
    DataDto data = repository.findData();
    cacheManager.getCache("dataCache").put("fixedKey", data);
}

@CacheEvict 후 같은 클래스 내부에서 @Cacheable 메서드를 직접 호출하는 방식은 피해야 한다. Spring Cache는 프록시 기반이라 self-invocation에서는 캐시 어노테이션이 적용되지 않는다. 스케줄러에서는 위처럼 CacheManager로 직접 넣거나, 캐시 메서드를 다른 빈으로 분리해 프록시를 통해 호출한다.

테스트 가이드

1. 직렬화/역직렬화 테스트

@Autowired
@Qualifier("redisObjectMapper")
ObjectMapper redisObjectMapper;

@Test
void testRedisSerialization() {
    YourDto original = new YourDto(...);
    
    // 직렬화
    String json = redisObjectMapper.writeValueAsString(original);
    
    // 역직렬화
    YourDto deserialized = redisObjectMapper.readValue(json, YourDto.class);
    
    assertThat(deserialized).usingRecursiveComparison().isEqualTo(original);
}

2. 캐시 동작 테스트

@Test
void testCaching() {
    // 첫 번째 호출 - DB 조회
    DataDto result1 = service.getCachedData("key");
    
    // 두 번째 호출 - 캐시에서 조회
    DataDto result2 = service.getCachedData("key");
    
    // Redis를 거치면 역직렬화된 새 인스턴스가 반환될 수 있으므로 값 동등성을 확인
    assertThat(result2).usingRecursiveComparison().isEqualTo(result1);
    
    // DB 호출이 1번만 발생했는지 검증
    verify(repository, times(1)).findData("key");
}

체크리스트

프로젝트에 Redis 캐싱을 적용하기 전 다음 사항을 확인하세요:

  • 캐싱 대상 클래스가 직렬화 가능한가?
  • 기본 생성자 또는 @JsonCreator 생성자가 있는가?
  • @Cacheable 메서드가 HTTP 응답 객체가 아닌 데이터 DTO를 반환하는가?
  • 캐시 키가 고유하게 설계되었는가?
  • 적절한 TTL이 설정되었는가?
  • Cache Stampede(폭주 트래픽) 대비책이 있는가? (분산 락, 음수 지터 등)
  • 민감한 정보가 캐싱되지 않는가?
  • 직렬화/역직렬화 테스트를 작성했는가?
  • 캐시 무효화 전략이 수립되었는가?

참고 자료

  • Spring Cache Abstraction: https://docs.spring.io/spring-framework/reference/integration/cache.html
  • Spring Data Redis: https://docs.spring.io/spring-data/redis/reference/
  • Jackson Annotations: https://github.com/FasterXML/jackson-annotations

부록: Redis 캐시 전략 패턴

Cache-Aside, Write-Through, Write-Behind, Refresh-Ahead, Cache Stampede 방지 전략은 내용이 길어져 별도 글로 분리했다.