느린 쿼리 하나가 전체 서비스를 멈출 수 있다
서비스 초기에는 데이터가 적어서 어떤 쿼리든 빠르게 실행됩니다. 하지만 사용자가 늘고 데이터가 수백만 건을 넘어서면, 어제까지 100ms이던 쿼리가 갑자기 10초 이상 걸리기 시작합니다. 그리고 이 하나의 느린 쿼리가 데이터베이스 커넥션을 점유하면서 다른 모든 쿼리까지 느려지는 연쇄 반응이 일어나죠.
Percona의 2024 Database Survey에 따르면, 데이터베이스 성능 문제의 70% 이상이 비효율적인 쿼리와 누락된 인덱스에서 비롯됩니다. 하드웨어를 업그레이드하기 전에, 먼저 쿼리와 인덱스를 점검하는 것이 순서입니다.
---
인덱스 — 데이터베이스 성능의 핵심
인덱스는 왜 빠른가
인덱스 없이 데이터를 찾으려면 테이블의 모든 행을 순서대로 스캔해야 합니다(Full Table Scan). 100만 행 테이블에서 한 건을 찾으려면 최악의 경우 100만 번의 비교가 필요하죠. 인덱스(B-Tree)를 사용하면 이 횟수가 약 20번(log₂(1,000,000) ≈ 20)으로 줄어듭니다.
인덱스 설계 원칙
```sql -- 1. 자주 WHERE/JOIN/ORDER BY에 사용되는 컬럼에 인덱스 CREATE INDEX idx_orders_user_id ON orders(user_id); CREATE INDEX idx_orders_created_at ON orders(created_at);
-- 2. 복합 인덱스 — 컬럼 순서가 중요! -- WHERE user_id = ? AND status = ? ORDER BY created_at DESC CREATE INDEX idx_orders_user_status_date ON orders(user_id, status, created_at DESC);
-- 3. 커버링 인덱스 — 쿼리에 필요한 모든 컬럼을 인덱스에 포함 CREATE INDEX idx_orders_covering ON orders(user_id, status) INCLUDE (total_amount, created_at); ```
복합 인덱스의 컬럼 순서는 결정적으로 중요합니다. B-Tree 인덱스는 왼쪽부터 순서대로 사용되므로, `(user_id, status, created_at)` 인덱스는 `WHERE user_id = ?`나 `WHERE user_id = ? AND status = ?`에는 사용되지만, `WHERE status = ?`만으로는 사용되지 않습니다. 이것을 "가장 왼쪽 접두사 규칙(Leftmost Prefix Rule)"이라고 합니다.
인덱스를 만들면 안 되는 경우
카디널리티(고유 값의 수)가 낮은 컬럼(예: `gender`, `is_active` 같은 boolean)에 단독 인덱스를 만드는 것은 대부분 효과가 없습니다. 인덱스를 통해 필터링해도 테이블의 절반 이상을 읽어야 한다면, 옵티마이저가 인덱스를 무시하고 Full Scan을 선택하는 것이 더 효율적이거든요.
---
EXPLAIN으로 쿼리 분석하기
```sql -- PostgreSQL EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT o.id, o.total_amount, u.name FROM orders o JOIN users u ON o.user_id = u.id WHERE o.status = 'completed' AND o.created_at >= '2025-01-01' ORDER BY o.created_at DESC LIMIT 20; ```
EXPLAIN 결과에서 주의 깊게 봐야 할 항목은 다음과 같습니다.
Seq Scan (Sequential Scan): 테이블 전체를 읽습니다. 소규모 테이블에서는 정상이지만, 대규모 테이블에서 이것이 보이면 인덱스 누락을 의심해야 합니다. Index Scan 또는 Index Only Scan이 나타나면 인덱스가 잘 작동하고 있다는 뜻입니다.
rows (estimated vs actual): 옵티마이저가 예상한 행 수와 실제 행 수의 차이가 크면, 통계 정보가 오래되었을 수 있습니다. `ANALYZE` 명령으로 통계를 갱신하세요.
Sort: 메모리 내 정렬(`Sort Method: quicksort Memory`)이면 괜찮지만, 디스크 정렬(`external merge Disk`)이 나타나면 `work_mem` 설정을 늘리거나 인덱스에 정렬 컬럼을 포함시켜야 합니다.
---
쿼리 최적화 패턴
N+1 문제 해결
ORM을 사용할 때 가장 흔히 마주치는 성능 문제입니다.
```typescript // ❌ N+1 — 사용자 100명 조회 시 101개의 쿼리 실행 const users = await prisma.user.findMany({ take: 100 }); for (const user of users) { const orders = await prisma.order.findMany({ where: { userId: user.id } }); }
// ✅ Eager Loading — 2개의 쿼리로 해결 const users = await prisma.user.findMany({ take: 100, include: { orders: { orderBy: { createdAt: 'desc' }, take: 5, }, }, }); ```
페이지네이션 — OFFSET의 함정
```sql -- ❌ OFFSET 방식 — 페이지가 깊어질수록 느려짐 SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 10000; -- → 10,020행을 읽고 10,000행을 버림!
-- ✅ 커서 기반 페이지네이션 — 일정한 성능 SELECT * FROM posts WHERE created_at < '2025-09-01T12:00:00Z' -- 이전 페이지 마지막 항목의 created_at ORDER BY created_at DESC LIMIT 20; ```
커서 기반 페이지네이션은 무한 스크롤이나 "더 보기" UI에 적합합니다. `OFFSET` 방식은 "5페이지로 이동" 같은 전통적 페이지네이션에 필요하지만, 가능하면 커서 방식을 우선 고려하세요.
불필요한 SELECT * 제거
```sql -- ❌ 모든 컬럼 조회 — 네트워크/메모리 낭비 SELECT * FROM users WHERE id = 123;
-- ✅ 필요한 컬럼만 조회 SELECT id, name, email FROM users WHERE id = 123; ```
특히 TEXT, BLOB 같은 대용량 컬럼이 포함된 테이블에서 `SELECT *`는 심각한 성능 저하를 유발합니다.
---
커넥션 풀링과 아키텍처 최적화
커넥션 풀
데이터베이스 커넥션을 생성하는 것은 비용이 큰 작업입니다(TCP 핸드셰이크, 인증, 메모리 할당). 매 요청마다 새 커넥션을 만들면 서버에 부하가 집중됩니다.
```typescript // Prisma — 커넥션 풀 설정 datasource db { provider = "postgresql" url = env("DATABASE_URL") // ?connection_limit=20&pool_timeout=10 } ```
서버리스 환경(AWS Lambda, Vercel Functions)에서는 PgBouncer 같은 외부 커넥션 풀러나 Prisma Accelerate를 사용하는 것이 좋습니다. 서버리스 함수는 호출마다 새 인스턴스가 생성될 수 있어, 커넥션이 폭발적으로 늘어날 수 있기 때문입니다.
읽기 복제 (Read Replica)
쓰기 작업은 프라이머리 DB에, 읽기 작업은 레플리카에 분산하면 부하를 효과적으로 줄일 수 있습니다. 대부분의 웹 서비스는 읽기:쓰기 비율이 80:20 이상이므로, 읽기 복제만으로도 상당한 성능 향상을 얻을 수 있습니다.
파티셔닝
```sql -- 시간 기반 파티셔닝 (PostgreSQL) CREATE TABLE logs ( id BIGSERIAL, message TEXT, created_at TIMESTAMPTZ NOT NULL ) PARTITION BY RANGE (created_at);
CREATE TABLE logs_2025_q1 PARTITION OF logs FOR VALUES FROM ('2025-01-01') TO ('2025-04-01'); CREATE TABLE logs_2025_q2 PARTITION OF logs FOR VALUES FROM ('2025-04-01') TO ('2025-07-01'); ```
수억 건 이상의 테이블에서는 파티셔닝이 필수입니다. 쿼리가 특정 파티션만 스캔하므로 성능이 크게 향상되고, 오래된 파티션을 DROP하여 데이터 정리도 간편해집니다.
---
모니터링과 지속적 최적화
데이터베이스 최적화는 일회성 작업이 아닙니다. 쿼리 패턴은 기능 추가에 따라 변하고, 데이터 분포는 시간에 따라 달라지니까요.
PostgreSQL의 `pg_stat_statements` 확장은 모든 쿼리의 실행 통계(호출 횟수, 평균 실행 시간, 총 실행 시간)를 기록합니다. 이 데이터를 정기적으로 분석하면, 최적화 대상 쿼리를 객관적으로 식별할 수 있습니다.
데이터베이스 최적화의 80%는 "올바른 인덱스"와 "효율적인 쿼리"에서 나옵니다. 하드웨어 업그레이드나 아키텍처 변경은 그 이후의 문제입니다. EXPLAIN을 습관적으로 사용하고, 슬로우 쿼리 로그를 모니터링하는 것만으로도 대부분의 성능 문제를 사전에 방지할 수 있습니다.