개요
DB에서 자주 변경되지 않지만, 자주 조회되는 데이터들이 있다면 Cache를 활용하면 좋다.
조회를 위해서 DB hit의 회수를 상당히 줄여주므로 서버 자원을 아낄 수 있기 때문이다.
DB connection pool에 여유가 생기니 병목 현상도 어느 정도 해소될 것이고,
복잡한 query를 필요한 작업이었다면 해당 query를 생략함으로써 DB의 부담을 줄여줄 것이다.
토이 프로젝트로 간단하게 쇼핑몰 서비스를 개발하고 있는데 카테고리 항목이 딱 Cache를 적용하기 좋았다.
처음에는 조회할 때마다 DB를 매번 조회했지만, 생각해 보니 카테고리는 한 번 설정되면 자주 바뀌지 않는 데이터였다.
(실제로 많은 쇼핑몰에서도 카테고리는 거의 변경되지 않는다.)
바로 카테고리에 Cache를 적용했다.
Spring Boot에서는 Cache를 AOP 기반으로 제공해줘서 사용이 매우 간편했다.
Cache에 저장하여 조회할 수 있게 해주는 @Cacheable
특정 Cache를 변경해주는 @CachePut
더이상 필요 없는 Cache를 지우는 @CacheEvict
그러나 모든 기능이 그렇듯이 Cache AOP에도 어느 정도 한계가 있을 거라 생각해서 구글링을 해봤고,
카카오 기술 블로그에서 재밌는 글을 읽어서 적용해 보고 공부한 내용을 작성해 봤다.
내용
#1 Annotation 사용의 한계
@Cacheable은 정말 간단하게 사용할 수 있다.
우선 @EnableCaching을 설정해 줘서 Spring 애플리케이션이 Cache를 사용할 수 있게 해 준다.
그리고 Cache가 필요한 서비스에 @Cacheable를 사용한다.
@Cacheable(value = ["category"])
fun getCategoryList(): List<CategoryDto> {
val categoryEntity = categoryRepository.findAll()
return categoryEntity.map {
categoryMapper.toDto(it)
}
}
@Cacheable의 parameter로 value를 사용하면 할당된 값으로 Cache의 이름을 지정하게 된다.
parameter 중에 key도 있는데, key를 사용하면 Cache 내부에서 key-value쌍으로 저장된다.
@Cacheable(value = ["category"], key = "#id")
fun getOneCategory (id: Int): CategoryDto {
...
}
여기서 우려 됐던 부분이 있는데 @Cacheable annotation에 key를 주입하는 방식이다.
Cache의 key는 식별되는 고유한 값이 필요하기 때문에 동일한 key 값을 사용해선 안 된다.
동일한 key 값을 사용할 수는 있지만, Cache를 하나만 저장하는 꼴이 되니 효율이 좋지 못하다.
따라서 key에는 특정 변수로 값을 할당해야 한다.
문제는 의도치 않게 key 인자 동일한 값을 할당하는 상황이 생길 수 있다.
key는 SpEL(Spring Expression Language) 표현식을 사용해서
JoinPoint(annotation이 사용된 메소드) 메소드의 parameter로 값을 할당받는다.
category(942) // Cache의 키로 id(942) 사용
여기서 JoinPoint의 parameter 명을 수정해 보자.
@Cacheable(value = ["category"], key = "#id")
fun getOneCategory (categoryId: Int): CategoryDto {
...
}
SpEL 표현식에서는 JoinPoint의 parameter인 id를 찾아서 key를 생성한다.
하지만 parameter에서는 id를 찾을 수 없으니 Cache의 key에 “null”이 할당된다.
앞서 말했던 Cache가 하나만 저장되는 문제가 발생한다.
이걸 컴파일 타임에 알 수 있다면 훨씬 안정적인 코드가 되는 건 자명하다.
빌드 과정에서 오류를 검증하는 코드를 작성하는 방법도 있을 테지만
@Cacheable이 AOP 기반인 점을 고려하면 올바른 방법은 아닐 테다.
(AOP 사용으로 공통 로직을 분리해 생산성을 높이고 있는데, @Cacheable의 사용 검증을 위한 코드 추가라니 좋지 못하다.)
검증 코드를 추가하는 대신에 다음의 방법으로 문제를 해결할 수 있다.
- 사용자 정의 AOP 구현 with Trailing Lambdas (후행 람다)
#2 Trailing Lambdas(후행 람다)
Trailing Lambdas는 Kotlin에서 제공하는 문법으로 함수형 프로그래밍 기법이다.
이 문법은 마지막에 오는 함수 형태의 인자를 lambda로 변환해서 넘겨준다.
다음은 함수를 인자로 넘겨주는 호출이다.
val result = delegate({ 1 + 3 })
Trailing Lambdas 문법을 사용하면 이렇게 바꿀 수 있다.
val result = delegate { 1 + 3 }
인자로 전달하는 { 1 + 3 } 함수를 lambda 형식으로 작성해서 간결한 코드를 작성할 수 있다.
이걸 활용해서 사용자 정의 AOP를 구현해 Cache를 사용해 보겠다.
#3 사용자 정의 AOP 구현 for Cache with Trailing Lambdas
거창하게 사용자 정의 AOP로 말하고 있지만 내부적으로 @Cacheable과 같은 annotation을 사용하는 Advice가 있고
그걸 감싸서 불편했던 점을 개선할 거다.
AOP에서 실제 로직을 담당하는 Advice를 정의해야 한다.
GeneralAdvice로 정의하고 Spring Bean으로 등록했다.
@Component
class GeneralAdvice {
companion object {
private const val CACHE_NAME = "GENERAL"
}
// cache() 고차함수, lambda를 parameter로 받을 수 있음
@Cacheable(value = [CACHE_NAME], key = "#key")
fun <T> cache(key: String, function: () -> T): T {
return function.invoke()
}
}
cache() 메소드는 함수를 인자로 받아 호출만 해준다.
@Cacheable annotation을 명시해서 Cache를 저장 / 조회할 수 있도록 AOP를 적용했다.
이렇게 정의한 GeneralAdvice를 필요한 곳에 선언해서 사용하면 다음과 같은 모습이다.
@Service
class CategoryService (
//...
val generalAdvice: GeneralAdvice
) {
// key값을 String으로 넘기고 있어서 JoinPoint의 parameter 변경에 대응할 수 있음
// 마지막 parameter가 lambda로, Trailing Lambdas 문법을 사용해 괄호 밖에 lambda를 작성
fun findById(categoryId: Int): CategoryDto = generalAdvice.cache("categoryId:${categoryId}") {
val categoryEntity = categoryRepository.findById(categoryId).orElseThrow { NoSuchElementException("Entity with id $id not found") }
return@cache categoryMapper.toDto(categoryEntity)
}
}
@Cacheable의 key로 사용할 값을 String으로 보낼 수 있게 됐다.
이로써 JoinPoint의 parameter가 변경되더라도 컴파일 타임에 에러가 발생해서
key가 “null”로 저장되는 문제를 사전에 막을 수 있다.
그리고 cache() 메소드를 호출하면서 마지막 parameter를 lambda로 선언했다.
즉, Trailing Lambdas 문법으로 호출하고 있는 모습이고 실행해서 로그를 확인해 보자.
log를 확인하면 select문이 한 번 실행되고 이후의 요청에서는 select가 실행되지 않는 걸 확인했으니,
Cache가 잘 적용 됐다는 걸 알 수 있다.
#4 전역 선언으로 의존관계 해소
기존의 @Cacheable의 key가 “null”로 저장돼서 Cache를 제대로 활용하지 못하는 문제를 해결했다.
그러나 AOP를 사용하기 위해서 GeneralAdvice Bean과 의존관계를 맺는 게 다소 번거롭다.
cache() 메소드를 전역적으로 사용해 해결해 보겠다.
@Component
class GeneralCache(
_generalAdvice: GeneralAdvice
) {
init {
GeneralCache.generalAdvice = _generalAdvice
}
companion object {
private lateinit var generalAdvice: GeneralAdvice
fun <T> cache(key: String, function: () -> T): T {
return generalAdvice.cache(key, function)
}
}
}
GeneralCache 클래스는 초기화하면서 generalAdvice에 GeneralAdvice Bean을 할당한다.
초기화 과정에서 GeneralAdvice Bean이 존재하지 않는다면, Null Safe한 _generalAdvice 변수 덕분에
build 진행 시 예외가 발생한다.
따라서 초기화에 대한 검증은 build 과정에서 자연스럽게 진행된다.
cache() 전역 함수는 parameter로 함수를 전달받아 generalAdvice 변수의 cache() 함수로 책임을 위임한다.
GeneralCache와 GeneralAdvice를 분리할 필요는 없으니 하나로 합쳐 마무리한다.
(@Cacheable 뿐만 아니라 @CachePut, @CacheEvict도 활용 가능하다.)
@Component
class GeneralCache(
_generalAdvice: GeneralAdvice
) {
init {
generalAdvice = _generalAdvice
}
companion object {
private lateinit var generalAdvice: GeneralAdvice
fun <T> cache(key: String, function: () -> T): T {
return generalAdvice.cache(key, function)
}
fun <T> put(key: String, function: () -> T): T {
return advice.put(generateKey(keys), function)
}
fun <T> evict(key: String, function: () -> T): T {
return advice.evict(generateKey(keys), function)
}
}
@Component
class GeneralAdvice {
companion object {
private const val CACHE_NAME = "GENERAL"
}
@Cacheable(value = [CACHE_NAME], key = "#key")
fun <T> cache(key: String, function: () -> T): T {
return function.invoke()
}
@CachePut(value = [CACHE_NAME], key = "#key")
fun <T> put(key: String, function: () -> T): T {
return function.invoke()
}
@CacheEvict(value = [CACHE_NAME], key = "#key")
fun <T> evict(key: String, function: () -> T): T {
return function.invoke()
}
}
}
#5 key값 여러 개 받기
Cache의 key 값을 여러 개 받을 수 있도록 개선해 보자.
Cache가 다방면에서 활용될 수 있으니 key 값도 다양하게 저장 돼야 하기 때문이다.
간단하게 가변인자를 사용할 거고, String 자료형 뿐만 아니라 다른 자료형도 받을 수 있게 Any 자료형을 사용한다.
@Component
class GeneralCache(
_generalAdvice: GeneralAdvice
) {
init {
generalAdvice = _generalAdvice
}
companion object {
private lateinit var generalAdvice: GeneralAdvice
private const val TOKEN = "::"
// 가변인자를 사용하여, 여러 개의 Any 타입의 keys 인자를 받음
fun <T> cache(vararg keys: Any, function: () -> T): T {
return generalAdvice.cache(generateKey(keys), function)
}
fun <T> put(vararg keys: Any, function: () -> T): T {
return generalAdvice.put(generateKey(keys), function)
}
fun <T> evict(vararg keys: Any, function: () -> T): T {
return generalAdvice.evict(generateKey(keys), function)
}
// 일관된 룰로 키 생성
private fun generateKey(keys: Array<out Any>) = keys.joinToString (TOKEN)
}
@Component
class GeneralAdvice {
companion object {
private const val CACHE_NAME = "GENERAL"
}
@Cacheable(value = [CACHE_NAME], key = "#key")
fun <T> cache(key: String, function: () -> T): T {
return function.invoke()
}
@CachePut(value = [CACHE_NAME], key = "#key")
fun <T> put(key: String, function: () -> T): T {
return function.invoke()
}
@CacheEvict(value = [CACHE_NAME], key = "#key")
fun <T> evict(key: String, function: () -> T): T {
return function.invoke()
}
}
}
정리
Kotlin의 Trailing Lambda 문법을 활용해 @Cacheable 사용 중 발생할 수 있는 key 오류 문제를 해결하는 방법을 정리했다.
- SpEL 기반 key 추출의 한계 → 컴파일 타임 검증 불가
- Trailing Lambda + 사용자 정의 AOP로 해결
- 전역 cache 함수로 의존성 제거 및 사용 편의성 개선
Cache 이름도 Advice에서 관리할 수 있는 점도 마음에 든다.
프로젝트 domain을 서비스별로 분류하고 있을 때, Cache가 필요한 서비스의 디렉터리 안에
Cache AOP를 만들어주면 될 것 같다.
자연스럽게 각 Cache AOP별로 Cache의 이름을 구별할 수 있게 되니 관리가 용이해 보인다.
다만, GeneralAdvice를 GeneralCache의 companion object 안에서 static 하게 접근하는 패턴은
초기화 시점 문제나 의존성 순환 문제가 발생할 수도 있다고 한다.
간단한 구조에서는 유용하지만 대규모 환경에서는 주의가 필요하다고 하니, 해당 부분을 개선해 볼 방법을 찾아야겠다.
사실 AOP를 Trailing Lambdas 문법으로 생성하면 해결할 수 있는 문제가 2가지나 더 있다.
- 구현의 번거로움
- AOP가 적용된 내부함수의 AOP 실행 불가
물론 카카오 기술 블로그에도 상세히 나와 있는 내용이고(AOP 한계 극복이 주제이니 당연하다.)
나는 Cache annotation 사용 개선에 집중했기 때문에 본문에는 언급하지 않았다.
다음에는 적절한 예제를 만들어서 적용 및 테스트해보고 2가지 문제 해결에 대해서 다뤄볼까 한다.
참고
https://tech.kakaopay.com/post/overcome-spring-aop-with-kotlin/#transactional-극복해보기
Kotlin으로 Spring AOP 극복하기! | 카카오페이 기술 블로그
Kotlin의 문법적 기능을 사용해서 Spring AOP 아쉬운 점을 극복한 경험을 공유합니다.
tech.kakaopay.com
'SpringBoot' 카테고리의 다른 글
[Spring] Spring Cache와 JPA 1차 캐시 비교 (1) | 2025.06.03 |
---|---|
[Spring] Cache 사용하기 (@Cacheable, @CachePut, @CacheEvict) (1) | 2025.05.06 |
[Spring] Filter, Interceptor 그리고 AOP (0) | 2025.03.03 |
[Spring] DTO와 VO (1) | 2025.02.24 |