대다수의 서비스에서는 쓰기보다는 읽기 작업이 많다.
정확한 수치는 아니겠지만, 100:1, 10,000:1 가량 된다는 글도 존재한다.
올바른 인덱스를 사용하거나, 역정규화를 한 읽기 작업에는 큰 비용이 들지 않지만, 그렇게 할 수 없는 조회 로직의 경우 한 번의 조회에도 큰 비용이 발생할 수 있다.
하지만 읽기 작업은 사이드 이펙트가 존재하지 않으며, 같은 입력에 대해 같은 결과를 반환한다. (물론 조회수 같은 요구사항이 존재한다면 사이드 이펙트가 존재한다고 볼 수 있다)
즉, 조회 로직이 멱등하다면 처음 조회한 결과를 다시 사용하여 데이터베이스에 접근할 필요 없이 빠르게 결과를 반환할 수 있다.
하지만 대부분의 읽기 대상은 불변하지 않으므로, 캐시를 갱신하지 않으면 캐시 불일치 정합성 문제가 발생할 수 있다.
그렇기에 대상이 변경되면 캐시를 재갱신하면 되지만, 만약 서버가 여러 대인 상황에서 이 작업을 어떻게 수행해야 할까?
우선 Spring Boot에는 캐시를 매우 간단하게 구현할 수 있게 해주는 기능을 제공한다.
build.gradle 파일에 다음과 같이 spring-boot-starter-cache 의존성을 추가한다.
버전을 명시하지 않아도
io.spring.dependency-management플러그인이 적용되어 있다면 알아서 적용된다.
dependencies {
...
implementation("org.springframework.boot:spring-boot-starter-cache")
...
}
우선 캐싱하고자 하는 메서드에 @Cacheable 어노테이션을 붙이고, cacheNames 속성에 캐시의 이름을 적는다.
그리고 캐시의 key를 SpEL 문법을 사용하여 적어주면 끝이다.
캐시에 key가 필요 없다면, 적지 않아도 된다.
@Service
class ProductQueryService(
private val productRepository: ProductRepository
) {
@Transactional(readOnly = true)
@Cacheable(cacheNames = ["product_detail"], key = "#productId")
fun findByProductId(productId: Long): ProductDetailResponse {
return productRepository.findByProductId(productId)?.let { ProductDetailResponse(it.id, it.name) }
?: throw IllegalArgumentException("Product not found. productId=$productId")
}
}
// 예제이기 때문에 메모리로 구현
private val log = LoggerFactory.getLogger(ProductRepository::class.java)
@Repository
class ProductRepository(
private val memory: MutableMap<Long, Product> = ConcurrentHashMap()
) {
init {
memory[1L] = Product(1L, "Product 1")
}
fun findByProductId(productId: Long): Product? {
log.info("call findByProductId productId=$productId")
return memory[productId]
}
}
그다음 실제 캐시 구현체를 등록해야 하는데, 이는 @EnableCaching 어노테이션으로 활성화한 Config Bean에서 Bean으로 등록하거나 별도의 Config Bean으로 등록하면 된다.
@EnableCaching
@Configuration
class CacheConfig {
@Bean
fun cacheManager(): CacheManager {
val cacheManager = SimpleCacheManager()
cacheManager.setCaches(listOf(ConcurrentMapCache("product_detail")))
return cacheManager
}
}
SimpleCacheManager 인스턴스를 생성하고 setCaches 메서드에 Collection<Cache> 타입을 매개변수로 전달하면 끝이다.
그리고 메서드를 여러 번 호출해 보면 캐싱이 적용되어 로그가 한 번만 남는 것을 볼 수 있다.
@SpringBootTest
class ProductQueryServiceTest {
@Autowired
lateinit var productQueryService: ProductQueryService
@Test
fun `캐시 테스트`() {
productQueryService.findByProductId(1L) // 로그가 한 번만 남는다.
productQueryService.findByProductId(1L)
productQueryService.findByProductId(1L)
}
}
위의 코드는 단순히 캐시를 적용하고 테스트해 보는 용도에서는 충분하지만, 실제 프로덕션 환경에서 적용하기는 부족하다.
이유는 새로운 캐시가 필요할 때마다 CacheConfig 클래스를 수정해야 하고, 사용 중인 Cache 인터페이스의 구현체가 ConcurrentMapCache이기 때문에 캐시의 수명 관리, 최대 개수 설정을 적용할 수 없기 때문이다.
우선 CacheConfig 클래스를 수정하지 않고 캐시를 등록할 수 있게 해보자
우선 직접 CacheConfig에서 캐시를 생성하지 않고, 주입을 받도록 변경한다.
스프링은 컬렉션으로 빈을 주입할 수 있는 기능을 제공하기에, List<Cache> 타입을 주입받을 수 있다.
제네릭 타입은 컴파일 시점에 소거되는데 어떻게 이것이 가능하냐면, Spring은
ResolvableType클래스와 슈퍼 타입 토큰을 사용하여 해결한다.
자세한 구현은 슈퍼 타입 토큰을 검색해 보면 될 것 같다.
@EnableCaching
@Configuration
class CacheConfig {
@Bean
fun cacheManager(caches: List<Cache>): CacheManager {
val cacheManager = SimpleCacheManager()
cacheManager.setCaches(caches)
return cacheManager
}
}
그리고 Cache 인터페이스의 구현체는 캐시를 사용하는 클래스에 정의하거나, 별도의 파일을 만들어 구현한다.
@Service
class ProductQueryService(
private val productRepository: ProductRepository
) {
@Transactional(readOnly = true)
@Cacheable(cacheNames = [PRODUCT_DETAIL_CACHE_NAME], key = "#productId")
fun findByProductId(productId: Long): ProductDetailResponse {
return productRepository.findByProductId(productId)?.let { ProductDetailResponse(it.id, it.name) }
?: throw IllegalArgumentException("Product not found. productId=$productId")
}
companion object {
const val PRODUCT_DETAIL_CACHE_NAME = "product_detail"
}
}
@Configuration
class ProductQueryServiceCacheConfig {
@Bean
fun productDetailCache(): Cache {
return ConcurrentMapCache(PRODUCT_DETAIL_CACHE_NAME)
}
}
여기서 고민을 해봐야 하는 것은 캐시의 이름을 어디서 정의를 해야 하냐는 것인데, 나는 캐시를 사용하는 곳에 선언하는 것을 선택했다.
이유는 캐시의 이름은 단순한 문자열이고 불변하기에, 어디에 두는 큰 영향이 없기 때문이다.
이것은 프로젝트의 규모와 개인의 스타일에 따라 한 파일에 관리하거나 다른 방법을 선택하면 될 것 같다.
캐시를 빈으로 등록하는 클래스의 위치 또한 마찬가지로, 나는 하나의 파일에 구현하는게 관리하기 더 편하다고 생각하여 이렇게 구현했다. 이것은 개인의 스타일마다 다를 수 있으니 편하게 구현하면 될 것 같다.
이제 캐시가 여러 개 생기더라도, CacheManager의 코드를 수정할 필요가 없어졌다.
또한 새로운 구현의 캐시가 필요할 때, 빈으로 등록하면 코드의 변경 없이 새로운 캐시를 추가할 수 있다.
사용 중인 캐시의 구현체인 ConcurrentMapCache의 내부 구현을 보면, 말 그대로 ConcurrentHashMap을 통해 간단하게 구현되어 있다.
간단하게 구현되어 있다는 말은 사용에 큰 어려움이 없지만, 큰 기능 또한 제공해 주지 않는다는 말이다.
Javadoc을 보면 알 수 있듯, 테스트 또는 간단한 시나리오에서 효과적이라고 나와 있다.
복잡한 운영 환경에서 효과적으로 캐시를 사용하려면 오래된 캐시, 자주 사용되지 않는 캐시는 제거되는 기능이 필요하다.
다행히도 이러한 기능은 거의 모든 캐시 라이브러리에서 지원한다.
그중 사용할 것은 Caffeine 캐시이다.
유명도를 따지면 EhCache도 있지만, 카페인 캐시는 EhCache를 포함해 다른 캐시 구현체에 비해 높은 성능을 제공한다고 한다.
https://github.com/ben-manes/caffeine/wiki/Design
https://github.com/ben-manes/caffeine/wiki/Benchmarks
카페인 캐시는 다음과 같이 의존성을 추가하여 사용할 수 있다.
dependencies {
...
implementation("com.github.ben-manes.caffeine:caffeine")
...
}
마찬가지로, 카페인 캐시 또한 버전을 명시하지 않아도
io.spring.dependency-management플러그인이 적용되어 있다면 알아서 적용된다.
카페인 캐시는 다음과 같이 생성할 수 있다.
@Bean
fun productDetailCache(): Cache {
val caffeine = Caffeine.newBuilder()
.recordStats()
.maximumSize(10)
.expireAfterWrite(Duration.ofMinutes(10))
.build<Any, Any>()
return caffeine; // 카페인은 Cache 인터페이스를 구현하고 있지 않음
}
하지만 생성한 카페인 캐시는 Cache 인터페이스를 구현하고 있지 않기 때문에 그대로 반환할 수 없다.
이는 Adapter 패턴을 구현한 org.springframework.cache.caffeine.CaffeineCache를 사용하여 해결할 수 있다.
@Bean
fun productDetailCache(): Cache {
return CaffeineCache(
PRODUCT_DETAIL_CACHE_NAME, Caffeine.newBuilder()
.recordStats()
.maximumSize(10)
.expireAfterWrite(Duration.ofMinutes(10))
.build()
)
}
캐시를 사용할 때의 주의점은 원본이 변경됐다면 캐시 또한 반영되어야 한다.
이를 지키지 못한다면 사용자는 과거의 값을 보거나, 잘못된 값을 보게 될 수 있다.
성능은 좋지만, 잘못된 값을 보여주는 서비스는 신뢰할 수 없으니, 사용자의 유입도 적어지고, 기존의 사용자도 이탈하게 될 것이다.
따라서 원본의 상태가 변경되면, 캐시를 강제로 만료시키는 작업이 필요하다.
스프링 캐시에는 @Cacheable 어노테이션 말고도 @CacheEvict, @CachePut 어노테이션을 제공하는데, 해당 어노테이션을 사용하면 강제로 캐시를 만료하거나, 갱신할 수 있다.
@Service
class ProductService(
private val productRepository: ProductRepository
) {
@Transactional
@CacheEvict(cacheNames = [PRODUCT_DETAIL_CACHE_NAME], key = "#productId")
fun updateProduct(productId: Long, name: String) {
val product = productRepository.findByProductId(productId)
?: throw IllegalArgumentException("Product not found. productId=$productId")
product.name = name
}
}
@CachePut, @CacheEvict를 사용하면 캐시를 쉽게 만료시킬 수 있지만, 해당 Key를 가진 캐시만 만료시킬 수 있다.
사용자가 캐시가 적용된 상세 조회를 요청하기 전에, 대부분은 목록을 조회하고 상세 조회를 요청할 것이다.
따라서 목록 또한 캐싱한다면 서비스의 성능을 높일 수 있을 것이다.
하지만 목록에는 Key를 선택하기 어렵고, 목록의 첫 페이지 조회는 무조건 발생하기에 첫 페이지만 캐싱하도록 하자.
@Service
class ProductQueryService(
private val productRepository: ProductRepository
) {
...
@Transactional(readOnly = true)
@Cacheable(cacheNames = [PRODUCTS_CACHE_NAME], condition = "#cursorProductId == null && #pageable.pageSize == 10")
fun findAll(cursorProductId: Long?, pageable: Pageable): List<ProductDetailResponse> {
return productRepository.findAll(cursorProductId, pageable)
.map { ProductDetailResponse(it.id, it.name) }
}
companion object {
...
const val PRODUCTS_CACHE_NAME = "products"
}
}
@Configuration
class ProductQueryServiceCacheConfig {
...
@Bean
fun productsCache(): Cache {
return CaffeineCache(
PRODUCTS_CACHE_NAME, Caffeine.newBuilder()
.recordStats()
.maximumSize(1)
.expireAfterWrite(Duration.ofMinutes(10))
.build()
)
}
}
목록의 첫 번째 페이지만 캐싱하기 위해 conditional 속성을 통해 캐시의 조건을 설정하였고, key가 필요 없기에 cacheNames만 적용하였다.
원본이 변경되면, 상세 조회 캐시는 @CacheEvict을 통해 제거되겠지만, 목록 조회 캐시는 제거되지 않는다.
따라서 수동으로 캐시를 만료시킬 방법이 필요하다.
캐시 만료 처리는 CacheConfig에서 등록했던 CacheManager를 통해 캐시 전체를 지우거나 특정 key에 대해 지울 수 있다.
따라서 CacheManager를 사용해서 캐시를 만료시키는 컴포넌트를 다음과 같이 만들 수 있다.
private val log = LoggerFactory.getLogger(CacheInvalidator::class.java)
@Component
class CacheInvalidator(
private val cacheManager: CacheManager
) {
fun clear(cacheName: String) {
val cache = cacheManager.getCache(cacheName) ?: return
log.info("cache clear. cacheName=$cacheName")
cache.clear()
}
fun evict(cacheName: String, cacheKey: String) {
val cache = cacheManager.getCache(cacheName) ?: return
log.info("cache evict. cacheName=$cacheName, cacheKey=$cacheKey")
cache.evict(cacheKey)
}
}
그리고 원본의 상태를 변경하는 메서드에 CacheInvalidator를 의존시켜 캐시를 지울 수도 있지만, 상태를 변경하는 로직은 비즈니스 로직과 연관된 로직이고, 캐시를 만료시키는 것은 비즈니스 로직과 상관없는 별도의 관심사이므로, 비즈니스 로직에 이를 포함하는 것은 안티 패턴으로 볼 수 있다.
따라서 이벤트를 기반으로 관심사를 분리하면 코드의 가독성을 높이고, 기존 코드의 변경 없이 새로운 기능을 추가할 수 있다.
@Service
class ProductService(
private val productRepository: ProductRepository,
private val applicationEventPublisher: ApplicationEventPublisher,
) {
@Transactional
@CacheEvict(cacheNames = [PRODUCT_DETAIL_CACHE_NAME], key = "#productId")
fun updateProduct(productId: Long, name: String) {
val product = productRepository.findByProductId(productId)
?: throw IllegalArgumentException("Product not found. productId=$productId")
product.name = name
applicationEventPublisher.publishEvent(ProductUpdatedEvent(productId))
}
}
@Component
class ProductCacheClearEventListener(
private val cacheInvalidator: CacheInvalidator
) {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun clearProductCache(event: ProductUpdatedEvent) {
cacheInvalidator.clear(PRODUCTS_CACHE_NAME)
}
}
목록은 첫 페이지만 캐싱 되는데, 목록에 포함되지 않는 원본이 수정되지 않으면, 굳이 캐시를 만료시킬 필요가 없다.
원본 수정이 잦다면 캐싱 된 목록을 조회한 뒤, 수정된 객체가 캐시에 포함되었을 때만 만료시키면 될 것 같다.
앞서 위에 작성한 코드를 보았다시피, 스프링 캐시는 @Transactional 과 같이 어노테이션을 사용하여 선언적으로 매우 쉽게 적용할 수 있다.
하지만 여기서 생각해 볼 것은 @Transactional처럼 프록시 패턴으로 구현된다는 것인데, @Transactional과 @Cacheable이 같이 구현되어 있다면 어떤 것이 먼저 호출될까?
그리고 캐시는 이미 조회한 결과를 재사용해서 반환하는 것이 목적인데, 캐시된 결과를 조회할 때 트랜잭션이 적용될 필요가 있을까?
따라서 해당 메서드가 호출될 때, 프록시의 순서에서 트랜잭션보다 캐시가 먼저 적용되어야 한다.
다음과 같이 order 속성을 통해 프록시가 호출되는 순서를 명시적으로 설정할 수 있다.
기본 설정은 0이라 결과가 어떻게 나올지 예측할 수 없다.
@Configuration
@EnableTransactionManagement(order = -1)
class TransactionConfig {
@Bean
fun dataSource(dataSourceProperties: DataSourceProperties): DataSource {
val hikariDataSource = HikariDataSource()
hikariDataSource.jdbcUrl = dataSourceProperties.url
hikariDataSource.username = dataSourceProperties.username
hikariDataSource.password = dataSourceProperties.password
hikariDataSource.driverClassName = dataSourceProperties.driverClassName
return LoggingDataSourceDecorator(hikariDataSource)
}
class LoggingDataSourceDecorator(
private val target: DataSource
) : DataSource by target {
override fun getConnection(): Connection {
log.info("getConnection")
return target.connection
}
}
}
@EnableCaching(order = 0)
@Configuration
class CacheConfig {
@Bean
fun cacheManager(caches: List<Cache>): CacheManager {
val cacheManager = SimpleCacheManager()
cacheManager.setCaches(caches)
return cacheManager
}
}
그리고 위에 작성했던 테스트를 돌려보면 캐싱이 적용되어도 커넥션을 획득하는 것을 볼 수 있다.
2025-04-07T21:00:15.036+09:00 INFO 62191 --- [two-level-cache-example] [ Test worker] o.c.example.config.TransactionConfig : getConnection
2025-04-07T21:00:15.054+09:00 INFO 62191 --- [two-level-cache-example] [ Test worker] o.c.e.repository.ProductRepository : call findByProductId productId=1
2025-04-07T21:00:15.056+09:00 INFO 62191 --- [two-level-cache-example] [ Test worker] o.c.example.config.TransactionConfig : getConnection
2025-04-07T21:00:15.057+09:00 INFO 62191 --- [two-level-cache-example] [ Test worker] o.c.example.config.TransactionConfig : getConnection
스프링 부트가 알아서 순서를 설정해 줄 수 있지만, 이런 설정은 명시적으로 순서를 설정하는 것이 좋다고 생각한다.
추가로 1 단위로 설정하는 것보단, 100 단위 정도로 넉넉하게 잡는 게 좋을 것 같다.
또한 트랜잭션의 성질을 표현하는 용어 중 ACID가 있다.
그 중 Isolation(격리성)이란, 커밋되지 않은 내용은 다른 곳에서 영향을 줄 수 없는 성질을 뜻한다.
만약 트랜잭션이 적용된 상태에서 캐시를 재갱신한 뒤, 예외가 발생해서 트랜잭션이 롤백된다면?
아무리 높은 수준의 격리 수준을 사용한다고 해도, 가장 낮은 단계의 격리 수준에서 발생할 수 있는 Dirty read가 발생하게 된다.
더불어 낮은 격리 수준이어도 롤백은 되지만, 캐시는 롤백이 되지 않는다.
즉, 신뢰할 수 없는 데이터를 조회하게 될 수 있다.
따라서 트랜잭션이 적용된다면, 커밋이 성공적으로 이뤄져야 캐시가 갱신되도록 해야 한다.
다행히 스프링은 트랜잭션이 커밋될때 캐시를 갱신해 주는 기능을 제공한다.
@Configuration
class ProductQueryServiceCacheConfig {
@Bean
fun productDetailCache(): Cache {
val caffeineCache = CaffeineCache(
PRODUCT_DETAIL_CACHE_NAME, Caffeine.newBuilder()
.recordStats()
.maximumSize(10)
.expireAfterWrite(Duration.ofMinutes(10))
.build()
)
return TransactionAwareCacheDecorator(caffeineCache)
}
}
// 또는
@EnableCaching(order = 0)
@Configuration
class CacheConfig {
@Bean
fun cacheManager(caches: List<Cache>): CacheManager {
validateDuplicateCache(caches)
val cacheManager = SimpleCacheManager()
return TransactionAwareCacheManagerProxy(cacheManager)
}
}
캐시를 처음부터 빈으로 등록할 때 TransactionAwareCacheDecorator로 감싸거나, CacheManager를 TransactionAwareCacheManagerProxy로 감싸면 된다.
TransactionAwareCacheManagerProxy의 내부 구현에서 Cache를 꺼낼 때, TransactionAwareCacheDecorator 인스턴스를 생성한 뒤, 반환한다.
매번 TransactionAwareCacheDecorator 객체를 생성해서 감싸는 것이 싫다면, 인자로 들어온 List\<Cache>를 미리 래핑하는 것이 좋아 보인다.
사람은 항상 실수하기 마련인데, IDE의 도움을 받거나, 컴파일 시점에 잡히는 오류는 어플리케이션이 배포되기 전에 바로 잡을 수 있다.
하지만 캐시는 문자열을 통해 이름을 지정하는데, 이는 IDE의 도움을 받거나, 컴파일 시점에 오류로 잡히지 않는다.
예를 들어, 캐시를 등록하는데 실수로 기존에 있던 캐시의 이름을 사용했다고 가정해 보자
@Configuration
class ProductQueryServiceCacheConfig {
@Bean
fun productDetailCache(): Cache {
return CaffeineCache(
PRODUCT_DETAIL_CACHE_NAME, Caffeine.newBuilder()
.recordStats()
.maximumSize(10)
.expireAfterWrite(Duration.ofMinutes(10))
.build()
)
}
@Bean
fun productsCache(): Cache {
return CaffeineCache(
// 실수로 PRODUCTS_CACHE_NAME이 아닌, 위에 선언한 PRODUCT_DETAIL_CACHE_NAME를 사용했다.
PRODUCT_DETAIL_CACHE_NAME, Caffeine.newBuilder()
.recordStats()
.maximumSize(1)
.expireAfterWrite(Duration.ofMinutes(10))
.build()
)
}
}
그리고 어플리케이션을 실행해 보면 중복된 캐시로 인한 에러는 발생하지 않는 것을 볼 수 있다.
중복된 캐시로 인해 문제는 발생하지는 않지만, 캐시가 적용되었다고 생각할 수 있다.
이러한 문제를 막으려면 CacheManager 빈을 등록하는 과정에서 List<Cache>를 주입받을 때, 검증 로직을 추가하여 해결할 수 있다.
@EnableCaching
@Configuration
class CacheConfig {
@Bean
fun cacheManager(caches: List<Cache>): CacheManager {
validateDuplicateCache(caches)
val cacheManager = SimpleCacheManager()
cacheManager.setCaches(caches)
return cacheManager
}
private fun validateDuplicateCache(cache: List<Cache>) {
val duplicateCacheNames = cache.groupBy { it.name }
.filter { it.value.size > 1 }
.keys
if (duplicateCacheNames.isNotEmpty()) {
throw IllegalStateException("Duplicate cache names: $duplicateCacheNames")
}
}
}
Caused by: java.lang.IllegalStateException: Duplicate cache names: [product_detail]
at org.cache.example.config.CacheConfig.validateDuplicateCache(CacheConfig.kt:27)
at org.cache.example.config.CacheConfig.cacheManager(CacheConfig.kt:16)
...
이렇게 간단히 로컬 캐시를 적용하는 방법에 대해 알아보았다.
캐시를 사용하면 데이터베이스 조회를 할 필요가 없으니, 조회 성능이 극적으로 향상된다.
따라서 캐시를 잘 설계하면 부하가 많아지더라도 단일 서버로 충분히 버틸 수 있는 능력을 제공한다.
하지만 캐시를 잘 설계하는 것은 어렵고, 캐시를 적용할 수 있는 곳을 찾기는 더 어렵다.
또한 운영 환경에서 단일 서버로 운영한다는 것은 있을 수 없는 일이다.
그렇다면 로컬에서는 단일 서버를 사용하지만, 다중 서버 환경에서 운영한다는 것을 가정하에 코드를 작성해야 한다.
그렇다면 다중 서버 환경에서 로컬 캐시를 사용하면 어떤 일이 발생할까?
A 서버와 B 서버가 존재하고, A, B 서버에 요청이 들어와 캐싱이 되었다고 가정하자.
그리고 사용자가 수정 요청을 보내고, 이 요청이 A 서버에서 수행되었다면, A 서버의 캐시는 만료됐을 것이다.
하지만 B 서버의 캐시는 여전히 과거의 정보를 가지고 있다.
즉, 다중 서버 환경에서는 캐시를 만료시킨다 한들, 캐시 불일치 문제를 해결할 수 없다.
그렇다면 지금까지 열심히 구현한 캐시 기능은 변경이 거의 발생하지 않거나, 캐시 불일치 문제가 발생해도 전혀 상관없는 매우 특수한 경우에만 적용할 수 있다.
그렇다면 캐시는 로컬에 분산 저장하지 않고, 데이터베이스처럼 서버에 두고 운영해야 한다.
이렇게 별도로 캐시 서버를 운영하는 것을 Remote Cache라고 한다.
여기서 Remote Cache는 주로 Redis를 사용하는데, 스프링은 Remote Cache를 편하게 구현할 수 있도록 해주는 Redis Cache 또한 제공한다.
Redis Cache는 생성자로 생성할 수 없다.
@Bean
fun myRedisCache(): RedisCache {
return RedisCache() // 생성자의 접근자가 protected로 되어있다.
}
Redis Cache를 사용하는 법은 위에 설명한 방법과는 다르게 그저 RedisCacheManager 빈을 등록하면 끝이다.
@Bean
fun redisCacheManager(redisConnectionFactory: RedisConnectionFactory) {
return RedisCacheManager.create(redisConnectionFactory)
}
그렇다면 Cache 구현체는 어디서 만들어야 하지 싶은데, Redis Cache를 사용하면 만들지 않아도 된다.
이유는 RedisCacheManager의 getMissingCache() 구현을 보면 알 수 있는데, isAllowRuntimeCacheCreation가 true이면 런타임에서 동적으로 캐시를 생성한다.
런타임에 동적으로 캐시를 만들고 싶지 않다면, RedisCacheManager를 create 메서드로 만들지 않고, builder 메서드를 사용한 뒤, initialCacheNames 메서드에 캐시 이름을 넣으면 된다.
public class RedisCacheManager extends AbstractTransactionSupportingCacheManager {
...
@Override
protected RedisCache getMissingCache(String name) {
return isAllowRuntimeCacheCreation() ? createRedisCache(name, getDefaultCacheConfiguration()) : null;
}
...
}
또한 여기서 눈여겨봐야 할 것은 상속하고 있는 AbstractTransactionSupportingCacheManager인데, transactionAware가 true이면 TransactionAwareCacheDecorator으로 감싸서 반환하는 것을 확인할 수 있다.
public abstract class AbstractTransactionSupportingCacheManager extends AbstractCacheManager {
...
@Override
protected Cache decorateCache(Cache cache) {
return (isTransactionAware() ? new TransactionAwareCacheDecorator(cache) : cache);
}
...
}
그리고 실제 캐시를 꺼내는 메서드인 AbstractCacheManager 클래스의 getCache에서 이를 사용하여 캐시를 초기화한다.
public abstract class AbstractCacheManager implements CacheManager, InitializingBean {
...
@Override
@Nullable
public Cache getCache(String name) {
// Quick check for existing cache...
Cache cache = this.cacheMap.get(name);
if (cache != null) {
return cache;
}
// The provider may support on-demand cache creation...
Cache missingCache = getMissingCache(name);
if (missingCache != null) {
// Fully synchronize now for missing cache registration
synchronized (this.cacheMap) {
cache = this.cacheMap.get(name);
if (cache == null) {
cache = decorateCache(missingCache);
this.cacheMap.put(name, cache);
updateCacheNames(name);
}
}
}
return cache;
}
...
}
그리고 이전에 등록했던 SimpleCacheManager 빈을 RedisCacheManager로 교체하면 된다.
@Configuration
class CacheConfig {
@Bean
fun redisCacheManager(redisConnectionFactory: RedisConnectionFactory) {
return RedisCacheManager.builder(redisConnectionFactory)
.transactionAware() // 트랜잭션 적용
.build()
}
}
이렇게 손쉽게 레디스 캐시를 적용할 수 있었다.
하지만 테스트를 실행하면 직렬화 문제가 발생하며 테스트가 실패한다.
Cannot deserialize
org.springframework.data.redis.serializer.SerializationException: Cannot deserialize
이유는 레디스에서 자바 객체를 직렬화, 역직렬화 과정에서 사용하는 RedisSerializer를 JdkSerializationRedisSerializer를 사용하기 때문이다.
public class RedisCacheManager extends AbstractTransactionSupportingCacheManager {
...
public static RedisCacheManager create(RedisConnectionFactory connectionFactory) {
Assert.notNull(connectionFactory, "ConnectionFactory must not be null");
RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory);
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig(); <--
return new RedisCacheManager(cacheWriter, cacheConfiguration);
}
...
}
public class RedisCacheConfiguration {
...
public static RedisCacheConfiguration defaultCacheConfig(@Nullable ClassLoader classLoader) {
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
registerDefaultConverters(conversionService);
return new RedisCacheConfiguration(TtlFunction.persistent(),
DEFAULT_CACHE_NULL_VALUES,
DEFAULT_ENABLE_TIME_TO_IDLE_EXPIRATION,
DEFAULT_USE_PREFIX,
CacheKeyPrefix.simple(),
SerializationPair.fromSerializer(RedisSerializer.string()),
SerializationPair.fromSerializer(RedisSerializer.java(classLoader)), <--
conversionService);
}
...
}
public interface RedisSerializer<T> {
...
static RedisSerializer<Object> java(@Nullable ClassLoader classLoader) {
return new JdkSerializationRedisSerializer(classLoader); <--
}
...
}
따라서 캐시로 사용할 객체에 Serializable 인터페이스를 구현시켜야 한다.
data class ProductDetailResponse(
val productId: Long,
val productName: String,
): Serializable
그리고 테스트를 실행해 보고, 실제 레디스에 잘 저장되었는지 확인해 보면 다음과 같은 key, value로 캐시가 저장된 것을 볼 수 있다.
{
"key": "product_detail::1",
"value": "��sr+org.cache.example.dto.ProductDetailResponses�s�/�bJ productIdLproductNametLjava/lang/String;xpt Product 1"
}
여기서 key는 사람이 읽기 쉽지만, value는 어떤 값으로 저장됐는지 사람이 알아볼 수 없다.
또한 Java 기본 직렬화는 이펙티브 자바 Item 85에도 나오듯이 사용을 지양해야 한다.
직렬화 형식은 JSON을 주로 사용하는데, JSON을 사용하면 Java 직렬화 보다 안전하고 빠르게 수행이 가능하고, 사람이 보기도 쉽다.
JSON 직렬화는 다음과 같이 GenericJackson2JsonRedisSerializer 클래스를 통해 수행할 수 있다.
@Bean
fun redisCacheManager(
redisConnectionFactory: RedisConnectionFactory,
objectMapper: ObjectMapper,
): RedisCacheManager {
val redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
GenericJackson2JsonRedisSerializer(objectMapper)
)
)
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.transactionAware()
.build()
}
하지만 테스트를 실행해 보면 다음과 같은 예외가 발생하며 직렬화는 수행되지만, 역직렬화에서 예외가 발생하는 것을 볼 수 있다.
class java.util.LinkedHashMap cannot be cast to class ...
이유는 직렬화 과정에서는 객체의 타입 없이도 직렬화가 가능하지만, JSON을 다시 역직렬화 할 때는 객체의 타입이 무엇인지 알 수 없으므로 LinkedHashMap 타입으로 직렬화가 된 것이다.
이는 ObjectMapper의 activateDefaultTyping 메서드를 통해 JSON 필드에 타입 정보를 기록해 둬서 역직렬화 시 해당 타입으로 역직렬화가 가능하게 할 수 있다.
@Bean
fun redisCacheManager(
redisConnectionFactory: RedisConnectionFactory,
objectMapper: ObjectMapper,
): RedisCacheManager {
val copiedObjectMapper = objectMapper.copy()
copiedObjectMapper.activateDefaultTyping(
copiedObjectMapper.polymorphicTypeValidator,
ObjectMapper.DefaultTyping.EVERYTHING,
JsonTypeInfo.As.PROPERTY
)
val redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
GenericJackson2JsonRedisSerializer(copiedObjectMapper)
)
)
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.transactionAware()
.build()
}
ObjectMapper.DefaultTyping.EVERYTHING은 deprecated 되었는데, 사용한 이유는 코틀린의 data 클래스를 사용하려면 해당 Enum을 사용해야 직렬화 시 타입 정보가 기록되기 때문이다.
그 뒤 테스트를 실행해 보면 무사히 성공하는 것을 확인할 수 있다.
redisCacheConfiguration를 생성할 때,entryTtl메서드를 통해 TTL도 설정할 수 있다.
또한 빌더에withInitialCacheConfigurations메서드를 사용하여 캐시별 상세 설정도 할 수 있다.
레디스 캐시를 적용하면 빠르고 쉽게 다중 서버 환경에서도 안전한 캐시를 사용할 수 있다.
또한 TTL 기능을 통해 오래된 캐시도 쉽게 삭제할 수 있다.
다만 카페인과 다르게 캐시의 최대 크기를 지정할 수 없기 때문에 캐시가 많아진다면 레디스에 장애가 발생할 수 있다.
그리고 직렬화 된 값에 구체 클래스 정보가 포함되므로, 해당 클래스의 패키지가 이동되거나 이름이 변경된다면 마찬가지로 장애가 발생할 수 있다.
가장 큰 문제는 캐시를 사용하더라도 결국 발생할 수밖에 없는 I/O인데, 레디스와 서버 사이의 통신으로 인해 발생하는 네트워크 지연 때문에 레디스가 아무리 빠르다고 해도 로컬 캐시보다 빠를 수 없다.
또한 요청에 따라 레디스의 부하가 비례하므로, 레디스에 더 높은 비용을 투자해야한다.
어떤 캐시는 정합성이 중요하지 않아서 레디스 캐시를 사용하지 않고 로컬 캐시만 사용해도 충분할 수 있다.
또한 어떤 캐시는 정합성이 중요해서 레디스 캐시를 반드시 적용해야 한다.
따라서 상황에 맞게 다양한 캐시 구현체를 사용할 필요가 있다.
스프링은 이러한 기능을 지원하는 CompositeCacheManager를 제공한다.
이름 그대로 Composite 패턴이 사용되었고, 생성자로 CacheManager 타입을 가변 인자로 받는다.
CompositeCacheManager의 구현은 리스트를 순회하며 null이 아니면 그 값을 반환한다.
RedisCacheManager는 동적으로 캐시가 생성되므로, 레디스 캐시를 사용한다면 절대로 null이 반환되지 않는다.
레디스 캐시를 설명할 때 말했듯, 동적으로 캐시를 생성하는 기능을 사용하지 않는다면 null이 반환된다.
따라서 CompositeCacheManager를 생성할 때, RedisCacheManager를 마지막에 둬야 한다.
@Bean
@Primary
fun cacheManager(
simpleCacheManager: SimpleCacheManager,
redisCacheManager: RedisCacheManager,
): CacheManager {
return CompositeCacheManager(simpleCacheManager, redisCacheManager)
}
public class CompositeCacheManager implements CacheManager, InitializingBean {
...
@Override
@Nullable
public Cache getCache(String name) {
for (CacheManager cacheManager : this.cacheManagers) {
Cache cache = cacheManager.getCache(name);
if (cache != null) {
return cache;
}
}
return null;
}
...
}
레디스를 통해 캐싱하는데, 요청이 너무 많아 레디스에 부하가 많아진다면 이는 레디스에 더 높은 비용을 투자하는 것 외에는 방법밖에는 답이 없다.
성능을 위해 캐싱을 사용하였고, 정합성을 위해 레디스를 사용하였지만, 아이러니하게 이러한 것들이 서비스의 성장을 가로막는 장애물로 전락한 것이다.
만약 로컬 캐시에 모든 캐시를 저장하고, 원본이 수정됐을 때, 수정되었다는 것을 모든 서버에 알릴 수 있으면 다중 서버 환경에서 로컬 캐시만으로 캐시를 구현할 수 있지 않을까?
레디스는 인메모리 DB 기능을 넘어, 메세지 브로커로 사용할 수 있는데, 이를 이용하면 로컬 캐시를 다중 서버에서 사용하면서, 모든 서버의 캐시를 무효화시킬 수 있다.
fun interface ExternalEventPublisher {
fun publish(event: ExternalEvent)
}
@Component
class RedisExternalEventPublisher(
private val stringRedisTemplate: StringRedisTemplate,
private val objectMapper: ObjectMapper,
) : ExternalEventPublisher {
override fun publish(event: ExternalEvent) {
log.info("publish external event. event=$event")
val serializedEvent = objectMapper.writeValueAsString(event)
val topic = event::class.simpleName!!
stringRedisTemplate.convertAndSend(topic, serializedEvent)
}
}
interface ExternalEvent
data class CacheInvalidateExternalEvent(
val name: String,
val key: String? = null,
) : ExternalEvent
abstract class ExternalEventDispatcher<T : ExternalEvent> {
val eventClass = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] as Class<T>
val topic: String = eventClass.simpleName
fun dispatch(event: ExternalEvent) {
val typedEvent = eventClass.cast(event)
doDispatch(typedEvent)
}
protected abstract fun doDispatch(event: T)
}
@Component
class CacheInvalidateExternalEventDispatcher(
private val cacheInvalidator: CacheInvalidator,
) : ExternalEventDispatcher<CacheInvalidateExternalEvent>() {
override fun doDispatch(event: CacheInvalidateExternalEvent) {
val (name, key) = event
if (key.isNullOrBlank()) {
log.info("clear cache from external event. name=$name")
cacheInvalidator.clear(name)
} else {
log.info("evict cache from external event. name=$name, key=$key")
cacheInvalidator.evict(name, key)
}
}
}
class RedisExternalEventMessageListener(
private val objectMapper: ObjectMapper,
private val externalEventDispatcher: ExternalEventDispatcher<*>,
) : MessageListener {
override fun onMessage(
message: Message,
pattern: ByteArray?,
) {
try {
val eventClass = externalEventDispatcher.eventClass
val event = objectMapper.readValue(message.body, eventClass)
externalEventDispatcher.dispatch(event)
} catch (e: Exception) {
log.error(e.message, e)
}
}
}
@Configuration
class RedisConfig(
private val objectMapper: ObjectMapper,
) {
@Bean
fun redisMessageListenerContainer(
connectionFactory: RedisConnectionFactory,
externalEventDispatchers: List<ExternalEventDispatcher<out ExternalEvent>>,
): RedisMessageListenerContainer {
return RedisMessageListenerContainer().apply {
setConnectionFactory(connectionFactory)
for (externalEventHandler in externalEventDispatchers) {
val channelTopic = ChannelTopic(externalEventHandler.topic)
addMessageListener(RedisExternalEventMessageListener(objectMapper, externalEventHandler), channelTopic)
}
}
}
}
캐시의 대상에 변경이 발생하면 externalEventPublisher.publish(CacheInvalidateExternalEvent(name, key))를 호출하여, 모든 서버에 캐시가 변경되었음을 알리고, 무효화를 시킬 수 있다.
위와 같이 PubSub을 사용한 방식은 변경의 대상이 명확한 경우에만 유효하다. (사용자가 요청을 보내 변경이 발생, 일정 시간마다 스케줄링)
랭킹, 통계(집계)와 같은 데이터의 경우 쿼리의 속도가 매우 느리고, 실시간으로 값이 변하기 때문에 로컬에 캐시를 저장할 수 없다.
랭킹 조회 -> A서버 연결 -> 홍길동 응답 -> 로컬에서 캐싱
(랭킹 순위 변경)
랭킹 조회 -> B서버 연결 -> 임꺽정 응답 -> 로컬에서 캐싱
랭킹 조회 -> 홍길동 또는 임꺽정 응답 (캐시 불일치 발생)
이러한 데이터는 레디스 캐시를 사용해야 하는데, 불행하게도 이런 데이터는 조회 빈도가 매우 높을 가능성이 높다.
그렇다면 레디스에 비용을 투자해서, 레디스 캐시를 사용하는 것밖에는 방법이 없을까?
생각해 보면, 위에서 보았듯이 캐시는 단순히 하나의 구현체만 사용할 수 있는 것이 아니다.
그렇다면 로컬 캐시와 원격 캐시를 분리해서 사용하지 않고, 하나의 캐시로 통합해서 사용한다면 이 두 가지 장점을 하나로 사용할 수 있지 않을까?
DB에서 조회한 결과를 레디스에 저장해두고, 어플리케이션에는 레디스에서 조회한 값을 캐싱하는 것이다.
즉, 레디스를 사용한 캐시를 2차 캐시 개념으로, 로컬에서 사용하는 캐시를 1차 캐시 개념으로 사용한다.
하지만, 이 기능을 어떻게 구현할 수 있으며, 기존 @Cacheable 어노테이션을 사용하여 편하게 캐싱을 적용할 수 있을까?
이는 이전에 봤던 TransactionAwareCacheDecorator 같이 사용하여, 데코레이터 패턴을 사용한 구현체를 만들어 적용할 수 있다.
class TwoLevelCacheDecorator<T>(
private val target: Cache,
private val remoteCache: RemoteCache,
private val timeout: Duration,
private val typeRef: TypeReference<T>,
) : Cache by target {
override fun get(key: Any): Cache.ValueWrapper? {
val cacheKey = key.toCacheKey()
val localValue = target[cacheKey]
if (localValue != null) {
return localValue
}
val cacheVersion = remoteCache.getCacheVersion(name, cacheKey)
val remoteValue = remoteCache.get(name, cacheKey, typeRef) ?: return null
target[cacheKey] = emoteValue.get() // 내부 구현에 allowNullValues가 true가 아니면 예외가 발생하므로 주의
val newCacheVersion = remoteCache.getCacheVersion(name, cacheKey)
if (cacheVersion != newCacheVersion) {
log.info("cache optimistic read fail. name=$name, key=$cacheKey")
target.evict(cacheKey)
return null
}
log.info("cache hit. name=$name, key=$cacheKey")
return remoteValue
}
// Key를 String으로 변환하는 이유는 Cacheable 어노테이션에서 사용되는 Key는 객체를 사용한다.
// 하지만 외부 이벤트를 받아 캐시를 만료시킬 때, 해당 값은 String 이므로 Key가 달라 캐시를 만료시킬 수 없다.
// 따라서 Key를 String으로 변환시켜야 한다.
private fun Any.toCacheKey() = if (this is SimpleKey) "" else this.toString()
override fun put(key: Any, value: Any?) {
val cacheKey = key.toCacheKey()
log.info("cache miss. name=$name, key=$cacheKey")
val cacheVersion = remoteCache.getCacheVersion(name, cacheKey)
val isAbsent = remoteCache.setIfAbsent(name, cacheKey, value, timeout)
if (isAbsent) {
val newCacheVersion = remoteCache.getCacheVersion(name, cacheKey)
if (newCacheVersion - cacheVersion != 1L) {
remoteCache.remove(name, cacheKey)
return
}
}
target.evict(cacheKey)
}
override fun evict(key: Any) {
val cacheKey = key.toCacheKey()
target.evict(cacheKey)
}
}
1차 캐시로 사용할 Cache 인터페이스를 target으로 사용한 뒤, Kotlin의 by 키워드를 사용하여, 구현하지 않아도 되는 메서드는 따로 재정의하지 않도록 할 수 있다.
구현이 조금 복잡할 수 있는데, 이유는 동시성 문제를 해결하기 위함이다.
그리고 구현에 대해 자세히 설명하겠다.
@Cacheable 어노테이션에 key를 사용하지 않으면 key가 null이 아닌, SimpleKey 타입으로 들어온다.
주석으로 설명되어 있듯, @Cacheable 어노테이션으로 key를 지정할 때는 SpEL 문법을 사용하여 key를 지정하는데, 해당 Key는 문자열이 아닌, 객체이기 때문에 evict 메서드를 외부에서 호출할 때 문제가 발생할 수 있다.
1차 캐시에서 값을 꺼낸 뒤, null이 아니면 해당 값을 사용한다.
그리고 null이면 캐싱이 되지 않았으므로, RemoteCache에서 값을 조회한다.
RemoteCache의 구현은 조금 뒤에 살펴보겠다
또한 RemoteCache에서 값을 꺼내기 전, 캐시 버전을 조회하고, 1차 캐시에 값을 할당한다.
이후 캐시 버전을 한 번 더 조회하고, 처음 조회했던 캐시 버전과 비교하여, 버전이 다르면 1차 캐시를 지운다.
여기서 의아한 점은 1차 캐시에 값을 할당하고, 버전을 비교하는 것인데, 버전 비교를 먼저 수행하고, 1차 캐시에 값을 할당하는 게 올바르다고 생각될 수 있다.
후자의 경우, 캐시 버전 비교 후, 1차 캐시가 갱신된다면 캐시가 만료될 때 까지, 캐시 부정합이 발생할 수 있기 때문이다.
캐시 만료 동작은 캐시 버전을 올리고, 2차 캐시를 만료하고, 1차 캐시를 만료시키는 순서로 동작한다.
A 스레드 조회 요청(캐시 갱신), B 스레드 수정 요청(캐시 만료)
A 1차 캐시 조회 (결과 없음)
A 캐시 버전 조회 -> 1
A 2차 캐시 조회 (결과 있음)
B DB 데이터 수정
A 캐시 버전 조회 -> 1
B 캐시 버전 증가 -> 2
B 2차 캐시 만료
B 1차 캐시 만료
A 1차 캐시 등록 (버전 일치) <- 캐시 부정합
A 1차 캐시 조회 (결과 없음)
A 캐시 버전 조회 -> 1
A 2차 캐시 조회 (결과 있음)
B DB 데이터 수정
A 1차 캐시 등록
A 캐시 버전 조회 -> 1
B 캐시 버전 증가 -> 2
B 2차 캐시 만료
B 1차 캐시 만료 <- 부정합 해결
A 1차 캐시 조회 (결과 없음)
A 캐시 버전 조회 -> 1
B DB 데이터 수정
B 캐시 버전 증가 -> 2
A 2차 캐시 조회 (결과 있음)
B 2차 캐시 만료
B 1차 캐시 만료
A 1차 캐시 등록
A 캐시 버전 조회 -> 2
A 1차 캐시 만료 (버전 불일치) <- 부정합 해결
put 또한 버전 비교를 사용하는데, setIfAbsent 결과가 참이면 버전 비교를 하고, 결과가 1이 아니면 캐시를 만료시킨다.
이러한 이유는 동시성 문제로 발생하는 캐시 부정합 때문인데, 발생하는 시나리오는 다음과 같다.
1차 캐시가 비어 있는 A 서버에 조회 요청(캐시 갱신)이 들어왔고, B 서버에서 값을 수정하는 요청(캐시 만료)이 거의 동시에 들어왔다. (2차 캐시에 값이 없고, 순서는 A 서버가 먼저 받는다고 가정)
A 서버에서 1차, 2차 캐시를 모두 조회했지만, 값이 없기에 실제 DB 호출을 한 뒤, RemoteCacheDecorator의 put 메서드가 호출된다.
그 뒤 B 서버에서 요청이 들어와, DB에 있는 값을 수정하고 캐시를 만료시켰다. (물론 캐시는 없지만)
그 뒤 A 서버에서 put 메서드가 호출되고, 내부의 setIfAbsent 메서드를 호출한다.
여기서 setIfAbsent의 인자로 들어간 값은 수정되기 전에 조회된 값이다.
따라서 2차 캐시에 과거의 값이 저장된다.
이후 B 서버에 수정 요청을 보낸 사용자가 조회했을 때, 분명 값을 수정했는데, 과거의 값이 보여 혼란이 올 수 있다.
정합성이 크게 필요 없는 경우 문제가 되지 않을 수 있지만, 비즈니스적으로 중요한 데이터라고 생각한다면 큰 문제가 발생할 수 있다.
따라서 2차 캐시에 쓴 값이, 호출한 스레드에서 호출했다면 캐시의 버전이 과거와 비교했을 떄, 반드시 1 만큼 상승했을 것이니, 1이 아니라면 갱신한 값이 다른 스레드에 의해 변경된 것이므로 할당된 캐시를 만료시켜야 한다.
evict는 단순하게 키를 변환하고, 1차 캐시를 만료시키는 것이 전부이다.
@Component
class RedisRemoteCache(
private val objectMapper: ObjectMapper,
private val stringRedisTemplate: StringRedisTemplate,
private val externalEventPublisher: ExternalEventPublisher,
private val setnxWithIncrRedisScript: RedisScript<Long>,
private val batchIncrRedisScript: RedisScript<Unit>,
) : RemoteCache {
override fun <T> get(
name: String,
key: String,
valueTypeRef: TypeReference<T>,
): ValueWrapper? {
val opsForValue = stringRedisTemplate.opsForValue()
val remoteCacheKey = toRemoteCacheKey(name = name, key = key)
val remoteValue = opsForValue[remoteCacheKey]
if (remoteValue.isNullOrBlank()) {
return null
}
return SimpleValueWrapper(remoteValue.let { objectMapper.readValue(it, valueTypeRef) })
}
private fun toRemoteCacheKey(
name: String,
key: String = "",
): String = "$CACHE_PREFIX$name$DELIMITER$key"
private fun toCacheVersionKey(
name: String,
key: String = "",
): String = "$CACHE_VERSION_PREFIX$name$DELIMITER$key"
override fun setIfAbsent(
name: String,
key: String,
value: Any?,
timeout: Duration,
): Boolean {
val isAbsent = setIfAbsentWithIncr(name, key, value, timeout)
if (isAbsent) {
externalEventPublisher.publish(CacheInvalidateExternalEvent(name, key))
}
return isAbsent
}
private fun setIfAbsentWithIncr(
name: String,
key: String,
value: Any?,
timeout: Duration,
): Boolean {
val remoteCacheKey = toRemoteCacheKey(name = name, key = key)
val cacheVersionKey = toCacheVersionKey(name = name, key = key)
val result =
stringRedisTemplate.execute(
setnxWithIncrRedisScript,
listOf(remoteCacheKey, cacheVersionKey),
objectMapper.writeValueAsString(value),
timeout.toSeconds().toString(),
)
return result != 0L
}
override fun remove(
name: String,
key: String?,
) {
if (key.isNullOrBlank()) {
val scanOptions = ScanOptions
.scanOptions()
.match(toRemoteCacheKey(name = name) + "*")
.count(100)
.build()
val remoteCacheKeys = mutableListOf<String>()
stringRedisTemplate.scan(scanOptions).use {
remoteCacheKeys.addAll(it.stream().toList())
}
val cacheVersionKeys = remoteCacheKeys.map { it.replace(CACHE_PREFIX, CACHE_VERSION_PREFIX) }
stringRedisTemplate.execute(batchIncrRedisScript, cacheVersionKeys)
stringRedisTemplate.delete(remoteCacheKeys)
} else {
increaseCacheVersion(name = name, key = key)
val remoteCacheKey = toRemoteCacheKey(name = name, key = key)
stringRedisTemplate.delete(remoteCacheKey)
}
externalEventPublisher.publish(CacheInvalidateExternalEvent(name, key))
}
override fun getCacheVersion(
name: String,
key: String,
): Long {
val opsForValue = stringRedisTemplate.opsForValue()
val cacheVersion = opsForValue[toCacheVersionKey(name = name, key = key)] ?: return 0
return cacheVersion.toLong()
}
private fun increaseCacheVersion(
name: String,
key: String,
) {
val cacheVersionKey = toCacheVersionKey(name = name, key = key)
val opsForValue = stringRedisTemplate.opsForValue()
opsForValue.increment(cacheVersionKey)
}
companion object {
private const val CACHE_PREFIX = "cache_"
private const val CACHE_VERSION_PREFIX = "cache_version_"
private const val DELIMITER = "$"
}
}
inline fun <reified T> twoLevelCacheDecorator(
target: Cache,
remoteCache: RemoteCache,
timeout: Duration,
): TwoLevelCacheDecorator<T> = TwoLevelCacheDecorator(target, remoteCache, timeout, jacksonTypeRef<T>())
RemoteCache는 구현이 더 복잡한데, 하나씩 메서드를 살펴보자.
우선 get의 경우 name과 key를 Redis에서 사용할 key로 변환한 뒤, 값을 조회한다.
그리고 조회된 값이 없거나 공백인 문자열이면 null을 반환한다.
또한 RemoteCacheDecorator의 스펙을 맞추기 위해, SimpleValueWrapper로 래핑한 뒤 반환한다.
"null" 문자열을 jackson으로 역직렬화하면 null이 반환된다. 반대로 null을 직렬화하면 "null"이 된다. 이를 통해 캐싱이 되지 않아 null인지, 캐싱 된 값이 null인지 구분할 수 있다.
이름 그대로 Redis의 setIfAbsent, 즉 SETNX 커맨드를 사용하여, 값이 없을 때만 값을 설정한다.
또한 버전을 증가시키는 연산과 같이 원자적으로 수행해야 하므로, RedisScript를 사용하여 SETNX와 INCR을 lua 스크립트로 수행한다.
@Bean
fun setnxWithIncrRedisScript(): RedisScript<Long> = RedisScript.of(
"local setnx_result = redis.call('SETNX', KEYS[1], ARGV[1]) " +
"if setnx_result == 1 then " +
"redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2])) " +
"redis.call('INCR', KEYS[2]) " +
"end " +
"return setnx_result",
Long::class.java,
)
삭제 또한 setIfAbsent와 마찬가지로 캐시의 버전을 갱신한다.
하지만 원자적으로 수행하지 않는데, 버전을 올린 것 자체가 캐시를 갱신하게끔 하므로, 굳이 원자적으로 처리하지 않아도 된다.
key가 비어 있으면 전체 대상을 삭제하는데, 이때 어떤 키가 존재하는지 알아야 하는데, 단순히 KEYS 명령을 사용하면 레디스에 큰 부하가 발생할 수 있으므로, SCAN을 사용하여 모든 키를 조회한다.
조회된 키는 버전과 다른 접두사를 사용하므로, 버전에 대한 접두사로 변경하고, 모든 키에 대해 INCR 명령을 수행한다.
@Bean
fun batchIncrRedisScript(): RedisScript<Unit> = RedisScript.of(
"for i, key in ipairs(KEYS) do " +
"redis.call('INCR', key) " +
"end " +
"return nil",
Unit::class.java,
)
그리고 키에 대한 모든 캐시를 지운다.
key가 있는 경우는 매우 단순하니, 설명은 생략한다.
위 구현은 완벽한 것이 아니다. 따라서 생각하지 못한 빈틈이 존재할 수 있다.
또한 낙관적 읽기 때문에, 일부 사용자는 잠깐 잘못된 값을 조회할 가능성이 존재한다.
따라서 정말 중요한 값이라면 캐싱하는 것을 고려해 보는 것이 좋을 것 같다.
Spring Cache에 대해 간단하게 캐시 구현체부터, 조금은 복잡하게 직접 커스텀하게 구현한 방법까지 알아보았다.
캐시를 사용하면 쉽고 간단하게 서비스의 성능을 극적으로 끌어 올릴 수 있지만, 반대로 문제 또한 쉽고 간단하게 발생할 수 있다.
마틴 파울러의 블로그 글에 Phil Karlton이라는 사람이 남긴 글이 있다.
컴퓨터 과학에서 어려운 것은 두 가지뿐이다: 캐시 무효화와 이름 짓기
캐시를 구현했을 때, 무효화 하는 것을 놓친다면 서비스의 신뢰도가 하락할 수 있고, 캐시 구현체에 문제가 발생한다면 장애가 발생하여 캐시를 적용하는 것보다 못한 결과가 발생할 수 있다.
또한 캐시에 의존적인 서비스가 된다면, 이후 캐시에 문제가 생겨도, 캐시를 걷어낼 수 없는 진퇴양난에 빠지게 될 것이다.
하지만 캐시를 적용하기 적절한 곳을 잘 찾아 적용한다면, 서비스의 신뢰도를 적은 비용으로도 훨씬 높일 수 있을 것이다.
글렌 쵝오👍