Ch.14 사례 - 느리니까 Redis 붙였는데 캐시 만료되면 여전히 느리다¶
앞에서 ORM이 만드는 SQL을 직접 확인해야 한다는 걸 봤다. 이번에는 한 단계 더 들어간다. SQL 자체가 느린 원인을 진단하지 않고, 캐시로 덮어버리면 어떤 일이 벌어지는가.
14-1. 사례 설명¶
2년차 백엔드 개발자가 주문 내역 조회 API를 만들고 있다. 유저 ID로 주문 내역을 가져오는 단순한 쿼리다.
@app.get("/orders/{user_id}")
async def get_orders(user_id: int):
result = db.execute(
text("SELECT * FROM orders WHERE user_id = :uid ORDER BY created_at DESC LIMIT 20"),
{"uid": user_id}
)
return result.fetchall()
개발 환경에서는 빠르다. orders 테이블에 데이터가 500건밖에 없으니까. 운영에 배포하고 3개월이 지나자 orders 테이블이 300만 건이 됐다. API 응답 시간이 2초를 넘기기 시작한다.
팀 리드가 말한다.
"느리면 Redis에 캐싱하면 되지 않나?"
그래서 Redis를 도입한다.
@app.get("/orders/{user_id}")
async def get_orders(user_id: int):
# Redis에서 먼저 조회
cache_key = f"orders:{user_id}"
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
# 캐시 미스: DB 조회
result = db.execute(
text("SELECT * FROM orders WHERE user_id = :uid ORDER BY created_at DESC LIMIT 20"),
{"uid": user_id}
)
orders = result.fetchall()
# 캐시에 저장 (TTL 5분)
redis.setex(cache_key, 300, json.dumps(orders))
return orders
캐시가 살아있는 5분 동안은 빠르다. 그런데 TTL이 만료되면? 다시 2초짜리 쿼리가 날아간다. 유저가 새 주문을 하면? 캐시를 무효화해야 하고, 다음 조회에서 또 2초가 걸린다.
"캐시를 더 오래 유지하면 되지 않나?"
그래서 TTL을 30분으로 늘린다. 이제 유저가 방금 한 주문이 30분 동안 목록에 안 뜬다. CS팀에 문의가 쏟아진다.
"주문했는데 내역에 안 보여요."
14-2. 결과 예측¶
여기서 질문이다.
- 이 쿼리가 느린 진짜 원인은 뭔가?
EXPLAIN을 찍어보면 어떤 결과가 나올까?- 인덱스 하나 추가하면 얼마나 빨라질까?
- 그래도 Redis가 필요한가?
14-3. 결과 분석¶
원인부터 확인한다. EXPLAIN을 찍어본다.
결과:
| id | select_type | table | type | possible_keys | key | rows | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | ALL | NULL | NULL | 3,012,458 | Using where; Using filesort |
type: ALL. Full Table Scan이다. 300만 건을 처음부터 끝까지 훑고 있다. possible_keys: NULL. 사용 가능한 인덱스가 아예 없다. Extra: Using filesort. 정렬도 별도로 수행하고 있다.
(Ch.11에서 type: ALL이 최악이라고 했다. 기억나는가?)
이제 인덱스를 추가한다.
다시 EXPLAIN:
| id | select_type | table | type | possible_keys | key | rows | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | ref | idx_orders_user_id | idx_orders_user_id | 47 | Using where; Using filesort |
type: ref. 인덱스를 타고 있다. rows: 47. 해당 user_id의 주문 47건만 읽는다. 300만 건에서 47건으로 줄었다. 6만 4천배 차이다.
하지만 Extra: Using filesort가 아직 남아 있다. 47건을 created_at으로 정렬하는 건 무시할 수 있는 수준이지만, 더 최적화할 수 있다.
DROP INDEX idx_orders_user_id ON orders;
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);
복합 인덱스(Composite Index)로 바꾸면:
| id | select_type | table | type | possible_keys | key | rows | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | ref | idx_orders_user_created | idx_orders_user_created | 47 | Backward index scan |
Using filesort가 사라졌다. 인덱스 자체가 user_id + created_at DESC로 정렬되어 있으니, 별도 정렬이 필요 없다. 인덱스 순서대로 앞에서 20개만 꺼내면 끝이다.
정리하면:
| 방식 | 스캔 행 수 | filesort | 응답 시간 (참고) |
|---|---|---|---|
| 인덱스 없음 (type: ALL) | 3,012,458 | 있음 | ~2,100ms |
| 단일 인덱스 user_id (type: ref) | 47 | 있음 | ~15ms |
| 복합 인덱스 user_id + created_at (type: ref) | 47 | 없음 | ~3ms |
| Redis 캐시 (TTL 내) | 0 (DB 안 감) | - | ~1ms |
측정 환경: MySQL 8.0, Docker (M1 Mac, 8GB RAM), orders 테이블 300만 건, user_id당 평균 47건
Redis 캐시가 1ms로 가장 빠르다. 그런데 복합 인덱스만으로도 3ms다. 2,100ms에서 3ms. 700배 개선이다. 이 정도면 대부분의 서비스에서 Redis 없이도 충분하다.
Redis를 붙이기 전에 물어봐야 할 질문은 "어떻게 캐싱할까"가 아니라 "왜 느린가"다.
14-4. 코드 설명¶
EXPLAIN의 type 컬럼 해석¶
Ch.11에서 잠깐 언급했던 EXPLAIN의 type 컬럼을 이번에 자세히 본다. MySQL 기준으로, 좋은 순서대로 나열하면:
| type | 의미 | 성능 |
|---|---|---|
| system | 테이블에 행이 1개 | 최상 |
| const | Primary Key 또는 Unique Index로 1건 조회 | 최상 |
| eq_ref | JOIN에서 Primary Key로 1건씩 매칭 | 매우 좋음 |
| ref | Non-unique Index로 조회 | 좋음 |
| range | 인덱스에서 범위 조회 (BETWEEN, >, <) | 괜찮음 |
| index | 인덱스 전체를 스캔 (Full Index Scan) | 나쁨 |
| ALL | 테이블 전체를 스캔 (Full Table Scan) | 최악 |
ALL이 나오면 일단 의심해야 한다. 데이터가 1,000건 이하라면 Full Table Scan이 더 빠를 수도 있다. (인덱스를 타는 것도 비용이니까.) 하지만 수만 건 이상이면 ALL은 거의 항상 문제다.
Full Table Scan (풀 테이블 스캔)
테이블의 모든 행을 처음부터 끝까지 읽는 것이다. Ch.10에서 본 List의 Linear Search와 같은 원리다. 인덱스가 없거나 인덱스를 탈 수 없는 쿼리에서 발생한다. EXPLAIN에서 type: ALL로 나타난다. 데이터가 적을 때는 오히려 Index Scan보다 빠를 수 있지만, 수만 건 이상에서는 성능의 적이다.
Index Scan (인덱스 스캔)
인덱스 자료구조(B+Tree)를 따라가면서 조건에 맞는 행만 찾는 것이다. Full Table Scan과 달리 필요한 데이터만 읽는다. EXPLAIN에서 type: ref, type: range, type: const 등으로 나타난다. Ch.11에서 다뤘던 B+Tree가 이 인덱스의 실체다.
사례의 핵심¶
이 사례에서 중요한 건 세 가지다.
첫째, Redis를 붙이기 전에 EXPLAIN을 찍어봤어야 한다. EXPLAIN 한 번이면 type: ALL이 보이고, 인덱스가 없다는 걸 바로 알 수 있다. 이걸 건너뛰고 Redis부터 도입한 거다.
둘째, 캐시는 원인을 제거하지 않는다. 캐시가 만료되면 느린 쿼리가 그대로 실행된다. TTL을 늘리면 데이터 정합성이 깨진다. 캐시 무효화 로직이 복잡해진다. 운영 비용이 늘어난다.
셋째, 인덱스 하나로 700배 빨라졌다. Redis 도입 비용 (인프라, 캐시 무효화 로직, 장애 대응)을 생각하면, 인덱스가 압도적으로 효율적인 해결책이다.
캐시가 필요한 경우는 분명 있다. DB에 부하를 줄여야 하는 경우, 같은 데이터를 수천 명이 동시에 조회하는 경우, 계산 비용이 큰 결과를 재활용하는 경우. 하지만 "느리니까 캐시"는 순서가 틀렸다. "왜 느린지 확인하고, 그래도 느리면 캐시"가 맞다.
(Ch.17에서 Redis 캐시를 언제, 어떻게 쓰는 게 맞는지를 다룬다. 그때까지 "Redis부터 붙이는 습관"은 좀 참아두자.)
그러면 인덱스를 어떻게 설계해야 하는가? 단일 인덱스와 복합 인덱스의 차이는 뭔가? 어떤 경우에 인덱스가 안 타는가? 다음에서 본다.