05. 쿼리 성능과 트랜잭션
데이터가 쌓여도 느려지지 않게 하는 방법과, 여러 요청이 같은 데이터를 건드릴 때의 규칙을 정리합니다.
인덱스의 동작과 복합 인덱스의 컬럼 순서
인덱스는 정렬된 사본입니다. 전체를 훑지 않고 찾아 들어갑니다.
CREATE INDEX idx_todos_user_created ON todos (user_id, created_at DESC);컬럼 순서가 핵심입니다. 인덱스는 왼쪽부터 순서대로만 쓸 수 있습니다.
-- (user_id, created_at) 인덱스에서
WHERE user_id = 1 -- 사용됩니다
WHERE user_id = 1 AND created_at > ... -- 사용됩니다
WHERE created_at > ... -- 사용되지 않습니다전화번호부를 성으로 정렬해 두면 이름만으로는 찾을 수 없는 것과 같습니다.
구분력이 낮은 컬럼을 앞에 두지 않습니다. 값이 두세 가지뿐인 컬럼을 선두에 두면 걸러지는 양이 적어 인덱스 효과가 거의 없습니다.
-- done은 true/false뿐이라 선두로는 의미가 약합니다
CREATE INDEX idx_bad ON todos (done, created_at);
-- 구분력 높은 컬럼을 앞에
CREATE INDEX idx_good ON todos (user_id, created_at DESC);컬럼에 함수를 씌우면 인덱스를 못 씁니다.
WHERE DATE(created_at) = '2026-10-26' -- 인덱스 사용 불가
WHERE created_at >= '2026-10-26'
AND created_at < '2026-10-27' -- 범위로 바꿉니다
WHERE LOWER(email) = 'a@b.com' -- 사용 불가
CREATE INDEX idx_email_lower ON users (LOWER(email)); -- 식 인덱스를 만들거나앞이 열린 LIKE도 마찬가지입니다.
WHERE title LIKE '공부%' -- 사용 가능
WHERE title LIKE '%공부%' -- 사용 불가 — 전문 검색을 고려합니다인덱스에는 비용이 있습니다. 쓰기마다 함께 갱신되고 공간을 차지합니다. 읽기가 느린 곳에만 만들고, 쓰지 않는 인덱스는 지웁니다.
-- PostgreSQL: 사용되지 않는 인덱스 찾기
SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE idx_scan = 0;실행 계획 읽기
추측 대신 DB에게 물어봅니다.
EXPLAIN ANALYZE
SELECT * FROM todos WHERE user_id = 1 ORDER BY created_at DESC LIMIT 20;Limit (cost=0.43..8.45 rows=20) (actual time=0.02..0.05 rows=20 loops=1)
-> Index Scan using idx_todos_user_created on todos
Index Cond: (user_id = 1)
Planning Time: 0.1 ms
Execution Time: 0.08 ms볼 것은 세 가지입니다.
- Seq Scan이 큰 테이블에 나오는가 — 인덱스를 못 쓰고 있습니다
- rows 추정치와 actual이 크게 다른가 — 통계가 오래됐을 수 있습니다
- 어느 단계가 시간을 가장 많이 쓰는가
EXPLAIN은 계획만 보여 주고, EXPLAIN ANALYZE는 실제로 실행합니다. 쓰기 쿼리에 ANALYZE를 붙이면 진짜로 데이터가 바뀌므로 트랜잭션 안에서 돌리고 롤백합니다.
작은 테이블에서는 Seq Scan이 더 빠를 수도 있습니다. 전체가 몇 백 행이면 인덱스를 거치는 것이 오히려 손해입니다.
N+1 문제가 생기는 지점과 해결 방법
목록을 가져온 뒤, 각 항목마다 추가 질의를 보내는 패턴입니다.
const todos = await db.query("SELECT * FROM todos LIMIT 20"); // 1번
for (const todo of todos) {
todo.user = await db.query("SELECT * FROM users WHERE id = $1", [todo.userId]);
}
// 20번 더 — 합계 21번목록이 20건이면 21번, 100건이면 101번입니다. 각각은 빠른데 합치면 느립니다.
ORM을 쓸 때 자기도 모르게 발생하는 경우가 많습니다. 관계 속성에 접근하는 순간 쿼리가 나갑니다.
해결은 한 번에 가져오는 것입니다.
// 조인
const rows = await db.query(`
SELECT t.*, u.email
FROM todos t
JOIN users u ON u.id = t.user_id
LIMIT 20
`);// 또는 두 번에 나눠 가져와 메모리에서 묶습니다
const todos = await db.query("SELECT * FROM todos LIMIT 20");
const userIds = [...new Set(todos.map((t) => t.userId))];
const users = await db.query("SELECT * FROM users WHERE id = ANY($1)", [userIds]);
const userById = new Map(users.map((u) => [u.id, u]));
todos.forEach((t) => (t.user = userById.get(t.userId)));ORM에는 보통 전용 옵션이 있습니다.
// Prisma
prisma.todo.findMany({ include: { user: true } });
// TypeORM
repo.find({ relations: ["user"] });개발 중에 쿼리 로그를 켜 두면 바로 눈에 띕니다. 같은 모양의 쿼리가 줄줄이 찍히면 N+1입니다.
트랜잭션과 격리 수준, 교착 상태
여러 작업을 전부 성공하거나 전부 취소되게 묶습니다.
await db.transaction(async (tx) => {
await tx.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, from]);
await tx.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, to]);
});중간에 오류가 나면 둘 다 되돌아갑니다. 돈이 사라지는 상황을 막습니다.
격리 수준은 다른 트랜잭션의 작업이 얼마나 보이는지를 정합니다.
| 수준 | 막아 주는 것 |
|---|---|
| Read Uncommitted | 거의 없음 |
| Read Committed | 커밋되지 않은 값 읽기 (PostgreSQL 기본) |
| Repeatable Read | 같은 질의의 결과가 중간에 바뀌는 것 (MySQL 기본) |
| Serializable | 거의 전부 — 대신 충돌이 잦습니다 |
기본값으로 충분한 경우가 대부분입니다. 다만 "읽고 판단해서 쓰는" 흐름에는 함정이 있습니다.
// 두 요청이 동시에 들어오면 둘 다 통과합니다
const count = await tx.query("SELECT COUNT(*) FROM todos WHERE user_id = $1", [userId]);
if (count >= 100) throw new Error("한도 초과");
await tx.query("INSERT INTO todos ...");읽는 시점에 잠금을 걸거나, DB 제약으로 막습니다.
SELECT * FROM accounts WHERE id = $1 FOR UPDATE; -- 다른 트랜잭션이 기다립니다교착 상태는 서로 상대가 잡은 것을 기다릴 때 생깁니다.
트랜잭션 A: 1번 잠금 → 2번 요청
트랜잭션 B: 2번 잠금 → 1번 요청DB가 감지해서 한쪽을 실패시킵니다. 예방법은 항상 같은 순서로 잠그는 것입니다.
const [first, second] = [fromId, toId].sort((a, b) => a - b);트랜잭션은 짧게 유지합니다. 안에서 외부 API를 호출하면 그동안 잠금이 유지되어 다른 요청이 전부 밀립니다.
// 위험합니다
await db.transaction(async (tx) => {
await tx.query("UPDATE ...");
await fetch("https://payment.example.com/charge"); // 몇 초가 걸릴 수 있습니다
});커넥션 풀과 동시 접속 처리
DB 연결은 만드는 데 비용이 큽니다. 미리 만들어 두고 돌려씁니다.
const pool = new Pool({
connectionString: config.databaseUrl,
max: 10, // 최대 연결 수
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});풀 크기는 크게 잡는다고 빨라지지 않습니다. DB가 감당할 수 있는 동시 연결 수가 한계이고, 넘기면 오히려 느려집니다.
서버 인스턴스 수 × 풀 크기가 DB의 최대 연결 수를 넘지 않아야 합니다. 서버를 10대로 늘렸는데 각각 풀을 20으로 잡으면 200개 연결을 요구합니다.
연결을 빌린 뒤 반납하지 않으면 풀이 말라 모든 요청이 멈춥니다.
const client = await pool.connect();
try {
await client.query("...");
} finally {
client.release(); // 반드시 반납합니다
}서버리스 환경에서는 인스턴스가 수시로 생겼다 사라져 연결이 폭증합니다. 이때는 외부 커넥션 풀러(PgBouncer 등)를 앞에 둡니다.
매개변수 바인딩과 스키마 마이그레이션
값을 문자열로 이어 붙이면 SQL 인젝션이 됩니다.
// 절대 하지 않습니다
db.query(`SELECT * FROM todos WHERE title = '${title}'`);
// 매개변수로 넘깁니다
db.query("SELECT * FROM todos WHERE title = $1", [title]);바인딩하면 값은 데이터로만 취급되어 SQL 문법으로 해석되지 않습니다. 자세한 내용은 07. 서버 보안에서 다룹니다.
테이블 이름이나 컬럼 이름은 바인딩할 수 없습니다. 허용 목록으로 검증해야 합니다.
스키마 변경은 파일로 기록하고 순서대로 적용합니다.
migrations/
├── 001_create_users.sql
├── 002_create_todos.sql
└── 003_add_todos_due_date.sql-- 003_add_todos_due_date.sql
ALTER TABLE todos ADD COLUMN due_date DATE;
CREATE INDEX CONCURRENTLY idx_todos_due_date ON todos (due_date);운영 중인 테이블에 거는 작업은 잠금 시간을 확인해야 합니다.
CREATE INDEX는 테이블을 잠급니다 → PostgreSQL은CONCURRENTLY를 씁니다NOT NULL컬럼을 기본값과 함께 추가하면 전체 행을 다시 씁니다- 컬럼 타입 변경은 대부분 전체 재작성입니다
안전하게 바꾸는 방법은 단계를 나누는 것입니다.
1. 새 컬럼 추가 (nullable)
2. 코드가 양쪽에 쓰도록 배포
3. 기존 데이터 채우기 (배치로 나눠서)
4. NOT NULL 제약 추가
5. 옛 컬럼 제거한 번에 바꾸려다 테이블이 몇 분간 잠기면 그 시간 동안 서비스가 멈춥니다.