캐싱과 캐싱의 필요성
💡 캐싱(Caching)
자주 사용되는 데이터를 더 빠른 캐시(Cache)에 저장하는 기법을 일컫는 용어
Cache?
Cache는 CPU 내부의 작은 하드웨어로, 지역성(Locality)의 원칙에 따라 자주 접근하게 되는 데이터를 저장해두는 임시(휘발성) 기억 장치다. <L1, L2, L3 Cache … 등등>
기본적으로 영속성을 위해 파일시스템(디스크)에 저장하고, 빠른 활용을 위해 메모리(RAM)에 저장한다면, 정말 많이 사용되는 데이터가 캐시에 저장된다.
캐시의 목적과 방식을 적용해, 빈번하게 접근하게 되는 데이터베이스의 데이터를 Redis 등의 인메모리 데이터베이스에 저장을 함으로서 데이터를 조회하는데 걸리는 시간과 자원을 감소시키는 기술을 캐싱이라고 한다.
웹 브라우저에서는 자주 바뀌지 않는 이미지 등을 브라우저 캐시에 저장해 페이지 로드를 줄이는 것도 캐싱의 일종이다.
캐싱 전략
기본적으로 캐시는 본래 저장소가 아닌 다른 곳에 데이터를 저장하는 행위이며, 언제든 사라질 수 있는 가능성이 있다.
그렇기 때문에, 너무 크지 않게 관리 되어야 하며, 캐시를 확인했을 때 필요한 데이터가 있을 수도, 없을 수도 있다는 것을 인지해야 한다.
그래서 캐시를 구현하고 사용할때는 해당 데이터가 얼마나 자주 사용될 것인가를 반드시 고려해야 한다.
- Cache Hit: 캐시에 찾고자 하는 데이터가 있을 때
- Cache Miss: 캐시에 찾고자 하는 데이터가 없을 때
- Eviction Policy: 캐시에 공간이 부족할때(캐시는 비교적 작다) 어떻게 공간을 확보할지에 대한 정책
캐시에 찾고자 하는 데이터가 있을지 없을지는 캐시에 접근하기 전까지는 모른다.
그래서 어떤 데이터를 얼마나 오래 캐시에 보관할지에 대한 전략을 잘 수립해 캐시 적중률을 높여야 한다.
Cache-Aside
Lazy Loading이라고도 하며, 데이터를 조회할 때 항상 캐시를 먼저 확인하는 전략이다.
캐시에 데이터가 있으면 캐시에서 데이터를, 없으면 원본에서 데이터를 가져온 뒤 캐시에 저장한다.
- 필요한 데이터만 캐시에 보관된다.
- 최초로 조회할 때 캐시를 확인하기 때문에 최초의 요청은 상대적으로 오래 걸린다.
- 반드시 원본을 확인하지 않기 때문에, 데이터가 최신이라는 보장이 없다.
Write-Through
데이터를 작성할 때 항상 캐시에 먼저 작성하고, 원본에도 작성하는 전략이다.
- 캐시의 데이터 상태는 항상 최신 데이터임이 보장된다.
- 자주 사용하지 않는 데이터도 캐시에 중복해서 작성하기 때문에, 시간이 오래 걸린다.
Write-Behind
캐시에만 데이터를 작성하고, 일정 주기로 원본을 갱신하는 방식이다.
(JPA의 영속성 컨텍스트가 이런 기능을 하지 않나?)
- 쓰기가 잦은 상황에 데이터베이스의 부하를 줄일 수 있다.
- 캐시의 데이터가 원본에 적용되기 전 문제가 발생하면 데이터 소실의 위험성이 존재한다.
Spring Boot 프로젝트에 캐싱 적용
CacheConfig를 만들고 @EnableCaching
과@Configuration
를 추가한다.
@Configuration
@EnableCaching
public class CacheConfig {
}
@EnableCaching
을 사용하려면 캐시를 관리하는 CacheManger
의 구현체가 Bean으로 등록되어야 한다.
cacheManager를 추가한다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(
RedisConnectionFactory redisConnectionFactory
) {
// 설정 구성
// Redis를 이용해서 Spring Cache를 사용할 때
// Redis 관련 설정을 모아두는 클래스
RedisCacheConfiguration configuration = RedisCacheConfiguration
.defaultCacheConfig()
.disableCachingNullValues() // null을 캐싱하지 않는다.
.entryTtl(Duration.ofSeconds(10)) // 기본 캐시 유지 시간 (10초 유지)
// 10초 이후에 들어오는 요청에 대해서는 캐시에서 쓰는게 아니라 새로 데이터를 사용
// TTL (Time to Live)
.computePrefixWith(CacheKeyPrefix.simple()) // 캐시를 구분하는 접두사 설정, 레디스의 키
// 캐시에 저장할 값을 어떻게 직렬화/역직렬화 할것인지
.serializeValuesWith(
RedisSerializationContext.SerializationPair.fromSerializer(RedisSerializer.java())
);
return RedisCacheManager
.builder(redisConnectionFactory)
.cacheDefaults(configuration)
.build();
}
}
@EnableCaching
을 적용했다면, 이제 어노테이션을 바탕으로 메서드의 결과를 캐싱할 수 있다. 대표적인 어노테이션으로 @Cacheable
, @CachePut
, @CacheEvict
가 있다.
@Cacheable == Cache Aside
readOne()
메서드에 @Cacheable을 추가
// cacheNames: 메서드로 인해서 만들어질 캐시를 지칭하는 이름
// key: 캐시에서 데이터를 구분하기 위해 키 값
@Cacheable(cacheNames = "itemCache", key = "args[0]")
public ItemDto readOne(Long id) {
log.info("Read One: {}", id);
return repository.findById(id)
.map(ItemDto::fromEntity)
.orElseThrow(()
-> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
cacheNames는 스프링에서 구분하기 위해 붙여주는 이름이고, key는 레디스, 혹은 또 다른 캐싱을 위한 DB에서 구분하기 위해 붙여주는 이름이다.
key = “args[0]” 으로 인해 메서드의 첫 번째 인자 Long id
값이 key로 들어가게 된다.
args[1]이면 두 번째 인자가 들어가게 될 것이다.
캐시에 데이터가 존재하지 않고, 처음 데이터를 read 했을 때 시간: 493ms
Redis에 데이터가 저장된걸 확인할 수 있다.
캐시에 데이터가 존재하고, 해당 데이터를 read 했을 때 시간: 13ms
그래도 꽤 차이나는 것을 확인할 수 있다.
실제로도 쿼리가 캐시에 없을 때만 날라간다. 캐시에 있다면 쿼리가 날라가지 않는다.
Cache Aside 전략이 DB를 보지 않고, 캐시에서만 확인하기 때문이다.
@Cacheable
이 포함되게 되면 CacheConfig
에서 설정한데로 캐싱 어노테이션이 동작하며,
전달된 인자가 동일한 호출에 대하여 캐시에서 데이터를 돌려주는 Cache Aside 방식의 캐싱이 된다.
즉, 처음에는 메서드를 실행해서 결과를 가져오지만, 해당 반환값을 캐시에 저장한 뒤 캐시가 삭제되기 전까지는 메서드를 실제로 실행하지 않고 캐시에서 데이터를 반환하는 것이다.
그래서 만약 내부에 로그를 작성해 둔다면, 처음 한번만 로그가 작성되며 그 이후로는 작성되지 않는다.
readAll()
메서드에 @Cacheable 추가
@Cacheable(cacheNames = "itemAllCache", key = "methodName")
public List<ItemDto> readAll() {
return repository.findAll()
.stream()
.map(ItemDto::fromEntity)
.toList();
}
이렇게 설정하게 된다면 key 이름이 메서드 이름인 readAll이 된다.
처음 readAll() 메서드 실행: 476ms
캐시에 존재할 때 readAll() 메서드 실행: 28ms
레디스에 존재하는 값
@CachePut == Write Through
create()
메서드에 @CachePut을 추가
@CachePut(cacheNames = "itemCache", key = "#result.id")
public ItemDto create(ItemDto dto) {
return ItemDto.fromEntity(itemRepository.save(Item.builder()
.name(dto.getName())
.description(dto.getDescription())
.price(dto.getPrice())
.stock(dto.getStock())
.build()
));
}
@Cacheable
은 데이터를 캐시에서 발견할 경우(Hit), 메서드 자체를 실행하지 않는다.
반면, @CachePut
은 항상 메서드를 실행하고, 결과를 캐싱한다.
즉, 지금처럼 생성 또는 수정에 대해서 적용하면 Write Through 전략처럼 동작한다.
create()
의 경우 key
를 #result.id
로 설정했다.
이는 반환되는 ItemDto.id
를 활용한다는 의미인데, 이렇게 만들어진 데이터가 캐시에 저장되기 때문에 readOne
메서드도 그 결과를 활용할 수 있다.
이는 Redis에 저장하는 데이터의 Key가 cacheName::key
의 형태로 저장되기 때문에, readOne
이 찾는 cacheName::key
도 동일한 Key를 찾아내서, Redis의 데이터를 활용할 수 있다.
즉 create 후 readOne 시 더 빠르게 결과를 받을 수 있다.
@CacheEvict
update()
메서드에 @CachePut과 @CacheEvict를 추가
@CachePut(cacheNames = "itemCache", key = "args[0]")
@CacheEvict(cacheNames = "itemAllCache", allEntries = true)
public ItemDto update(Long id, ItemDto dto) {
Item item = itemRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
item.setName(dto.getName());
item.setDescription(dto.getDescription());
item.setPrice(dto.getPrice());
item.setStock(dto.getStock());
return ItemDto.fromEntity(itemRepository.save(item));
}
@CacheEvict
는 주어진 정보를 바탕으로 저장된 캐시를 지워준다.
아이템의 정보가 바뀌었으니, 데이터를 전부 돌려준 결과가 더이상 유효하지 않아서(DB와 캐시의 내용이 일치하지 않음)
모든 캐시를 지운다.
itemCache에 새로운 데이터가 쓰여지고, DB에도 쓰여졌으니 기존의 itemAllCache의 내용과 일치하지 않게 된다.
그래서 CacheEvict를 통해 itemAllCache를 지운다.
Delete 메소드 (@Caching)
이렇게 CRU 캐싱을 완료했다. 그렇다면 delete()
는 어떻게 구성해야 할까?
delete하게 되면 DB에서 데이터가 지워진다. 그럼 캐시에서도 지워야 한다.
상황에 따라 다를 테지만,
내용이 바뀌었으니 itemAllCache를 지우고, itemCache에 존재하는 지워진 데이터도 지운다.
캐시의 효율성을 높이려면 ItemAllCache의 특정 데이터만 지우는 방안을 연구해봐야 할 것 같다.
우선 다음과 같이 구성했다.
// #id는 메서드의 인자 이름을 참조하는 방식
@Caching(evict = {
@CacheEvict(cacheNames = "itemAllCache", allEntries = true),
@CacheEvict(cacheNames = "itemCache", key= "#id")
})
public void delete(Long id) {
itemRepository.deleteById(id);
}
@Caching
은 Spring Framework의 캐싱 기능에서 사용되는 메타 어노테이션으로, 주로 한 메서드에 여러 개의 캐시 작업을 적용할 때 사용된다.
즉, 한 메서드에 여러 개의 @Cacheable
, @CachePut
, @CacheEvict
를 동시에 적용할 수 있도록 해준다.
이를 통해 효율적으로 캐시를 관리할 수 있다.
readOne(3, 4, 5), readAll() 수행
delete(3) 수행
이제 Write Back 전략을 구현해보는 일만 남았다.
'데이터베이스 > Redis' 카테고리의 다른 글
Redis 응용 - 검색 결과 캐싱 (0) | 2024.08.16 |
---|---|
Spring Boot와 Redis로 Session Clustering 간단 구현 (0) | 2024.08.14 |
Redis 기초 및 Spring Boot 연동 (0) | 2024.08.06 |