인덱스 · 트랜잭션 · ACID · 격리수준
B-Tree 인덱스로 100만 행에서 0.001초, 트랜잭션으로 데이터 일관성 보장.
"사용자가 많아지면서 페이지가 느려졌어요" → 90%는 인덱스 누락. "주문은 생성됐는데 결제는 안 찍혔어요" → 100%는 트랜잭션 누락. 이 토픽 하나로 프로덕션 장애의 절반을 예방할 수 있다.
인덱스는 책의 '찾아보기'다. 500쪽짜리 책에서 "파이썬" 단어를 찾을 때, 처음부터 읽으면 몇 시간이지만 책 뒤 찾아보기(색인)를 보면 "파이썬 ... 237쪽" 한 줄로 끝난다. DB 인덱스도 똑같다.
| 사용자 수 | 인덱스 없음 (Full Scan) | 인덱스 있음 (B-Tree) |
|---|---|---|
| 1,000 | 1~10ms | < 1ms |
| 100,000 | 100ms~1초 | < 1ms |
| 10,000,000 | 10초+ (타임아웃) | < 5ms |
| 원리 | O(n) 순차 | O(log n) 이진탐색 |
-- 인덱스 생성 (가장 흔한 케이스)
CREATE INDEX idx_users_email ON users(email);
-- 복합 인덱스 (컬럼 순서 중요!)
CREATE INDEX idx_posts_user_created ON posts(user_id, created_at DESC);
-- 유니크 인덱스 = 인덱스 + 중복 방지
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- 실행 계획 확인 — 인덱스 쓰는지 검증하는 핵심 명령
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'kim@example.com';
-- "Index Scan using idx_users_email" ← 인덱스 사용 중 ✅
-- "Seq Scan on users" ← 풀스캔 중 ❌ (인덱스 없거나 안 먹힘)`EXPLAIN ANALYZE`는 DB가 쿼리를 어떻게 실행하는지 보여준다. `Seq Scan`이 보이면 인덱스를 검토해야 한다. Claude에게 쿼리 최적화를 시킬 때 이 결과를 함께 붙이면 정확한 조언이 나온다.
| 이런 쿼리라면 | 이런 인덱스 | 이유 |
|---|---|---|
| `WHERE email = ?` | `idx(email)` | 단일 컬럼 등호 |
| `WHERE user_id=? AND created_at > ?` | `idx(user_id, created_at)` | 복합 인덱스, 순서 중요 |
| `ORDER BY created_at DESC` | `idx(created_at DESC)` | 정렬 자체가 인덱스 활용 |
| `WHERE LOWER(email) = ?` | `idx(LOWER(email))` | 함수 인덱스 |
| `WHERE email LIKE 'kim%'` | `idx(email)` | ✅ 전방 일치는 OK |
| `WHERE email LIKE '%kim%'` | ❌ | 인덱스 못 씀 (전체 스캔) |
인덱스의 비용. 읽기는 빨라지지만 쓰기(INSERT/UPDATE/DELETE)는 느려진다 — 인덱스도 갱신해야 하니까. 그리고 디스크 용량도 먹는다. 모든 컬럼에 인덱스를 박으면 오히려 느려질 수 있다. 원칙: WHERE·JOIN·ORDER BY에 자주 등장하는 컬럼에만 붙인다.
왜 B-Tree? DB가 쓰는 B-Tree는 디스크 I/O에 최적화된 자료구조다. 한 노드에 수백 개 키를 담아 트리 높이를 3~4단으로 유지 — 10억 행이어도 디스크를 3~4번만 읽으면 원하는 행을 찾는다(ch18-2 이진탐색의 디스크 확장판).
트랜잭션은 '전부 아니면 전혀'다. 계좌 이체를 생각해보자. A의 잔액을 1000원 빼고, B의 잔액에 1000원 더한다. 두 동작 사이에서 서버가 죽으면? 돈이 사라진다. 트랜잭션은 이 두 동작을 묶어서, 둘 다 성공하거나 둘 다 없던 일(롤백)로 만든다.
-- ❌ 위험: 트랜잭션 없이 두 줄
UPDATE accounts SET balance = balance - 1000 WHERE id = 'A';
-- 여기서 서버 크래시 → B는 못 받음, A만 손해
UPDATE accounts SET balance = balance + 1000 WHERE id = 'B';
-- ✅ 안전: 트랜잭션으로 묶기
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 1000 WHERE id = 'B';
COMMIT;
-- 중간에 에러나면 전체 ROLLBACK
-- 실전: 앱 코드 (Node.js + Postgres)
await client.query('BEGIN');
try {
await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [1000, 'A']);
await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [1000, 'B']);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK'); // ← 필수
throw err;
}돈·재고·포인트를 건드리는 로직에는 반드시 트랜잭션. Claude가 만든 코드에 `BEGIN/COMMIT`이 없다면 반드시 지시해서 추가시켜야 한다.
트랜잭션의 4대 보장 — ACID. 암기 필수. 이걸 모르면 DB 설계 리뷰에서 감이 안 잡힌다.
| 글자 | 이름 | 뜻 | 깨지면? |
|---|---|---|---|
| A | Atomicity (원자성) | 전부 성공 or 전부 롤백 | 돈 절반만 이체 |
| C | Consistency (일관성) | 제약조건·규칙 유지 | 재고 음수 |
| I | Isolation (격리성) | 동시 트랜잭션 서로 간섭 없음 | 레이스 컨디션 |
| D | Durability (지속성) | 커밋 후 전원 꺼져도 유지 | 주문 사라짐 |
격리 수준(Isolation Level)은 '동시 트랜잭션이 서로 얼마나 보이는가'를 조절하는 다이얼이다. 엄격할수록 안전하지만 느리다. 바이브코더가 기본 이상은 알아야 레이스 컨디션 버그를 피한다.
| 격리 수준 | Dirty Read | Non-repeatable | Phantom | 흔한 DB 기본값 |
|---|---|---|---|---|
| Read Uncommitted | ⚠️ | ⚠️ | ⚠️ | 거의 사용 안 함 |
| Read Committed | ✅ | ⚠️ | ⚠️ | PostgreSQL 기본 |
| Repeatable Read | ✅ | ✅ | ⚠️ | MySQL 기본 |
| Serializable | ✅ | ✅ | ✅ | 가장 엄격, 가장 느림 |
| 현상 | 뜻 | 예시 |
|---|---|---|
| Dirty Read | 아직 커밋 안 된 변경을 다른 트랜잭션이 읽음 | 롤백될 데이터를 보고 결정 |
| Non-repeatable Read | 같은 쿼리를 두 번 하는데 결과가 다름 | 중간에 다른 트랜잭션이 값 변경 |
| Phantom Read | 같은 범위 쿼리에 새 행이 등장 | 중간에 INSERT 발생 |
-- 재고 차감 같은 민감 로직 → 격리 수준 올리기
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT stock FROM products WHERE id = 'X'; -- 100개
-- 다른 트랜잭션이 동시에 재고 차감 시도 시 직렬화 실패로 롤백
UPDATE products SET stock = stock - 1 WHERE id = 'X';
COMMIT;
-- 또는 더 가벼운 방법: 행 잠금
BEGIN;
SELECT stock FROM products WHERE id = 'X' FOR UPDATE; -- ← 이 행 잠금
UPDATE products SET stock = stock - 1 WHERE id = 'X';
COMMIT;재고·좌석 예약·포인트 차감처럼 동시 호출이 동시에 같은 행을 건드릴 때 SERIALIZABLE 또는 `FOR UPDATE` 행 잠금. 안 하면 두 사람이 같은 좌석을 예매하는 사고.
Durability는 디스크 fsync로 보장된다. DB는 COMMIT 시점에 변경 내역을 WAL(Write-Ahead Log)에 적고, 그게 디스크에 안전히 기록됐음을 OS에 확인받은 뒤에야 클라이언트에게 성공을 알린다. Supabase·PlanetScale 같은 관리형 DB는 이걸 여러 노드로 복제까지 자동 처리.
Claude에게 이렇게 말하면 정확히 고쳐준다. ① "posts 테이블에서 user_id 조회가 느려. `user_id` 컬럼에 인덱스 추가하고 `EXPLAIN ANALYZE`로 확인해줘." ② "주문 생성 + 결제 기록 로직을 트랜잭션으로 묶어서, 결제가 실패하면 주문도 롤백되게 해줘." ③ "재고 차감 로직에 `FOR UPDATE` 잠금 걸어줘. 동시 예약 레이스 방지."
한 줄 요약: 자주 WHERE에 걸리는 컬럼엔 인덱스, 돈·재고 건드리는 로직은 트랜잭션, 동시성 민감하면 격리 수준/FOR UPDATE. 이 세 줄이면 DB 장애의 80%를 예방.
트랜잭션의 4대 보장(ACID)을 풀어서 쓰시오.
모든 컬럼에 인덱스를 만들수록 DB는 항상 빨라진다.
users 테이블의 email 컬럼에 중복을 허용하지 않는 인덱스를 추가하는 SQL을 작성하시오.
CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT);PostgreSQL의 기본 격리 수준은 Serializable이다.
동시에 같은 행을 수정하는 레이스 컨디션을 막기 위해 PostgreSQL에서 쓰는 SQL 키워드는?