카테고리 없음

인덱스 걸었는데 왜 또 느려져요? → Redis로 해결한 썰

그zi운아이 2025. 8. 15. 18:42

🚀 인덱스를 걸었는데 또 느려졌다? → Redis 캐시로 완전체 만든 썰 Part 2

📱 인사말

안뇽 여러분~ 지난번에 인덱스 최적화로 p95 개선한 썰 풀었는데 기억하시나요?

[지난편 요약]

  • WHERE만 보고 인덱스 걸었다가 filesort 지옥 맛봄 😵
  • 정렬 컬럼 + 타이브레이커(id) 포함한 전용 인덱스로 해결
  • p95 기준 3~10배 성능 개선 달성! 🎉

그런데... 트래픽이 더 늘어나니까 또 한계가 보이더라구요 😅

그래서 이번엔 Redis 캐시까지 도입해서 완전체를 만들어봤습니다!
K6 테스트로 정량 측정한 결과가 레전드급이라 공유해봅니다 🔥


🤔 인덱스 최적화했는데 왜 또 느려졌을까?

지난번 인덱스 개선 요약

지난번에 이런 삽질을 했었죠:

-- ❌ WHERE만 보고 만든 나쁜 인덱스
CREATE INDEX idx_bad_product_brand ON product(brand_id);
CREATE INDEX idx_bad_deleted_only ON product(deleted_at);

-- ✅ 정렬까지 고려한 좋은 인덱스  
CREATE INDEX idx_live_created ON product (deleted_at, created_at, id);
CREATE INDEX idx_live_price ON product (deleted_at, price, id);
CREATE INDEX idx_live_brand_created ON product (deleted_at, brand_id, created_at, id);

결과: p95 기준 3~10배 성능 개선 달성!

 

그런데 새로운 문제가...

인덱스 최적화로 단일 쿼리는 빨라졌지만:

  •  "아 동접자 늘어나니까 또 느려져요 ㅠㅠ"
  • "인덱스 다 최적화했는데??"
  • "DB CPU가 100% 찍혀요"
  • "아..."

근본적인 문제들:

  1. 핫데이터 반복 조회: 인기상품들을 계속 DB에서 가져오니까 부하 심함
  2. 복잡한 조인: 상품-브랜드-좋아요 3개 테이블 조인은 여전히 무거움
  3. 동접자 증가: 커넥션 풀 터지면서 DB 큐잉 현상 발생
-- 이 쿼리가 계속 날아옴 (1초에 수십번)
SELECT 
    p.id, p.name, p.price,
    b.name AS brand_name,
    COALESCE(pl.like_count, 0) AS like_count
FROM product AS p
LEFT JOIN brand AS b ON p.brand_id = b.id  
LEFT JOIN product_likes AS pl ON pl.product_id = p.id
WHERE p.deleted_at IS NULL
ORDER BY p.created_at DESC
LIMIT 20;

인덱스는 완벽해도 반복 호출 자체가 병목!

 


🛠️ 캐시 도입 삽질 스토리

1차 시도: @Cacheable 써봤는데...

처음엔 Spring 기본 캐시 어노테이션으로 시작했어요:

// ❌ 이렇게 했다가 멘붕옴
@Cacheable(value = "products", key = "#searchCommand")
public Page<ProductInfo> searchProducts(ProductSearchCommand searchCommand) {
    return productRepository.searchByCondition(searchCommand);
}

결과: 컴파일은 되는데 런타임에서 터짐 ㅋㅋ

문제점들:

  • Page 객체 직렬화 안됨
  • 캐시 무효화 타이밍 조절 불가
  • 복잡한 검색 조건 처리 어려움

2차 시도: RedisTemplate 직접 사용

그래서 RedisTemplate로 직접 구현해봤어요:

// ❌ 이것도 노답
@Service  
public class ProductService {
    
    public ProductInfo getProduct(Long id) {
        String key = "product:" + id;
        String cached = redisTemplate.opsForValue().get(key);
        
        if (cached != null) {
            return objectMapper.readValue(cached, ProductInfo.class);
        }
        
        ProductInfo product = productRepository.findById(id);
        redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(product));
        return product;
    }
}

