캐싱은 시스템의 성능 및 확장성을 개선하는 데 목표를 두는 기술입니다.
자주 액세스하는 데이터를 애플리케이션 가까이에 있는 빠른 스토리지에 일시적으로 복사하여 데이터를 캐시 합니다.
그렇게 캐시 된 데이터를 사용한다면 보다 클라이언트 애플리케이션에 대한 응답 시간을 훨씬 향상할 수 있습니다.
어떤 데이터를 캐싱해야 할까?
하지만 우리는 모든 데이터를 캐싱할 수 없고 다른 데이터보다 접근이 많고 변화가 적은 데이터를 선택하여 캐싱을 진행하여야 합니다.
저의 캐싱에 대한 오해 역시 잘못된 데이터를 선택하며 시작되었습니다.
# 게시판 메인화면 조회 기능
[GET] /posts?category={category}
- 최근 20개의 게시글을 보여줍니다.
- category 파라미터가 지정되지 않으면 전체, 지정된다면 지정된 카테고리의 게시글만 보여줍니다.
- 게시글에는 제목, 썸네일, 카테고리, 내용 요약에 관한 데이터가 필요합니다.
# 단일 게시물 조회 기능
[GET] /posts/{postId}
- 게시글에는 제목, 썸네일, 카테고리, 내용에 관한 데이터가 필요합니다.
## DB 모델링
게시글
- 제목
- 썸네일 이미지
- 카테고리
- 내용
위와 같은 기능 요구사항이 있다면 캐싱을 어디에 적용하면 좋을까요?
캐싱을 통한 성능 개선에 초점이 맞추어져 있던 저는 아래와 같이 "카테고리에 따른 게시글 전체 조회"를 수행하는 레퍼지토리 메서드에 캐싱을 적용하였습니다.
@Repository
class PostRepository {
@Cacheable(key = '#category')
fun findAllByCategoryOrderById(category Category): List<PostEntity>
...
}
당시 제가 캐싱을 적용할 대상을 "카테고리에 따른 게시글 전체 조회"를 수행하는 레퍼지토리 메서드로 선택한 이유는 메인 화면에서 사용되는 API이고 많은 데이터 조회를 요구하기에 해당 메서드에 캐싱을 적용하면 성능을 높일 수 있을 것이란 판단 때문이었습니다.
하지만 지금의 저는 이는 캐싱된 데이터의 특성을 고려하지 않은 잘못된 선택이라 생각합니다.
캐싱 대상 데이터 고려사항
- 상대적으로 정적으로 유지되는가?
- 반복적으로 사용하는 데이터인가?
- 데이터의 최신화가 실시간으로 이뤄지지 않아도 괜찮은 데이터인가?
- 처리에 오래 걸리는 데이터인가?
게시글 데이터 각각으로 본다면 위의 고려사항을 만족하지만 "게시판 메인화면 조회 기능"에서의 PostRepository#findAllByCategoryOrderById
결과의 경우 위의 고려사항을 만족하지 못한다 생각합니다.
- 상대적으로 정적으로 유지되는가? - 최근 20개라는 조건은 게시글이 추가될 때마다 변경됩니다.
- 데이터의 최신화가 실시간으로 이뤄지지 않아도 괜찮은 데이터인가? - 게시글이 추가될 때마다 최신화되어야 할 데이터입니다.
추가로 PostRepository#findAllByCategoryOrderById
의 결과로 아이디 1, 2, 3, 4의 데이터를 캐싱하였다고 가정해 봅시다.
PostRepository#findAllByCategoryOrderById
의 결과를 캐싱 대상으로 삼는다면 PostRepository#findById
와 같은 메서드에서 캐싱되어 있는 아이디가 1인 데이터를 조회하여도 캐싱된 데이터를 사용할 수 없습니다.
저는 이렇게 잘못된 데이터를 선택하여 캐싱을 적용하였기에 캐싱된 데이터가 종속적이고 데이터를 공유하지 못하는 문제를 겪었습니다.
종속적이지 않고 공유할 수 있는 데이터를 선택하자
위의 경우 게시물 데이터 그 자체는 종속적이지 않고 공유할 수 있는 데이터지만 PostRepository#findAllByCategoryOrderById
의 게시물 리스트 데이터는 종속적이고 공유되지 못했습니다.
이에 종속적이지 않고 공유할 수 있는 데이터를 선택하기 위해 PostRepository#findById
의 결과와 같이 단일 데이터를 선택하였습니다.
그리고 다수 데이터 조회에서도 캐싱된 단일 데이터를 활용할 수 있도록 CacheManager
를 추가 구현하였습니다.
EHCACHE
우선 캐시의 경우 EHCACHE를 활용하여 로컬 캐시로 구성하였습니다.
@Bean(LOCAL_CM)
fun localCacheManager(): CacheManager {
val cacheManager = EhcacheCachingProvider().cacheManager
val cache10Configuration = CacheConfigurationBuilder.newCacheConfigurationBuilder(
Any::class.java,
Any::class.java,
ResourcePoolsBuilder.newResourcePoolsBuilder()
.heap(10, EntryUnit.ENTRIES)
).build()
val selectPostCacheConfig: javax.cache.configuration.Configuration<Any, Any> =
Eh107Configuration.fromEhcacheCacheConfiguration(cache10Configuration)
runCatching {
cacheManager.createCache(SELECT_POST_CACHE, selectPostCacheConfig)
}.onFailure {
log.error(it) { "Failed to create cache" }
}
return JCacheCacheManager(cacheManager)
}
PostCacheManager
캐싱된 데이터를 공유하여 사용하기 위해서는 어떤 데이터가 캐싱되어 있는지 알 수 있어야 합니다.
하지만 스프링 CacheManager
의 인터페이스는 아래와 같고 key
를 통해서만 캐싱된 값을 조회하는 메서드만 제공하고 있었습니다.
public interface Cache {
Object getNativeCache();
...
ValueWrapper get(Object key);
}
하지만 EHCACHE로 생성한 Cache
의 nativeCache
의 경우 javax.cache.Cache
로 아래와 같은 인터페이스를 가지고 있습니다.
public interface Cache<K, V> extends Iterable<Cache.Entry<K, V>>, Closeable {
V get(K key);
...
Iterator<Cache.Entry<K, V>> iterator();
}
Iterable
를 확장하고 있기에 iterator()
가 존재하였고 이를 통해 key
를 알지 않고도 캐싱된 값을 조회하는 메서드를 구현할 수 있었습니다.
@Suppress("UNCHECKED_CAST")
@Service
class PostCacheManager(
private val cacheManager: CacheManager,
) {
private var selectPostCache: Cache<Any, Any> = cacheManager.getCache(SELECT_POST_CACHE)?.nativeCache as Cache<Any, Any>
fun getAllPostValues(): List<PostRecord> {
val values = mutableListOf<PostRecord>()
selectPostCache.iterator().forEach {
values.add(it.value as PostRecord)
}
return values
}
fun addSelectPostCache(records: List<PostRecord>) {
records.forEach {
selectPostCache.put(it.id, it)
}
}
}
개선된 PostRepository
PostCacheManager
를 활용한 개선된 PostRepository
구현을 살펴보면 아래와 같습니다.
@Repository
class PostRepository(
private val postJpaRepository: PostJpaRepository,
private val postCacheManager: PostCacheManager
) {
@Cacheable(key = '#id')
fun findById(id Long): PostEntity? {
return postJpaRepository.findById(id);
}
fun findAllByCategoryOrderById(category Category): List<PostEntity> {
val cachedPosts = postCacheManager.getAllPostValues.filter(it -> !it.category.equals(category)).toList() // category와 일치하는 캐싱된 데이터를 조회한다.
val cachedPostIds = cachedPosts.map { it.id } // 캐싱된 데이터의 Id들
val notCachedPost = postJpaRepository.findAllByCategoryOrderByIdNotIn(cachedPostIds)
postCacheManager.addSelectPostCache(notCachedPost) // 조회한 데이터를 캐시에 넣는다.
return cachedPosts + notCachedPost;
}
}
PostRepository#findById
를 캐싱하여 캐싱된 데이터를 PostCacheManage
를 활용하여 PostRepository#findAllByCategoryOrderById
에서도 사용하고 있는 것을 확인할 수 있습니다.
마무리
그간 잘못된 데이터에 캐시를 적용하며 캐싱을 마치 임시저장과 같이 사용해 왔던 것 같습니다.
그렇기에 캐시를 도입하며 API 성능은 개선할 수 있었지만 '내가 제대로 캐시를 사용하고 있는가?' 하는 생각을 떨쳐낼 수 없었던 것 같습니다.
하지만 이번에 이전과 새로운 관점으로 캐시를 도입해보며 조금 더 효율적으로 캐시를 사용할 수 있었던 것 같습니다.
개발에 있어 완전한 정답은 없다는 것을 알기에 지금 새롭다고 느끼는 관점을 다양하게 활용해 보며 장단을 확인해 보고 조금 더 좋은 코드를 작성할 수 있는 개발자가 되도록 노력하겠습니다.
감사합니다.
추가 링크
- Azure Architecture 캐싱 지침: https://learn.microsoft.com/ko-kr/azure/architecture/best-practices/caching
- 캐싱과 캐싱 전략: https://loosie.tistory.com/800
'개발' 카테고리의 다른 글
유연한 코드를 작성하기 위한 고민 (0) | 2024.08.20 |
---|---|
하나의 유즈케이스에서 여러 번 쿼리를 요청하면? (0) | 2024.08.19 |
jOOQ 사용기 (2) | 2024.07.23 |
[FEW] MVP 기능을 구현하며 (0) | 2024.07.14 |
이미지가 포함된 multipart/form-data 요청 Swagger-UI 만들기 (0) | 2024.06.28 |