서비스 초기에는 아무 문제 없이 10ms 만에 뚝딱 끝나던 조회가, 데이터가 수십만 건을 넘어가고 동시 접속자가 늘어나는 순간 갑자기 수 초씩 걸리며 DB 커넥션을 바닥내기 시작합니다.
백엔드 장애의 80% 이상은 결국 데이터베이스(DB) 병목에서 터집니다. 하지만 무작정 DB 스펙을 올리기(Scale-up) 전에 쿼리와 인덱스를 조금만 손봐도 성능이 수십 배에서 수백 배까지 개선되는 경우가 대부분입니다.
이번 글에서는 서비스 운영 중에 쿼리 속도가 느려졌을 때 당황하지 않고 가장 먼저 확인해봐야 할 실전 최적화 체크리스트들을 차근차근 정리해 보았습니다.
1. EXPLAIN 실행 계획에서 꼭 봐야 할 3가지
쿼리 튜닝의 첫걸음은 MySQL이 내 쿼리를 어떻게 실행하는지 들여다보는 것입니다.
EXPLAIN ANALYZE
SELECT id, user_id, amount, created_at
FROM transactions
WHERE user_id = 1042 AND created_at >= '2026-01-01'
ORDER BY created_at DESC;
실행 계획 결과에서 아래 3개 항목을 집중해서 확인하세요.
type(접근 방식):- 🚨
ALL(Full Table Scan): 테이블 전체를 처음부터 끝까지 다 뒤지는 상태입니다. 인덱스가 아예 안 타고 있다는 뜻이므로 즉각 조치가 필요합니다. - ⚠️
index(Full Index Scan): 인덱스 트리를 전부 훑는 상태입니다. - ✅
range,ref,eq_ref,const: 인덱스를 타서 원하는 데이터 범위만 콕 집어 읽는 이상적인 상태입니다.
- 🚨
rows&filtered:- MySQL이 쿼리를 만족시키기 위해 대략 몇 줄을 읽어야 하는지 나타냅니다.
rows수가 실제 반환되는 행보다 너무 크다면 인덱스 필터링이 덜 되고 있다는 신호입니다.
- MySQL이 쿼리를 만족시키기 위해 대략 몇 줄을 읽어야 하는지 나타냅니다.
Extra:Using filesort: 정렬할 인덱스가 없어서 메모리나 임시 디스크에서 별도로 소팅(sorting)하고 있다는 경고입니다.Using index: **커버링 인덱스(Covering Index)**가 적용되어 테이블 데이터 블록을 열어보지도 않고 인덱스만으로 조회를 끝냈다는 최고의 상태입니다.
2. 복합 인덱스(Composite Index)의 황금률
컬럼이 여러 개인 쿼리에는 인덱스도 여러 컬럼을 묶어 걸어주어야 합니다. 이때 컬럼의 **순서(Ordering)**가 성능을 좌우합니다.
인덱스 컬럼 배치 공식
[동등 조건(=) 컬럼] ➔ [범위 조건(>, <, BETWEEN, LIKE) 컬럼] ➔ [ORDER BY 정렬 컬럼]
예를 들어 WHERE status = 'PAID' AND created_at >= '2026-01-01' 형태의 쿼리가 잦다면, 인덱스는 (created_at, status)가 아니라 (status, created_at) 순서로 만들어야 status로 범위를 단숨에 압축한 뒤 일자별 범위를 깔끔하게 스캔할 수 있습니다.
3. ORM의 단골 함정: N+1 문제와 지연 로딩
Prisma, TypeORM, JPA 등 편리한 ORM을 쓸 때 가장 흔히 저지르는 실수가 바로 N+1 쿼리입니다.
- 문제 상황: 사용자 100명을 조회하는 쿼리 1번 실행 후, 반복문 돌며 각 사용자의 최근 거래 내역을 조회하는 쿼리 100번이 추가로 날아가 총 101번의 네트워크 왕복이 발생하는 현상.
- 해결책:
- 반드시 조인 페치(
JOIN FETCH)나 ORM의 인클루드(include: { transactions: true }) 옵션을 통해 한 번의 조인 쿼리 혹은WHERE user_id IN (...)쿼리로 묶어서 가져오도록 코드를 수정해야 합니다.
- 반드시 조인 페치(
4. 무거운 OFFSET 대신 Keyset(No-Offset) 페이지네이션
페이지 번호를 누르는 전형적인 페이지네이션은 뒤로 갈수록 쿼리가 치명적으로 느려집니다.
-- 1,000,000번째 레코드부터 20건을 읽으려면 앞의 100만 건을 다 읽고 버려야 합니다 (엄청난 낭비!)
SELECT * FROM orders ORDER BY id DESC LIMIT 20 OFFSET 1000000;
이를 해결하는 것이 바로 **Keyset 페이지네이션 (Seek Method)**입니다. 무한 스크롤이나 피드 형태의 UI에 완벽하게 어울립니다.
-- 직전 페이지의 마지막 id만 알고 있다면, OFFSET 없이 인덱스를 타고 즉시 점프합니다!
SELECT * FROM orders
WHERE id < 154200
ORDER BY id DESC
LIMIT 20;
5. 대용량 Insert와 PK 설계: UUID v7 vs Auto-Increment
초당 수천 건 이상의 결제 로그나 센서 데이터를 밀어 넣을 때 PK를 무엇으로 잡느냐가 쓰기 성능을 결정합니다.
- Auto-Increment (BigInt): 인덱스 리프 노드 맨 끝에 순차적으로 착착 쌓이므로 B-Tree 분할(Split)이 없어 쓰기 속도가 가장 빠릅니다. 하지만 샤딩 환경이나 ID 예측 공격에 취약합니다.
- 랜덤 UUID (v4): 전 세계 어디서든 고유하지만, 무작위 문자열이라 B-Tree 인덱스 중간중간을 마구잡이로 비집고 들어가 인덱스 단편화(Fragmentation)와 심각한 쓰기 지연을 일으킵니다.
- 추천 대안 (UUID v7): 타임스탬프가 앞쪽에 붙어 있어 시간 순서대로 정렬되는 최신 UUID 표준입니다. 분산 환경의 고유성을 지키면서도 Auto-Increment에 버금가는 순차 쓰기 성능을 챙길 수 있습니다.
대량 적재 시에는 쿼리를 건건이 날리지 말고 500~1,000건씩 묶어 INSERT INTO ... VALUES (...), (...) 형태의 **배치 인서트(Batch Insert)**로 처리하면 처리량이 10배 이상 치솟습니다.
6. 트랜잭션 데드락(Deadlock)과 리플리케이션 지연
- 데드락 방지 팁: 복수의 자원을 업데이트할 때는 모든 트랜잭션이 동일한 순서로 테이블과 로우를 잠그도록(Lock) 코드 룰을 통일하세요. A는 1번 유저 ➔ 2번 유저 순으로 잠그고, B는 2번 유저 ➔ 1번 유저 순으로 잠그면 필연적으로 교착 상태가 터집니다.
- 읽기 복제본(Read Replica) 지연: 무거운 통계/조회 쿼리를 복제본 DB로 분리했더라도, 방금 회원이 글을 쓰고 리다이렉트된 직후 읽을 때는 마스터(Primary) DB를 바라보게 해야 “방금 쓴 글이 안 보여요!” 같은 리플리케이션 지연(Replication Lag) 이슈를 피할 수 있습니다.
먼저 읽어볼 가이드
검색 유입이 많은 핵심 글부터 이어서 보세요.
- Redis, RabbitMQ, Kafka 운영할 때 꼭 겪게 되는 병목과 트러블슈팅 미들웨어가 뻗으면 백엔드 전체가 마비됩니다. Redis 빅키(BigKey)와 메모리 방출 정책, RabbitMQ 데드레터(DLX)와 쿼럼 큐, Kafka 컨슈머 랙과 리밸런싱 지옥 탈출법을 정리했습니다.
- Vercel 배포하다가 빌드 터졌을 때: OOM부터 504 타임아웃까지 실전 해결법 로컬에선 잘 되던 프로젝트가 Vercel에 올리기만 하면 터지는 이유와 해결책. 빌드 메모리 초과(OOM), 504 게이트웨이 타임아웃, 환경 변수 누락, 빠른 롤백 팁을 정리했습니다.
- 실무에서 써본 AI 에이전트 & RAG 아키텍처: 삽질 줄이는 핵심 설계 팁 단순 챗봇을 넘어 스스로 생각하고 행동하는 AI 에이전트 만들기. LLM 파라미터(Temperature/Top-P) 튜닝, 프롬프트 캐싱, RAG vs 파인튜닝 선택 기준, LangGraph 상태 관리까지 실전 정리.
심사 대기 중에는 광고 대신 관련 가이드를 먼저 보여줍니다.