이제 Redis를 이용해 캐시를 적용해보자.
스프링이 제공하는 캐시 추상화
스프링은 AOP 방식으로 메소드에 캐시를 적용하는 기능을 제공하고 있다. 캐시는 @Transactional 어노테이션을 통해 트랜잭션을 제공하는 것과 마찬가지로 AOP를 이용해 메서드 실행 과정에 투명하게 적용된다.
또한 스프링은 특정 기술을 추상화 계층으로 가져가고 개발자에게는 인터페이스를 동일한 형태로 제공한다. 캐시도 마찬가지인데 EhCacheManager, RedisCacheManager등의 다양한 기술들이 모두 CacheManager 인터페이스를 구현하고 있기 때문에 서버의 환경에 맞추어 다양한 캐시 기술들을 선택적으로 적용할 수 있다..
이렇게 특정 기술에 종속되지 않게 하는 방식을 PSA(Portable Service Abstraction)라고 한다.
나는 상품 조회에 캐시를 적용하려고 하고 아래는 내가 캐시에 저장할 dto이다
public class ProductResponse {
private Long id;
private String name;
private String description;
private Long price;
private Long stock;
private List<NutrientResponse> nutrients;
private List<CategoryResponse> categories;
private Long likeCount;
private Long sellerId;
private String sellerName;
private boolean isLiked;
private String imageFilePath;
private LocalDateTime deleteDate;
}
1. 설정 및 빈 등록하기
프로젝트에서 세션 데이터 저장소로 이미 레디스를 사용하고 있는 상태였다.
@Configuration
@EnableRedisHttpSession
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setDefaultSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
RedisTemplate 빈을 등록해준다.
@EnableCaching
@Configuration
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
.defaultCacheConfig()
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.
fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))
)
.entryTtl(Duration.ofMinutes(5L)); // 캐쉬 저장 시간
return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
캐시를 관리해 줄cacheManager를 빈에 등록하면서 캐시 정책을 설정한다.
serializeKeysWith() 와 serializeValuesWith() 를 통해 Key와 Value에 대한 직렬화를 설정해주었다.
- StringRedisSerializer : String값을 직렬화하여 저장
- GenericJackson2JsonRedisSerializer은 모든 classType을 json 형태로 저장할 수 있는 범용적인 Jackson2JsonRedisSerializer이고, classType을 함께 저장한다.
2. 캐시 적용하기
@Cacheable 어노테이션을 통해서 간단하게 캐시를 설정할 수 있다.
@Transactional(readOnly = true)
@Cacheable(value = "product", key = "#productId")
public RsData<ProductResponse> get(Long productId) {
...
}
@Cacheable 는 캐시를 저장/조회하기 위한 어노테이션으로 캐시에 데이터가 없을 경우에는 로직을 실행한 후에 캐시에 데이터를 추가하고, 캐시에 데이터가 있으면 데이터를 반환한다.
value 속성은 캐시의 이름을 지정하고, key 속성은 캐시의 키를 지정한다.
캐시까지 적용했으니 앱을 실행하여 잘 동작하는 지 확인해보자.
오류
🚩InvalidDefinitionException : java.time.LocalDateTime not supported
com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Java 8 date/time type `java.time.LocalDateTime` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
(through reference chain: com.mypill.global.rsdata.RsData["data"]->com.mypill.domain.product.dto.response.ProductResponse["deleteDate"])
오류를 만났다.
GenericJackson2JsonRedisSerializer를 사용하면 클래스 타입에 상관없이 직렬화/역직렬화가 가능하지만 날짜 타입에 대해서는 지원되지 않는다.
내가 저장할 ProductResponse의 deleteDate 타입이 LocalDateTime 타입이라서 발생한 문제이다.
ObjectMapper를 커스텀해서 문제를 해결해보자
@Configuration
public class MapperConfig {
public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Bean
public ObjectMapper serializingObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer())
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer());
objectMapper
.registerModules(javaTimeModule, new Jdk8Module())
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
return objectMapper;
}
public class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
@Override
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeString(value.format(FORMATTER));
}
}
public class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctx) throws IOException {
return LocalDateTime.parse(p.getValueAsString(), FORMATTER);
}
}
}
@EnableCaching
@Configuration
public class RedisCacheConfig {
@Autowired
private ObjectMapper objectMapper;
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
.defaultCacheConfig()
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.
fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))
)
.entryTtl(Duration.ofMinutes(5L)); // 캐쉬 저장 시간
return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
Custom ObjectMapper를 생성하고 JavaTimeModule를 등록해주었다. 이걸 GenericJackson2JsonRedisSerializer의 파라미터로 넘겨주면 된다.
다시 앱을 실행해서 확인해보자...!
오류
🚩ClassCastException
java.lang.ClassCastException:
class java.util.LinkedHashMap cannot be cast to class com.mypill.global.rsdata.RsData
(java.util.LinkedHashMap is in module java.base of loader 'bootstrap';
com.mypill.global.rsdata.RsData is in unnamed module of loader org.springframework.boot.devtools.restart.classloader.RestartClassLoader @4799c353)
으잉...? 또 다시 오류를 만났고 직렬화 할 때 반환 타입에 대한 정보가 없는 문제인 것 같다.
GenericJackson2JsonRedisSerializer는 모든 클래스 타입의 정보를 저장한다고 알고있어서 좀 당황했다. 왜지??
검색해보니, 커스텀 ObjectMapper를 사용한 경우에는 예외적이라고 한다. ObjectMapper가 직렬화/역직렬화시 클래스타입 정보를 포함하지 않기 때문에 해당 데이터에 타입 정보가 아예 존재하지 않는 것이다.
이 문제를 해결하기 위해서는 ObjectMapper가 클래스 타입을 포함하도록 코드를 수정해주어야한다.
아래 코드를 추가해주었다.
objectMapper.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class).build(),
ObjectMapper.DefaultTyping.NON_FINAL
);
activeDefaultTyping() 메서드를 통해서 타입을 설정할 수 있고,
ObjectMapper.DefaultTyping을 NON_FINAL로 설정하면, 클래스의 최종 서브 클래스에 대해서만 타입 정보가 저장된다.
성공
다시 앱을 실행해서 확인해보니 상품 페이지를 조회할 때 캐시가 잘 생성되어 저장되는 것을 확인할 수 있다.

