안녕하세요. 요즘은 레디스를 조금씩 공부하고 있는데, 최근 진행 중인 프로젝트에서 캐싱 작업을 추가할 일이 생겼어요.
그래서 추가하고 그 과정을 기록으로 남기고자 해요. 레디스 개념은 너무 쉽게 찾아볼 수 있어서 인메모리 key-value 기반의 데이터베이스라는 정도만 언급하고, 위에 공부 중인 자료를 남겨놓고 스무스하게 넘어가볼게요~
그래서 프로젝트에서 왜 캐싱이 필요했냐?
하면, 우선 특정 장소 사이의 거리 정보가 필요했어요. 해당 프로젝트의 핵심 기능 중 하나가 모임 코스 조회에요.
그리고 그 코스 안에서 거리 정보를 조회할 수 있어요. (아래 이미지 참고)
코스 조회 API는 동일한 요청이 특정 기간동안 반복적으로 들어올 가능성이 높아요. 여기서 거리 정보는 TMap에서 제공하는 OpenAPI를 활용했는데, 같은 요청에 대해 OpenAPI를 매번 요청하는 것은 비효율적이라 판단했고, 캐싱 작업을 추가하기로 결정했어요.
캐싱 전략은 look aside 읽기 전략을 사용했어요.
그럼 서론은 서둘러 마무리하고, 레디스를 활용하여 캐싱 작업을 추가한 과정에 대해 소개해볼게요.
Redis Database
레디스를 조금씩 공부하기도 했고, 사용해볼 수 있는 좋은 기회가 될 것 같아 캐시 데이터베이스로 레디스를 선택하긴 했어요.
또한, Caffeine과 같은 로컬 캐시에 비해 데이터 정합성을 효과적으로 보장해주기 때문에 선택하지 않을 이유가 없다고 생각했어요.
(하지만 단일 어플리케이션에 좋아요 개수와 같이 데이터의 정합성이 크게 중요하지 않다면 Caffeine과 같은 로컬 캐시를 적용해도 속도를 포함해서 얻을 수 있는 이점이 많다고 생각해요)
Upstash 플랫폼을 통해 무료로 레디스, 카프카 등 다양한 데이터베이스를 생성할 수 있어요. (함께 플젝하는 팀원으로부터 추천받을 수 있어 영광이었습니당) 사이드 프로젝트인만큼, 무료라는 큰 장점을 갖고 있는 upstash 플랫폼으로 결정하여, 레디스 데이터베이스를 생성했어요.
아주 쉽게 생성할 수 있어요! 생성하고 나면 아래 CLI 명령어를 통해 터미널에서 레디스 서버로 접근이 가능해요.
Redis Configuration
이제 Spring Boot에서 Redis Database에 접근할 수 있도록 몇가지 설정 과정을 거쳐볼게요.
Dependencies
implementation("org.springframework.boot:spring-boot-starter-data-redis")
(Gradle) 레디스를 사용하기 위한 의존성을 추가해요.
Application.yml
redis:
host: ${REDIS_HOST}
port: 6379
password: ${REDIS_PASSWORD}
ssl: true
레디스 연결을 위해 필요한 속성 값들이에요.
- redis.host: 엔드포인트
- redis.port: 연결 포트 (기본값 6379)
- redis.password: 레디스 접속 비밀번호
- redis.ssl: 데이터 교환 간 암호화 여부
@Configuration
@EnableConfigurationProperties(RedisProperties::class)
class RedisConfig {
}
@ConfigurationProperties(prefix = "redis")
data class RedisProperties(
val host: String,
val port: Int,
val password: String,
)
application.yml 설정 파일을 추가했으면, Config 클래스를 통해 값을 받아오도록 해요.
설정 파일 포맷에 맞춰 RedisProperties 데이터 클래스를 생성하고, RedisConfig 클래스를 추가하여 @EnableConfigurationProperties 어노테이션을 활용하면 값 연결 성공!
RedisConfig
생성한 RedisConfig 클래스에 추가적으로 설정해볼게요.
@Configuration
@EnableRedisRepositories
@EnableCaching
@EnableConfigurationProperties(RedisProperties::class)
class RedisConfig {
...
}
- EnableRedisRepositories: 레디스 데이터베이스와 연결 가능하게 해요
- EnableCaching: 스프링 캐싱 기능을 사용할 수 있게 해요
@Bean
fun lettuceConnectionFactory(redisProperties: RedisProperties): LettuceConnectionFactory {
val redisConfig =
RedisStandaloneConfiguration().apply {
hostName = redisProperties.host
port = redisProperties.port
password = RedisPassword.of(redisProperties.password)
}
val clientConfig =
LettuceClientConfiguration.builder()
.useSsl()
.build()
return LettuceConnectionFactory(redisConfig, clientConfig)
}
Redis 서버와 연결할 수 있도록 하는 빈 설정이에요.
@Bean
fun <T> redisTemplate(
redisConnectionFactory: RedisConnectionFactory,
objectMapper: ObjectMapper,
): RedisTemplate<String, T> {
val redisTemplate = RedisTemplate<String, T>()
redisTemplate.connectionFactory = redisConnectionFactory
redisTemplate.keySerializer = StringRedisSerializer()
redisTemplate.valueSerializer = GenericJackson2JsonRedisSerializer(objectMapper)
return redisTemplate
}
Spring 어플리케이션에서 Redis 서버와 데이터를 주고받을 수 있게 도와주는 템플릿을 설정하는 빈이에요.
제네릭 구조를 활용하여 다양한 자료 구조 사용이 가능하게 했어요.
@Bean
fun cacheManager(redisConnectionFactory: RedisConnectionFactory): CacheManager {
val redisCacheConfiguration =
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofDays(7))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
GenericJackson2JsonRedisSerializer(),
),
)
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build()
}
캐시 매니저 설정을 담당하는 빈이에요. 해당 코드를 통해 캐시 데이터를 json 형식으로 저장하고, 캐시된 데이터는 7일 후 만료되는 것을 알 수 있어요.
아래는 위의 어노테이션과 빈 등록을 포함한 RedisConfig의 전체 코드에요.
@Configuration
@EnableRedisRepositories
@EnableCaching
@EnableConfigurationProperties(RedisProperties::class)
class RedisConfig {
@Bean
fun lettuceConnectionFactory(redisProperties: RedisProperties): LettuceConnectionFactory {
val redisConfig =
RedisStandaloneConfiguration().apply {
hostName = redisProperties.host
port = redisProperties.port
password = RedisPassword.of(redisProperties.password)
}
val clientConfig =
LettuceClientConfiguration.builder()
.useSsl()
.build()
return LettuceConnectionFactory(redisConfig, clientConfig)
}
@Bean
fun <T> redisTemplate(
redisConnectionFactory: RedisConnectionFactory,
objectMapper: ObjectMapper,
): RedisTemplate<String, T> {
val redisTemplate = RedisTemplate<String, T>()
redisTemplate.connectionFactory = redisConnectionFactory
redisTemplate.keySerializer = StringRedisSerializer()
redisTemplate.valueSerializer = GenericJackson2JsonRedisSerializer(objectMapper)
return redisTemplate
}
@Bean
fun cacheManager(redisConnectionFactory: RedisConnectionFactory): CacheManager {
val redisCacheConfiguration =
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofDays(7))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
GenericJackson2JsonRedisSerializer(),
),
)
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build()
}
}
@ConfigurationProperties(prefix = "redis")
data class RedisProperties(
val host: String,
val port: Int,
val password: String,
)
캐시 적용
이제 본격적으로 캐시를 적용해보기로 해요.
@Component
class TmapNavigationAdapter(
private val tmapApiClient: RestClient,
) : NavigationPort {
@Cacheable(
value = ["Distance"],
key = "#startPlace.id + '_' + #endPlace.id",
unless = "#result == T(com.piikii.application.domain.course.Distance).EMPTY",
)
override fun getDistance(
startPlace: Place,
endPlace: Place,
): Distance {
val startCoordinate = startPlace.getCoordinate()
val endCoordinate = endPlace.getCoordinate()
return if (startCoordinate.isValid() && endCoordinate.isValid()) {
getDistanceFromTmap(startCoordinate, endCoordinate)
} else {
Distance.EMPTY
}
}
...
}
TMap OpenAPI를 호출하는 코드에요. @Cacheable 어노테이션을 통해 캐시 데이터를 저장하고 조회할 수 있어요.
- value: 캐싱할 데이터의 키 값 앞에 붙는 값 (ex. Distance${key name})
- key: 캐싱할 데이터의 키 값 (ex. 1_2)
- unless: 데이터를 캐싱하지 않는 조건 (해당 코드에서는 반환 결과가 Distance.EMPTY일 때 캐싱하지 않음)
위와 같은 옵션을 통해 캐싱된 데이터가 Redis 데이터베이스에 저장돼요. 이후 이미 저장된 키 값과 동일한 값일 경우 메서드를 실행하지 않고, 레디스 서버에서 키 값을 통해 캐싱된 데이터를 바로 조회해요.
추가로, getDistance()와 같은 캐싱 어노테이션이 붙은 메서드는 같은 클래스 내의 메서드에서 호출하면 프록시를 타지 않아 캐싱되지 않아요. 따라서 해당 메서드를 다른 메서드에서 호출하려면 클래스를 분리해야 해요.
API 테스트
마지막으로 캐싱 전후로 같은 API를 호출하여 테스트해볼게요.
속도 차이가 약 63% 이상 차이나는 것을 확인할 수 있어요. 굿!
데이터 캐싱도 잘 되는 것을 확인할 수 있어요.
관련 레포를 첨부하며 글을 마무리해보도록 할게요.
관련 PR
프로젝트 레포지토리
마무리
이론으로만 들었던 레디스 캐시를 직접 다뤄볼 수 있어서 재밌었어요. 레디스 자체를 좀 더 공부하고, 다양한 자료구조도 활용해보면 좋을 것 같아요. 또한, Caffeine과 같은 로컬 캐시도 기회가 될 때 적용해보고 그 차이를 직접 느껴보고 싶다는 생각도 들었네요 :)
References
GPT 자문
코드리뷰
'Server' 카테고리의 다른 글
[Server] 웹 서버 vs 웹 어플리케이션 서버(WAS) vs API 서버 (1) | 2024.03.15 |
---|