📌 문제 상황

Redis 캐시는 자주 사용되는 데이터를 메모리에 저장하여 빠르게 접근할 수 있는 도구입니다.
하지만 캐시를 잘못 설계하거나 적절히 사용하지 않으면,
캐싱이 오히려 성능 병목을 일으킬 수 있습니다.

이번 포스팅에서는 Redis를 이용해 특정 API 호출에서 발생한 성능 저하 문제를 분석하고,
캐시 전략을 개선하여 응답 속도를 대폭 줄인 사례를 소개합니다.

  • 주문량 폭주 시간대(11:00~13:00)에 주문 처리 시간이 평균 3초에서 최대 8초까지 증가
  • DB 부하로 인한 주문 실패율 증가 (실패율 4.5%)
  • 상품 재고 정보의 불일치 발생

원인 분석

  • 동일한 데이터 요청이 반복적으로 발생하나, 매번 DB를 직접 조회.
  • 캐시 만료 시간이 비효율적으로 설정되어 데이터를 자주 갱신.
  • 캐시 적중률(Cache Hit Ratio)이 60% 이하로 낮은 수준.

🎯 해결 목표

  • 주문 처리 시간을 1초 이내로 단축
  • 주문 실패율 1% 미만으로 감소
  • 실시간 재고 정보의 정확도 99.9% 달성
@Service
@Slf4j
public class OrderProcessingService {
    
    private final RedisTemplate<String, Object> redisTemplate;
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    
    private static final String STOCK_KEY_PREFIX = "stock:";
    private static final String ORDER_LOCK_PREFIX = "order_lock:";
    
    @Autowired
    public OrderProcessingService(RedisTemplate<String, Object> redisTemplate,
                                OrderRepository orderRepository,
                                ProductRepository productRepository) {
        this.redisTemplate = redisTemplate;
        this.orderRepository = orderRepository;
        this.productRepository = productRepository;
    }

    @Transactional
    public OrderResult processOrder(OrderRequest request) {
        String lockKey = ORDER_LOCK_PREFIX + request.getProductId();
        String stockKey = STOCK_KEY_PREFIX + request.getProductId();
        
        try {
            // Redis를 활용한 분산 락 획득 (Redisson 사용)
            boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "LOCKED", Duration.ofSeconds(3));
            
            if (!locked) {
                return OrderResult.builder()
                    .success(false)
                    .message("주문 처리 중 입니다. 잠시 후 다시 시도해주세요.")
                    .build();
            }
            
            // Redis에서 재고 확인
            Integer currentStock = (Integer) redisTemplate.opsForValue().get(stockKey);
            if (currentStock == null || currentStock < request.getQuantity()) {
                return OrderResult.builder()
                    .success(false)
                    .message("재고가 부족합니다.")
                    .build();
            }
            
            // 재고 감소
            redisTemplate.opsForValue().set(stockKey, currentStock - request.getQuantity());
            
            // 주문 정보 저장 (비동기 처리)
            CompletableFuture.runAsync(() -> {
                Order order = Order.builder()
                    .productId(request.getProductId())
                    .quantity(request.getQuantity())
                    .userId(request.getUserId())
                    .orderStatus(OrderStatus.COMPLETED)
                    .build();
                
                orderRepository.save(order);
                
                // 재고 정보 DB 동기화 (배치 처리)
                redisTemplate.opsForList()
                    .rightPush("stock_sync_queue", new StockSyncEvent(request.getProductId(), currentStock - request.getQuantity()));
            });
            
            return OrderResult.builder()
                .success(true)
                .message("주문이 성공적으로 처리되었습니다.")
                .build();
            
        } finally {
            redisTemplate.delete(lockKey);
        }
    }
}

📊 성능 개선 결과 시각화

💡 주요 개선 포인트

1. Redis 분산 락 도입

  • 동시 주문 처리 시 발생하는 경쟁 상태 해결
  • 락 획득 타임아웃 3초 설정으로 데드락 방지

2. 재고 관리 최적화

  • Redis Cache를 활용한 실시간 재고 관리
  • 비동기 배치 처리로 DB 동기화 부하 감소

3. 주문 처리 비동기화

  • CompletableFuture를 활용한 비동기 주문 처리
  • DB 작업 병렬 처리로 응답 시간 단축

📈 성능 개선 결과

1. 주문 처리 시간

  • 피크 타임 평균 처리 시간: 8초 → 0.8초 (90% 감소)
  • 일반 시간대 처리 시간: 3초 → 0.4초 (87% 감소)

2. 시스템 안정성

  • 주문 실패율: 4.5% → 0.8% (82% 감소)
  • 재고 정보 정확도: 94% → 99.9% (5.9%p 향상)
  • CPU 사용률: 85% → 45% (47% 감소)

🔧 운영 시 고려사항

이번 Redis 학습 포스팅은 사용자 요청이 빈번히 발생하는 사용자 프로필 조회 API에 적용되었습니다.
이 접근 방식은 반복적인 데이터 요청이 많은 서비스에서 유용하게 활용될 수 있습니다.

1. Redis 장애 대비

@Recover
public OrderResult fallbackProcess(RedisConnectionFailureException ex, OrderRequest request) {
    // MySQL을 이용한 폴백 처리 로직
    return processOrderWithMySQL(request);
}

2. 데이터 정합성 모니터링

@Scheduled(fixedRate = 300000) // 5분마다 실행
public void checkDataConsistency() {
    List<Product> products = productRepository.findAll();
    for (Product product : products) {
        String redisStock = redisTemplate.opsForValue().get(STOCK_KEY_PREFIX + product.getId());
        if (!product.getStock().equals(Integer.valueOf(redisStock))) {
            log.error("재고 불일치 발생: productId={}, DBStock={}, RedisStock={}", 
                product.getId(), product.getStock(), redisStock);
            // 알림 발송
            alertService.sendAlert(new StockMismatchAlert(product.getId()));
        }
    }
}

결론 및 배운 점

Redis 캐시는 잘 설계될 경우 데이터베이스 부담을 줄이고 응답 속도를 크게 개선할 수 있습니다.

하지만 잘못된 캐싱 전략은 오히려 병목 현상을 초래할 수 있으므로, 성능 측정과 적절한 설계가 중요합니다.

이번 사례를 통해 캐싱 설계의 중요성과 성능 최적화를 경험할 수 있었습니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글