본문 바로가기
Spring Framework/스프링

스프링에서 Redis 캐시 사용하기 (@Cacheable, @CacheEvict) [Kotlin]

by 스코리아 2023. 12. 20.

안녕하세요, 스코리아입니다.
오늘은 스프링 3.1.0 환경에서 Redis 캐시를 사용해 보고, DB(Disk)로 불러올 때와 속도 비교를 해보겠습니다. 언어는 코틀린(Kotlin)으로 진행하겠습니다.

 

스프링에서 Redis 캐시 사용하기


Redis는 NoSql로서, 인메모리 DB이기 때문에 Disk(하드)에서 불러오는 DB보다 훨씬 속도가 빠릅니다. 그렇기 때문에 Redis로 캐시 작업을 하기에 용이합니다.


하지만 Redis에 많은 데이터가 누적이 된다면 메모리 부족 현상이 순시 간에 나타날 수 있습니다. 그렇기 때문에 TTL(time-to-live)이라 부르는 '만료시간'을 설정하여 일정 시간이 지나면 자동으로 삭제되게끔 설정할 수 있습니다.


Redis 설치 (Mac)

Redis를 아직 설치하지 않으셨다면, 터미널에 아래 명령어를 쳐서 간편하게 설치하실 수 있습니다. (맥에서)

brew install redis
brew services start redis

캐시 어노테이션

스프링에서는 @Cacheable, @CacheEvict, @CachePut 어노테이션을 통해 간단하게 캐시를 저장하고 삭제합니다.

@Cacheable(key = "#userId", value = ["userInfo"]) 캐시 존재시 캐시 정보를 가져오고 없으면 등록 (key: userId를 key값으로 설정, value: userInfo를 캐시이름으로 설정)
@CachePut 기존 캐시 업데이트
@CacheEvict 캐시 삭제

 


이제 Redis를 스프링 프로젝트에 적용해 보겠습니다.


1. build.gradle.kts에 redis dependency 추가

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

 

2. application.yml 파일에 redis 정보 추가

 spring:
  data:
    redis:
      host: localhost
      port: 6379

 

Redis host 주소는 내부망 혹은 사설망에서 주로 사용합니다. 외부망 연결 시 네트워크 레이턴시 문제, 보안 문제 등이 발생하기 때문에 추천하지 않습니다. 기본 설치 시 localhost로 접근 가능합니다.

port는 기본이 6379입니다.

 

참고 : 스프링 3.0 이상부터는 spring.redis 경로가 아닌 spring.data.redis 경로로 바뀌었습니다.

 

3. 메인 Application에 캐시 어노테이션 추가

@EnableCaching
@SpringBootApplication
class DemoApplication

 

@EnableCaching 어노테이션을 추가해야, 캐시 기능을 사용할 수 있습니다.

 

4. RedisConfig 추가

@Configuration
class RedisConfig(
    @Value("\${spring.data.redis.host}") // application.yml에서 redis 정보 가져오기
    val host: String,

    @Value("\${spring.data.redis.port}")
    val port: Int,
) {

    @Bean
    fun lettuceConnectionFactory(): LettuceConnectionFactory { // Lettuce 기능 사용
        val lettuceClientConfiguration = LettuceClientConfiguration.builder()
            .commandTimeout(Duration.ZERO)
            .shutdownTimeout(Duration.ZERO)
            .build()
        val redisStandaloneConfiguration = RedisStandaloneConfiguration(host, port)
        return LettuceConnectionFactory(redisStandaloneConfiguration, lettuceClientConfiguration)
    }

    @Bean
    fun redisTemplate(): RedisTemplate<*, *> {
        return RedisTemplate<Any, Any>().apply {
            this.connectionFactory = lettuceConnectionFactory()

            // "\xac\xed\x00" 같은 불필요한 해시값을 보지 않기 위해 serializer 설정
            this.keySerializer = StringRedisSerializer()
            this.hashKeySerializer = StringRedisSerializer()
            this.valueSerializer = StringRedisSerializer()
        }
    }

    @Bean
    fun cacheManager(): CacheManager {
        val configuration = RedisCacheConfiguration.defaultCacheConfig()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                GenericJackson2JsonRedisSerializer()
            )) // Serialize 관련 설정
            .entryTtl(Duration.ofMinutes(2)) // 캐시 기본 ttl 2분 설정
            .disableCachingNullValues() // Null 캐싱 제외
        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(lettuceConnectionFactory())
            .cacheDefaults(configuration)
            .build()
    }
}

 

