스프링 프레임워크

Implementing Redis Cache in Spring Boot: From Basic Setup to Serialization

blogger903 2025. 3. 17. 14:32
728x90

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 설정

@EnableCachingCacheManager는 다음과 같은 역할을 합니다:

  1. @EnableCaching
  • Spring의 캐시 인프라를 활성화하는 어노테이션
  • 내부적으로 다음 작업을 수행:
    • 캐시 관련 어노테이션(@Cacheable, @CacheEvict 등)을 감지하는 프록시 생성
    • 메서드 호출을 가로채서 캐시 처리 로직을 추가

예시:

@EnableCaching  // 이게 없으면 @Cacheable 등이 동작하지 않음
@Configuration
public class CacheConfig {
    // ...
}
  1. 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정보를 환경변수로 분리하여 동적으로 업데이트할 수 있는 구조로 빼고 대응