일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 멀티모듈 테스트컨테이너
- springsecurity
- 백준
- redissonlock aop
- 형상관리
- RefreshToken
- aop
- OptimisticLock
- TestContainers
- @transactional
- DI
- spring DI
- S3
- AccessToken
- interface
- netty
- 낙관적 락 롤백
- 낙관적 락 재시도
- spring aop
- ObjectOptimisticLockingFailureException 처리
- kotest testcontainers
- 우아한 테크러닝
- 알고리즘
- multimodule testcontainers
- Invalid property 'principal.username' of bean class
- ObjectOptimisticLockingFailureException
- jpa
- Spring Cloud Gateway
- 소수찾기 java
- java
- Today
- Total
조급하면 모래성이 될뿐
카테고리 목록 캐싱 처리하기 본문
Why?
프로젝트를 하면서 지역별로 현재 참여 가능한 게시글이 몇 개인지 응답하는 API를 구현해야 했다. 이 API의 응답은 지역별로 참여가능한 글의 개수를 확인하고, 검색 필터조건으로 사용하기 위함이었다. 다음과 같은 데이터가 있다면
id | city | province | status |
1 | 경기도 | 성남시 분당구 | IN_PROGRESS |
2 | 경기도 | 성남시 분당구 | DONE |
3 | 경기도 | 용인시 | IN_PROGRESS |
4 | 서울특별시 | 서초구 | DONE |
아래와 같은 정보를 만들어야 한다.
- 경기도: 2
- 성남시 분당구 1
- 용인시: 1
- 서울특별시: 0
- 서초구: 0
- 이 API의 응답은 자주 바뀌지 않고 고정적이다.
- 바뀌는 경우는 게시글 등록, 삭제, 상태가 변경되었을 때이다.
위 문제를 해결하기 위해서는 Board 테이블을 전체 조회하여 카운팅이 필요하다. 그러나 위 응답 결과는 자주 바뀌지 않는다. 게시글에 변경이 일어난 경우에 영향을 받는다.
때문에 매번 Board 테이블을 조회하는 것이 부담스럽게 느껴졌다. Board 테이블의 데이터가 많아질수록 부담이 될 것이다. 조회 횟수를 줄이기 위해 캐싱을 적용하고자 했다.
Spring Boot Cache, ConcurrentMap vs Redis
캐싱을 위해 Spring Cache의 기본 ConcurrentMap이나 Redis를 활용할 수 있다.
ConcurrentMap | Redis | |
분산 환경 | Bad - 단일 JVM 내에서 작동하기 때문 - 단일 JVM 메모리에 데이터를 저장하기 때문에 메모리 한계 및 성능 이슈가 발생할 수 있다. |
Good - 여러 서버에 데이터를 분산 및 복제 가능 - 높은 확장성 및 가용성을 제공한다. |
지속성 | - 영구저장 불가능 - 서버 재시작 시 데이터 손실 |
- 지속적으로 저장 가능 - 서버 재시작 및 장애 상황에 대응 가능 |
데이터 유효성 관리 | - 별도의 로직으로 유효성을 검사하고 만료된 항목을 제거해야 함 | - 만료시간 지정 가능 - 따라서 자동으로 제거 가능 |
네트워크 오버헤드 | Good 로컬 메모리에 직접 접근하기 때문에 네틍워크 오버헤드가 없음 |
Bad 네트워크를 통해 데이터를 주고받기 때문에 네트워크 오버헤드가 발생 |
나는 Redis를 사용했다. 현재 프로젝트 수준에서는 CouncurrentMap으로도 충분히 대응이 가능하다. 하지만 이 방식은 너무 간단했고, 더 큰 시스템에서는 잘 사용하지 않을 것이라 생각했다. 조금 더 자주 사용될 것 같은 Redis를 통해 캐싱을 적용해 보았다.
적용
1. 의존성 추가
- redis사용을 위한 의존성, spring boot에서 cache를 사용하기 위한 의존성 2개를 추가
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-cache")
2. Redis 설정
- application.yml에 설정했다.
- redis는 docker 컨테이너를 통해 실행시켰다.
spring:
config:
activate:
on-profile: local
redis:
host: localhost
port: 6379
3. @EnableCaching
- 캐싱을 활성화하기 위해 사용한다. 이 설정을 해주어야 캐싱을 위한 어노테이션들을 사용할 수 있다.
- @Cacheable, @CacheEvict 등등..
@EnableCaching
class Application
fun main(args: Array<String>) {
runApplication<Application>(*args)
}
4. Cache 어노테이션 적용
- 캐싱을 적용하기 위한 어노테이션을 적용해 준다.
- 지원하는 어노테이션은 @Cacheable, @CachePut, @CacheEvict 등이 있다. 각각 저장, 갱신, 삭제 시 사용되는 어노테이션이다.
- 워낙 잘 설명된 글이 많아서.. 참조로 대체한다. spring-cache-tutorial, 뱀귤님 블로그
- AOP로 동작하기 때문에 주의해야 한다는 점만 기억하자.
4-1. 조회로직에서 저장을 위해 @Cacheable을 적용.
4-2. 저장로직에서는 삭제를 위해 @CacheEvict를 적용
- 나의 경우는 List를 저장하고 있다. CachePut으로 정보를 업데이트하기보다 삭제하고, 다시 조회 시 최초 1회 Redis에 저장되도록 한다.
@Cacheable(value = ["regions"], key = "'all'")
@Transactional(readOnly = true)
fun findAllRegions(): List<CityResponse> {
val responses = mutableListOf<CityResponse>()
boardQuerydslRepository.findAllRegions().groupBy { it.city }
.mapValues { (city, provinces) ->
responses.add(CityResponse.of(city, provinces))
}
return responses
}
@Transactional
@CacheEvict(value = ["regions"], key = "'all'")
fun saveBoard(userId: Long, request: CreateRequest): Long? {
val user = userRepository.findByIdOrNull(userId)
val board = boardRepository.save(request.toBoard(user))
return board.id
}
- value는 cacheNames, key는 식별자이다. 위와 같이 설정하면 redis에 regions::all의 key값으로 저장된다.
- SpEL 문법을 사용하기 때문에 key값에 리터럴을 의미하는 ''를 붙여주었다. 안 붙이면 예외가 발생한다..
org.springframework.expression.spel.SpelEvaluationException: EL1008 E: Property or field 'all' cannot be found on object of type 'org.springframework.cache.interceptor.CacheExpressionRootObject' - maybe not public or not valid?
그리고.. 위에 depedencies 하고 @EnableCaching 주석만 추가하면.. 기본으로 RedisCacheManager를 자동 구성한다고 설명이 되어있어서.. 가능할 것이라고 생각하여 실행했더니 예외가 발생했다.
Cannot find cache named 'regions' for Builder [public java.util.List com.example.board.service.BoardService.findAllRegions()] caches=[regions] | key=''all'' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='' | sync='false'
디버깅을 해보니.. cacheManager가 RedisCacheManager를 사용하지 않고, JCacheCacheManager를 사용하고 있었다.
AbstractCacheResolver.java
음.. JCacheCacheManager는 JCache 스펙에 따라 캐시를 관리하는 클래스라는데.. 어쨌든 RedisCacheManager 자동구성이 안 돼서 문제가 되는 것 같아서 CacheManager를 RedisCacheManager로 설정하는 Bean을 추가했다.
@Configuration
class RedisConfig(
@Value("\${spring.redis.host}")
private val redisHost: String,
@Value("\${spring.redis.port}")
private val redisPort: Int
) {
@Bean
fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
val jsonSerializer = SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer())
val stringSerializer = SerializationPair.fromSerializer(StringRedisSerializer())
val cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(stringSerializer)
.serializeValuesWith(jsonSerializer)
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(cacheConfiguration)
.build()
}
}
CacheManager를 지정해 주니 정상적으로 동작했다.
- 결과적으로 지역 정보를 조회 시 최초 1번만 DB에 접근하여 Redis에 저장한다.
- 새로운 게시글이 등록되면 Redis의 데이터를 삭제한다.
- 이후, API를 호출했을 때 다시 DB접근하여 Redis 데이터를 최초 1번 저장하게 된다.
리스트 저장시 @class와 List 정보 제거하기
위 설정으로했을 때 redis에 아래와 같이 저장이 되었다.
get regions::all
{
["java.util.ArrayList",
[
{
"@class":"com.example.board.controller.dto.CityResponse",
"cityId":2,
"cityName":"xeaxb2xbdxeaxb8xb0xebx8fx84",
...
}
...
]
...
]
}
클래스 정보가 포함되었을 때는 이식성이 떨어지고, 성능 저하 문제가 발생할 수 있다.
이식성은 클래스의 구조나 패키지가 변경되면 저장된 데이터가 영향을 받게 된다. 또한 해당 데이터를 역직렬화하기 위해서는 해당 클래스 정보와 일치하는 데이터가 존재해야 한다. 또한 불필요한 메타데이터로 직렬화된 데이터 크기를 증가시키기 때문에 성능 저하를 초래할 수 있다.
먼저 "java,util.ArrayList"와 같은 메타정보는 List <CityResponse> 타입을 한번 래핑 한 일급객체를 만들어주어 제거할 수 있었다.
data class RegionResponse(
val list: List<CityResponse> = emptyList()
) : Serializable
@Class 정보를 제거하기 위해서는 직렬화 방식을 변경해주어야 했는데, 이를 위해 RedisCacheManagerBuilderCustomizer를 통해 설정을 추가했다.
(Jackson2JsonRedisSerializer 와 GenericJackson2JsonRedisSerializer 이 처리하는 직렬화 방식이 다르다.)
@Bean
fun redisCacheManagerBuilderCustomizer(): RedisCacheManagerBuilderCustomizer {
return RedisCacheManagerBuilderCustomizer { builder: RedisCacheManagerBuilder ->
builder
.withCacheConfiguration("regions",
RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
SerializationPair.fromSerializer(Jackson2JsonRedisSerializer(RegionResponse::class.java))
))
}
}
그러나 여전히 @Class 정보가 포함되어 저장되었다. CacheManager의 설정상태를 디버깅해보니 추가한 설정이 적용되어있지 않았다. 결과적으로 Jackson2JsonRedisSerializer로 동작하지 않고, default 설정인 GenericJackson2JsonRedisSerializer을 사용하고 있었다.
처음부터 설정을 잘못한 건지.. 설정정보를 추가해도 계속 의도한 대로 동작하지 않았다.. initialCacheConfiguration 필드도 비어있다.
결과적으로는 CacheManager에 대한 설정을 하면서 cacheName에 대한 설정을 추가하여 해결했다.
기본 설정을 추가했다.
@Bean
fun cacheManager(connectionFactory: RedisConnectionFactory): CacheManager {
val cacheDefaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(
SerializationPair.fromSerializer(StringRedisSerializer())
)
.serializeValuesWith(
SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer())
)
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(cacheDefaultConfig).also {
regionsCacheConfig(it)
}.build()
}
private fun regionsCacheConfig(cacheBuilder: RedisCacheManagerBuilder) {
cacheBuilder
.withCacheConfiguration(
REGIONS_CACHE_NAME,
RedisCacheConfiguration.defaultCacheConfig()
.serializeValuesWith(
SerializationPair.fromSerializer(Jackson2JsonRedisSerializer(RegionResponse::class.java))
)
)
}
regions의 캐시에 접근할 때, CacheManager의 상태를 다시 디버깅해 보면 initialCacheConfiguration에 추가한 설정이 포함된 것을 확인할 수 있다.
결과적으로 데이터 저장 시 클래스 정보를 제거하고, 역직렬화도 가능하다.
성능 확인
- 게시판 테이블에 10만 건의 데이터를 넣고 캐싱 적용 전, 후의 실행시간을 측정해 보았다.
- 10만 건 기준 캐싱을 적용하지 않았을 때 API의 실행시간은 평균 1.5초 정도 소요되었다.
캐싱 적용 전
- exec 1462 ms
- exec 2171 ms
- exec 2278 ms
- 캐싱을 적용해주고 난 후에는 최초 조회 시에만 오래 걸렸고, 이후에는 약 20ms정도로 훨씬 나은 조회속도를 보였다.
캐싱 적용 후
- exec 3189 ms
- exec 25 ms
- exec 19 ms
참조
- https://bcp0109.tistory.com/386
'TroubleShooting > Yapp' 카테고리의 다른 글
Kotlin AOP 따라해보기 (0) | 2023.09.21 |
---|---|
HttpOnly 쿠키로 토큰 관리 & 재발급 로직 구현 (0) | 2023.08.07 |
JWT 로그인 구현 (0) | 2023.07.24 |