옌의 로그

Redis 캐시 저장, DTO 직렬화/역직렬화 깨짐 해결기 본문

스터디/스프링

Redis 캐시 저장, DTO 직렬화/역직렬화 깨짐 해결기

dev-yen 2025. 9. 12. 13:35

최근 API 응답 속도 개선을 위해 일부 조회성 API에 @Cacheable을 사용해보았다. Redis를 캐시 저장소로 설정하고, DTO를 JSON으로 저장하는 방식이었는데…

단순해 보이던 이 작업이 생각보다 다양한 직렬화 문제를 만나게 해주었음 ㅋㅋ(ㅠ)

 

@Cacheable로 데이터 캐싱하기

application.yml에 캐시 매니저 등록하기

spring:
  cache:
    type: redis
  redis:
    host: localhost
    port: 6379
    # password: yourpassword (필요 시)

 

캐싱할 데이터에 @Cacheable 설정

@Cacheable(cacheNames = "MY_DTO_CACHE", key = "#id")
public MyDto getMyDto(Long id) {
    return myRepository.findById(id)
        .orElseThrow(() -> new NotFoundException("데이터가 없습니다."));
}
이렇게 설정해두면, 같은 id로 메서드가 다시 호출되었을 때 DB에 접근하지 않고 캐시된 값을 반환해주므로 성능을 향상시킬 수 있다.

 

  • cacheNames (또는 value)
    : 사용할 캐시 공간(Cache name) 을 명시. Redis에서는 이 값이 key prefix로 사용됨.
  • key
    : 캐시 저장 시 사용할 고유 키값
    ex) MY_DTO_CACHE::123
  • unless
    : 조건에 따라 캐싱을 하지 않을 때 사용
    ex) unless = "#result == null" → 반환값이 null이면 캐싱하지 않음.
  • condition
    : 메서드 실행 에 캐싱 여부를 판단
    ex) condition = "#id > 100" → id가 100보다 클 때만 캐싱

 

@EnableCaching 설정

@Configuration
@EnableCaching  // <- 이게 있어야 캐시 관련 기능이 동작함
public class CacheConfig {}

 

Spring Boot 2.x 이상에서는 spring.cache.type=redis 만으로도 Redis 캐시 매니저가 자동 구성되기 때문에RedisCacheManager를 따로 만들지 않아도 기본 캐시 동작이 가능하지만, @EnableCaching 은 자동으로 선언되지 않기 때문에,
@Cacheable, @CachePut 같은 캐시 애노테이션을 사용하려면 반드시 명시적으로 선언해줘야 한다

 

 

여기까지하면 문제없이 동작할 줄 알았다. (하지만 그건 오산이었다. 경기도 오산~)

 


 

첫 번째 문제: LocalDateTime 직렬화 에러

LocalDateTime을 포함한 DTO를 캐시에 저장하려 했더니

public record ProductDto(
    Long id,
    String name,
    LocalDateTime createdAt
) {}

 

