[Spring] Cache 사용하기 (@Cacheable, @CachePut, @CacheEvict)
개요
메뉴, 카테고리와 같이 자주 변경되지 않는 정적 데이터들이 있다.
게다가 메뉴와 카테고리를 가져오기 위해서 DB를 조회하는 빈도수도 상당히 높은 걸 알 수 있다.
데이터 양이 적다면 유야무야 넘어가며 매번 발생하는 요청을 처리할 수도 있을 테지만,
반대의 상황이 온다면 성능에 영향을 끼칠 수 있다.
이럴 때 Caching 기능을 사용한다면 DB를 조회하는 횟수를 획기적으로 줄일 수 있다.
동일한 데이터를 요청하면 DB에서 가져오지 않고 미리 Caching 한 데이터를 건네주면 되기 때문이다.
DB에서 데이터가 변경되더라도 문제되지 않는다.
필요한 경우 수정된 데이터만 갱신하거나, Cache를 비운 후 다시 Caching 하면 된다.
편리하게도 Spring은 AOP 방식으로 Cache 서비스를 적용하는 기능을 제공하므로 손쉽게 사용할 수 있다.
Cache를 잘 활용한다면 DB 조회 빈도수를 줄일 수 있다는 사실을 알게 됐으니 실제로 사용해 보겠다.
내용
1. 기본 Cache를 사용하기 위한 Spring Boot 설정
Spring Boot 종속성 추가
implementation 'org.springframework.boot:spring-boot-starter-cache'
Spring에서 annotation 기반의 Cache 기능을 사용하기 위해서는 별도의 선언이 필요하다.
바로 @EnableCaching annotation을 사용해야 한다.
Spring 애플리케이션이 적용해도 되고, 설정 class에 적용해도 무방하다.
현재는 간단한 사용법을 익히는 것을 목적으로 하고 있어서 Spring 애플리케이션에 추가한다.
@EnableCaching 설정
@EnableCaching
@SpringBootApplication
class StudyApplication {
fun main(args: Array<String>) {
runApplication<StudyApplication>(*args)
}
}
설정이 완료되면 이제 Cache를 사용할 수 있다.
2. @Cacheable, Cache를 저장/조회하기
테스트를 위해서 카테고리 테이블을 생성했다.
간단하게 id 값과 name이 들어가 있는 테이블이다.
카테고리 테이블을 조회해서 모든 데이터를 가져오는 함수를 아래와 같이 작성해 실행했다.
@Service
class CategoryService (
val categoryRepository: CategoryRepository,
val categoryMapper: CategoryMapper
) {
/**
* 모든 카테고리 데이터 조회
*/
@Transactional(readOnly = true)
fun getCategoryList(): List<CategoryDto> {
val categoryEntity = categoryRepository.findAll()
return categoryEntity.map {
categoryMapper.toDto(it)
}
}
}
위의 log를 보면 서비스 요청이 있을 때마다 DB에 연결해서 select하는 걸 알 수 있다.
이제부터 Cache를 적용해 DB를 1번만 조회하도록 해보겠다.
Cache를 사용하기 위해 @Cacheable annotation를 사용한다.
대체로 메소드 단위에서 사용되고 클래스 또는 인터페이스에는 사용되는 경우가 드물다.
(자세한 내용은 “번외”란 에 작성)
@Service
class CategoryService (
val categoryRepository: CategoryRepository,
val categoryMapper: CategoryMapper
) {
/**
* 모든 카테고리 데이터 조회
*/
@Transactional(readOnly = true)
@Cacheable(value = ["category"])
fun getCategoryList (): List<CategoryDto> {
...
}
/**
* 카테고리 데이터 1개 조회
*/
@Transactional(readOnly = true)
@Cacheable(value = ["category"], key = "#id")
fun getOneCategory (id: Int): CategoryDto {
...
}
}
@Cacheable annotation이 적용된 메소드는 Caching 여부에 따라서 2가지의 흐름을 따른다.
- 만약 요청 데이터가 Caching 됐으면
- 꺼내서 반환해준다.
- 만약 요청 데이터가 Caching이 안 됐으면
- DB로 가서 데이터를 조회한다.
- 조회 결과를 Caching 하고, 메소드 로직을 수행한다.
그리고 위의 코드에서 알 수 있듯이 @Cacheable annotation에 여러 가지 parameter를 넣을 수 있다.
우선은 value와 key만 살펴보면 다음과 같다.
- value : Cache 데이터의 이름
- key : Cache 데이터의 키값
getOneCategory(id: Int) 메소드에서 Cache 데이터의 키값으로 “#id”를 지정했다.
다르게 말하면 메소드의 특정 parameter를 키값으로 사용하겠다는 의미이다.
만약 parameter가 없다면 key의 디폴트는 0이고, parameter가 여러 개라면 이들의 hashCode 값을 조합하여 키를 생성한다.
getCategoryList()에서는 value 값만 있으니, key를 0으로 해서 List<CategoryDto>가 들어갈 거고
Cache: "category"
└── Key: 0 → Value: List<CategoryDto>[(id=1, name="신발"), (id=2, name="전자기기"), (id=3, name="가구"), ...]
getOneCategory()에서는 value와 key 모두 지정 됐으니 key-value(실제 데이터)가 들어갈 것이다.
Cache: "category"
├── Key: 1 → Value: CategoryDto(id=1, name="신발")
├── Key: 2 → Value: CategoryDto(id=2, name="전자기기")
├── Key: 3 → Value: CategoryDto(id=3, name="가구")
├── ...
이제 @Cacheable을 적용한 서비스 메소드를 호출해 보면 DB select는 한 번만 이뤄지는 모습을 확인할 수 있다.
parameter가 객체일 때의 동작이나, parameter의 값이 특정 조건에서만 Cache를 적용한다면 아래와 같다.
@Cacheable(value = ["category"], key = "#category.categoryName")
fun getOneCategory (category: CategoryDto ): CategoryDto {
...
}
@Cacheable(value = ["category"], key = "#category.categoryName", condition = "#user.type == "ADMIN")
fun getOneCategory (category: CategoryDto , user: User): CategoryDto {
...
}
3. @CachePut, 특정 Cache 업데이트하기
@Cacheable을 테스트하면서 MySQL Workbench에서 직접 데이터를 변경해 봤다.
내심 Spring Cache가 알아서 변경된 데이터를 가져와서 업데이트해주길 원했지만, 엉뚱한 발상이었다.
Spring 애플리케이션의 입장에서는 Cache에 데이터가 있으니 굳이 DB에게 데이터를 요청할 필요가 없다.
애플리케이션은 계속 Cache에서만 데이터를 가져오게 되므로, 다른 경로(나의 경우 DBMS)로 업데이트한 카테고리 데이터는 Cache가 삭제되지 않는 한 DB에만 머무르게 된다.
그렇다면 Spring 애플리케이션을 통해서 데이터 업데이트를 해야 하는데 @CachePut annotation을 사용하면 된다.
@CachePut의 특징으론, 메소드 실행을 방해하지 않고 그 결과를 Cache에 반영한다는 것이다.
이 점을 활용해서 Spring 애플리케이션에서 DB의 내용을 업데이트하고, 그 결과를 Cache에 반영하겠다.
테스트를 위해서 우선 Cache에 데이터를 쌓아보자.
@Cacheable(value = ["category"], key = "#id")
fun getOneCategory(id: Int) {
...
}
위에서 설명했듯이, getOneCategory() 메소드로 카테고리를 1개씩 조회하면서 그 결과를 “category” Cache에 저장하고 Cache에 들어가는 각각의 데이터는 카테고리 ID(id)로 식별한다.
Cache: "category"
├── Key: 1 → Value: CategoryDto(id=1, name="신발")
├── Key: 2 → Value: CategoryDto(id=2, name="전자기기")
├── Key: 3 → Value: CategoryDto(id=3, name="가구")
├── ...
여기서 2번 카테고리인 “전자기기”를 변경할 계획이다.
바로 아래의 코드를 사용해서 말이다.
@CachePut(value = ["category"], key = "#category.categoryId")
fun updateOneCategory(category: CategoryDto): categoryDto {
// 변경할 카테고리 조회 (Select)
val categoryEntity = categoryRepository.findById(category.categoryId)
.orElseThrow {
NoSuchElementException("Entity with id $id not found")
}
category.categoryName.ifEmpty {
throw IllegalArgumentException("There is no category name in category")
}
// 내용 수정 (Update)
categoryEntity.categoryName = category.categoryName
categoryRepository.save(categoryEntity)
// 내용 수정 확인 (Select) - JPA 1차 캐시 (Persistence context) 덕분에 DB에 요청을 날리지 않음
val updatedEntity = categoryRepository.findById(category.categoryId)
.orElseThrow {
NoSuchElementException("Entity with id $id not found")
}
// 반환하는 값으로 Cache 업데이트
return categoryMapper.toDto(updatedEntity)
}
@CachePut의 parameter를 보면 다음을 의미한다.
#1 메소드의 실행 결과를 Cache에 업데이트 한다.
#2 업데이트 할 Cache는 “category” Cache이다.
#3 업데이트 할 데이터는 key(category.categoryId)로 지정된 데이터이다.
카테고리 데이터를 변경하기 전에 상태를 확인할 겸 전자기기 카테고리를 요청해 보자.
이제 변경 요청을 날리고 변경된 내용을 응답으로 받아보았다.
그리고 Cache에 잘 반영 됐는지 확인하기 위해서 카테고리를 조회했다.
updateOneCategory() 메소드의 최종 실행 결과가 “category” Cache에 잘 반영 됐고,
getOneCategory() 메소드로 카테고리를 요청하면 Cache에서 데이터를 가져올 테니 쿼리가 실행되지 않을 것이다.
응답도 잘 오고 있고, 조회를 요청했을 때 log도 예상대로 찍히는 모습을 확인할 수 있었다.
4. @CacheEvict, Cache 비우기
@CachePut으로 Cache를 업데이트한다는 건, 결국 DB와의 동시성 문제를 해결하기 위함이다.
하나씩 변경하고 있지만 어쩔 때는 다수의 데이터를 변경해야 할 때가 있다.
혹은 일정 시간이 지나면 Cache 전체를 갱신해야 할지도 모른다.
이럴 때 사용할 수 있는 annotation이 @CacheEvict이다.
Cache에 있는 모든 데이터를 지우기 위해서
@CacheEvict에 Cache 이름을 넣어주고, allEntries parameter에 true를 주면 된다.
그러면 메소드가 실행될 때 Cache를 모두 지우게 된다.
@CacheEvict(value = ["category"], allEntries = true)
fun updateCategory(category: CategoryDto) {
...
}
@Cacheable, @CachePut과 마찬가지로 key값을 지정해서 해당 데이터만 제거할 수도 있다.
@CacheEvict(value = ["category"], key = "#category.categoryId")
fun updateCategory(category: CategoryDto) {
...
}
정리
자주 조회되며 잘 변하지 않는 데이터(예: 메뉴, 카테고리)는 DB 부담을 줄이기 위해 Caching이 필요하다.
Spring은 AOP 기반으로 간단히 Cache 기능을 제공한다. (@EnableCaching, @Cacheable, @CachePut 등)
1. @EnableCaching
클래스에서 캐시를 이용할 수 있도록 설정해 주는 annotation이다.
2. @Cacheable
메소드에 붙여 Caching 된 데이터가 있으면 DB 조회 없이 Cache 값을 반환한다.
만약 Caching된 데이터가 없으면, DB 조회 후 결과를 Cache에 저장한다.
@Cacheable(value = ["category"], key = "#id")
fun getOneCategory(id: Int): CategoryDto
3. @CachePut
DB에서 값을 업데이트할 때 Cache도 함께 갱신한다.
@Cacheable과 달리 무조건 메소드를 실행하고 그 결과를 Cache에 반영한다.
@CachePut(value = ["category"], key = "#category.categoryId")
fun updateOneCategory(category: CategoryDto): CategoryDto
4. @CacheEvict
Cache의 데이터를 제거할 때 사용한다.
key를 사용하면 지정된 Cache 데이터만 삭제하고,
@CacheEvict(value = ["category"], key = "#id")
fun deleteOneCategory(id: Int)
allEntries를 true로 주면 해당 Cache의 모든 데이터를 제거한다.
@CacheEvict(value = ["category"], allEntries = true)
fun deleteAllCategories()
번외 - @Cacheable은 메소드 단위에 주로 쓰일까?
1. Spring AOP 기반 동작
Spring Cache는 AOP(Aspect-Oriented Programming) 방식으로 동작합니다.
AOP는 Proxy 객체를 만들어 메소드 실행 전후에 추가 동작을 삽입합니다.
따라서 Proxy는 메소드 단위로 Cache 처리 로직을 끼워 넣을 수 있습니다.
즉, Proxy가 감싸는 대상은 메소드 실행이므로, @Cacheable도 메소드 단위에서 작동합니다.
2. 클래스에 선언해도 동작은 메소드 기준
클래스에 @Cacheable을 붙이면 클래스 내 모든 public 메소드에 일괄 적용되지 않습니다.
실제로 캐시가 작동하려면 메소드 단위에서 key 계산, condition 확인, Cache 저장/조회 등을 해야 하므로, 메소드에 구체적으로 달아줘야 합니다.
3. 인터페이스에는 동작하지 않는 경우가 많음
@Cacheable은 구현체의 메소드 실행을 감싸는 방식이므로,
인터페이스에만 @Cacheable을 달면 실제 실행 시점에는 구현체가 해당 annotation을 보지 못하고 캐시가 작동하지 않습니다.
Proxy는 구현체를 감싸기 때문에, annotation도 구현 클래스의 메소드에 있어야 합니다.
4. 결론
@Cacheable은 AOP 기반으로 메소드 실행 전후에 개입하는 방식이라 메소드 단위에 붙여야 정확히 작동합니다.
클래스나 인터페이스에는 붙일 수는 있지만, 실제 캐시 처리에는 영향이 없거나 예측하기 어려운 방식으로 작동할 수 있습니다.