본문 바로가기
개발/스터디

[WIL] DB 조회가 느릴 때, 인덱스와 캐시 중 뭘 먼저 봐야 할까?

by 글쓰는 개발자 2026. 3. 13.

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 메모리 안에서 바로 꺼내므로 네트워크 자체가 없다. 그러나 서버별로 캐시 타이밍이 달라 정합성 문제가 생긴다.

도입 기준:

  1. Redis 홉이 실제 병목인지 먼저 측정 (로컬 Redis는 보통 0.5~2ms)
  2. 해당 필드의 stale 허용 범위 정의
  3. 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가 자연스럽다. 성능 문제가 실측으로 확인됐을 때 전환을 고려하는 게 맞다.


이번 주 핵심 교훈

인덱스든 캐시든, 도입 전에 측정이 먼저다.
수치 없이 최적화하면 복잡도만 늘어난다.

반응형