[SpringBoot] Kotlin + Spring에 @Cacheable 적용하기

다은·2025년 11월 17일

SpringBoot

목록 보기
14/14
post-thumbnail

외국인 유학생을 위한 AI 기반 한국어 학습 서비스, LearnMate 개발기입니다!

AppStore 👉 https://apps.apple.com/kr/app/learnmate-%EB%9F%B0%EB%A9%94%EC%9D%B4%ED%8A%B8/id6753644353


LearnMate 홈 화면에서는 한국어 학습 현황을 확인할 수 있습니다. 이 정보는 새로운 레벨을 클리어해야만 업데이트됩니다.

즉, Write보다 Read가 훨씬 빈번하게 발생하는 구조인 것이죠. 이러한 트래픽 패턴을 고려해 캐싱 전략을 적용하여 조회 성능을 개선했습니다.



1. 기존 로직

학습 현황 조회 API의 기존 로직입니다.

  1. 모든 course list를 조회
  2. userId를 이용해 유저가 풀이한 stepProgressStep 리스트를 Set으로 변환
  3. course list를 순회하며 각 course가 stepProgressStepSet 안에 존재하는지를 판단하며 상태 체크
  4. 결과 dto list 반환
override fun getCourseInfo(userId: Long): CourseListDto {
        val courseList = CourseType.getAllCourseList()
        val completedStepSet = getAllCompletedStepTypeSet(userId)

        val courseDtoList = courseList.map {

            val courseStatus = getCourseStatus(it, completedStepSet)
            val stepDtoList = getStepDtoList(it, courseStatus, completedStepSet)
            val progress = getCourseProgress(stepDtoList)

            CourseDto.toCourseDto(
                course = it,
                stepList = stepDtoList,
                progress = progress,
                courseStatus = courseStatus
            )
        }

        return CourseListDto.toCourseListDto(courseDtoList)
    }

DB 접근 횟수가 많은 로직은 아니지만, list를 순회하고 각 요소에 대해 3번의 상태 체크를 수행합니다.

때문에 조회 요청이 들어올 때마다 이러한 동일한 결과 데이터를 산출하기 위해 불필요한 작업이 수행되게 됩니다.



2. Redis 도입

캐시 도입 전략은 다음과 같습니다.

  1. 해당 API 요청이 들어올 때 캐시 확인
  2. Hit -> 값 리턴
  3. Miss -> 로직 수행 후 캐시에 값 저장
  4. 학습 마무리 API 요청 시 기존 캐시 evict

1. @Cacheable

https://docs.spring.io/spring-boot/reference/io/caching.html
스프링은 AOP를 기반으로 작동하는 캐시 기능을 제공하며, 다양한 캐시 저장소와 호환됩니다.

또한 어노테이션을 기반으로 동작하기 때문에 비즈니스 로직과 캐시 로직이 분리되어 코드 결합도가 느슨해진다는 장점도 있습니다.


2. 의존성 추가

implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-cache")

3. 캐시 기능 활성화

@EnableCaching 어노테이션을 추가해 스프링 캐시 기능을 사용함을 명시해줍니다.
해당 어노테이션은 4번에 나올 config에 붙여도 무관합니다.

@EnableCaching // 추가
@EnableJpaAuditing
@SpringBootApplication
class DevApplication

fun main(args: Array<String>) {
	runApplication<DevApplication>(*args)
}

4. Redis 캐시 설정

Redis에 DTO를 어떤 형식으로 넣을 것인지 명시해주어야 합니다.

@Configuration
class RedisConfig {

    @Bean
    fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
        val cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            // String으로 Key 직렬화
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer())
            )
            // JSON으로 Value 직렬화
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer())
            )
            .entryTtl(Duration.ofDays(1))

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(connectionFactory)
            .cacheDefaults(cacheConfig)
            .build()
    }
}

5. 캐시 읽기

캐시 기능을 도입할 함수 위에 @Cacheable 어노테이션을 명시해줍니다.
#를 이용해 userId를 key값으로 명시하고, courses라는 이름으로 하여 캐싱하도록 설정했습니다.

    @Cacheable(cacheNames = ["courses"], key = "#userId")
    override fun getCourseInfo(userId: Long): CourseListDto {
        val courseList = CourseType.getAllCourseList()
        val completedStepSet = getAllCompletedStepTypeSet(userId)
        ...
       }

