WIL (Weekly I Learned)
이번 주 핵심 질문
"DB 조회가 느릴 때, 인덱스와 캐시 중 뭘 먼저 봐야 할까?"
1. 인덱스 — 만능이 아니다
복합 인덱스를 설계할 때 카디널리티가 높은 컬럼을 앞에 두는 게 원칙이다.
좋음: (brand_id, status) → brand_id가 더 다양한 값을 가짐
나쁨: (status, brand_id) → status는 'ACTIVE', 'INACTIVE' 2가지뿐
그런데 실험해보니 status IN ('ACTIVE', 'OUT_OF_STOCK') 조건이 전체 데이터의 대부분을 매칭하면 MySQL 옵티마이저가 인덱스 대신 풀스캔을 선택했다. 인덱스를 추가했는데도 성능이 그대로였고, 오히려 UC-2(좋아요순)는 p99가 소폭 악화됐다. 인덱스 메타데이터 오버헤드로 추정된다.
배운 것: 인덱스는 카디널리티가 낮은 컬럼 위주의 조건에는 효과가 없다. 이 경우엔 캐시가 더 적합한 접근이다.
커버링 인덱스란 쿼리에 필요한 모든 컬럼을 인덱스 안에 포함시켜, 인덱스만으로 결과를 반환하고 테이블 본체에는 접근하지 않는 전략이다.
일반 인덱스: 인덱스에서 id 찾기 → 테이블에서 name, price 다시 읽기 (2번 접근)
커버링 인덱스: 인덱스 안에 id, name, price 모두 포함 → 테이블 접근 없음 (1번)
풀스캔이더라도 테이블보다 인덱스가 작기 때문에 디스크 I/O가 줄어드는 효과가 있다. 단, SELECT 컬럼이 많거나 옵티마이저가 무시하면 효과가 없다. 지금 상황에서는 우선순위가 낮다.
2. 부하테스트 — 평균만 보면 속는다
k6로 부하테스트를 진행하면서 p95/p99의 중요성을 직접 체감했다.
UC-1 ConcurrentHashMap 캐시 적용 결과
p95: 40ms ← 대부분의 요청은 캐시 hit
p99: 4002ms ← 캐시 miss 순간 DB 풀스캔 중첩
평균만 봤으면 "개선됐다"고 착각했을 수치다. p99가 4초라는 건 100명 중 1명은 여전히 4초를 기다린다는 의미다.
왜 p99까지 봐야 하는가:
- avg는 극단값에 왜곡된다. 99ms짜리 요청 1개가 섞이면 평균이 크게 올라간다
- p95는 "대부분의 사용자" 경험을 보여주지만, 느린 1~5%를 숨긴다
- p99는 "가장 고통받는 사용자"가 얼마나 나쁜 경험을 하는지 드러낸다
- 실무에서 장애는 평균이 아니라 p99 구간에서 먼저 터진다
성능 개선을 증명할 때는 avg가 아니라 p95/p99 기준으로 before/after를 비교해야 한다.
부하테스트 시나리오는 처음부터 혼합 트래픽보다 단일 API로 분리하는 게 병목 파악이 훨씬 명확하다.
3. 캐시 전략 — 도입 전에 합의가 먼저다
Cache-Aside가 조회 API에 가장 자연스러운 전략이다.
읽기: 캐시 확인 → miss면 DB 조회 후 캐시 저장
쓰기: DB 업데이트 → 캐시 Evict
캐시를 도입하기 전에 먼저 정해야 할 것들이 있다.
- stale 허용 범위: 상품명/이미지는 수십 초 허용 가능, 가격/재고는 불허 또는 수 초
- TTL: 짧게 가져갈수록 일관성 높지만 DB 부하 증가
- Cache Stampede: 캐시 만료 순간 여러 스레드가 동시에 DB를 치는 현상. ConcurrentHashMap은 락이 없어서 특히 취약하다.
4. ConcurrentHashMap vs Redis 직접 실험
ConcurrentHashMap 캐시를 먼저 도입하고 문제를 직접 확인했다.
항목 ConcurrentHashMap Redis
| TTL 설정 | ❌ 불가 | ✅ 가능 |
| 분산 환경 | ❌ 불가 | ✅ 가능 |
| Stale 방지 | ❌ 수동 evict 필요 | ✅ TTL 자동 만료 |
| 메모리 제한 | ❌ OOM 위험 | ✅ maxmemory 설정 |
| Cache Stampede 방지 | ❌ 없음 | ❌ 별도 구현 필요 |
단일 서버라도 Redis를 써야 하는 이유는 서버 재시작 시 캐시 유지, 메모리 안전성, 나중에 멀티 인스턴스 확장 시 코드 변경 없음 때문이다.
5. 2단 캐시 (Caffeine + Redis) — 복잡도 먼저 따져야
로컬 캐시(Caffeine)를 Redis 앞에 두면 네트워크 홉이 줄어 빠르다.
네트워크 홉(hop)이란 데이터가 출발지에서 목적지까지 가는 동안 거치는 네트워크 구간 하나하나를 말한다. 홉이 많을수록 지연(latency)이 쌓인다.
로컬 캐시(Caffeine): 애플리케이션 → JVM 메모리 (홉 없음, ~0ms)
Redis 캐시: 애플리케이션 → 네트워크 → Redis (홉 1개, ~0.5~2ms)
DB 조회: 애플리케이션 → 네트워크 → DB (홉 1개, 수십~수백ms)
로컬 캐시는 JVM 메모리 안에서 바로 꺼내므로 네트워크 자체가 없다. 그러나 서버별로 캐시 타이밍이 달라 정합성 문제가 생긴다.
도입 기준:
- Redis 홉이 실제 병목인지 먼저 측정 (로컬 Redis는 보통 0.5~2ms)
- 해당 필드의 stale 허용 범위 정의
- TTL-only로 충분한지, Pub/Sub 동기화까지 필요한지 판단
측정 없이 도입하면 복잡도만 올라가는 "최적화처럼 보이는 과잉 설계"가 된다.
6. 페이지네이션 — Offset은 깊어질수록 느리다
Cursor(커서) 란 "지금 내가 어디까지 봤는지를 가리키는 포인터"다. 책갈피와 같다. "마지막으로 본 항목의 기준값(ID, 시간 등)"을 다음 요청에 넘겨서 그 이후부터 조회하는 방식이다.
-- Offset: 10000개 읽고 버림 (페이지 깊어질수록 느려짐)
LIMIT 20 OFFSET 10000
-- Cursor: "이 시간 이후 것만" → 항상 20개만 읽음
WHERE created_at < '마지막으로 본 시간'
ORDER BY created_at DESC LIMIT 20
웹은 특정 페이지로 바로 이동하는 기능이 필요해서 Offset을 쓰는 경우가 많고, 앱은 무한 스크롤 구조라 Cursor가 자연스럽다. 성능 문제가 실측으로 확인됐을 때 전환을 고려하는 게 맞다.
이번 주 핵심 교훈
인덱스든 캐시든, 도입 전에 측정이 먼저다.
수치 없이 최적화하면 복잡도만 늘어난다.
'개발 > 스터디' 카테고리의 다른 글
| WIL - 배치 기반 주간·월간 랭킹 설계: 핵심 정리 (0) | 2026.04.17 |
|---|---|
| [WIL] Redis Hot Key 를 공부하면서 배운 것들 (0) | 2026.04.10 |
| [WIL]대기열 관련 공부 (0) | 2026.04.03 |
| [WIL] TDD로 회원가입/내정보조회/비밀번호 변경 구현 (0) | 2026.02.06 |
| 글쓰기 스터디 3차 모집 (0) | 2025.12.22 |