🧩 RDB 공통 원리
PostgreSQL을 알아도, Oracle을 알아도, 결국 매일 부딪히는 문제는 같습니다. "동시에 같은 행을 건드리면 어떻게 되는가", "이 쿼리는 왜 느린가". 이 트랙은 특정 제품이 아니라 모든 관계형 DB가 공유하는 원리를 다룹니다. 락·트랜잭션·격리 수준·조인 알고리즘·튜닝 — 한 번 배우면 어느 DB로 옮겨도 그대로 쓰입니다.
같은 주제를 두 번 배우는 게 아닙니다. 여기서는 왜 이 문제가 존재하고 어떤 선택지가 있는가를, 벤더 가이드에서는 그 DB가 그걸 어떻게 구현했는가를 봅니다.
| 주제 | 🧩 RDB 공통 (여기) | 벤더 가이드 |
|---|---|---|
| 격리 수준 | 이상현상 5종이 왜 생기는가, 표준 4단계, 벤더별 해석 차이 | PG 7장 — 스냅샷·xmin, pg_stat_activity |
| 동시성 제어 | MVCC와 2PL의 구조적 차이, 무엇을 포기하고 무엇을 얻는가 | PG 3장 — 튜플 버전, Oracle 6장 — Undo |
| 락 | 낙관적·비관적·분산 락의 선택 기준과 함정 | PG 7장 — pg_locks, Oracle 8장 — 락 모드 |
| 실행계획·조인 | NL/Hash/Merge의 비용 모델, 스캔 방식 선택 원리 | PG 6장 — EXPLAIN, Oracle 튜닝 팁 |
| 인덱스 | B+Tree 구조, 선두 컬럼 규칙, 커버링·선택도 | PG 5장 — GIN·BRIN 등 6종, Oracle 9장 |
먼저 이 장면부터 — 재고 1개가 사라지는 순간
커리큘럼을 읽기 전에, 이 트랙 전체가 왜 필요한지 보여주는 장면 하나를 봅시다. 재고 10개짜리 상품을 두 사용자가 동시에 1개씩 주문했습니다. 코드에는 버그가 없습니다. 그런데 재고는 8이 아니라 9가 됩니다.
재고 10개짜리 상품에 주문 두 건이 동시에 들어옵니다. 세션 A와 B 모두 BEGIN으로 트랜잭션을 시작했습니다. 애플리케이션 코드는 지극히 평범합니다 — "재고를 읽고, 1을 빼서, 저장한다".
두 세션이 각각 SELECT stock을 실행합니다. 둘 다 10을 읽습니다. 여기까지는 아무 문제가 없어 보입니다. 문제는 이 값이 이미 낡은 값이 될 운명이라는 것입니다.
A가 먼저 UPDATE … stock = 10 - 1을 실행하고 커밋합니다. 재고는 정상적으로 9가 되었습니다.
이제 B가 자기가 읽었던 10을 기준으로 stock = 10 - 1을 실행합니다. 결과는 또 9. A의 차감이 통째로 사라졌습니다. 주문은 2건인데 재고는 1개만 줄었죠. 이것이 Lost Update입니다. 대부분의 DB 기본 격리 수준(Read Committed)에서 정상 동작으로 처리되며, 에러 한 줄 나지 않습니다.
처방 1 — 비관적 락. "충돌은 반드시 난다"고 가정하고 읽을 때부터 행을 잠급니다(SELECT … FOR UPDATE). B는 A가 커밋할 때까지 대기했다가, 갱신된 9를 다시 읽고 8로 만듭니다. 확실하지만 대가가 있습니다 — 대기, 데드락, 그리고 처리량 저하. T3의 8장에서 다룹니다.
처방 2 — 낙관적 락. "충돌은 드물다"고 가정하고 락 없이 진행하되, version 컬럼으로 커밋 시점에 검증합니다. B의 UPDATE는 0행이 갱신되며 충돌이 드러나고, 애플리케이션이 재조회 후 재시도합니다. 대기가 없어 빠르지만, 경합이 심하면 재시도가 폭주합니다. T3의 9장에서 다룹니다.
처방 3 — 분산 락. 앞의 둘은 DB 한 대 안에서만 통합니다. 앱 인스턴스 3대의 스케줄러가 같은 배치를 동시에 돌리거나, 보호해야 할 자원이 외부 결제 API처럼 DB 트랜잭션 바깥에 있으면 Redis·ZooKeeper 같은 외부 조정자가 필요합니다. 다만 TTL 만료로 인한 이중 실행 위험이 있어 fencing token이 따라붙습니다. 그리고 가장 중요한 질문 — 유니크 제약이나 멱등키로 풀리는 문제라면 분산 락은 쓰지 않는 게 정답입니다. T3의 10장에서 다룹니다.
"재고 차감에 비관적 락과 낙관적 락 중 뭘 쓰죠?" · "우리 서비스 격리 수준이 Read Committed인데 괜찮나요?" · "이 쿼리에 인덱스가 있는데 왜 Full Scan이 뜨죠?" · "Hash Join이 Nested Loop보다 항상 빠른가요?" · "스케줄러가 인스턴스마다 중복 실행되는데 분산 락이 답인가요?"
커리큘럼 — 5트랙 16장
앞의 순서대로 읽으면 뒤 장의 발판이 됩니다. 각 카드의 "이 장에서 답하는 질문"이 곧 그 장의 학습 목표입니다.
T1. 기초 — 옵티마이저를 이해하기 위한 전제 기초
3~5장의 튜닝 이야기는 전부 이 세 장 위에 서 있습니다. "인덱스를 타지 않는 이유"를 외우지 않고 설명할 수 있게 만드는 것이 목표입니다.
01RDB 모델과 SQL 논리적 실행 순서
우리가 쓰는 순서(SELECT→FROM→WHERE)와 DB가 처리하는 순서(FROM→WHERE→GROUP BY→HAVING→SELECT→ORDER BY→LIMIT)는 다릅니다. 이 차이 하나가 수많은 "왜 안 되지?"의 원인입니다.
- 왜
WHERE에서는SELECT별칭을 못 쓰는데ORDER BY에서는 되나? WHERE와HAVING은 무엇이 다른가?NULL은 왜= NULL로 비교되지 않는가? 3값 논리란?
02페이지·블록과 B+Tree 인덱스 구조
인덱스를 "빠르게 해주는 마법"이 아니라 정렬된 트리 자료구조로 이해합니다. 여기서 복합 인덱스의 선두 컬럼 규칙이 자연스럽게 유도됩니다.
- 루트→리프 탐색이 왜 로그 시간인가? 레벨 3~4가 왜 흔한가?
(a, b, c)인덱스에서WHERE b = ?가 왜 못 타는가?- 커버링 인덱스·클러스터링 팩터·선택도(selectivity)란?
03통계정보와 카디널리티 추정
옵티마이저의 모든 판단은 "이 조건에 몇 행이 걸릴까?"라는 추정에서 출발합니다. 추정이 틀어지면 플랜 전체가 무너집니다.
- 히스토그램과 NDV로 행 수를 어떻게 추정하는가?
- 추정 1행 → 실제 100만 행이면 무슨 일이 벌어지는가?
- 통계가 낡으면 왜 어제 빠르던 쿼리가 오늘 느려지는가?
T2. 트랜잭션과 격리 수준 — 정합성의 뼈대 중급
요청하신 "트랜잭션 / 아이솔레이션 레벨"의 본진입니다. 이상현상 5종을 세션 2개 타임라인으로 직접 재현해 보는 실습이 중심입니다.
04트랜잭션과 ACID, 그리고 트랜잭션 경계 설계
ACID 암기가 아니라, 트랜잭션을 어디서 열고 어디서 닫을 것인가라는 실무 설계 문제로 접근합니다. 장애의 상당수가 여기서 시작됩니다.
- savepoint·중첩 트랜잭션·autocommit은 실제로 어떻게 동작하나?
- 트랜잭션 안에서 외부 API를 호출하면 왜 위험한가?
@Transactional범위를 넓게 잡으면 무엇이 무너지나?
05격리 수준 4단계 × 이상현상 5종
Dirty Read · Non-repeatable Read · Phantom · Lost Update · Write Skew를 각각 재현하고, 어느 격리 수준에서 막히는지 눈으로 확인합니다.
- Read Committed에서 같은 쿼리가 두 번 다른 결과를 주는 이유는?
- MySQL의 RR과 PostgreSQL의 RR은 왜 다르게 동작하는가?
- Serializable만 막을 수 있는 Write Skew는 어떤 모양인가?
06MVCC vs 2PL — 동시성 제어의 두 갈래
"읽기가 쓰기를 막지 않는다"는 말이 공짜가 아니라 트레이드오프라는 것을 이해합니다. 두 방식의 비용 구조를 비교합니다.
- 스냅샷 방식은 무엇을 대가로 읽기 락을 없앴는가?
- PG는 왜 VACUUM이 필요하고 Oracle은 왜 Undo가 필요한가?
- SQL Server의 기본 잠금 방식은 왜 읽기가 쓰기를 막는가?
T3. 락 — 동시성의 실전 이 트랙의 하이라이트
요청하신 낙관적 락 · 비관적 락 · 분산 락을 각각 한 장씩, 예시 코드와 함께 다룹니다. 앞의 애니메이션이 이 트랙 전체의 예고편입니다.
07DB 락의 종류 — 무엇이 무엇을 막는가
락을 "걸린다/안 걸린다"가 아니라 모드 × 대상 × 호환성 행렬로 이해합니다. 락 대기 체인을 읽는 법까지.
- 공유(S)·배타(X)·의도(IS/IX) 락은 서로 어떻게 충돌하는가?
- 행·페이지·테이블 락과 락 에스컬레이션이란?
- FK가 걸린 테이블에 INSERT하면 왜 부모 행이 잠기는가?
- DDL(ALTER TABLE) 한 줄이 왜 서비스 전체를 멈추는가?
08비관적 락 — 먼저 잠그고 시작한다
FOR UPDATE 계열의 모든 것. 특히 SKIP LOCKED를 이용한 작업 큐 패턴은 실무에서 바로 쓰는 무기입니다.
FOR UPDATE와FOR SHARE는 언제 갈리는가?NOWAIT/SKIP LOCKED로 대기를 어떻게 제어하나?- 여러 워커가 겹치지 않게 작업을 집어가는 큐를 어떻게 만드나?
- 락 획득 순서가 왜 데드락을 만들고, 어떻게 표준화하나?
09낙관적 락 — 일단 진행하고 커밋에서 검증한다
version 컬럼 한 개로 대기 없는 동시성을 얻는 방법과, 그 대가인 재시도 전략을 설계합니다.
- version / updated_at / 값 비교(CAS) 중 무엇을 쓸 것인가?
- 충돌 시 재시도를 몇 번, 얼마 간격으로? (지수 백오프)
- JPA
@Version은 내부적으로 어떤 SQL을 만드는가? - 충돌률 몇 %부터 비관적 락이 유리해지는가?
10분산 락 — DB 하나로 못 막을 때
가장 오남용되는 도구입니다. 먼저 "정말 필요한가?"를 묻는 체크리스트부터 시작해서, 필요할 때 안전하게 쓰는 법을 봅니다.
- DB advisory lock / Redis / ZooKeeper·etcd — 무엇을 언제?
- TTL이 만료됐는데 작업이 안 끝났다면? (fencing token)
- Redlock 논쟁 — 왜 "안전하지 않다"는 반론이 나왔나?
- 유니크 제약·멱등키로 락 없이 푸는 방법은 없는가?
11데드락과 락 경합 — 실전 진단
실제 데드락 로그를 놓고 누가 무엇을 기다렸는지 역추적하는 훈련. 락 경합은 대개 코드가 아니라 순서의 문제입니다.
- 데드락 로그에서 두 트랜잭션의 락 순서를 어떻게 읽는가?
- 핫 로우(인기 상품 한 줄)에 몰리는 경합은 어떻게 분산하나?
lock_timeout/innodb_lock_wait_timeout은 얼마가 적절한가?
T4. 실행계획과 조인 — 쿼리는 이렇게 실행된다 중급
요청하신 Nested Loop · Hash Join 등 조인 알고리즘이 여기 있습니다. 실행계획을 읽는 법 → 스캔 방식 → 조인 → 집계 순으로, 플랜 트리를 아래에서 위로 읽어 올라가는 순서입니다.
12실행계획 읽는 법 — 벤더 공통 문법
PostgreSQL EXPLAIN ANALYZE, MySQL EXPLAIN ANALYZE, Oracle DBMS_XPLAN은 표기만 다를 뿐 읽는 방법은 같습니다.
- 플랜 트리는 어디서부터 읽어야 하는가?
- 추정 행 수 vs 실제 행 수의 괴리를 어디서 찾는가? (튜닝의 8할)
- cost·rows·actual time·loops는 각각 무엇을 뜻하는가?
13스캔 방식 — 인덱스를 타거나, 못 타거나
"인덱스를 만들었는데 왜 안 타나요?"에 대한 완전한 답 8가지를 정리합니다. 가장 자주 받는 질문이자 가장 자주 잘못 진단되는 문제입니다.
- Full/Seq Scan이 오히려 빠른 경우는 언제인가?
- Index Only Scan과 Bitmap Scan은 언제 선택되는가?
- 암묵적 형변환·함수 감싸기·
LIKE '%x'가 왜 인덱스를 죽이는가?
14조인 알고리즘 — NL · Hash · Sort Merge
조인 문법(INNER/OUTER)이 아니라 조인 알고리즘을 다룹니다. 옵티마이저가 왜 그걸 골랐는지 설명할 수 있게 됩니다.
- 세 알고리즘의 비용 공식과 손익분기점은?
- 드라이빙 테이블(선행 집합)은 왜 작은 쪽이어야 하는가?
- 조인 순서 경우의 수가 폭발하면 옵티마이저는 어떻게 하는가?
- 세미/안티 조인, 서브쿼리 언네스팅이란?
15집계·정렬·그룹핑의 비용
조인만큼 자주 병목이 되지만 훨씬 덜 이야기되는 영역. 정렬이 메모리를 넘치면 무슨 일이 벌어지는지 봅니다.
- Sort Aggregate와 Hash Aggregate는 어떻게 갈리는가?
- 작업 메모리 초과 시 디스크 spill이 성능에 얼마나 영향을 주나?
DISTINCT·윈도우 함수를 남용하면 어디서 터지는가?
T5. 튜닝과 안티패턴 — 실무로 내려오기 실전
요청하신 튜닝 기법과 쓰면 안 되는 쿼리가 여기 모입니다. 개별 기법을 나열하기 전에 방법론을 먼저 세웁니다 — 측정 없이 하는 튜닝은 도박이기 때문입니다.
16SQL 튜닝 방법론과 안티패턴 카탈로그
느린 쿼리를 찾고 → 원인을 좁히고 → 고치고 → 검증하는 루프. 그리고 현업에서 반복해서 발견되는 안티패턴 20+개를 ❌ 나쁜 쿼리 / 왜 / ✅ 개선 3단으로.
- 느린 쿼리를 어디서 찾는가? (slow log · 대기 이벤트 · top query)
- 인덱스 추가·쿼리 재작성·스키마 변경 중 무엇을 먼저?
- 대용량 UPDATE/DELETE를 안전하게 쪼개는 방법은?
- 딥 페이지네이션(
OFFSET 1000000)을 어떻게 없애는가?
🔐 락 3종 비교 — 지금 바로 고르기
16장을 다 읽기 전에도 쓸 수 있도록, 세 가지 락의 선택 기준을 한 표로 정리했습니다. (9장·10장의 요약)
| 구분 | 🔁 낙관적 락 | 🔒 비관적 락 | 🌐 분산 락 |
|---|---|---|---|
| 전제 | 충돌은 드물다 | 충돌은 자주 난다 | 여러 프로세스/노드가 경쟁한다 |
| 구현 | version 컬럼 + WHERE version = ? | SELECT … FOR UPDATE | Redis SET NX PX, ZooKeeper, DB advisory lock |
| 대기 | 없음 (실패 후 재시도) | 있음 (블로킹) | 있음 (TTL·타임아웃) |
| 충돌 감지 시점 | UPDATE 실행 시 (0행) | 락 획득 시 (선점) | 락 획득 시 |
| 데드락 | 없음 | 있음 — 락 순서 표준화 필요 | 드묾 — 대신 락 만료 중 이중 실행 위험 |
| 주 비용 | 재시도 (CPU·응답시간 분산) | 동시성 저하 (처리량) | 네트워크 왕복 + 운영 복잡도 |
| 잘 맞는 곳 | 게시글·프로필 수정, 낮은 경합 갱신 | 재고 차감, 잔액 이체, 좌석 예약 | 다중 인스턴스 스케줄러, 외부 API 중복 호출 방지 |
| 대표 함정 | 경합 급증 시 재시도 폭주 → 백오프 없으면 장애 증폭 | 트랜잭션이 길면 전체 대기 → 락 구간 최소화 | TTL 만료 후 두 워커가 동시 진행 → fencing token 필수 |
1. 락 없이 풀리는가? → UNIQUE 제약, 멱등키, UPDATE … SET stock = stock - 1 WHERE stock >= 1 같은 원자적 단일 문장이면 락은 필요 없습니다.
2. 한 DB 안에서 끝나는가? → 예라면 낙관적(경합 낮음) 또는 비관적(경합 높음).
3. DB 밖 자원까지 보호해야 하는가? → 그때만 분산 락. 그리고 fencing token을 잊지 마세요.
세 가지가 실제 SQL로는 이렇게 생겼습니다.
-- ❶ 락이 필요 없는 경우 — 원자적 단일 문장이 가장 빠르고 안전하다
UPDATE products
SET stock = stock - 1
WHERE id = 1
AND stock >= 1; -- 0행이면 재고 부족 → 애플리케이션에서 실패 처리
-- ❷ 비관적 락 — 읽는 순간부터 잠근다 (다른 계산이 끼어야 할 때)
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 여기서 대기 발생
-- … 검증 로직 … (이 구간이 길수록 전체 처리량이 떨어진다)
UPDATE products SET stock = 8 WHERE id = 1;
COMMIT;
-- ❸ 낙관적 락 — 락 없이 읽고, 커밋 시점에 version으로 검증
SELECT stock, version FROM products WHERE id = 1; -- stock=10, version=3
UPDATE products
SET stock = 9, version = 4
WHERE id = 1 AND version = 3; -- 0행 = 누군가 먼저 바꿈 → 재조회 후 재시도
-- ❹ 작업 큐 — 여러 워커가 겹치지 않게 집어간다 (비관적 락의 응용)
SELECT id, payload FROM jobs
WHERE status = 'ready'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED; -- 잠긴 행은 대기 없이 건너뛴다
워커 A가 락을 잡고 작업하다 GC 스톱이나 네트워크 지연으로 멈춘 사이 TTL이 만료되면, 워커 B가 같은 락을 잡습니다. 그리고 A가 깨어나 자기가 아직 락을 가진 줄 알고 작업을 마저 진행합니다 — 둘이 동시에 실행되는 것이죠. 그래서 락을 줄 때 단조 증가하는 번호(fencing token)를 함께 발급하고, 최종 저장소에서 더 낮은 번호의 쓰기를 거부해야 합니다. 락만으로는 상호배제가 보장되지 않습니다.
🧪 격리 수준 × 이상현상 매트릭스
표준(ANSI SQL)이 정의한 것과 실제 제품의 동작은 다릅니다. 아래 표는 표준 기준이고, 벤더 차이는 그 아래 표에서 봅니다.
| 격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read | Lost Update | Write Skew |
|---|---|---|---|---|---|
| Read Uncommitted | 발생 | 발생 | 발생 | 발생 | 발생 |
| Read Committed 대부분의 기본값 | ✗ 차단 | 발생 | 발생 | 발생 | 발생 |
| Repeatable Read | ✗ | ✗ 차단 | 표준상 발생 PG·MySQL은 차단 | ✗ (구현별) | 발생 |
| Serializable | ✗ | ✗ | ✗ | ✗ | ✗ 차단 |
| 이상현상 | 한 줄 정의 | 현실의 사고 사례 |
|---|---|---|
| Dirty Read | 커밋되지 않은 남의 변경을 읽음 | 롤백될 주문을 집계에 포함해 매출이 부풀려짐 |
| Non-repeatable Read | 같은 행을 두 번 읽었더니 값이 다름 | 화면 상단 잔액과 하단 합계가 어긋남 |
| Phantom Read | 같은 조건으로 두 번 읽었더니 행 개수가 다름 | 정원 체크 후 예약 확정 사이에 다른 예약이 끼어들어 초과 예약 |
| Lost Update | 읽고-계산하고-쓰는 사이 남의 갱신이 덮임 | 위 애니메이션의 재고 차감 — 2건 팔고 1개만 차감 |
| Write Skew | 각자 다른 행을 써서 충돌은 없지만, 둘을 합치면 불변식이 깨짐 | "당직은 최소 1명" 규칙 아래 두 명이 동시에 당직을 빼서 0명이 됨 |
대부분의 서비스가 기본값인 Read Committed에서 돌아갑니다. 이 수준은 Lost Update를 막아주지 않습니다. 즉 BEGIN … SELECT … UPDATE … COMMIT으로 감쌌다고 해서 재고 차감이 안전해지지 않습니다. 안전하게 만드는 건 트랜잭션이 아니라 락 또는 원자적 UPDATE 문장입니다.
🔗 조인 알고리즘 3종
같은 JOIN 문법이라도 옵티마이저는 상황에 따라 완전히 다른 알고리즘을 고릅니다. 재생해 보세요.
users(100만 행)와 orders(1000만 행)를 조인합니다. SQL 문장은 하나지만, 옵티마이저가 고를 수 있는 실행 방법은 최소 세 가지입니다. 어느 것이 빠른지는 상황에 따라 완전히 달라집니다.
Nested Loop. 외부 테이블(드라이빙 집합)의 행을 하나씩 들고 가서, 내부 테이블에서 짝을 찾습니다. 외부가 12행이면 내부 탐색도 12번. 그래서 외부 결과가 작고 내부 조인 키에 인덱스가 있을 때 압도적으로 빠릅니다. 반대로 옵티마이저가 "12행이겠지" 했는데 실제로 120만 행이면 탐색이 120만 번 일어나 그대로 장애가 됩니다. OLTP 단건 조회의 기본 전략입니다.
Hash Join. 작은 쪽 테이블로 해시 테이블(사전)을 메모리에 만들고(Build), 큰 쪽을 한 번 훑으면서 해시로 즉시 매칭합니다(Probe). 양쪽을 딱 한 번씩만 읽으므로 대량 데이터 조인에 강하고, 인덱스가 없어도 잘 작동합니다. 단 = 조인에만 쓸 수 있고, 해시 테이블이 메모리에 안 들어가면 디스크로 넘쳐(spill) 급격히 느려집니다. 배치·분석 쿼리의 주력입니다.
Sort Merge Join. 양쪽을 조인 키로 정렬한 뒤 지퍼를 채우듯 양쪽 포인터를 함께 전진시키며 맞춥니다. 정렬 비용이 크지만, 이미 인덱스 순서로 정렬돼 있거나 ORDER BY가 어차피 필요하다면 그 비용이 상쇄됩니다. Hash Join이 못 하는 범위 조인(<, BETWEEN)도 처리할 수 있습니다.
정리하면 — 소량+인덱스면 Nested Loop, 대량 등가 조인이면 Hash, 정렬이 이미 있거나 범위 조인이면 Sort Merge입니다. 중요한 건 이 선택을 옵티마이저가 예상 비용으로 한다는 점입니다. 통계가 낡아 행 수 추정이 틀리면 잘못된 알고리즘을 고르고, 그것이 "어제까지 멀쩡하던 쿼리가 오늘 갑자기 느려진" 사고의 가장 흔한 원인입니다. → 14장에서 실제 실행계획으로 확인합니다.
| 알고리즘 | 동작 | 최적 조건 | 대략적 비용 | 주요 위험 |
|---|---|---|---|---|
| Nested Loop | 외부 행마다 내부를 탐색 | 외부 결과 소량 + 내부 조인 키 인덱스 | N × log M |
외부 행 수 추정 실패 시 비용 폭발 |
| Hash Join | 작은 쪽 해시 빌드 → 큰 쪽 프로브 | 대량 등가(=) 조인, 인덱스 없어도 가능 | N + M |
메모리 부족 시 디스크 spill, 등가 조인 전용 |
| Sort Merge | 양쪽 정렬 후 병합 | 이미 정렬됨 · 범위 조인 · 대량 + ORDER BY |
N logN + M logM |
정렬 비용·메모리 사용량이 큼 |
🚫 쓰면 안 되는 쿼리 — 안티패턴 카탈로그
16장에서 각각을 실행계획과 함께 파헤치지만, 목록만으로도 당장 코드 리뷰에 쓸 수 있습니다. 빈도 × 파급력 순으로 정렬했습니다.
| # | 안티패턴 | 왜 문제인가 | 대안 |
|---|---|---|---|
| 1 | N+1 쿼리 | 목록 1번 + 건별 N번 → 네트워크 왕복이 지배적 비용이 됨 | 조인 / IN 일괄 조회 / ORM fetch join |
| 2 | OFFSET 딥 페이지네이션 | OFFSET 1000000은 앞의 100만 행을 읽고 버린다 | 키셋 페이지네이션 (WHERE id < ? ORDER BY id DESC LIMIT n) |
| 3 | WHERE 컬럼을 함수로 감싸기 | WHERE DATE(created_at) = ? → 인덱스 사용 불가 | 범위 조건으로 변환하거나 함수 기반 인덱스 |
| 4 | 암묵적 형변환 | 문자 컬럼에 숫자 비교 → 컬럼 측이 변환되어 인덱스 무력화 | 타입을 맞춰 바인딩 / 컬럼 타입 정정 |
| 5 | SELECT * | 불필요한 컬럼·TOAST/LOB까지 읽고, 커버링 인덱스를 놓침 | 필요한 컬럼만 명시 |
| 6 | 선행 와일드카드 LIKE '%kw%' | B+Tree는 앞에서부터 비교 → 전체 스캔 | 전문 검색 인덱스 / 역순 컬럼 / 검색엔진 분리 |
| 7 | 대량 UPDATE·DELETE 한 방 | 긴 트랜잭션 → 락 장기 점유, 로그 폭증, 복제 지연 | PK 범위로 청킹 후 반복 (배치 커밋) |
| 8 | 트랜잭션 안에서 외부 API 호출 | 외부 지연이 그대로 락 점유 시간이 됨 | 트랜잭션 밖으로 분리 / 아웃박스 패턴 |
| 9 | NOT IN + NULL | 목록에 NULL이 하나라도 있으면 결과가 통째로 0행 | NOT EXISTS 또는 LEFT JOIN … IS NULL |
| 10 | 중복을 DISTINCT로 덮기 | 조인 카디널리티 오류를 감추고 정렬 비용까지 추가 | 조인 조건을 바로잡거나 EXISTS로 변경 |
| 11 | 거대한 IN 리스트 | 수천 개 바인딩 → 파싱 비용·플랜 캐시 오염 | 임시 테이블 조인 / 배치 분할 |
| 12 | OR 조건 남발 | 서로 다른 컬럼의 OR는 인덱스 결합이 어려움 | UNION ALL 분리 또는 복합 조건 재설계 |
| 13 | 인덱스 과다 생성 | INSERT/UPDATE마다 모든 인덱스 갱신 → 쓰기 성능 붕괴 | 미사용 인덱스 제거, 복합 인덱스로 통합 |
| 14 | 페이징 없는 전체 조회 | 데이터 증가에 비례해 언젠가 반드시 터진다 | 필수 LIMIT + 커서 기반 순회 |
| 15 | COUNT(*) 전체 건수 매 요청 | 대형 테이블에서 매번 전체 스캔 | 근사치 통계 / 카운터 테이블 / "다음 페이지 유무"만 확인 |
| 16 | 상관 서브쿼리 반복 실행 | 외부 행마다 서브쿼리 재실행 | 조인으로 평탄화 또는 윈도우 함수 |
| 17 | 커서 루프 (RBAR) | 행 단위 반복 처리 — 집합 연산의 장점을 버림 | 집합 기반 단일 문장으로 재작성 |
| 18 | 읽고-계산하고-쓰기 | 락 없이 하면 Lost Update (맨 위 애니메이션) | 원자적 UPDATE / 낙관적·비관적 락 |
| 19 | ORDER BY RAND() | 전체 행에 난수를 매겨 정렬 | PK 랜덤 샘플링 / 미리 계산된 랜덤 컬럼 |
| 20 | 인덱스 없는 FK | 부모 삭제·갱신 시 자식 전체 스캔 + 락 확대 | 모든 FK 컬럼에 인덱스 생성 |
가장 자주 보는 세 가지는 미리 코드로 봐 둡시다.
-- ❌ 1. 딥 페이지네이션 — 100만 행을 읽고 버린 뒤 10행을 준다
SELECT * FROM orders ORDER BY id DESC LIMIT 10 OFFSET 1000000;
-- ✅ 키셋(커서) 페이지네이션 — 마지막으로 본 id부터 10행만 읽는다
SELECT * FROM orders
WHERE id < 8481920 -- 직전 페이지의 마지막 id
ORDER BY id DESC LIMIT 10;
-- ❌ 2. 컬럼을 함수로 감싸면 idx(created_at)를 쓸 수 없다
SELECT * FROM orders WHERE DATE(created_at) = '2026-07-28';
-- ✅ 범위 조건으로 바꾸면 인덱스를 그대로 탄다
SELECT * FROM orders
WHERE created_at >= '2026-07-28'
AND created_at < '2026-07-29';
-- ❌ 3. NOT IN 목록에 NULL이 섞이면 결과가 통째로 0행이 된다
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM blacklist); -- user_id에 NULL 존재 시
-- ✅ NOT EXISTS는 NULL에 안전하고 대개 더 빠르다
SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM blacklist b WHERE b.user_id = u.id);
📈 SQL 튜닝 방법론 — 순서가 8할
기법을 아무리 많이 알아도 순서가 틀리면 시간만 씁니다. 위에서부터 내려오세요. 아래로 갈수록 비용과 리스크가 커집니다.
| 단계 | 무엇을 하는가 | 먼저 확인할 것 |
|---|---|---|
| 0. 측정 | 느린 쿼리를 데이터로 특정한다 | slow query log, 누적 실행시간 상위 쿼리, 대기 이벤트 |
| 1. 실행계획 확인 | 추정 행 수와 실제 행 수의 괴리를 찾는다 | 통계가 최신인가? 이것만으로 해결되는 경우가 많다 |
| 2. 쿼리 재작성 | 불필요한 작업을 없앤다 | 안티패턴 표에 걸리는 게 있는가? 안 쓰는 컬럼·조인·정렬은? |
| 3. 인덱스 | 접근 경로를 만든다 | 쓰기 비용은? 기존 인덱스로 커버되지 않는가? |
| 4. 스키마·모델링 | 정규화 조정, 파티셔닝, 집계 테이블 | 영향 범위가 크다 — 앞 단계로 안 될 때만 |
| 5. 파라미터·하드웨어 | 메모리·병렬도·커넥션 풀 조정 | 대개 마지막. 쿼리 문제를 장비로 덮으면 비용만 는다 |
① 이 쿼리가 정말 병목인가? (전체 응답시간에서 차지하는 비중) ② 결과 행이 정말 다 필요한가? (페이징·집계로 줄일 수 있나) ③ 실시간이어야 하는가? (배치·캐시·비동기로 옮길 수 있나) — 셋 중 하나만 걸려도 SQL을 건드리지 않고 끝나는 경우가 많습니다.
🏷 벤더 차이 매트릭스
"공통 원리"를 배웠어도 실무에서는 제품 차이에 걸립니다. 자주 부딪히는 지점만 모았습니다.
| 항목 | PostgreSQL | MySQL (InnoDB) | Oracle | SQL Server |
|---|---|---|---|---|
| 기본 격리 수준 | Read Committed | Repeatable Read | Read Committed | Read Committed (잠금 기반) |
| 동시성 제어 | MVCC (튜플 버전) | MVCC (Undo 로그) | MVCC (Undo 세그먼트) | 기본 2PL, RCSI 옵션 |
| RR에서 Phantom | 차단 (스냅샷) | 차단 (갭 락) | RR 미지원 | RR에서 발생 |
| 지원 격리 수준 | RC · RR · Serializable(SSI) | RU · RC · RR · Serializable | RC · Serializable만 | RU · RC · RR · Snapshot · Serializable |
| 읽기가 쓰기를 막나 | 아니오 | 아니오 | 아니오 | 기본 설정에서는 막음 |
SKIP LOCKED | 9.5+ 지원 | 8.0+ 지원 | 지원 | READPAST 힌트 |
| 락 대기 타임아웃 | lock_timeout | innodb_lock_wait_timeout | FOR UPDATE WAIT n | SET LOCK_TIMEOUT |
| 실행계획 확인 | EXPLAIN (ANALYZE, BUFFERS) | EXPLAIN ANALYZE (8.0.18+) | DBMS_XPLAN.DISPLAY_CURSOR | 실제 실행 계획 (SSMS) |
| 정리/공간 회수 | VACUUM 필요 | 퍼지 스레드 | Undo 자동 관리 | 고스트 정리 |
✍️ 이해도 체크
BEGIN → SELECT stock → 앱에서 -1 계산 → UPDATE → COMMIT으로 구현했고, DB는 기본 격리 수준(Read Committed)입니다. 동시 주문 시 재고가 잘못 차감되는 문제가 보고되었습니다. 원인으로 가장 정확한 것은?UPDATE … SET stock = stock - 1 WHERE stock >= 1처럼 원자적 단일 문장으로 바꾸거나, SELECT … FOR UPDATE(비관적 락)로 읽는 순간부터 잠그거나, version 컬럼(낙관적 락)으로 커밋 시점에 충돌을 감지해 재시도하는 것입니다.= 조건으로 조인합니다. 조인 키에는 인덱스가 없습니다. 옵티마이저가 선택할 가능성이 가장 높고, 또 실제로 가장 적합한 알고리즘은?N + M 비용이라 대량 등가 조인에 가장 적합합니다. Sort Merge도 가능하지만 양쪽 정렬 비용(N logN + M logM)이 추가되므로, 이미 정렬돼 있거나 범위 조인일 때가 아니면 보통 Hash에 밀립니다. 다만 Hash Join은 해시 테이블이 작업 메모리를 넘으면 디스크로 spill되어 급격히 느려지므로, 이 경우 작은 쪽이 build 대상으로 선택됐는지 실행계획에서 확인해야 합니다.🧭 추천 학습 경로
16장을 순서대로 읽는 게 기본이지만, 목적이 뚜렷하다면 아래처럼 골라 읽어도 됩니다.
🌱 백엔드 개발자 (2주)
01 → 04 → 05 → 08 → 09 → 13 → 14 → 16
매일 쓰는 순서 그대로입니다. 실행 순서로 SQL의 오해를 걷어내고, 트랜잭션 경계와 락 두 종류를 잡은 뒤, 인덱스가 안 먹는 이유와 조인으로 마무리합니다.
🎤 면접·기술 리뷰 대비 (3일)
05 → 06 → 08 → 09 → 14 + 락 비교표 · 벤더 매트릭스
가장 자주 나오는 질문 셋 — "격리 수준 설명해 보세요", "낙관적 락과 비관적 락의 차이는?", "Nested Loop와 Hash Join은 언제 갈리나요?"에 집중합니다.
🚒 지금 장애 대응 중
11 → 07 → 12 + 안티패턴 표
데드락·락 경합부터 보고, 실행계획으로 범인을 좁힙니다. 벤더별 진단 쿼리는 PG 14장·Oracle 12장에 있습니다.
🌐 분산 시스템 설계
04 → 05 → 06 → 09 → 10
트랜잭션 경계와 격리 수준을 확실히 한 뒤 분산 락으로 갑니다. 순서를 지키세요 — 10장만 먼저 읽으면 안 써도 될 곳에 분산 락을 쓰게 됩니다.
16개 챕터는 순차적으로 집필됩니다. 각 챕터는 다른 가이드와 동일한 형식 — 단계별 애니메이션 + 두 세션 실습 SQL + 이해도 퀴즈 — 으로 만들어집니다. 집필 순서는 T3 락(07~11) → T4 조인(12~15) → T2 트랜잭션(04~06) → T5 튜닝(16) → T1 기초(01~03)로, 요청 빈도가 높은 주제부터 진행합니다.
이 페이지의 주제를 특정 DB에서 확인하고 싶다면 — PostgreSQL 7장 · 트랜잭션과 격리 수준(실제 pg_locks 진단), PG 3장 · MVCC, Oracle 8장 · 트랜잭션과 락, Oracle 쿼리 튜닝 팁, PG 6장 · 플래너와 EXPLAIN.