6. 캐시 삭제

endStep()이 호출될 때, 즉 course를 마무리할 때 학습 현황이 update됩니다.
따라서 해당 함수에 @CacheEvict를 명시해 #userId 키를 가진 캐시를 삭제합니다.

 @CacheEvict(cacheNames = ["courses"], key = "#userId")
 @Transactional
 override fun endStep(userId: Long, stepProgressId: Long) {
        val stepProgress = getStepProgress(stepProgressId, userId)
        stepProgress.ensureNotCompleted()
        stepProgress.completeStep()
    }

추가로, @CachePut 이라는 어노테이션도 존재하는데요, 이는 함수의 리턴 값을 캐시의 내용으로 변환하는 것입니다.

해당 함수는 리턴값이 없으므로 Put 대신 Evict 기능을 이용했습니다.



3. 트러블 슈팅

1. 문제

실행하니 다음과 같은 SerializationException이 발생했습니다.

org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Cannot construct instance of `learn_mate_it.dev.domain.course.application.dto.response.CourseListDto` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 84] 

2. 원인

Jackson이 JSON을 객체로 변환할 때, 기본적으로 다음과 같은 순서로 작동합니다.

  1. CourseListDto의 기본 생성자를 이용해 빈 껍데기 객체를 먼저 생성
  2. JSON의 필드명과 객체의 프로퍼티명을 비교하여 값을 채워 넣음

그런데 Kotlin의 data class는 주 생성자에 선언된 프로퍼티를 기반으로 만들어지기 때문에, 별도로 설정하지 않으면 기본 생성자를 만들 방법이 없습니다. 그래서 Jackson이 1단계에서 실패하고 예외를 던지는 겁니다.


3. 시도 1 - jackson module

위의 문제를 해결하기 위해, 아래 모듈을 도입했습니다.

해당 모듈을 도입함으로써 기본 생성자 없이 data class주생성자를 이용해 JSON 데이터를 객체로 변환할 수 있음을 기대했습니다.

implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

그러나, 다시 돌려보니 동일한 문제가 발생했습니다.

RedisConfig에서 StringRedisSerializer()을 new를 이용해 생성하면, 이는 스프링이 관리하고 코틀린 모듈을 아는 ObjectMapper가 아니라, 코틀린을 모르는 순수 자바용 ObjectMapper가 생성됩니다.

때문에 새롭게 추가한 모듈을 읽지 못해 동일한 오류가 나는 것이었고, 따라서 Serializer이 코틀린을 알도록 수정해야 합니다.


4. 시도 2 - Redis Config Object Mapper 등록

Redis Config에서 직접 ObjectMapper을 생성한 후 Kotlin Module을 등록했습니다.

이후, GenericJackson2JsonRedisSerializer()의 생성자에 kotlin module이 등록된 ObjectMapper을 넘겨줌으로써 3번의 문제를 해결했습니다.

@Configuration
class RedisConfig {

    @Bean
    fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
        val kotlinModule = KotlinModule.Builder().build()

		// 1. Object Mapper 생성 및 Kotlin Module 등록
        val objectMapper = ObjectMapper()
            .registerModule(kotlinModule)

		// 2. Serializer의 생성자에 직접 생성한 objectMapper 전달
        val jsonSerializer = GenericJackson2JsonRedisSerializer(objectMapper)

        val cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer())
            )
            .serializeValuesWith(
				// 3. 생성한 jsonSerializer 이용               
            	RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)
            )
            .entryTtl(Duration.ofDays(1))

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(connectionFactory)
            .cacheDefaults(cacheConfig)
            .build()
    }

}

이걸로 문제가 해결되는 줄 알았습니다 ㅎㅎ
실행해보니 다음과 같은 ClassCastException이 발생했습니다.

java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class learn_mate_it.dev.domain.course.application.dto.response.CourseListDto (java.util.LinkedHashMap is in module java.base of loader 'bootstrap'; learn_mate_it.dev.domain.course.application.dto.response.CourseListDto is in unnamed module of loader 'app')
at learn_mate_it.dev.domain.course.application.service.impl.CourseServiceImpl$$SpringCGLIB$$0.getCourseInfo(<generated>)
at

Redis에 들어간 데이터를 확인해보니, 해당 타입의 정보 없이 데이터만 들어가있었습니다.

