캐싱 전략은 애플리케이션에서 성능 최적화와 데이터 접근 속도를 높이는 핵심 기법으로 특히 스프링 애플리케이션에서 레디스를 활용할 경우, RedisTemplate을 통해 다양한 캐싱 패턴을 구현할 수 있습니다. 여기서는 대표적인 다섯 가지 전략을 살펴보고, 각 상황을 설명하는 간단한 코드 예시를 함께 정리해 보겠습니다.
캐싱 전략 정리
Cache-Aside
Cache-Aside 패턴은 가장 보편적으로 사용되는 방식으로 애플리케이션이 먼저 DB가 아닌 캐시를 조회하고, 캐시에 없을 경우 DB에서 읽은 후 캐시에 적재합니다. 단순하고 효율적이라는 장점이 있지만, 최초 캐시 미스 시 요청자는 지연을 경험할 수 있습니다.
public String getUserName(Long userId) {
String key = "user:" + userId;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
String fromDb = userRepository.findById(userId)
.map(User::getName)
.orElseThrow();
redisTemplate.opsForValue().set(key, fromDb, Duration.ofMinutes(10));
return fromDb;
} 캐싱 조회 후, 없을 경우에만 리포지토리에서 데이터를 가져온 후, 다시 적재
Write-Through
Write-Through 패턴은 쓰기 요청이 들어올 때 DB와 캐시에 동시에 기록하는 방식입니다. 항상 최신 데이터가 캐시에 존재한다는 점에서 안정적이지만, 쓰기 성능이 저하될 수 있고 자주 읽히지 않는 데이터도 불필요하게 캐시에 쌓일 수 있습니다.
@Transactional
public void updateUserName(Long userId, String newName) {
userRepository.updateName(userId, newName);
String key = "user:" + userId;
redisTemplate.opsForValue().set(key, newName);
} // 쓰기 요청 시, 리포지토리와 함께 캐싱에도 동시에 적재
Write-Behind
Write-Behind 패턴은 캐시에 먼저 쓰고, 이후 비동기적으로 DB에 반영하는 전략입니다. 쓰기 성능이 빠르다는 장점이 있지만, 캐시 장애 시 DB와 데이터 불일치가 발생할 수 있어 동기화 로직이 필요합니다.
public void saveUserName(Long userId, String newName) {
String key = "user:" + userId;
redisTemplate.opsForValue().set(key, newName);
taskExecutor.submit(() -> userRepository.updateName(userId, newName));
} // 캐시에 먼저 쓴 후, 비동기적으로 DB에 반영
Read-Through
Read-Through 패턴은 애플리케이션이 캐시를 직접 다루지 않고, 캐시가 DB 조회와 갱신을 담당하는 구조입니다. 개발자는 캐시 관리 코드를 줄일 수 있지만, 구현체에 종속되는 단점이 있습니다. 스프링 애플리케이션에서는 일반적으로 캐시 어노테이션과 캐시 매니저를 활용해 이와 유사한 효과를 얻습니다.
@Cacheable(value = "userCache", key = "#userId")
public String getUserName(Long userId) {
return userRepository.findById(userId)
.map(User::getName)
.orElseThrow();
}
@Cacheable이 붙은 메서드가 호출되면 스프링은 먼저 캐시(userCache)에 해당 키가 존재하는지 확인합니다. 여기서 key = "#userId"라는 표현은 메서드의 파라미터 값인 userId를 그대로 캐시 키로 사용한다는 의미입니다. 예를 들어, userId가 5라면 캐시 키는 "5"가 됩니다.
만약 캐시에 값이 이미 존재한다면, DB를 거치지 않고 캐시된 데이터를 바로 반환합니다. 반대로 캐시에 값이 없다면 실제 메서드 로직이 실행되며, 이 과정에서 userRepository.findById(userId)... 코드가 DB를 조회하게 됩니다. 조회된 결과는 캐시에 저장된 뒤 호출자에게 반환됩니다.
Refresh-Ahead
Refresh-Ahead 패턴은 캐시 데이터가 만료되기 전에 미리 갱신해 두는 방식입니다. 인기 데이터에 유리하며 캐시 미스로 인한 지연을 줄일 수 있지만, 미리 갱신된 데이터가 실제로 사용되지 않을 경우 낭비가 발생할 수 있습니다.구현은 스케줄러를 활용하여 만료 직전 데이터를 선제적으로 갱신합니다.
@Scheduled(fixedRate = 60000)
public void refreshPopularUsers() {
List<Long> popularIds = analyticsService.getPopularUserIds();
for (Long id : popularIds) {
String fromDb = userRepository.findById(id)
.map(User::getName)
.orElse(null);
if (fromDb != null) {
redisTemplate.opsForValue().set("user:" + id, fromDb, Duration.ofMinutes(10));
}
}
}
캐싱 전략 시 발생하는 대표적 문제와 대응
캐시 페네트레이션 (Cache Penetration)
캐시 페네트레이션은 애초에 존재하지 않는 데이터에 대한 요청이 반복적으로 들어와 캐시가 전혀 활용되지 못하는 상황을 말합니다. 예를 들어, 존재하지 않는 사용자 ID(-1, 9999999 등)를 계속 조회하면 캐시에 없으니 매번 DB를 타게 되고, 결국 DB에 불필요한 부하가 쌓입니다.
이를 막기 위해 잘못된 요청을 애플리케이션 레벨에서 차단하거나, Bloom Filter로 미리 데이터 존재 여부를 판단할 수 있습니다. 또한 DB에 없다는 결과도 짧게 캐싱하여 동일한 잘못된 요청이 반복되지 않도록 하는 방법도 있습니다.
애플리케이션에서 조건 체크 및 널로 캐싱하는 방법
public String getUserName(Long userId) {
// 잘못된 ID 요청 차단 (캐시 페네트레이션 방지 1단계)
if (userId <= 0) throw new IllegalArgumentException("Invalid userId");
String key = "user:" + userId;
// 1. 캐시 조회
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
// DB에 없는 값도 "NULL"로 캐싱했으므로 구분해서 반환
return cached.equals("NULL") ? null : cached;
}
// 2. 캐시에 없으면 DB 조회
String fromDb = userRepository.findById(userId)
.map(User::getName)
.orElse(null);
// 3. 조회 결과를 캐시에 저장 (DB에 없으면 "NULL" 캐싱, TTL 짧게 설정)
redisTemplate.opsForValue().set(key,
fromDb == null ? "NULL" : fromDb,
Duration.ofMinutes(1));
return fromDb;
}
캐시 어밸랜치(Cache Avalanche)
캐시 어밸랜치는 대규모 캐시 데이터가 한꺼번에 만료되면서 모든 요청이 DB로 몰리는 현상을 말합니다. 예를 들어, 자정에 모든 캐시 TTL이 동시에 끝나도록 설정했다면 순간적으로 DB에 폭주가 발생할 수 있습니다. 이를 막기 위해 캐시 만료 시간을 랜덤하게 분산시키거나, 인기 데이터에 대해 이중 캐시나 미리 갱신을 적용합니다.
public void cacheUser(Long userId, String name) {
int randomSeconds = ThreadLocalRandom.current().nextInt(30, 300);
redisTemplate.opsForValue().set("user:" + userId, name, Duration.ofMinutes(10).plusSeconds(randomSeconds));
}
핫 키 문제(Hot Key Problem)
핫 키 문제는 특정 키에 요청이 몰리면서 캐시 서버 한쪽 노드가 과부하되는 현상입니다. 대표적으로 "오늘의 인기글" 같은 데이터가 수십만 명에게 동시에 요청될 때 발생합니다. 이를 완화하기 위해 여러 노드에 데이터를 분산 저장하거나, 로컬 캐시와 조합해 트래픽을 흡수할 수 있습니다. 또한 동시에 같은 키에 대한 요청이 몰릴 경우 DB를 한 번만 조회하고 결과를 여러 요청자에게 브로드캐스트하는 방식도 활용됩니다.
@Cacheable(value = "hotPostCache", key = "'todayHot'")
public PostSummary getTodayHotPost() {
return postRepository.findTodayHot(); // DB는 한 번만 호출됨
}'CS' 카테고리의 다른 글
| 스프링 시큐리티 - 필터의 종류 (1) (0) | 2025.07.03 |
|---|---|
| 스프링 시큐리티 - 필터 정보 공유 및 상속과 요청 전파 (2) | 2025.07.02 |
| 스프링 시큐리티 - 전체적인 흐름 (0) | 2025.06.28 |
| Redis가 싱글 스레드로 만들어진 이유 (2) | 2025.06.05 |
| MySQL InnoDB에서 갭락과 넥스트키 락이란 무엇이며, 어떻게 팬텀 리드를 방지하나요? (0) | 2025.05.29 |