나는 Look Aside + Write Around 전략을 사용할 것이기 때문에 데이터가 변경되거나 삭제되었을 경우에는 캐시도 함께 삭제해주어야 한다.
@CacheEvict 어노테이션을 사용하여 상품이 변경, 삭제될 때 캐시도 같이 삭제되도록 했다.
@Transactional
@CacheEvict(value = "product", key = "#productId")
public RsData<ProductResponse> update(Member actor, Long productId, ProductRequest request) {
...
}
@Transactional
@CacheEvict(value = "product", key = "#productId")
public RsData<ProductResponse> softDelete(Member actor, Long productId) {
...
}
@Transactional
@CacheEvict(value = "product", key = "#productId")
public void updateStockAndSalesByOrder(Long productId, Long quantity) {
...
}
2번 상품이 삭제되었을 때 캐시도 같이 삭제되는 것을 확인할 수 있다.

참고
'트러블슈팅' 카테고리의 다른 글
[Spring] Redis 캐시 적용하기 (1/2) (1) | 2023.10.19 |
---|---|
[Spring] Redis를 통한 세션 불일치 문제 해결 (2/2) (0) | 2023.10.14 |
[Spring] Redis를 통한 세션 불일치 문제 해결 (1/2) (0) | 2023.10.12 |
[Spring] 재고처리 동시성 이슈 해결하기 (2/2) (0) | 2023.07.22 |
[Spring] 재고처리 동시성 이슈 해결하기 (1/2) (0) | 2023.07.22 |
이제 Redis를 이용해 캐시를 적용해보자.
스프링이 제공하는 캐시 추상화
스프링은 AOP 방식으로 메소드에 캐시를 적용하는 기능을 제공하고 있다. 캐시는 @Transactional 어노테이션을 통해 트랜잭션을 제공하는 것과 마찬가지로 AOP를 이용해 메서드 실행 과정에 투명하게 적용된다.
또한 스프링은 특정 기술을 추상화 계층으로 가져가고 개발자에게는 인터페이스를 동일한 형태로 제공한다. 캐시도 마찬가지인데 EhCacheManager, RedisCacheManager등의 다양한 기술들이 모두 CacheManager 인터페이스를 구현하고 있기 때문에 서버의 환경에 맞추어 다양한 캐시 기술들을 선택적으로 적용할 수 있다..
이렇게 특정 기술에 종속되지 않게 하는 방식을 PSA(Portable Service Abstraction)라고 한다.
나는 상품 조회에 캐시를 적용하려고 하고 아래는 내가 캐시에 저장할 dto이다
public class ProductResponse {
private Long id;
private String name;
private String description;
private Long price;
private Long stock;
private List<NutrientResponse> nutrients;
private List<CategoryResponse> categories;
private Long likeCount;
private Long sellerId;
private String sellerName;
private boolean isLiked;
private String imageFilePath;
private LocalDateTime deleteDate;
}
1. 설정 및 빈 등록하기
프로젝트에서 세션 데이터 저장소로 이미 레디스를 사용하고 있는 상태였다.
@Configuration
@EnableRedisHttpSession
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setDefaultSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
RedisTemplate 빈을 등록해준다.
@EnableCaching
@Configuration
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
.defaultCacheConfig()
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.
fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))
)
.entryTtl(Duration.ofMinutes(5L)); // 캐쉬 저장 시간
return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
캐시를 관리해 줄cacheManager를 빈에 등록하면서 캐시 정책을 설정한다.
serializeKeysWith() 와 serializeValuesWith() 를 통해 Key와 Value에 대한 직렬화를 설정해주었다.
- StringRedisSerializer : String값을 직렬화하여 저장
- GenericJackson2JsonRedisSerializer은 모든 classType을 json 형태로 저장할 수 있는 범용적인 Jackson2JsonRedisSerializer이고, classType을 함께 저장한다.
2. 캐시 적용하기
@Cacheable 어노테이션을 통해서 간단하게 캐시를 설정할 수 있다.
@Transactional(readOnly = true)
@Cacheable(value = "product", key = "#productId")
public RsData<ProductResponse> get(Long productId) {
...
}
@Cacheable 는 캐시를 저장/조회하기 위한 어노테이션으로 캐시에 데이터가 없을 경우에는 로직을 실행한 후에 캐시에 데이터를 추가하고, 캐시에 데이터가 있으면 데이터를 반환한다.
value 속성은 캐시의 이름을 지정하고, key 속성은 캐시의 키를 지정한다.
캐시까지 적용했으니 앱을 실행하여 잘 동작하는 지 확인해보자.
오류
🚩InvalidDefinitionException : java.time.LocalDateTime not supported
com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Java 8 date/time type `java.time.LocalDateTime` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
(through reference chain: com.mypill.global.rsdata.RsData["data"]->com.mypill.domain.product.dto.response.ProductResponse["deleteDate"])
오류를 만났다.
GenericJackson2JsonRedisSerializer를 사용하면 클래스 타입에 상관없이 직렬화/역직렬화가 가능하지만 날짜 타입에 대해서는 지원되지 않는다.
내가 저장할 ProductResponse의 deleteDate 타입이 LocalDateTime 타입이라서 발생한 문제이다.
ObjectMapper를 커스텀해서 문제를 해결해보자
@Configuration
public class MapperConfig {
public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Bean
public ObjectMapper serializingObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer())
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer());
objectMapper
.registerModules(javaTimeModule, new Jdk8Module())
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
return objectMapper;
}
public class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
@Override
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeString(value.format(FORMATTER));
}
}
public class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctx) throws IOException {
return LocalDateTime.parse(p.getValueAsString(), FORMATTER);
}
}
}
@EnableCaching
@Configuration
public class RedisCacheConfig {
@Autowired
private ObjectMapper objectMapper;
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
.defaultCacheConfig()
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair.
fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))
)
.entryTtl(Duration.ofMinutes(5L)); // 캐쉬 저장 시간
return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
Custom ObjectMapper를 생성하고 JavaTimeModule를 등록해주었다. 이걸 GenericJackson2JsonRedisSerializer의 파라미터로 넘겨주면 된다.
다시 앱을 실행해서 확인해보자...!
오류
🚩ClassCastException
java.lang.ClassCastException:
class java.util.LinkedHashMap cannot be cast to class com.mypill.global.rsdata.RsData
(java.util.LinkedHashMap is in module java.base of loader 'bootstrap';
com.mypill.global.rsdata.RsData is in unnamed module of loader org.springframework.boot.devtools.restart.classloader.RestartClassLoader @4799c353)
으잉...? 또 다시 오류를 만났고 직렬화 할 때 반환 타입에 대한 정보가 없는 문제인 것 같다.
GenericJackson2JsonRedisSerializer는 모든 클래스 타입의 정보를 저장한다고 알고있어서 좀 당황했다. 왜지??
검색해보니, 커스텀 ObjectMapper를 사용한 경우에는 예외적이라고 한다. ObjectMapper가 직렬화/역직렬화시 클래스타입 정보를 포함하지 않기 때문에 해당 데이터에 타입 정보가 아예 존재하지 않는 것이다.
이 문제를 해결하기 위해서는 ObjectMapper가 클래스 타입을 포함하도록 코드를 수정해주어야한다.
아래 코드를 추가해주었다.
objectMapper.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class).build(),
ObjectMapper.DefaultTyping.NON_FINAL
);
activeDefaultTyping() 메서드를 통해서 타입을 설정할 수 있고,
ObjectMapper.DefaultTyping을 NON_FINAL로 설정하면, 클래스의 최종 서브 클래스에 대해서만 타입 정보가 저장된다.
성공
다시 앱을 실행해서 확인해보니 상품 페이지를 조회할 때 캐시가 잘 생성되어 저장되는 것을 확인할 수 있다.