그래서 역직렬화 할 때, 타입 정보가 없으니 가장 만만한 LinkedHashMap으로 만든 후, 이를 courseListDto로 만들려다가 ClassCastException이 발생한 것입니다.

따라서, 타입 정보를 Redis에 같이 저장해야 역직렬화 문제를 해결할 수 있습니다.


5. 시도 3 - 타입 정보 저장

우선, 시도 1에서 추가한 dependency 대신 .registerKotlinModule()를 이용해 코틀린 모듈을 명시하도록 수정했습니다.

그리고 activateDefaultTyping()를 이용해 해당 타입의 정보를 같이 명시하도록 설정했습니다.

수정된 RedisConfig의 objectMapper 부분은 다음과 같습니다.

val objectMapper = ObjectMapper()
            .registerKotlinModule()
            .activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder()
                    .allowIfBaseType(Any::class.java)
                    .build(),
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
            )

다시 실행해보니 또 SerializationException이 발생했습니다! ㅎㅎ

org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Unexpected token (START_OBJECT), expected START_ARRAY: need Array value to contain `As.WRAPPER_ARRAY` type information for class java.lang.Object
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 1] 
at org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer.deserialize(GenericJackson2JsonRedisSerializer.java:305)

타입 정보를 이용해 역직렬화 하라고 명시했음에도, 데이터를 저장할 때 @class와 같은 타입 정보가 저장되지 않아 역직렬화가 실패한 것으로 보입니다.


저는 data class 타입의 dto를 사용하고 있었는데, 코틀린은 data classfinal로 취급합니다.

그래서 config의 ObjectMapper.DefaultTyping.NON_FINAL <- 이 부분 때문에 dto class의 타입 정보가 들어가지 않았습니다.


해당 문제를 해결하기 위해, 아래와 같은 방법을 고려했습니다.

1. DefaultTyping의 타입 완화
2. data class -> class로 변환

1번에서 DefaultTying을 EVERYTHING으로 바꾸는 것을 고려했습니다. 그런데 deprecated되었다고 해서 대신 NON_FINAL_AND_ENUM 으로 변경하고 2번 방법을 채택하기로 했습니다.


6. 해결 - data class -> open class

최종적으로 리턴할 dto의 타입을 open class로 변환하면서 문제가 해결되었습니다. 코틀린은 class도 내부적으로 final로 취급하기 때문에 open 키워드를 붙여주었습니다.

최종 Redis Config의 코드는 다음과 같습니다.

@Configuration
class RedisConfig {

    @Bean
    fun cacheManager(connectionFactory: RedisConnectionFactory): RedisCacheManager {
        val objectMapper = ObjectMapper()
            .registerKotlinModule()
            .activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder()
                    .allowIfBaseType(Any::class.java)
                    .build(),
                ObjectMapper.DefaultTyping.NON_FINAL_AND_ENUM,
                JsonTypeInfo.As.PROPERTY
            )

        val jsonSerializer = GenericJackson2JsonRedisSerializer(objectMapper)

        val cacheConfig = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
            .entryTtl(Duration.ofDays(1))

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(connectionFactory)
            .cacheDefaults(cacheConfig)
            .build()
    }
}



4. 성능 테스트

간단하게 캐시를 도입하기 전과 이후의 조회 성능을 로컬에서 JMeter을 이용해 테스트해봤습니다. DB의 Row는 약 24000개입니다.

해당 API를 위한 조회 쿼리는 아래 포스팅과 같이 이미 인덱스가 붙은 상태입니다.
https://velog.io/@dooo_it_ly/DB-PostgreSQL-Partial-Index로-쿼리-성능-개선하기

1. 캐시 도입 이전

동시에 5000명이 동시에 요청을 보낼 때의 시간 지표
평균 요청 처리 시간 : 150ms
최소 요청 처리 시간 : 2ms
최대 요청 처리 시간 : 940ms
에러 발생 확률 : 0%


2. 캐시 도입 이후

동시에 5000명이 동시에 요청을 보낼 때의 시간 지표
평균 요청 처리 시간 : 30ms
최소 요청 처리 시간 : 1ms
최대 요청 처리 시간 : 254ms
에러 발생 확률 : 0%


profile
CS 마스터를 향해 ..

0개의 댓글