문제점들:

  • 보일러플레이트 코드 지옥
  • try-catch 떡칠
  • 타입 안전성 제로
  • 캐시 무효화 로직 복잡

3차 시도: 아키텍처 다시 설계

삽질 끝에 깨달은 것: 기존 코드는 건드리지 말고 캐시 레이어만 추가하자!

🏗️ 최종 캐시 아키텍처 (이제야 제대로)

데코레이터 패턴으로 깔끔하게 분리

@Service
@Primary  // 이게 포인트! 기존 서비스 대체
@RequiredArgsConstructor
public class CachedProductService implements ProductService {
    
    private final ProductRepository repo;         // 원래 레포
    private final CacheService cache;           // 캐시 추상화
    private final CachePolicy policy;          // 캐시 정책
    private final ProductCacheKeyGenerator keyGenerator; // 키 생성
    
    private static final TypeRef<ProductInfo> PRODUCT = new TypeRef<>() {};
    private static final TypeRef<PageView<ProductInfo>> PAGE_OF_PRODUCT = new TypeRef<>() {};
    
    @Override
    public ProductInfo getProduct(Long id) {
        return cache.getOrLoad(
            keyGenerator.createDetailKey(id),        // 키 생성
            PRODUCT,                                 // 타입 정보
            () -> repo.findProductInfoById(id)       // 로딩 로직
                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND)),
            policy                                   // TTL 정책
        );
    }
    
    @Override
    public Page<ProductInfo> getProducts(ProductSearchCommand c) {
        return cache.getOrLoad(
            keyGenerator.createListKey(c),           // 키 생성
            PAGE_OF_PRODUCT,                         // 타입 정보
            () -> {
                Page<ProductInfo> result = repo.searchByCondition(c);
                return PageView.from(result);        // 직렬화 가능한 형태로 변환
            },
            policy                                   // TTL 정책
        ).toPage();  // 다시 Page로 변환
    }
}

개꿀팁들:

  • 기존 코드 한 줄도 안바꿈
  • @Primary로 자동 DI 교체
  • 람다로 로딩 로직 깔끔하게 전달
  • 타입 안전한 캐시 서비스

 

타입 안전한 캐시 서비스 구현

@Component
@RequiredArgsConstructor
public class RedisCacheService implements CacheService {
    
    private final StringRedisTemplate redis;
    private final JsonCodec codec;
    private final SingleFlightRegistry singleFlight = new SingleFlightRegistry();
    private final VersionClock versionClock;
    
    @Override
    public <T> T getOrLoad(CacheKey key, TypeRef<T> typeRef, 
                          Loader<T> loader, CachePolicy policy) {
        
        final String redisKey = key.asString();
        
        // 1. 캐시에서 먼저 찾기
        String cached = redis.opsForValue().get(redisKey);
        if (cached != null) {
            T decoded = decodeOrEvict(cached, typeRef, redisKey);
            if (decoded != null) return decoded;  // 타입 안전!
        }
        
        // 2. 없으면 로더로 가져오기 (SingleFlight 적용!)
        CompletableFuture<String> flight = singleFlight.computeIfAbsent(redisKey, () ->
            CompletableFuture.supplyAsync(() -> {
                // 혹시 다른 스레드가 이미 캐시했나 더블체크
                String doubleCheck = redis.opsForValue().get(redisKey);
                if (doubleCheck != null) {
                    T decoded = decodeOrEvict(doubleCheck, typeRef, redisKey);
                    if (decoded != null) return doubleCheck;
                }
                
                // 진짜 DB 접근은 딱 한 번만!
                try {
                    T value = loader.load();
                    writeCache(redisKey, value, policy);
                    return codec.encode(value);
                } catch (Exception e) {
                    throw new CoreException(ErrorType.INTERNAL_ERROR);
                }
            })
        );
        
        try {
            String json = flight.join();
            return codec.decode(json, typeRef);
        } catch (Exception e) {
            // 캐시 실패해도 원본 데이터는 리턴
            try {
                return loader.load();
            } catch (Exception ex) {
                throw new CoreException(ErrorType.INTERNAL_ERROR);
            }
        }
    }
    
