
Spring Cache와 Redis를 결합하여 캐싱을 적용해본 실습을 정리
캐싱은 반복되는 데이터 조회에서 DB 부하를 줄이고 응답 속도를 높이는 핵심 기술임.
Cache 전략 요약
| 전략 | 설명 | 비고 |
|---|---|---|
| Cache-Aside | 데이터를 찾을 때 캐시를 먼저 확인하고 없으면 DB 조회 | 가장 일반적임 최초 요청자는 캐싱 되어 있지 않은 정보를 받기 때문에 오래걸림 |
| Write-Through | DB에 데이터를 쓸 때 캐시에도 동시에 업데이트 | 캐시의 데이터 상태는 항상 최신 데이터임이 보장 |
| Write-Behind | DB 쓰기 전 캐시에 먼저 저장 후, 나중에 배치로 DB 반영 | 쓰기 성능 극대화 (실습 미진행), 데이터 유실 가능성 있음 |
Write-Behind(Write-Back)
이번에 직접 구현하진 않았으나, 쓰기 작업이 매우 빈번할 때 사용하는 방식. 모든 데이터를 캐시에 먼저 저장한 뒤 일정 주기에 따라 DB에 한꺼번에 반영하여 DB 부하를 줄이는 전략.
용어정리
- 캐시 적중(Cache Hit): 캐시에 접근했을 때 찾고 있는 데이터가 있는 경우
- 캐시 누락(Cache Miss): 캐시에 접근했을 때 찾고 있는 데이터가 없는 경우를
- 삭제 정책(Eviction Policy): 캐시에 공간이 부족할때 어떻게 공간을 확보하는지에 대한 정책
@EnableCaching가장 먼저 캐싱 기능을 활성화하기 위해 설정 클래스를 작성함. Redis를 저장소로 사용하기 위해 RedisCacheManager를 빈으로 등록함.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues() // null 값은 캐싱하지 않음
.entryTtl(Duration.ofSeconds(10)) // TTL(Time To Live) 10초 설정
.computePrefixWith(CacheKeyPrefix.simple())
.serializeValuesWith(SerializationPair.fromSerializer(RedisSerializer.java()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
실습을 통해 Cache-Aside(Look Aside)와 Write-Through 전략을 직접 적용해 봄.
@Cacheable: 조회 성능 최적화 (Cache-Aside)데이터가 캐시에 있으면 바로 반환하고, 없으면 DB에서 조회 후 캐시에 저장하는 방식임.
readOne), 전체 목록 조회(readAll)// 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));
}
@CachePut: 데이터 동기화 (Write-Through)메서드를 항상 실행하여 DB에 데이터를 저장하고, 그 결과를 캐시에도 즉시 업데이트함.
create), 수정(update)@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()
));
}
@CacheEvict: 캐시 데이터 삭제데이터가 변경되거나 삭제될 때 기존의 낡은 캐시를 제거함.
내용이 수정되었지만 readAll은 수정되 전 내용이 캐싱되어 있어서 다음 요청자는 수정된 정보를 알 수 없음.
이템의 정보가 바뀌었으니, 데이터를 전부 돌려준 결과가 더이상 유효하지 않기 때문에 캐싱을 삭제 해야함.
@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));
}
단순 ID 조회가 아닌, 검색어(query)와 페이징 정보(pageNumber, pageSize)를 조합하여 복합적인 키로 캐싱을 구현함.
@SpringBootApplication
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
public class RedisApplication {
// ...
}
pageSerializationMode = VIA_DTO 설정은 Page 인터페이스의 구현체를 JSON 등으로 변환할 때 발생하는 복잡한 구조 문제를 해결해 줌.@Cacheable(
cacheNames = "itemSearchCache",
key = "{ args[0], args[1].pageNumber, args[1].pageSize }"
)
public Page<ItemDto> searchByName(String query, Pageable pageable) { ... }
Spring Boot에서 제공하는 어노테이션만으로도 복잡한 캐싱 로직을 비즈니스 로직과 분리하여 깔끔하게 처리할 수 있었음.
다만 캐싱성격이 데이터가 있을 경우 빠르지만, 캐싱된 데이터가 없을경우(캐싱미스) 조금의 지연 발생하는 것을 알았다.
서비스의 성격에 따라 적절한 TTL과 Evict 정책을 세우고 캐싱 히트, 캐싱 미스를 잘 확인해서 캐싱 전략을 세워야 겠다.