@Service@RequiredArgsConstructorpublicclassProductService{privatefinalProductRepositoryproductRepository;privatefinalRedisTemplate<String,Product>redisTemplate;privatestaticfinalStringKEY_PREFIX="product:";privatestaticfinalDurationTTL=Duration.ofMinutes(30);publicProductfindById(Longid){Stringkey=KEY_PREFIX+id;// 1. 캐시 조회Productcached=redisTemplate.opsForValue().get(key);if(cached!=null){returncached;// Cache Hit}// 2. Cache Miss → DB 조회Productproduct=productRepository.findById(id).orElseThrow(()->newNoSuchElementException("상품이 없습니다."));// 3. 캐시에 저장redisTemplate.opsForValue().set(key,product,TTL);returnproduct;}@Transactionalpublicvoidupdate(Longid,ProductUpdateRequestrequest){Productproduct=productRepository.findById(id).orElseThrow(()->newNoSuchElementException("상품이 없습니다."));product.update(request);// DB 업데이트 후 캐시 무효화redisTemplate.delete(KEY_PREFIX+id);}}
장단점
장점
단점
구현이 단순하고 직관적
첫 요청은 항상 Cache Miss (Cold Start)
실제로 요청된 데이터만 캐싱
캐시 만료 전까지 stale 데이터 가능
캐시 장애 시에도 DB에서 조회 가능
애플리케이션에 캐시 로직이 침투
2. Write-Through
데이터를 쓸 때 캐시와 DB에 동시에 쓰는 패턴이다.
동작 흐름
1
2
3
4
5
6
7
쓰기:
1. 캐시에 데이터 저장
2. 캐시가 동기적으로 DB에도 저장
→ 두 저장소가 항상 동기화됨
읽기:
1. 항상 캐시에서 읽기 (캐시에 최신 데이터 보장)
의사 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
publicvoidsaveProduct(Productproduct){// 캐시와 DB에 동시 저장redisTemplate.opsForValue().set(KEY_PREFIX+product.getId(),product,TTL);productRepository.save(product);}publicProductfindById(Longid){// 캐시에서 바로 조회 (항상 최신)Productcached=redisTemplate.opsForValue().get(KEY_PREFIX+id);if(cached!=null){returncached;}// Fallback (캐시 장애 등)returnproductRepository.findById(id).orElseThrow();}
장단점
장점
단점
캐시 데이터 일관성 보장
쓰기 지연 증가 (캐시 + DB 모두 기다림)
읽기 시 항상 Cache Hit
사용되지 않는 데이터까지 캐싱 (메모리 낭비)
3. Write-Behind (Write-Back)
데이터를 캐시에만 먼저 쓰고, DB 반영은 비동기로 나중에 하는 패턴이다.
동작 흐름
1
2
3
4
5
6
쓰기:
1. 캐시에 데이터 저장 (즉시 반환)
2. 백그라운드에서 일정 주기/조건에 따라 DB에 반영
읽기:
1. 캐시에서 읽기
개념 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 쓰기 — 캐시에만 저장하고 큐에 등록publicvoidsaveProduct(Productproduct){redisTemplate.opsForValue().set(KEY_PREFIX+product.getId(),product);writeQueue.add(product);// 비동기 처리 큐}// 별도 스레드/스케줄러에서 주기적으로 DB 반영@Scheduled(fixedDelay=5000)publicvoidflushToDatabase(){List<Product>batch=writeQueue.drain();if(!batch.isEmpty()){productRepository.saveAll(batch);}}
장단점
장점
단점
쓰기 속도가 매우 빠름
캐시 장애 시 데이터 유실 위험
DB 부하 분산 (배치 처리)
구현 복잡도 높음
짧은 시간에 같은 키를 여러 번 업데이트하면 마지막 값만 DB에 반영 (write 최적화)
데이터 일관성 보장 어려움
어떤 전략을 언제 쓸까?
시나리오
추천 전략
이유
일반적인 읽기 중심 API
Cache-Aside
구현 간단, 필요한 데이터만 캐싱
데이터 정합성이 중요한 서비스
Write-Through
캐시 = DB 동기화 보장
쓰기가 매우 빈번한 서비스 (조회수, 좋아요)
Write-Behind
쓰기 성능 극대화, 배치 처리
세션 스토어
Write-Through
세션 유실 방지
랭킹/리더보드
Write-Behind
실시간 반영은 캐시, DB는 주기적 동기화
실무에서 흔히 하는 실수
TTL 설정 누락 — 캐시가 영원히 남아 메모리가 부족해진다
Cache Stampede — 인기 키의 TTL이 동시에 만료되어 DB에 요청이 몰린다 → TTL에 랜덤 값을 추가하자 (자세한 해결 전략은 Redis 캐싱 전략 참고)
캐시 무효화 순서 실수 — 캐시 삭제 후 DB 업데이트 vs DB 업데이트 후 캐시 삭제. 후자가 안전하다
직렬화 비용 무시 — 큰 객체를 매번 JSON 직렬화/역직렬화하면 오히려 느려질 수 있다
정리
캐싱은 시스템 성능을 크게 향상시킬 수 있지만, 일관성과 복잡도 사이의 트레이드오프가 항상 존재한다. 서비스의 읽기/쓰기 비율, 데이터 정합성 요구 수준, 장애 허용 범위를 고려해서 적절한 전략을 선택하자. 대부분의 경우 Cache-Aside로 시작하고, 필요에 따라 Write-Through나 Write-Behind를 부분적으로 도입하는 것이 현실적인 접근이다.