    private <T> T decodeOrEvict(String json, TypeRef<T> typeRef, String redisKey) {
        try {
            return codec.decode(json, typeRef);  // 타입 안전한 디코딩
        } catch (Exception e) {
            redis.delete(redisKey);  // 깨진 캐시 삭제
            return null;
        }
    }
    
    private <T> void writeCache(String redisKey, T value, CachePolicy policy) {
        Duration ttl = jitter(policy.ttlDetail(), 0.1);  // 지터 적용
        String json = codec.encode(value);
        redis.opsForValue().set(redisKey, json, ttl);
    }
}

 

Page 직렬화 문제 해결

// ✅ 직렬화 가능한 PageView 클래스
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageView<T> {
    private List<T> content;
    private long totalElements;
    private boolean hasNext;
    private int page;
    private int size;
    
    public static <T> PageView<T> from(Page<T> page) {
        return new PageView<>(
            page.getContent(), 
            page.getTotalElements(), 
            page.hasNext(),
            page.getNumber(),
            page.getSize()
        );
    }
    
    public Page<T> toPage() {
        Pageable pageable = PageRequest.of(page, size);
        return new PageImpl<>(content, pageable, totalElements);
    }
}

🔄 캐시 처리 흐름 

이제 요청이 어떻게 처리되는지 보자:

캐시 처리 플로우:

1. 사용자 요청: GET /api/products?page=0&sort=LATEST
   ↓
2. CachedProductService.getProducts() 호출
   ↓  
3. 캐시 키 생성: "shop:cache:product:v1:list:page=0&sort=LATEST"
   ↓
4. RedisCacheService.getOrLoad() 실행
   ↓
5. Redis에서 키 찾기: GET shop:cache:product:v1:list:page=0&sort=LATEST
   ↓
6-A. 캐시 HIT인 경우:
     JSON 디코딩 → PageView 객체 리턴 (응답시간: ~7ms)
   ↓
6-B. 캐시 MISS인 경우:
     SingleFlight로 중복 요청 방지
     → 원본 서비스 호출 (DB 접근)
     → PageView로 변환 후 Redis 저장  
     → 결과 리턴 (응답시간: ~25ms)
   ↓
7. PageView → Page 변환 후 클라이언트에 응답

SingleFlight가 중요한 이유:

  • 동시에 같은 상품 100명이 조회해도 DB는 1번만 접근
  • 캐시 스탬피드 현상 완전 방지

⚡ 핵심 최적화 테크닉들

1. SingleFlight로 똑같은 요청 합치기

문제상황: 인기상품 캐시 만료시 동시에 100개 요청이 DB로 몰림 💥

// 🔥 이게 핵심! 똑같은 키면 한 번만 DB 접근
@Component
public class SingleFlightRegistry {
    
    private final ConcurrentMap<String, CompletableFuture<String>> inflight = new ConcurrentHashMap<>();
    
    public CompletableFuture<String> computeIfAbsent(String key, Supplier<CompletableFuture<String>> supplier) {
        return inflight.computeIfAbsent(key, k -> supplier.get())
                      .whenComplete((result, throwable) -> inflight.remove(key));  // 완료 후 제거
    }
}

결과: 동시 요청 100개 → DB 접근 1개로 감소! 🎯

2. 네임스페이스 버전으로 스마트 무효화

개별 캐시 키 찾아서 지우는 거 너무 빡셈. 그래서 버전 시스템 도입!

@Component
public class ProductCacheKeyGenerator extends CacheKeyGenerator {
    
    public CacheKey createListKey(ProductSearchCommand cmd) {
        Map<String, Object> params = new HashMap<>();
        params.put("type", "list");
        if (cmd.brandId() != null) params.put("brand", cmd.brandId());
        if (cmd.sortType() != null) params.put("sort", cmd.sortType());
        if (cmd.pageable() != null) params.put("page", cmd.pageable().getPageNumber());
        
        return buildKey(params);  // 내부적으로 버전 포함
    }
}

