Implementing Redis Cache in Spring Boot: From Basic Setup to Serialization
캐시무효화와 Cache Aside를 통해 캐시를 사례를 구성했습니다.
환경
- mac
- kotlin
- springboot
준비사항
- springboot project
- redis cluster ( container )
다루는 내용
- @Cacheable, @CacheEvict
- Spring Cache, Spring Data Redis
- Jackson
1. Spring Cache와 Redis 연동을 위한 기본 설정
- 필요한 의존성 설명
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.3")
- 기본 구성 요소 설명
org.springframework.boot:spring-boot-starter-data-redis
├── org.springframework.data:spring-data-redis
│ └── RedisCache (기본 구현체)
├── io.lettuce:lettuce-core
│ └── Redis 클라이언트 라이브러리
└── org.springframework.boot:spring-boot-starter
따라서 별도의 추가 의존성 없이 Redis Cluster와 Cache 추상화를 모두 사용할 수 있습니다.
2. Redis Cache 설정
@EnableCaching
과 CacheManager
는 다음과 같은 역할을 합니다:
@EnableCaching
- Spring의 캐시 인프라를 활성화하는 어노테이션
- 내부적으로 다음 작업을 수행:
- 캐시 관련 어노테이션(@Cacheable, @CacheEvict 등)을 감지하는 프록시 생성
- 메서드 호출을 가로채서 캐시 처리 로직을 추가
예시:
@EnableCaching // 이게 없으면 @Cacheable 등이 동작하지 않음
@Configuration
public class CacheConfig {
// ...
}
CacheManager
- 캐시 저장소를 추상화하는 인터페이스
- 주요 역할:
- 다양한 캐시 저장소(Redis, EhCache, Local 등) 통합 관리
- 캐시의 생명주기 관리 (생성, 조회, 삭제)
- 캐시별 설정 적용 (TTL, 직렬화 방식 등)
@Bean
public CacheManager cacheManager() {
RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory())
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // 캐시 TTL 설정
)
.build();
return cacheManager;
}
정리하면:
@EnableCaching
: 캐시 기능 자체를 활성화CacheManager
: 활성화된 캐시 기능을 실제로 관리하고 구현
3. Redis Cache 상세 설정
- Redis 연결 구성
- 캐시 매니저 설정
- 직렬화/역직렬화 설정
- TTL 및 기타 캐시 속성 설정
@ConfigurationProperties(prefix = "redis")
data class RedisProperties(
val cluster: ClusterProperties,
val timeout: Int = 5
) {
data class ClusterProperties(
val nodes: List<String>,
val maxRedirects: Int = 3
)
}
@Configuration
@EnableConfigurationProperties(RedisProperties::class)
class RedisConfig(private val redisProperties: RedisProperties) {
@Bean
fun redisConnectionFactory(): RedisConnectionFactory {
val clusterConfig = RedisClusterConfiguration(redisProperties.cluster.nodes).apply {
maxRedirects = redisProperties.cluster.maxRedirects
}
val clientConfig = LettuceClientConfiguration.builder()
.clientOptions(ClientOptions.builder()
.autoReconnect(true)
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(redisProperties.timeout.toLong())))
.build())
.readFrom(ReadFrom.REPLICA_PREFERRED)
.build()
return LettuceConnectionFactory(clusterConfig, clientConfig)
}
}
4. Jackson 직렬화 설정 심화
- Jackson 기본 동작 방식
- 타입 정보 처리 방법
- ObjectMapper 커스텀 설정
- 일반적인 직렬화 이슈 해결 방법
Jackson 기본 동작방식
기본 역직렬화 메커니즘:
- Jackson은 기본적으로 복잡한 객체(Object)를 역직렬화할 때
LinkedHashMap
으로 변환합니다 - 타입 정보가 없으면 Jackson은 구체적인 타입을 알 수 없어서 기본적으로 Map으로 처리합니다
적용한 방법:
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
data class MyClass(...)
objectMapper.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Any::class.java)
.build(),
ObjectMapper.DefaultTyping.NON_FINAL_AND_ENUMS,
JsonTypeInfo.As.PROPERTY
)
작동 방식:
- 타입 정보를 JSON에 포함시켜 저장
{ "@class": "com.example.MyClass", // 타입 정보 포함 "property": "value" }
- 역직렬화 시 이 타입 정보를 사용해서 올바른 클래스로 변환합니다
이번 캐시 사례에서는
Redis 직렬화 옵션중 GenericJackson2JsonRedisSerializer를 적용했습니다.
GenericJackson2JsonRedisSerializer는 내부에서 ObjectMapper를 사용하여 직렬화/역직렬화를 수행합니다.
ObjectMapper가 정확한 타입으로 역직렬화하려면 타입 정보가 필요하죠. 그래서 @JsonTypeInfo 또는 ObjectMapper 설정이 필요한 것입니다.
Jackson ObjectMapper가 이 데이터를 역직렬화할 때도 어떤 클래스로 변환할지 알아야 합니다. 그래서 @JsonTypeInfo나 activateDefaultTyping 설정이 필요합니다.
@Configuration
@EnableCaching
class CacheConfig(
@Qualifier("redisObjectMapper") private val redisObjectMapper: ObjectMapper
) {
@Bean
fun cacheManager(redisConnectionFactory: RedisConnectionFactory): CacheManager {
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(defaultConfig())
.build()
}
private fun defaultConfig(): RedisCacheConfiguration {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(
RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
GenericJackson2JsonRedisSerializer(redisObjectMapper)
)
)
}
}
@Configuration
class RedisObjectMapperConfig {
@Bean("redisObjectMapper")
fun redisObjectMapper(): ObjectMapper {
return ObjectMapper().apply {
registerModule(JavaTimeModule())
registerModule(
KotlinModule.Builder()
.build()
).activateDefaultTyping(
BasicPolymorphicTypeValidator.builder().allowIfBaseType(Any::class.java).build(),
ObjectMapper.DefaultTyping.NON_FINAL_AND_ENUMS,
JsonTypeInfo.As.PROPERTY
).configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
}
}
}
추가로 타입 정보 전달 좀더 정리하였습니다.
Redis 캐시의 JSON 직렬화/역직렬화와 타입 안전성
Redis에 객체를 캐시할 때는 JSON 형태로 직렬화하여 저장합니다. 이때 타입 안전성을 보장하기 위해 Jackson의 activateDefaultTyping
을 활용하는 방법을 알아보겠습니다.
JSON 직렬화와 타입 정보의 중요성
data class Student(val name: String, val age: Int)
val student = Student("Kim", 25)
// 1. 타입 정보가 없는 경우
{
"name": "Kim",
"age": 25
}
// -> 역직렬화 시 어떤 클래스로 변환해야 할지 모름
// 2. 타입 정보가 포함된 경우
{
"@class": "com.example.Student", // 타입 정보
"name": "Kim",
"age": 25
}
// -> Student 클래스로 정확한 역직렬화 가능
activateDefaultTyping 메서드 상세 설명
objectMapper.activateDefaultTyping(
ptv: PolymorphicTypeValidator, // 타입 검증기
applicability: DefaultTyping, // 타입 정보 추가 범위
includeAs: JsonTypeInfo.As // 타입 정보 포함 방식
)
1. PolymorphicTypeValidator (타입 검증기)
역직렬화 시 허용할 타입을 검증하는 역할
보안을 위해 신뢰할 수 있는 타입만 허용
BasicPolymorphicTypeValidator.builder() .allowIfBaseType(Any::class.java) // Any 클래스의 하위 타입만 허용 .build()
2. DefaultTyping (타입 정보 추가 범위)
어떤 타입에 대해 타입 정보를 추가할지 결정
enum DefaultTyping {
NONE, // 타입 정보 추가하지 않음
OBJECT_AND_NON_CONCRETE, // Object와 추상/인터페이스 타입에만
NON_CONCRETE_AND_ARRAYS, // 추상/인터페이스와 배열에
NON_FINAL, // final이 아닌 타입에
EVERYTHING // 모든 타입에 타입 정보 추가
}
3. JsonTypeInfo.As (타입 정보 포함 방식)
타입 정보를 JSON에 어떻게 포함시킬지 결정
// 1. PROPERTY 방식
{
"@class": "com.example.Student",
"name": "Kim",
"age": 25
}
// 2. WRAPPER_OBJECT 방식
{
"com.example.Student": {
"name": "Kim",
"age": 25
}
}
// 3. WRAPPER_ARRAY 방식
[
"com.example.Student",
{
"name": "Kim",
"age": 25
}
]
실제 구현 예시
@Configuration
@EnableCaching
class CacheConfig {
private fun defaultConfig(): RedisCacheConfiguration {
val cacheObjectMapper = ObjectMapper().apply {
// 기본 모듈 등록
registerModule(JavaTimeModule())
registerModule(KotlinModule.Builder().build())
// 타입 정보 설정
activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Any::class.java)
.build(),
ObjectMapper.DefaultTyping.EVERYTHING, // 모든 타입에 타입 정보 추가
JsonTypeInfo.As.PROPERTY // @class 속성으로 포함
)
}
return RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
GenericJackson2JsonRedisSerializer(cacheObjectMapper)
)
)
}
}
ObjectMapper.DefaultTyping.EVERYTHING은 Deprecated되어 NON_FINAL_AND_ENUMS를 적용하여 data class에서는
아래 애노테이션을 추가로 적용합니다
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
5. 실제 사용 및 주의사항
실제 사용
@Cacheable
@Transactional(readOnly = true)
@Cacheable(
value = ["organization"],
keyGenerator = "organizationCacheKeyGenerator",
unless = "#result == null"
)
override fun findOrganization(id: Long): OrganizationDto {
val findResult = organizationPort.findById(id)
return findResult.toDto()
}
@CacheEvict
@Transactional
@CacheEvict(value = ["organization"], key = "#command.id")
override fun execute(command: UpdateOrganizationCommand): Organization {
val organization = organizationPort.findById(command.id.toLong())
organization.updateName(command.name)
return organizationPort.save(organization)
}
6. redis cache fallback
circuitbreaker를 통해 redis의 장애가 발생하는 경우 main db로 fallback 하도록 하는 것도 추상화 잘 되어있습니다.
보다 안정적인 기능을 제공하는 필수적인 기능입니다.
spring의 장점인 추상화를 통해 간단하게 적용할수 있습니다.
의존성 추가
implementation("io.github.resilience4j:resilience4j-spring-boot3:{version}")
코드 예시
@CircuitBreaker(name = "testCache", fallbackMethod = "getFallback")
@Cacheable(value = ["test"], key = "#id")
@Transactional(readOnly = true)
fun get(id: Long): TestResponse {
return testRepository.findById(id)
.map { TestResponse.from(it) }
.orElseThrow { NoSuchElementException("Test entity not found with id: $id") }
}
주의 사항
- 캐시 데이터 타입변경
- 기존 데이터는 건들지않고 추가하는 것을 추천, deprecated 필드에는 @JsonIgnore 적용
- 캐시된 데이터와 타입이 다른경우
- cacheVersion정보를 환경변수로 분리하여 동적으로 업데이트할 수 있는 구조로 빼고 대응
'스프링 프레임워크' 카테고리의 다른 글
spring aop와 annotation 맛보기 (1) | 2024.09.03 |
---|---|
springboot에 redisson cache 적용하기 (0) | 2024.06.18 |
springboot에 CacheResolver로 MultipleCacheManager 구성하기 (0) | 2024.06.18 |
springboot에 JCache로 ehcache 로컬캐시 적용하기 (0) | 2024.06.17 |