나는 Look Aside + Write Around 전략을 사용할 것이기 때문에 데이터가 변경되거나 삭제되었을 경우에는 캐시도 함께 삭제해주어야 한다.
@CacheEvict 어노테이션을 사용하여 상품이 변경, 삭제될 때 캐시도 같이 삭제되도록 했다.
@Transactional
@CacheEvict(value = "product", key = "#productId")
public RsData<ProductResponse> update(Member actor, Long productId, ProductRequest request) {
...
}
@Transactional
@CacheEvict(value = "product", key = "#productId")
public RsData<ProductResponse> softDelete(Member actor, Long productId) {
...
}
@Transactional
@CacheEvict(value = "product", key = "#productId")
public void updateStockAndSalesByOrder(Long productId, Long quantity) {
...
}
2번 상품이 삭제되었을 때 캐시도 같이 삭제되는 것을 확인할 수 있다.

참고
'트러블슈팅' 카테고리의 다른 글
[Spring] Redis 캐시 적용하기 (1/2) (1) | 2023.10.19 |
---|---|
[Spring] Redis를 통한 세션 불일치 문제 해결 (2/2) (0) | 2023.10.14 |
[Spring] Redis를 통한 세션 불일치 문제 해결 (1/2) (0) | 2023.10.12 |
[Spring] 재고처리 동시성 이슈 해결하기 (2/2) (0) | 2023.07.22 |
[Spring] 재고처리 동시성 이슈 해결하기 (1/2) (0) | 2023.07.22 |