오늘은 최종프로젝트 30일차이다.
슬슬 막바지를 향해가고 있다. 오늘은 조회 성능을 향상하기 위해 캐싱 관련 기술을 적용시키려고 한다.
먼저 캐싱은 로컬캐시와 원격 캐시가 있다.
로컬캐시
- 자바 애플리케이션에서 cache 시스템을 사용
- 각각의 애플리케이션은 개별로 된 캐시 시스템을 가지고 있고, 1:1 방식으로 사용한다.
- 따라서 여러 서버로 구성된 환경에서는 각각의 애플리케이션에서 캐시 데이터를 서로 공유 불가능하다.
- Spring에서 사용가능한 cache 기능은 ConcurrentMap, CaffeineCache, SimpleCache 등 있다.
원격캐시
- 애플리케이션 외부에 독립적인 메모리 저장소를 별도로 구축하여 한 곳에서 관리한다.
- 서버 확장 시 동기화가 필요하진 않지만 네트워크 트래픽이 더 많이 발생한다.
- EhCache 서버를 구축 or 레디스 서버 구축
우리 프로젝트에서는 로컬캐시를 사용하기 위해서는 단순 조회 쪽에서만 봤을 때는 사용해도 무방하지만 로컬캐시는 메모리 공간이 Redis의 원격 캐시보다 작기 때문에 저장하는 데이터가 모자라진 않겠지만 서버의 성능에 충분히 영향을 받을 수 있기 때문에 배제한 부분도 있고, 또한 제일 큰 문제는 로컬 캐시는 동기화가 안되기 때문에 만약에 공연 데이터가 하나 생길 때마다 각 서버에 있는 로컬 캐시에 업데이트를 진행해야 되는 부분과 한 번에 처리해야될 부분을 여러 개로 관리를 진행하기에 유지보수가 증가하는 부분이 부담이였다. 따라서 우리 프로젝트에서는 Redis를 사용하고 있고 한번에 여러 개의 서버를 동시에 관리해야 되기 때문에 Redis 캐시를 사용했다.
캐싱전략
대표적으로 사용되는 캐싱전략은 다음과 같다.
- Cache-Aside
- Write-Through
- Write-Behind
- Read-Through
Cache-Aside
- 데이터를 캐시에 먼저 쓰고, 이후에 배경 작업이나 일정 시간 후에 데이터베이스에 비동기적으로 쓰는 전략이다.
- 장점: 캐시와 데이터 소스 간의 일관성을 유지하기 쉽다. 필요할 때만 데이터를 캐시 하기 때문에, 캐시 미스가 자주 발생하지 않는 시나리오에서 효율적이다.
- 단점: 첫 번째 요청에서는 항상 캐시 미스가 발생하므로, 데이터베이스에 접근해야 한다. 이는 지연 시간에 영향을 줄 수 있다.
Write-Through Caching
- 데이터를 캐시에 쓸 때 동시에 데이터베이스에도 write를 수행한다. 이는 캐시 시스템이 데이터의 업데이트를 데이터베이스로 자동으로 전달하는 것을 의미한다. 애플리케이션은 캐시에만 데이터를 write 하기 때문에 데이터 일관성을 유지하기 쉽다.
- 장점: 캐시와 데이터베이스 간의 일관성을 자동으로 유지한다. 데이터를 쓸 때마다 캐시가 업데이트되므로, 캐시 미스가 발생할 확률이 줄어든다.
- 단점: 모든 write 연산이 캐시를 거치므로 쓰기 연산의 지연 시간이 증가할 수 있고, 모든 캐시를 write 하기 때문에 리소스가 낭비될 수 있다.
Write-Behind
- 데이터 변경사항은 먼저 캐시에 적용되고, 이후 설정된 정책에 따라 비동기적으로 데이터베이스에 반영한다. 이 전략은 쓰기 연산을 최적화하기 위해 설계되었으며, 배치 처리를 통해 데이터베이스의 write 부하를 줄일 수 있다.
- 장점: write 연산의 지연 시간을 줄일 수 있으며, 여러 번의 write를 배치 처리하여 데이터베이스의 부하를 줄일 수 있다.
- 단점: 캐시와 데이터베이스 간의 일시적인 일관성 문제가 발생할 수 있으며, 캐시 서버에 장애가 발생하면 데이터 손실의 위험이 있다.
Read-Through
- Cache-Aside와 유사하지만, 데이터를 캐시에서 찾을 수 없을 때 캐시 레이어가 직접 데이터베이스에서 데이터를 조회하고, 이를 캐시에 저장한 후 반환하는 자동화된 프로세스를 가진다. 애플리케이션은 항상 캐시를 통해 데이터에 접근하며, 캐시 미스 처리는 캐시 레이어가 담당한다.
- 장점: 애플리케이션 코드가 데이터베이스에서 데이터를 직접 로드하는 로직을 구현할 필요가 없어, 구현의 복잡성을 줄일 수 있다.
- 단점: Cache-Aside 전략과 유사하게 첫 요청에서는 캐시 미스가 발생한다.
프로젝트에서 메인페이지의 정보를 캐싱하기 위해서는 Cache-Aside 전략을 사용하기로 했다. 일단 처음 캐싱구현을 해보는 것이기 때문에 애너테이션을 이용한 편리한 구현을 하기 위함이다. 스프링 프레임워크에서 제공하는 Cache 애너테이션 기능은 Cache-Aside전략을 베이스로 하고 있기 때문이다. 또한 위의 설명대로 여러 가지의 전략이 있지만 메인페이지 특성상 일관적인 데이터를 제공하면서 서버의 장애가 발생했을 때 정상적으로 데이터가 제공되어야 하며 캐시서버가 장애가 발생해도 정상적으로 서버에서 데이터를 가져올 수 있어야 한다. 따라서 전반적으로 캐싱전략을 고려해 봤을 때 제일 리스크가 적은 Cache-Aside전략이 적합하다고 생각했다.
Spring Boot에서 제공하는 캐시 기능을 추상화한 Cache와 캐시를 관리하는 CacheManager 인터페이스를 통해 추상화되어 있다.
public interface Cache {
// 캐시 이름 리턴
String getName();
// 캐시에서 Object key 와 매핑된 객체 리턴
@Nullable
<T> T get(Object key, @Nullable Class<T> type);
// 캐시에 새로운 데이터 입력
void put(Object key, @Nullable Object value);
// 캐시에서 Object key 와 매핑되는 데이터 삭제
void evict(Object key);
}
public interface CacheManager {
// 캐시 중 name 인자와 매핑되는 Cache 객체 리턴
@Nullable
Cache getCache(String name);
// 캐시 이름들을 리턴
Collection<String> getCacheNames();
}
캐싱기능을 구현하기 위해서는 애플리케이션에 맞는 CacheManager의 구현체가 필요하다.
스프링 프레임워크에서 제공하는 구현체는 많지만 본 프로젝트에서는 Redis를 사용하기 때문에 RedisCacheManager 구현체를 사용한다.
아래의 코드는 RedisCacheManager 구현체를 구현한 코드이다.
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory cf) {
// RedisCache의 기본 설정을 정의
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
// 키 직렬화에 StringRedisSerializer를 사용 및 모든 키를 문자열로 직렬화
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
// 값 직렬화에 GenericJackson2JsonRedisSerializer를 사용 및 객체를 JSON으로 직렬화
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()))
// 캐시 항목의 유효 시간을 30분으로 설정. 시간이 만료되면 항목은 자동으로 삭제
.entryTtl(Duration.ofMinutes(30L));
//위에서 정의한 캐시 설정을 적용
return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(cf)
.cacheDefaults(redisCacheConfiguration)
.build();
}
// Redis와의 문자열 기반 작업을 위한 템플릿을 빈으로 등록
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}
@EnableCaching 애너테이션을 사용하여 캐싱기능을 활성화한다.
활성화 시 다음과 같은 기능을 사용할 수 있다.
- @Cacheable
- @CachePut
- @CacheEvict
- @Caching
@Cacheable
- 캐시데이터가 존재하지 않을 때 메서드 리턴 값을 캐시에 저장한다. 존재하면 메서드를 실행하지 않고 결괏값 반환
@CachePut
- 캐시데이터 수정 시 사용한다. 메서드의 리턴 값이 캐시에 없을 시 저장하고 있을 시 업데이트한다.
@CacheEvict
- 캐시데이터를 삭제한다.
@Caching
- 2개 이상의 캐시 애너테이션을 조합하여 사용하는 애너테이션
- @Cacheable, @CachePut, @CacheEvict 등을 여러 개 정의할 수 있다.
위의 설명한 캐시기능들을 사용하여 프로젝트에 적용한 코드는 다음과 같다.
@Override
@Transactional(readOnly = true)
@Cacheable(value = "goodsGlobalCache", key = "{#cursorId ?: 'default', #categoryName}", cacheManager = "redisCacheManager")
public GoodsGetCursorResponse getGoodsWithCursor(Long cursorId, int size, String categoryName) {
List<GoodsGetQueryResponse> goodsGetQueryResponses =
goodsRepository.findAllByGoodsAndCategoryName(
cursorId,
size,
categoryName
);
Long nextCursorId = -1L;
if (!goodsGetQueryResponses.isEmpty()) {
int lastSize = goodsGetQueryResponses.size() - 1;
nextCursorId = goodsGetQueryResponses.get(lastSize).getGoodsId();
}
return new GoodsGetCursorResponse(goodsGetQueryResponses, nextCursorId);
}
@Override
@Cacheable(value = "goodsCategoryGlobalCache", cacheManager = "redisCacheManager")
public List<GoodsCategoryGetResponse> getAllGoodsCategory() {
List<GoodsCategory> goodsCategorieList = goodsCategoryRepository.findAll();
return goodsCategorieList
.stream()
.map(category -> new GoodsCategoryGetResponse(category.getName()))
.collect(
Collectors.toList()
);
}
@CacheEvict(value = "goodsCategoryGlobalCache", allEntries = true)
public void clearGoodsCategoryCache() {
}
/**
* 특정 카테고리에 해당하는 Goods 캐시를 삭제
*
* @param categoryName 삭제할 캐시의 카테고리 이름
*/
public void evictCacheForCategory(String categoryName) {
String pattern = "goodsGlobalCache::*," + categoryName;
Set<String> keys = stringRedisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
stringRedisTemplate.delete(keys);
}
}
위코드에서는 메인페이지에서 사용하는 공연 커서기반 페이지네이션 기능과 카테고리 조회기능에 대해 캐싱 기능을 적용했다. 적용하면서 고민했던 문제는 공연데이터가 업데이트되었을 때 어떻게 업데이트를 할 것인지가 고민이었는데 공연 데이터는 상시적으로 업데이트되는 데이터가 아닐뿐더러 만약에 업데이트가 된다면 @CachePut기능을 사용하여 순차적으로 업데이트를 해줘야 하지만 커서기반 페이지네이션 특성상 데이터 하나 추가 시 다른 데이터는 자동으로 커서 Id의 기준점이 변경되면서 업데이트를 진행하기가 힘들다. 또한 리소스 낭비를 방지하기 위해 만료시간을 짧게 잡아놨기 때문에 업데이트의 의미가 없다고 생각했다. 따라서 해당 카테고리의 캐시 데이터를 삭제처리하여 클라이언트가 조회했을 때 다시 캐시데이터를 저장하는 것이 데이터의 손실을 방지할 수 있다고 생각했다.
삭제기능을 추가하기 위해 애너테이션을 사용하여 구현하려고 했으나 특정 카테고리를 기준으로 공연캐시데이터를 삭제하기란 불가능하여 RedisConfig에서 StringRedisTemplate를 사용하여 저장된 특정 패턴의 공연 데이터 key를 찾아 삭제하는 메서드를 구현하여 문제를 해결했다.
마지막으로 구현한 캐싱기능을 적용하기 전 측정한 응답속도와 적용 후 응답속도를 비교해 봤을 때 20Ms -> 5ms로 4배 정도 상승했다. 하지만 Cache-Aside전략의 단점인 캐시데이터가 저장이 안 되어있을 시 지연시간이 발생해 40ms정도의 응답속도를 보였다.
'내일 배움 캠프' 카테고리의 다른 글
2024-02-01 (0) | 2024.02.04 |
---|---|
2024-01-31 (0) | 2024.02.02 |
2024-01-30 (1) | 2024.02.01 |
2024-01-29 (0) | 2024.01.30 |
2024-01-26 (1) | 2024.01.27 |