// 캐시 키에 네임스페이스 버전 포함
private CacheKey buildKey(Map<String, Object> params) {
    String version = versionClock.current("product");  // 현재 버전
    return new CacheKey(String.format("shop:cache:product:v%s:%s", version, 
        params.entrySet().stream()
            .map(e -> e.getKey() + "=" + e.getValue())
            .collect(Collectors.joining("&"))));
}

// 상품 정보 변경시 버전만 올리면 끝!
@EventListener
public void onProductChanged(ProductEvent event) {
    switch (event.type()) {
        case STOCK_CHANGED, LIKE_CHANGED -> {
            versionClock.bump("product");  // 버전 증가 → 모든 리스트 캐시 무효화
        }
    }
}

@Component
public class RedisVersionClock implements VersionClock {
    
    private final RedisTemplate<String, String> redis;
    private static final String NS_KEY_PREFIX = "shop:cache:ns:";
    
    @Override
    public String current(String ns) {
        String key = NS_KEY_PREFIX + ns;
        String v = redis.opsForValue().get(key);
        return v == null ? "0" : v;
    }
    
    @Override
    public void bump(String ns) {
        String key = NS_KEY_PREFIX + ns;
        redis.opsForValue().increment(key);
    }
}

개념: 버전이 바뀌면 기존 캐시는 자동으로 못찾게 됨 = 무효화!

3. 캐시 워밍업으로 UX 레벨업

사용자가 첫 페이지 열었을 때도 빠르게 보이게 하려고 워밍업 추가:

@Component
@RequiredArgsConstructor
public class ProductWarmup implements CacheWarmer {
    
    private final CacheService cache;
    private final ProductRepository repo;
    private final CachePolicy policy;
    private final ProductCacheKeyGenerator keyGenerator;
    
    @Async
    @EventListener(ApplicationReadyEvent.class)  // 앱 시작시 실행
    public void warmOnBoot() {
        log.info("🔥 캐시 워밍업 시작!");
        
        // 최신상품 리스트 미리 캐싱
        ProductSearchCommand latest = new ProductSearchCommand(
            PageRequest.of(0, 20), ProductSortType.LATEST, null
        );
        cache.preload(keyGenerator.createListKey(latest), PAGE_OF_PRODUCT,
            () -> PageView.from(repo.searchByCondition(latest)), policy);
        
        // 인기상품 리스트 미리 캐싱  
        ProductSearchCommand popular = new ProductSearchCommand(
            PageRequest.of(0, 20), ProductSortType.LIKES_DESC, null
        );
        cache.preload(keyGenerator.createListKey(popular), PAGE_OF_PRODUCT,
            () -> PageView.from(repo.searchByCondition(popular)), policy);
        
        // 인기상품 상세 정보 미리 캐싱
        repo.searchByCondition(popular).getContent().stream()
            .map(ProductInfo::productId)
            .forEach(id ->
                cache.preload(keyGenerator.createDetailKey(id), PRODUCT,
                    () -> repo.findProductInfoById(id).orElse(null), policy)
            );
        
        log.info("✅ 캐시 워밍업 완료!");
    }
}

결과: 첫 방문자도 캐시된 데이터로 빠른 응답! 🚀

4. TTL Jitter로 캐시 만료 시간 분산

문제상황: 모든 인기상품 캐시가 동시에 만료되면서 DB 폭격

// 랜덤 지터 추가로 만료시간 분산
private Duration jitter(Duration base, double ratio) {
    if (base == null) return null;
    long ms = base.toMillis();
    long delta = (long) (ms * ratio);
    long j = ThreadLocalRandom.current().nextLong(-delta, delta + 1);
    return Duration.ofMillis(Math.max(1000, ms + j));
}

// 30분 ± 10% = 27~33분 사이 랜덤 만료
Duration ttl = jitter(Duration.ofMinutes(30), 0.1);

📊 K6 성능 테스트

🔥 테스트 시나리오

