Redisson으로 캐시 스탬피드 방지: Look Aside + Write Around + Cache Busting 전략 적용

오형상·2025년 5월 16일
0

오늘의 식탁

목록 보기
10/11

Spring Boot에서 @Cacheable 어노테이션 하나면 캐시 설정이 끝날 줄 알았습니다.
하지만 실제 Redis를 사용해 캐시를 적용하면서 다양한 전략이 존재하고, 그에 따른 고려사항이 많다는 것을 깨달았습니다.

이 글에서는 일반적인 캐시 전략 소개는 다루지 않고,
제가 상품 조회 및 레시피 조회 API에 Redis 캐시를 적용한 과정
그 과정에서 발생한 문제점 및 해결 방안을 중심으로 정리하고자 합니다.


상품 및 레시피 조회 API에 캐시를 적용할 때, 다음과 같은 이유로 Look Aside + Write Around 전략을 선택했습니다.

선택 배경

  1. 상품/레시피 데이터의 변경 빈도가 낮음
    → 자주 변경되지 않는 데이터는 조회 성능을 높이는 데 캐시가 효과적입니다.

  2. 쓰기 요청 시 캐시 일관성 유지 비용 최소화
    → 캐시에 바로 쓰지 않고 DB에만 쓰는 Write Around 전략을 통해 불필요한 캐시 갱신을 줄였습니다.

적용 흐름

  • 조회 시: Redis 캐시를 먼저 확인 → 없으면 DB 조회 → Redis에 저장 (Look Aside)
  • 저장/수정/삭제 시: DB에만 반영하고, Redis 캐시는 반영 X

⚠️ 캐시 적용 후 마주친 두 가지 문제

캐시 전략 적용 이후 다음과 같은 문제에 직면했습니다:

1. 수정·삭제 시 캐시의 데이터 정합성 미보장

DB에 있는 데이터가 수정/삭제되었음에도, Redis에 남아있는 캐시가 그대로 사용되는 문제가 발생했습니다.
→ 조회 API에서는 오래된 정보가 노출되는 문제로 이어졌습니다.

2. 캐시 TTL 만료 시점에 다수의 요청 몰림 (Cache Stampede)

TTL이 동시에 만료된 캐시 키에 다수의 요청이 한 번에 들어오면서,
→ 모든 요청이 동시에 DB를 조회하게 되는 Cache Stampede 현상이 발생할 수 있었습니다.
→ 이는 특히 트래픽이 집중되는 시간대에 DB 부하로 이어질 수 있습니다.


문제 해결을 위한 개선

🔸 1번 문제 해결: Cache Busting (캐시 무효화)

  • DB 수정/삭제 시점에 해당 캐시 키를 삭제(Eviction)
  • 직접 Redis 키를 삭제했습니다.
    → 이후 조회 시 캐시가 다시 채워지므로 정합성 유지 가능
  • 실제로 적용한 코드는 다음과 같습니다.
    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));
    }

🔸 2번 문제 해결: Redisson 분산 락을 이용한 Cache Stampede 방지

  • 캐시 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 캐시를 안정적으로 운용하기 어렵습니다.
캐시 전략은 도메인의 특성과 트래픽 패턴을 고려하여 적절히 선택하고,
데이터 정합성과 성능 이슈를 함께 해결할 수 있도록 보완 로직을 설계하는 것이 중요하다는 것을 배웠습니다.

0개의 댓글