스프링에서 Redis를 사용하는 방법에는 Jedis와 Lettuce가 있는데, Lettuce의 성능이 Jedis보다 많게는 몇십 배 넘게 좋다고 합니다. 그렇기 때문에 Lettuce를 사용하는 것이 좋겠습니다. (참고: 이전에는 Jedis가 redis 기본 모듈이었지만, 스프링 2.0 이상부터는 Lettuce가 기본 모듈로 채택되었다고 합니다)

 

이후 RedisConnectionFactory를 사용하는 redisTemplate 빈을 만들어주어 기본적인 RedisTemplate를 사용할 수 있습니다. StringRedisSerializer()serializer 설정을 해주는 것인데, 생략할 시 redis 콘솔에서 출력했을 때 "\xac\xed\x00" 같은 값이 prefix에 붙어 노출되게 됩니다.

 

그다음은 CacheManager 빈입니다. Serialize 관련 설정은 RedisCacheConfiguration에서 이루어집니다. key 직렬화에는 StringRedisSerializer를 등록하며, value 직렬화에는 GenericJackson2JsonRedisSerializer를 등록해 줍니다. 추가적으로 캐시의 TTL(유효기간)을 2분으로 지정해 주었습니다. 또한 disableCachingNullValues를 통해 Null 데이터는 캐싱을 제외하였습니다.

 

5. Controller 정보

/**
 * 내 정보 보기
 */
@GetMapping("/info")
fun searchMyInfo(): BaseResponse<MemberDtoResponse> {
    val userId = (SecurityContextHolder.getContext().authentication.principal as CustomUser).username
    val response = memberService.searchMyInfo(userId)
    return BaseResponse(data = response)
}

/**
 * 내 정보 저장
 */
@PutMapping("/info")
fun saveMyInfo(@RequestBody @Valid memberDtoRequest: MemberDtoRequest): BaseResponse<Unit> {
    val userId = (SecurityContextHolder.getContext().authentication.principal as CustomUser).username
    val resultMsg: String = memberService.saveMyInfo(userId, memberDtoRequest)
    return BaseResponse(statusMessage = resultMsg)
}

 

내 정보 /info API에 캐싱을 적용해보고자 합니다. 다만, 저는 재사용 가능성이 높은 서비스 계층에서 Redis 캐시를 사용하는 게 더 유용할 것이라고 판단하여, 서비스 계층에서 캐시 어노테이션을 적용하겠습니다.

 

'내 정보 보기 API'는 Get으로 호출되며, '내 정보를 저장 API'는 Put으로 호출됩니다.

두 API 모두 SecurityContextHolder에서 username을 가져와서, 서비스 계층에 userId의 정보를 넘겨주고 받은 값을 return 하는 API입니다.

 

설계 : '내 정보 보기 API'가 처음 호출 되었을 때는 DB에서 가져오지만 그 후 2분(TTL) 간은 Redis 캐시에서 가져오는 형태. '내 정보를 저장 API' 호출 시 DB 업데이트 후 캐시가 삭제되는 형태.

 

6. MemberDtoResponse, Service 정보

data class MemberDtoResponse(
    val id: Long = 0,
    val userId: String = "",
    val name: String = "",
    val birthDate: String = "",
    val gender: String = "",
    val email: String = "",
)

 

먼저, return 할 reponse 객체인 MemberDtoResponse 데이터 클래스를 만들어줍니다.

= 0 , = "" 와 같은 기본적인 초기화가 되어있지 않는 상태에서 캐싱을 하면 "cannot deserialize from Object value" 오류를 내뱉기 때문에 각 멤버 변수에 기본 값이 있는 형태여야 합니다. 각 변수에 기본값을 설정하면, Jackson이 기본 생성자를 사용하여 클래스를 인스턴스화할 수 있습니다.

/**
 * 내 정보 조회
 */
@Cacheable(key = "#userId", value = ["userInfo"])
fun searchMyInfo(userId: String): MemberDtoResponse {
    val member: Member = memberRepository.findByUserId(userId) ?: throw InvalidInputException("userId", "회원 아이디(${userId})가 존재하지 않는 유저입니다.")
    return member.toDto()
}

/**
 * 내 정보 수정
 */