// 3단계 캐시 테스트: 워밍업 → 인덱스 테스트 → 캐시 테스트
export const options = {
  scenarios: {
    cache_warmup: {
      executor: 'constant-vus',
      vus: 2,
      duration: '15s',
      exec: 'warmupCache',
    },
    index_performance: {
      executor: 'constant-vus', 
      vus: 3,
      duration: '30s',
      startTime: '16s',
      exec: 'testPageDepthPerformance',
    },
    cache_performance: {
      executor: 'constant-vus',
      vus: 4, 
      duration: '45s',
      startTime: '16s',
      exec: 'testCachePerformance',
    },
  }
};

// 캐시 Hit/Miss 성능 테스트
function testCacheHitMiss(productId) {
  // 1차 요청 (Cache Miss)
  const start1 = Date.now();
  const response1 = http.get(`${BASE_URL}/api/products/${productId}`);
  const duration1 = Date.now() - start1;
  
  firstRequestTime.add(duration1);
  
  sleep(0.1); // 캐시 저장 시간
  
  // 2차 요청 (Cache Hit)
  const start2 = Date.now();
  const response2 = http.get(`${BASE_URL}/api/products/${productId}`);
  const duration2 = Date.now() - start2;
  
  secondRequestTime.add(duration2);
  
  // Cache Hit 여부 판단
  const isCacheHit = duration2 < duration1 * 0.6;
  cacheHitRate.add(isCacheHit ? 1 : 0);
}

🎯 실제 테스트 결과

테스트 환경:

  • 로컬 MySQL 8.x + Redis 7.x
  • 상품 데이터: 약 10만 건
  • K6 VU: 최대 7명 동시 접속
  • 테스트 시간: 총 61초
==========================================
🚀 최종 K6 테스트 결과 🚀  
==========================================

✅ 총 요청: 1,507개
⏱️  평균 응답: 7.27ms 
🔥 95% 응답: 14.0ms
📈 캐시 히트율: 100%
🎯 처리량: 24.5 req/s
🔒 실패율: 0%

📊 캐시 성능 상세:
├── 1차 요청 (Cache Miss): 7.67ms
├── 2차 요청 (Cache Hit):  7.14ms  
└── 3차 요청 (Cache Hit):  6.87ms

🏆 페이지 깊이별 성능:
├── Shallow (0-5페이지):   7.3ms 
├── Medium (10-50페이지):  7.4ms
├── Deep (100-500페이지):  7.7ms  
└── Extreme (1000+페이지): 6.8ms

🎉 성능 개선 비율: 평균 121%

📈 단계별 성능 비교

 

단계   평균 응답시간 P95 응답시간  특징
인덱스 최적화 전 ~80ms ~300ms filesort 지옥
인덱스 최적화 후 ~25ms ~50ms 정렬 인덱스 활용
Redis 캐시 적용 7.27ms 14.0ms 캐시 히트 100%

종합 개선율:

  • 처음 대비 91% 응답시간 단축
  • 인덱스 최적화 대비 71% 추가 개선

💡 배운 것들 & 아직 부족한 것들

잘한 것들 🎯

  • 점진적 도입: 기존 코드 안건드리고 캐시만 추가
  • 타입 안전성: 컴파일 타임에 타입 체크로 런타임 에러 방지
  • 체계적 테스트: K6로 정량적 성능 측정
  • 이벤트 기반: 트랜잭션-캐시 라이프사이클 깔끔하게 분리

아직 아쉬운 것들 😅

  • Redis 메모리 사용량 모니터링 부족 → Grafana 대시보드 추가 예정
  • 캐시 키 네이밍 룰 정리 필요 → 팀 가이드라인 만들어야함
  • 캐시 일관성 54% → 80% 목표 달성을 위한 추가 최적화 필요

🏁 마무리

인덱스 최적화 + Redis 캐시 조합으로 91% 성능 개선 달성! 🎉

가장 의미있는 건 단계적 접근이었습니다:

  1. 문제 정의: p95 tail latency 집중 분석
  2. 인덱스 최적화: 정렬까지 고려한 전용 인덱스
  3. 캐시 도입: 반복 조회 부하 해결
  4. 정량 측정: K6로 객관적 성능 검증

핵심 교훈:

"성능 개선은 한 번에 끝나지 않는다"
"측정할 수 없으면 개선할 수 없다"