Redis 캐싱 전략 완벽 가이드: Look Aside vs Write Back 및 Spring Boot @Cacheable 실무 적용
1. 서론
백엔드 시스템의 성능을 최적화하는 과정에서 가장 가성비가 좋고 즉각적인 효과를 볼 수 있는 기술은 단연 ‘캐싱(Caching)‘입니다. 관계형 데이터베이스(RDBMS)는 디스크 I/O를 기반으로 동작하기 때문에 물리적인 속도의 한계가 명확하며, 트래픽이 몰릴 경우 병목 현상의 주범이 됩니다. 반면 Redis와 같은 인메모리(In-Memory) 저장소는 RAM에 데이터를 저장하고 처리하므로, 디스크보다 수백 배 빠른 접근 속도를 제공합니다.
하지만 “Redis를 도입했다”는 사실만으로 모든 성능 문제가 해결되는 것은 아닙니다. 캐시를 데이터베이스 앞에 배치할지, 뒤에 배치할지, 데이터를 언제 쓰고 언제 지울지에 대한 ‘캐싱 전략(Caching Strategy)‘이 부재하다면, 오히려 데이터 정합성이 깨지거나(Data Inconsistency), 예기치 못한 시점에 DB 부하가 급증하여 시스템이 셧다운 되는 참사를 맞이할 수 있습니다. 오늘은 가장 널리 사용되는 두 가지 핵심 전략인 Look Aside 패턴과 Write Back 패턴을 비교 분석하고, Spring Boot 환경에서 @Cacheable을 사용하여 이를 우아하게 구현하는 실무 가이드를 제공하겠습니다.
2. 본론
1: 읽기 주도 전략 – Look Aside 패턴 (Lazy Loading)
Look Aside 패턴은 ‘Lazy Loading’이라고도 불리며, 웹 서비스 환경에서 가장 범용적으로 사용되는 표준적인 캐싱 전략입니다. 이름에서 알 수 있듯이 애플리케이션이 데이터를 찾을 때 캐시를 먼저 ‘살짝 쳐다보고(Look Aside)’, 없으면 DB로 가는 방식입니다.
1. 동작 메커니즘
- 애플리케이션(웹 서버)은 데이터를 조회할 때 먼저 Redis 캐시 저장소를 확인합니다.
- Cache Hit: 캐시에 데이터가 있다면, DB를 거치지 않고 곧바로 데이터를 반환합니다. 가장 이상적인 시나리오입니다.
- Cache Miss: 캐시에 데이터가 없다면, 애플리케이션은 DB에 직접 쿼리를 날려 데이터를 가져옵니다.
- Update Cache: DB에서 가져온 데이터를 나중을 위해 Redis에 저장한 후, 클라이언트에게 반환합니다.
2. 장점과 아키텍처적 특징
이 패턴의 가장 큰 장점은 ‘안정성(Resilience)‘입니다. Redis가 다운되더라도 애플리케이션은 멈추지 않습니다. 단지 모든 요청이 DB로 직접 전달되어 응답 속도가 느려질 뿐, 서비스 자체는 계속 유지될 수 있습니다. 또한, 실제로 사용자가 요청한 데이터만 캐시에 저장되므로 메모리 효율성이 높습니다. 불필요한 전체 데이터를 캐싱하느라 비싼 메모리 비용을 낭비할 필요가 없습니다.
3. 단점 및 주의사항
반면, 캐시가 비어있는 초기 단계나 캐시가 만료된 직후에는 모든 요청이 DB로 몰리게 되어 초기 응답 속도가 느릴 수 있습니다. 이를 해결하기 위해 서비스 배포 전 주요 데이터를 미리 캐시에 올려두는 ‘캐시 워밍(Cache Warming)’ 작업을 수행하기도 합니다. 또한, DB의 데이터가 수정되었을 때 캐시 데이터를 갱신해주지 않으면 사용자는 계속해서 과거의 데이터를 보게 되는 데이터 불일치 문제가 발생하므로, 쓰기 작업 시 반드시 캐시를 무효화(Evict)하거나 갱신하는 로직이 동반되어야 합니다.
2: 쓰기 주도 전략 – Write Back 패턴 (Write Behind)
Write Back 패턴은 쓰기 작업이 매우 빈번한 시스템(예: 로그 수집, 대규모 조회수 카운팅)에 적합한 전략입니다. 데이터를 DB에 바로 저장하는 것이 아니라 캐시에 먼저 저장하고, 특정 시점에 모아서 DB에 반영하는 방식입니다.
1. 동작 메커니즘
- 애플리케이션은 모든 데이터를 Redis 캐시에 먼저 저장합니다. 이때 DB에는 저장하지 않습니다.
- 캐시에 데이터가 저장되면 클라이언트에게는 바로 성공 응답을 보냅니다. (매우 빠른 쓰기 속도)
- 별도의 배치(Batch) 작업이나 스케줄러가 주기적으로 캐시에 쌓인 데이터를 읽어서 DB에 한꺼번에 저장(Bulk Insert)합니다.
2. 장점과 아키텍처적 특징
이 패턴의 핵심은 ‘쓰기 쿼리의 회수를 획기적으로 줄이는 것’입니다. 예를 들어, 인기 게시글의 조회수가 1초에 1,000번 증가한다고 가정해 봅시다. 일반적인 방식이라면 DB에 UPDATE 쿼리가 1,000번 날아갑니다. 하지만 Write Back 패턴을 사용하면 Redis에서 카운트만 1,000번 증가시키고, DB에는 1분 뒤에 최종 합계인 +1000을 업데이트하는 쿼리 1번만 날리면 됩니다. DB 부하를 극적으로 줄일 수 있는 강력한 방법입니다.
3. 단점 및 주의사항
하지만 ‘데이터 유실 위험‘이라는 치명적인 단점이 있습니다. 데이터가 아직 DB에 저장되기 전, 오직 Redis 메모리에만 존재하는 상태에서 서버가 다운되거나 재시작된다면 그동안 쌓인 데이터는 영원히 사라지게 됩니다. 따라서 로그 데이터나 조회수처럼 약간의 오차가 허용되는 데이터에는 적합하지만, 결제 정보나 주문 내역처럼 정합성이 생명인 데이터에는 절대 사용해서는 안 되는 전략입니다.
3: Spring Boot와 @Cacheable을 이용한 실무 구현
대부분의 조회 위주 웹 서비스에서는 Look Aside 패턴을 채택합니다. Spring Boot는 Spring Cache Abstraction을 통해 복잡한 캐시 로직을 어노테이션 몇 개로 추상화하여 제공합니다.
1. Redis 설정 (RedisCacheManager)
단순히 의존성만 추가하면 자바의 직렬화(Serialization)를 사용하게 되는데, 이는 Redis에서 데이터를 눈으로 확인하기 어렵고 클래스 변경 시 호환성 문제가 생깁니다. 따라서 JSON 포맷으로 직렬화하도록 설정을 커스텀해야 합니다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// Key는 String, Value는 JSON으로 직렬화 설정
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // 기본 TTL 10분 설정
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}
}
2. @Cacheable과 @CacheEvict 적용
서비스 계층의 메서드에 어노테이션을 붙여 Look Aside 패턴을 구현합니다.
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
// [조회] Look Aside 패턴 자동 적용
// key: boards::게시글ID 형태로 저장됨
// 데이터가 캐시에 있으면 메서드 내부 로직을 실행하지 않고 캐시 값 반환
@Cacheable(value = "boards", key = "#boardId", unless = "#result == null")
public BoardResponseDto getBoard(Long boardId) {
Board board = boardRepository.findById(boardId)
.orElseThrow(() -> new NotFoundException("게시글이 없습니다."));
return new BoardResponseDto(board);
}
// [수정] 데이터 정합성을 위해 캐시 제거
// 게시글이 수정되면, 기존에 캐싱된 데이터는 낡은 데이터(Stale Data)가 되므로 삭제해야 함
@CacheEvict(value = "boards", key = "#boardId")
@Transactional
public void updateBoard(Long boardId, BoardUpdateRequestDto dto) {
Board board = boardRepository.findById(boardId).orElseThrow();
board.update(dto);
// 메서드 종료 후 Redis에서 해당 키 삭제 -> 다음 조회 시 DB에서 새로 로딩
}
}
4: 실무에서 놓치기 쉬운 문제, 캐시 스탬피드(Cache Stampede)
캐시를 운영하다 보면 ‘캐시 스탬피드(Thundering Herd)‘라는 무서운 현상을 마주할 수 있습니다. 이는 TTL(만료 시간) 설정과 관련이 깊습니다.
만약 수많은 사용자가 동시에 접근하는 ‘인기 검색어’ 데이터의 캐시 만료 시간이 정확히 ‘오후 1시 00분 00초’라고 가정해 봅시다. 1시 00분 00초가 되는 순간, 캐시는 삭제되고 수천 개의 요청이 동시에 Redis를 뚫고 DB로 쇄도하게 됩니다. DB는 이 순간적인 부하를 견디지 못하고 CPU 사용량이 100%를 치솟으며 장애가 발생할 수 있습니다.
해결 전략:
- Jitter(지터) 적용: 모든 캐시의 만료 시간을 동일하게 설정하지 않고, 랜덤한 시간(예: 10분 ± 30초)을 추가하여 만료 시점을 분산시킵니다.
- Mutex Lock 적용: 캐시가 만료되었음을 감지했을 때, 모든 요청을 DB로 보내지 않고 분산 락을 획득한 단 하나의 요청만 DB에 접근하도록 제한합니다. 나머지 요청은 잠시 대기하다가 락을 획득한 요청이 캐시를 갱신하면(Write Back), 갱신된 캐시 데이터를 읽어가도록 합니다. 앞서 다룬 Redisson을 활용하면 이를 효과적으로 구현할 수 있습니다.
3. 결론
지금까지 Redis를 활용한 두 가지 핵심 캐싱 전략인 Look Aside와 Write Back의 차이점, 그리고 Spring Boot에서의 실무 구현 방법까지 깊이 있게 살펴보았습니다.
요약하자면, 일반적인 웹 서비스의 읽기 성능 최적화를 위해서는 Look Aside 패턴을 기본으로 사용하되, @CacheEvict를 통해 데이터 변경 시 캐시를 적절히 날려주어 정합성을 유지해야 합니다. 반면 로그 수집이나 조회수 카운팅과 같이 쓰기 부하가 극심한 경우에 한하여 Write Back 패턴을 제한적으로 사용하는 것이 좋습니다.
캐시는 ‘약’이지만 잘못 쓰면 ‘독’이 됩니다. 특히 데이터 생명주기(TTL)를 설정하지 않아 Redis 메모리가 가득 차서 서버가 뻗거나, 잘못된 키 설계로 엉뚱한 데이터를 반환하는 실수가 빈번하게 발생합니다. 오늘 다룬 전략과 주의사항들을 바탕으로, 여러분의 서비스 특성에 맞는 최적의 캐싱 아키텍처를 설계해 보시기 바랍니다. 올바른 캐싱 전략 하나가 DB 서버 10대를 증설하는 것보다 더 큰 효과를 낼 수 있습니다.