일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 |
- JPA
- 브루트포스
- 해시
- 프로그래머스
- greedy
- Backtracking
- 깊이우선탐색
- 동적계획법
- programmers
- 백트래킹
- 스프링
- 백준
- 부분수열의합
- dynamic programming
- 네트워크
- 이분탐색
- 구현
- HashMap
- 너비우선탐색
- 알고리즘
- boj
- DynamicProgramming
- 그리디
- DFS
- BFS
- Algorithm
- 해시맵
- DP
- Spring
- Network
- Today
- Total
옌의 로그
Redis 캐시 저장, DTO 직렬화/역직렬화 깨짐 해결기 본문
최근 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만 붙이면 되겠지 ? 라고 생각했는데 직렬화 문제가 이렇게 많을 줄 몰랐다. ㅋㅋㅋㅋ
'스터디 > 스프링' 카테고리의 다른 글
[JPA] @OneToMany 연관관계, List vs Set 뭐가 더 나을까? (0) | 2025.09.17 |
---|---|
[Spring] Swagger에서 ErrorCode Enum 자동화하기 (1) | 2025.08.27 |
[Spring] JPA 연관관계 매핑과 성능 최적화 (Fetch Join vs. Open-in-view) (7) | 2025.08.25 |
[Spring] ApplicationEventPublisher에 대한 고찰 (feat. Fcm push) (3) | 2025.08.08 |
[Spring] 스프링 핵심 원리 기본편 | 1. 객체 지향 설계와 스프링 (0) | 2023.08.24 |