🚀 인덱스를 걸었는데 또 느려졌다? → 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% 찍혀요"
- "아..."
근본적인 문제들:
- 핫데이터 반복 조회: 인기상품들을 계속 DB에서 가져오니까 부하 심함
- 복잡한 조인: 상품-브랜드-좋아요 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% 성능 개선 달성! 🎉
가장 의미있는 건 단계적 접근이었습니다:
- 문제 정의: p95 tail latency 집중 분석
- 인덱스 최적화: 정렬까지 고려한 전용 인덱스
- 캐시 도입: 반복 조회 부하 해결
- 정량 측정: K6로 객관적 성능 검증
핵심 교훈:
"성능 개선은 한 번에 끝나지 않는다"
"측정할 수 없으면 개선할 수 없다"