Spring Boot에서 @Cacheable
어노테이션 하나면 캐시 설정이 끝날 줄 알았습니다.
하지만 실제 Redis를 사용해 캐시를 적용하면서 다양한 전략이 존재하고, 그에 따른 고려사항이 많다는 것을 깨달았습니다.
이 글에서는 일반적인 캐시 전략 소개는 다루지 않고,
제가 상품 조회 및 레시피 조회 API에 Redis 캐시를 적용한 과정과
그 과정에서 발생한 문제점 및 해결 방안을 중심으로 정리하고자 합니다.
상품 및 레시피 조회 API에 캐시를 적용할 때, 다음과 같은 이유로 Look Aside + Write Around 전략을 선택했습니다.
상품/레시피 데이터의 변경 빈도가 낮음
→ 자주 변경되지 않는 데이터는 조회 성능을 높이는 데 캐시가 효과적입니다.
쓰기 요청 시 캐시 일관성 유지 비용 최소화
→ 캐시에 바로 쓰지 않고 DB에만 쓰는 Write Around 전략을 통해 불필요한 캐시 갱신을 줄였습니다.
캐시 전략 적용 이후 다음과 같은 문제에 직면했습니다:
DB에 있는 데이터가 수정/삭제되었음에도, Redis에 남아있는 캐시가 그대로 사용되는 문제가 발생했습니다.
→ 조회 API에서는 오래된 정보가 노출되는 문제로 이어졌습니다.
TTL이 동시에 만료된 캐시 키에 다수의 요청이 한 번에 들어오면서,
→ 모든 요청이 동시에 DB를 조회하게 되는 Cache Stampede 현상이 발생할 수 있었습니다.
→ 이는 특히 트래픽이 집중되는 시간대에 DB 부하로 이어질 수 있습니다.
public MessageResponse deleteRecipe(UUID recipeUuid, String email) {
Customer customer = getCustomerByEmail(email);
Recipe recipe = getRecipeByUuid(recipeUuid);
validatePermission(customer, recipe.getCustomer());
recipeRepository.delete(recipe);
// 캐시 무효화
String recipeCacheKey = RedisKeyHelper.getRecipeKey(recipeUuid);
cacheRedisTemplate.delete(recipeCacheKey);
return new MessageResponse(recipe.getUuid(), messageUtil.get(MessageCode.RECIPE_DELETED));
}
캐시 TTL이 만료되어 여러 요청이 동시에 캐시 미스를 유발할 때, DB에 과부하가 걸리는 현상을 방지하기 위해 Redisson의 분산 락을 도입했습니다.
락을 획득한 스레드만 DB에 접근하고, 나머지는 일정 시간 대기 후 캐시 재시도합니다.
실제로 적용한 코드는 다음과 같습니다.
@Transactional(readOnly = true)
public RecipeDto getRecipeDetail(UUID recipeUuid) {
String recipeCacheKey = RedisKeyHelper.getRecipeKey(recipeUuid);
// 1. 캐시에서 데이터 조회
RecipeDto cachedRecipe = (RecipeDto) cacheRedisTemplate.opsForValue().get(recipeCacheKey);
if (cachedRecipe != null) {
increaseRecipeViewCount(recipeUuid);
return cachedRecipe;
}
// 2. 캐시에 없으면 락 획득 후 다시 확인 및 저장
String recipeLockKey = RedisKeyHelper.getRecipeLockKey(recipeUuid);
RLock lock = redisson.getLock(recipeLockKey);
try {
boolean isLocked = lock.tryLock(300, 2000, TimeUnit.MILLISECONDS);
if (isLocked) {
try {
// 캐시 재확인 (다른 스레드가 락을 선점하여 캐싱했을 수도 있음)
RecipeDto doubleCheckCache = (RecipeDto) cacheRedisTemplate.opsForValue().get(recipeCacheKey);
if (doubleCheckCache != null) {
increaseRecipeViewCount(recipeUuid);
return doubleCheckCache;
}
// DB 조회
RecipeDto recipeDto = recipeRepository.findRecipeDtoByUuid(recipeUuid)
.orElseThrow(() -> new AppException(RECIPE_NOT_FOUND));
List<RecipeStepDto> stepDtos = recipeStepRepository.findStepsByRecipeUuid(recipeUuid);
List<RecipeItemDto> itemDtos = recipeItemRepository.findItemsByRecipeUuid(recipeUuid);
recipeDto.setSteps(stepDtos);
recipeDto.setItems(itemDtos);
// 캐시에 저장 (1일 유지)
cacheRedisTemplate.opsForValue().set(recipeCacheKey, recipeDto, Duration.ofDays(1L));
increaseRecipeViewCount(recipeUuid);
return recipeDto;
} finally {
lock.unlock();
}
} else {
for (int i = 0; i < 3; i++) {
Thread.sleep(100); // 100ms 대기
RecipeDto retryCache = (RecipeDto) cacheRedisTemplate.opsForValue().get(recipeCacheKey);
if (retryCache != null) {
increaseRecipeViewCount(recipeUuid);
return retryCache;
}
}
throw new AppException(RECIPE_NOT_FOUND);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Thread interrupted during lock acquisition", e);
}
}
단순히 @Cacheable
을 붙이는 것만으로는 실전에서 Redis 캐시를 안정적으로 운용하기 어렵습니다.
캐시 전략은 도메인의 특성과 트래픽 패턴을 고려하여 적절히 선택하고,
데이터 정합성과 성능 이슈를 함께 해결할 수 있도록 보완 로직을 설계하는 것이 중요하다는 것을 배웠습니다.