에러발생 (LocalDateTime 직렬화 오류)

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.LocalDateTime` not supported by default

 

해결

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'

jackson-datatype-jsr310 의존성을 추가하고

@Bean
public RedisCacheConfiguration cacheConfiguration() {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new JavaTimeModule());
    objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

    return RedisCacheConfiguration.defaultCacheConfig()
        .serializeValuesWith(
            RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))
        );
}

ObjectMapper에 JavaTimeModule을 등록해 LocalDateTime을 ISO-8601 문자열로 직렬화하도록 설정했다.

+(WRITE_DATES_AS_TIMESTAMPS를 비활성화해야 "2023-01-01T00:00:00" 형식으로 문자열 직렬화 된다)

 

 

Spring에서 Redis 캐시를 사용할 경우, 기본적으로 사용하는 직렬화 방식은 다음 중 하나

  • JdkSerializationRedisSerializer: Java 기본 직렬화 방식 (속도 느림, 버전 의존성 높음)
  • Jackson2JsonRedisSerializer<T>: JSON 기반 직렬화 (가독성 ↑, 유연성 ↑)
  • GenericJackson2JsonRedisSerializer: ObjectMapper 설정을 공유하며 타입 정보 포함 가능 (최근 많이 사용)
더보기

 

JdkSerializationRedisSerializer

Java 기본 직렬화 방식

: 빠르게 prototype을 만들거나, 완전히 내부 서비스이고 Redis 내용 확인이 필요 없는 경우

  • Java의 ObjectOutputStream, ObjectInputStream 기반으로 객체를 바이너리로 직렬화함.
  • Redis에 저장될 때 바이너리 데이터(0xACED...)로 들어감.
  • 직렬화/역직렬화 대상 객체는 Serializable 인터페이스를 반드시 구현해야 함.

장점

  • Java 기본 기능이라 별도 의존성 없이 사용 가능.
  • 빠르게 적용 가능.

단점

  • 버전 의존성: 클래스 구조가 바뀌면 역직렬화 오류 발생 (serialVersionUID mismatch 등).
  • 가독성 없음: Redis에 저장된 내용을 사람이 확인하기 힘듦.
  • 속도 느림: JSON 직렬화 대비 성능이 낮고, 비효율적인 바이너리 포맷.

 

 

Jackson2JsonRedisSerializer<T>

Jackson 기반 JSON 직렬화기 - 타입 미포함

  • Jackson 기반으로 객체를 JSON 문자열로 직렬화.
  • 명시적으로 타입을 지정해야 하며, 해당 클래스 정보는 JSON 내에 포함되지 않음.
Jackson2JsonRedisSerializer<MyDto> serializer = 
    new Jackson2JsonRedisSerializer<>(MyDto.class);

 

 

장점

  • 가독성 우수: Redis CLI로 JSON 확인 가능 (GET key 시 JSON 포맷으로 나옴).
  • 속도 좋음, 유연성 높음.
  • Java 객체를 그대로 Redis에서 가져와 사용할 수 있음.

 단점

  • 타입 정보 없음 → 복잡한 객체 구조나 다형성에는 적합하지 않음.
  • 역직렬화할 때 반드시 클래스 타입 지정 필요.
  • Object로 저장하면 역직렬화 시 LinkedHashMap으로 나옴 (→ ObjectMapper 수동 조작 필요).

사용처

  • DTO별로 명확한 캐싱 로직이 있는 경우.
  • RedisCacheConfiguration을 캐시마다 분기해서 직렬화기 적용할 수 있을 때.
  • enum, 날짜 등 직렬화 커스터마이징이 필요한 경우 (예: JavaTimeModule 등록).

 

GenericJackson2JsonRedisSerializer

Jackson 기반 JSON 직렬화기 - 타입 포함

  • Jackson 기반 JSON 직렬화.
  • 객체에 클래스 메타데이터 포함 → 역직렬화 시 타입을 자동 복원.
  • 내부적으로 @class 필드가 JSON에 포함됨.
  • 타입 정보 포함은 activateDefaultTyping() 또는 내부 커스터마이징된 방식으로 처리됨.

장점

  • 다양한 타입을 유연하게 처리할 수 있음 (특히 Object로 저장해도 문제 없음).
  • 매번 직렬화기 타입 지정하지 않아도 됨 → 범용 직렬화기로 활용 가능.

단점

  • JSON에 클래스 정보 (@class)가 들어감 → 응답/데이터 보안, 외부 노출 우려.
  • API 응답을 Redis 캐시에서 가져오는 구조라면 API 응답 포맷이 깨질 수 있음.
  • 직렬화 크기가 더 큼 (불필요한 메타데이터 포함됨).

사용처

  • 다양한 객체 타입을 저장/조회해야 하는 범용 Cache.
  • 단일 serializer로 RedisCacheManager를 구성해야 할 때.
  • 내부 캐시 용도 (외부 응답용 아님)로 범용 저장이 필요한 경우.

 


보안상 권장 사항

  • GenericJackson2JsonRedisSerializer를 사용할 경우에는 역직렬화 대상 클래스의 범위를 제한하거나, Redis 데이터가 외부 노출되지 않도록 주의해야 함.
  • 외부 응답에 사용할 캐시는 절대 @class 포함된 JSON을 사용하지 않도록 주의해야 함 → 이럴 때는 반드시 Jackson2JsonRedisSerializer로 타입 고정하여 분리 사용해야 함.

 

 

 

두 번째 문제: LinkedHashMap cannot be cast 오류

이번엔 캐시에서 꺼낸 객체가 DTO로 역직렬화되지 않고 LinkedHashMap이 되어 다음과 같은 오류가 발생

java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to com.example.dto.ProductDto

 

해결

그래서 ObjectMapper에 타입 정보를 함께 저장하기 위해 activateDefaultTyping 설정

objectMapper.activateDefaultTyping(
    BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(),
    ObjectMapper.DefaultTyping.NON_FINAL,
    JsonTypeInfo.As.PROPERTY
);

 

그리고 이를 포함한 GenericJackson2JsonRedisSerializer를 캐시에 등록

@Bean
public RedisCacheConfiguration cacheConfiguration() {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new JavaTimeModule());
    objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    objectMapper.activateDefaultTyping(...); // 타입 정보 포함

    return RedisCacheConfiguration.defaultCacheConfig()
        .serializeValuesWith(
            RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))
        );
}

캐시 문제는 깔끔히 해결!

 


하지만… 이번엔 예상 못한 부작용이 발생ㅋㅋㅋ

 

 

세 번째 문제: API 응답이 타입 정보까지 노출됨

캐시에서 꺼낸 값을 그대로 API 응답에 사용하다 보니, 타입 정보까지 응답에 포함되는 문제가 발생:

{
  "@class": "com.example.dto.ProductDto",
  "id": 123,
  "name": "상품",
  "createdAt": "2023-08-24T10:30:00"
}

activateDefaultTyping은 내부적으로 @class 속성을 JSON에 포함시켜 직렬화한다.
하지만 이건 역직렬화를 위한 메타정보일 뿐, 클라이언트에 보여선 안 되는 값이다..;.

 

 

최종 해결: Jackson2JsonRedisSerializer + DTO별 RedisCacheConfiguration 분리

결국 activateDefaultTyping() 없이, DTO별로 직렬화기를 따로 설정하는 방식으로 해결했다.

@EnableCaching
@Configuration
public class CacheConfig {

    @Bean
    public ObjectMapper cacheObjectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        return mapper;
    }

    @Bean
    public RedisCacheManager redisCacheManager(
        RedisConnectionFactory redisConnectionFactory,
    	ObjectMapper cacheObjectMapper
    ) {
		// 직렬화기 설정
        Jackson2JsonRedisSerializer<MyDto> myDtoSerializer =
            new Jackson2JsonRedisSerializer<>(cacheObjectMapper, MyDto.class);
        
        RedisCacheConfiguration myDtoConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(5))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(myDtoSerializer)
            );
        
        // cache이름에 따라 다른 설정 적용
        Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
	    configMap.put("MY_DTO_CACHE", myDtoConfig);

        return RedisCacheManager.builder(redisConnectionFactory)
	        .withInitialCacheConfigurations(configMap)
            .build();
    }
}

 

+entryTtl(Duration.ofMinutes(10)) 처럼 TTL도 캐시마다 다르게 설정해줄 수 있음.

(예: 조회량 많은 API는 짧게, 조회 빈도 낮은 데이터는 길게)

 

 

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProductDto {
    private Long id;
    private String name;
    private LocalDateTime createdAt;
}

Jackson2JsonRedisSerializer는 역직렬화 시 빈 생성자 + setter 를 필요로 해서 @Data, @NoArgsConstructor 추가

 

 


 
그 외 알아두면 좋을 내용 추가

 

캐시 무효화 : @CacheEvict

캐시는 항상 "정확한 데이터"를 반환해야 의미가 있으므로, DB 변경 시 캐시 무효화(eviction)도 함께 고려

@CacheEvict(cacheNames = "MY_DTO_CACHE", key = "#id")
public void deleteMyDto(Long id) {
    myRepository.deleteById(id);
}
  • @CacheEvict: 지정된 키의 캐시 값을 제거함
  • allEntries = true 옵션을 주면 해당 cacheNames의 모든 데이터를 비움
  • 수정 로직에서는 @CachePut도 함께 고려할 수 있음
  •  

캐시 갱신 : @CachePut

업데이트 연산 시 캐시를 삭제하고 다시 저장하는 대신, @CachePut을 사용하면 DB와 캐시를 동시에 갱신

@CachePut(cacheNames = "MY_DTO_CACHE", key = "#dto.id")
public MyDto updateMyDto(MyDto dto) {
    return myRepository.save(dto);
}
  • @CachePut: 메서드 실행 후 결과값을 캐시에 저장
  • 갱신 로직에서 유용하게 사용됨
  • 단, @Cacheable과는 다르게 항상 메서드가 실행된다는 점 유의

 

캐시 전용 DTO를 별도로 만들기

캐시에 저장할 객체는 역직렬화만 잘 되면 되는 구조이므로, 꼭 API 응답용 구조일 필요 X

public record CachedProduct(
    Long id,
    String name,
    LocalDateTime createdAt
) {}
  • @Cacheable에는 CachedProduct를 사용
  • API 응답에는 ProductResponseDto를 변환해서 사용
@Cacheable(cacheNames = "PRODUCT_CACHE", key = "#id")
public CachedProduct getProductCache(Long id) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new NotFoundException("없음"));
    return new CachedProduct(product.getId(), product.getName(), product.getCreatedAt());
}

public ProductResponseDto getProduct(Long id) {
    CachedProduct cached = getProductCache(id);
    return new ProductResponseDto(cached.id(), cached.name(), cached.createdAt());
}

이렇게 분리하면 캐시는 DTO 구조 변경에도 영향을 덜 받고, API 응답 구조도 깔끔히 유지할 수 있다

 

 

@JsonIgnore / @JsonInclude 활용

하나의 DTO를 조건적으로 직렬화/노출 제어하는 방법

public record ProductDto(
    Long id,
    String name,

    @JsonInclude(JsonInclude.Include.NON_NULL)
    String nickname,  // null일 경우 응답에서 제외됨

    @JsonIgnore
    String internalMemo  // 항상 응답에서 제외
) {}
  • @JsonInclude: 특정 조건(NON_NULL, NON_EMPTY 등)에 따라 응답 필드를 제외
  • @JsonIgnore: 필드를 아예 직렬화 대상에서 제외
  • 단점: 동일 DTO로 캐시도 하고, 응답도 할 경우 제어가 까다로워질 수 있음

 


 

 

마무리하며. . .

처음엔 단순히 @Cacheable로 조회 API 성능을 개선해야겠다!. @Cacheable만 붙이면 되겠지 ? 라고 생각했는데 직렬화 문제가 이렇게 많을 줄 몰랐다. ㅋㅋㅋㅋ

 

 

 

 

 

Comments