@CacheEvict(key = "#userId", value = ["userInfo"])
fun saveMyInfo(userId: String, memberDtoRequest: MemberDtoRequest): String {
    val member: Member = memberRepository.findByUserId(userId) ?: throw InvalidInputException("userId", "회원 아이디(${userId})가 존재하지 않는 유저입니다.")
    memberDtoRequest.id = member.id

    val memberEntity: Member = memberDtoRequest.toEntity()
    memberEntity.password = passwordEncoder.encode(memberDtoRequest.password)

    memberRepository.save(memberEntity)
    return "수정이 완료되었습니다."
}

 

[내 정보 조회 : searchMyInfo]

@Cacheable 키워드캐시 존재 시 캐시 정보를 가져오고, 존재하지 않을 경우 Redis에 새로운 데이터를 생성하는 역할을 합니다. 즉, 첫 번째 호출 시 캐시가 존재하지 않으므로 memberRepository(DB)를 통해 호출되는 것이고, 이후 호출부턴 Redis에서 호출됩니다. 위의 RedisConfig에서 캐시 TTL을 2분으로 설정하였기 때문에 2분 이후에는 캐시 데이터가 자동 삭제될 것입니다. 때문에 2분 이후에 api를 재호출하면, 다시 DB에서 호출되는 형태가 될 것입니다.

 

@Cacheable 키워드를 보면 key에는 캐시 할 키인 userId가 지정되어 있고, value에는 캐시 이름인 userInfo가 지정되어 있습니다. key 값은 함수의 매개변수 값 중에서 선택해주셔야 하며, key 값에는 prefix로 '#'이 들어가야 합니다. 또한 코틀린에서 value 지정 시에는 배열 []을 이용하여 지정해주셔야 합니다.

 

[내 정보 수정 : saveMyInfo]

@CacheEvict 키워드캐시를 삭제하는 역할을 합니다. 삭제할 키인 userId를 key로 지정하고, 캐시 이름인 userInfo를 value로 설정하였습니다. 즉, 해당 서비스 함수 호출 시 memberRepository(DB)에 값이 업데이트되고 userInfo을 가진 캐시가 삭제됩니다.

 

물론 여기서 @CacheEvict를 쓰지 않고 @CachePut을 쓰는 방법도 있습니다. @CachePut 키워드는 기존 캐시를 삭제하지 않고 업데이트해 주는 역할을 합니다. 이 키워드를 쓰려면 해당 함수에서 String으로 반환하면 안 되고, searchMyInfo 함수처럼 MemberDtoResponse로 반환해야 합니다.

 

 

7. 테스트

(1) "/info" (GET) API 첫 호출 시

첫 호출 시에는 DB에서 SQL이 호출되기 때문에 API 호출 시 188ms이 걸렸습니다.

API 소요시간
API 실행 결과

 

아래 로그를 보면, DB에서 SQL이 호출됨을 알 수 있습니다. (Redis 캐시 사용 되지 않음)

로그

 

 

(2) "/info" (GET) API 2번째 호출 시

API가 36ms로, 매우 빠른 속도로 호출되었습니다! 속도로 봤을 때도 Redis 캐시가 적용된 걸 알 수 있는데요.

API 소요시간

 

아래 로그에서 보면, DB에서 아무 SQL도 호출되지 않습니다 (이로 인해 Redis 캐시가 적용된 걸 알 수 있습니다!)

로그

 

 

(3) 2분이 지난 후 "/info" (GET) API 호출 시

Redis 캐시 TTL이 2분이기 때문에 첫 번째 API 호출 이후로 2분이 지나면 Redis에서 캐시 데이터가 삭제됩니다.

그러므로 아래와 같이 DB에서 SQL이 다시 호출됩니다.

로그

 

 

(4) "/info" (PUT) API 호출 후에 "/info" (GET) API 호출하기

"/info" (PUT) API는 '내 정보 수정 API'입니다. 그러므로 이 API가 정상적으로 호출되었다면, 캐시가 삭제되어야 합니다.

API 실행 결과

 

아래처럼 "/info" (GET) API를 호출해 보니, 회원정보가 잘 수정됨을 확인할 수 있고, 

API 실행 결과

 

로그를 보니, DB를 통해 접근한 것을 알 수 있습니다. 이로 인해 "/info" (PUT) API를 통해 캐시가 삭제되었음을 확인할 수 있습니다.

로그

 


지금까지 코틀린, 스프링에서 Redis 캐시를 사용하는 방법을 알아보았습니다.

읽어주셔서, 감사합니다.