flowchart LR
A["동시성 제어의 딜레마"] --> B["완벽한 데이터 안전성 (ACID)"]
A --> C["초고속 처리 성능 (Throughput)"]
B ---|트레이드오프 Trade-off| C
style A fill:#F1F5F9,stroke:#475569,stroke-width:2px;
style B fill:#DCFCE7,stroke:#16A34A,stroke-width:2px;
style C fill:#BAE6FD,stroke:#0284C7,stroke-width:2px;
0. 데이터베이스 동시성(Concurrency)의 본질: 줄서기와 은행 잔고
혼자만 쓰는 가계부 앱이라면 데이터베이스는 아무런 문제를 일으키지 않습니다. 내가 쓰고, 내가 읽으면 끝입니다.
하지만 사용자가 수백만 명인 쇼핑몰에서 “남은 재고가 단 1개인 한정판 스니커즈”를 동시에 1,000명이 ‘결제하기’ 버튼을 누른다면 어떤 일이 벌어질까요? * 데이터베이스가 순차적으로 하나씩 처리하자니 서버가 느려 터지고, * 여러 사람이 동시에 장부를 고치게 내버려두자니 재고가 마이너스로 떨어지는 참사(동시성 버그)가 터집니다.
데이터베이스 엔지니어링의 역사는 “어떻게 하면 데이터를 안전하게(무결성) 지키면서도, 최대한 많은 사람에게 빠르게(동시성) 서빙할 수 있는가”를 둘러싼 끝없는 줄타기였습니다.
1. 교착상태(Deadlock) vs 라이브락(Livelock): 좁은 골목길의 대치
1-1. 데드락(Deadlock)이란?
좁은 외나무다리에서 마주친 두 염소의 이야기와 같습니다: * 트랜잭션 A는 자원 1(테이블 A)을 잠근 뒤, 자원 2(테이블 B)를 얻으려고 기다립니다. * 트랜잭션 B는 자원 2(테이블 B)를 잠근 뒤, 자원 1(테이블 A)을 얻으려고 기다립니다. * 두 트랜잭션 모두 영원히 잠금을 풀지 않고 서로를 기다리며 멈춰버립니다.
graph LR
subgraph Deadlock["데드락 (순환 대기)"]
TxA["트랜잭션 A"] -->|"점유 (Lock)"| Res1["자원 1 (계좌 A)"]
TxA -.->|"대기 (Wait)"| Res2["자원 2 (계좌 B)"]
TxB["트랜잭션 B"] -->|"점유 (Lock)"| Res2
TxB -.->|"대기 (Wait)"| Res1
end
style TxA fill:#FEE2E2,stroke:#DC2626,stroke-width:2px;
style TxB fill:#FEE2E2,stroke:#DC2626,stroke-width:2px;
style Res1 fill:#FEF3C7,stroke:#D97706,stroke-width:1px;
style Res2 fill:#FEF3C7,stroke:#D97706,stroke-width:1px;
데드락을 원천 차단하는 가장 단순하고 강력한 방법은 “모든 트랜잭션이 여러 테이블이나 레코드를 수정할 때 항상 정해진 순서(예: Primary Key 오름차순)대로 락을 획득하게 만드는 것”입니다. A \(\rightarrow\) B 순서로만 락을 얻게 통일하면 순환 대기(Circular Wait) 고리가 절대 형성되지 않습니다.
1-2. 라이브락(Livelock)과의 결정적 차이
- 데드락: 프로세스가
WAITING상태에 빠져 CPU를 전혀 쓰지 않고 굳어있는 상태. - 라이브락: 복도에서 두 사람이 마주쳤을 때 양보하려고 서로 같은 방향으로 비켜서기를 무한 반복하듯, 상태는 계속 활발히 변경(
RUNNING)되며 CPU를 100% 태우고 있지만 정작 아무런 실질적 작업도 전진하지 못하는 상태입니다.
2. 비관적 락(Pessimistic) vs 낙관적 락(Optimistic)
데이터 충돌을 예상하고 대처하는 철학에 따라 락의 방식이 완전히 갈립니다.
| 비교 항목 | 비관적 락 (Pessimistic Lock) | 낙관적 락 (Optimistic Lock) |
|---|---|---|
| 기본 철학 | “충돌은 무조건 발생한다. 그러니 내가 먼저 독점하겠다.” | “충돌은 거의 안 일어날 것이다. 일단 작업하고 커밋할 때 확인하자.” |
| 구현 방식 | 데이터베이스 자체 락 메커니즘 (SELECT ... FOR UPDATE) |
애플리케이션 레벨의 버전 컬럼 (UPDATE ... SET version = version + 1 WHERE version = 1) |
| 적합한 환경 | 충돌 빈도가 매우 높고 데이터 정합성이 치명적인 금융 계좌 이체, 한정판 티켓팅 | 충돌 빈도가 낮고 읽기 위주인 게시판 글 수정, 블로그 포스팅 |
| 성능 영향 | 다른 트랜잭션이 대기하므로 동시 처리량(Throughput) 저하 위험 | 락 오버헤드가 없어 평소에는 초고속이나, 충돌 시 재시도(Retry) 비용 발생 |
3. 트랜잭션 격리 수준 (Isolation Levels)과 3대 이상현상
SQL 표준(ANSI/ISO SQL)은 동시성과 안전성의 타협점을 4단계의 격리 수준으로 정의합니다.
+--------------------------------------------------------------------------+
| 격리 수준이 높아질수록 안전성 UP! 동시 처리 속도는 DOWN! |
| |
| [낮음] Read Uncommitted --> Dirty Read 발생 |
| Read Committed --> Non-Repeatable Read 발생 |
| Repeatable Read --> Phantom Read 발생 (MySQL은 MVCC로 방어) |
| [높음] Serializable --> 이상현상 0건, 순차 실행(속도 최저) |
+--------------------------------------------------------------------------+
3대 이상현상 명쾌한 비유:
- Dirty Read (더러운 읽기): A가 계좌 잔액을 100만 원에서 200만 원으로 올렸으나 아직 커밋(확정)하지 않았는데, B가 이를 읽고 200만 원인 줄 알고 행동함. 이후 A가 오류로 롤백해버리면 B는 유령 데이터를 본 셈이 됨.
- Non-Repeatable Read (반복 불가능한 읽기): B가 한 트랜잭션 안에서 잔액을 두 번 조회했는데, 그 1초 사이에 A가 값을 수정하고 커밋해버려 첫 번째 조회와 두 번째 조회의 잔액이 다르게 나옴.
- Phantom Read (유령 읽기): B가
WHERE age >= 30조건으로 회원 수를 세었을 때 5명이었는데, 잠시 후 다시 세니 그사이 A가 35세 신규 회원을 INSERT하여 갑자기 6명으로 늘어나는 현상.
4. MVCC (다중 버전 동시성 제어): 읽기와 쓰기가 싸우지 않는 비결
과거 데이터베이스에서는 “누군가 데이터를 수정(쓰기)하고 있으면 다른 사람은 그 데이터를 읽을 수도 없었습니다(Shared Lock 대기).”
이 거대한 병목을 깨부순 혁신이 바로 MVCC (Multi-Version Concurrency Control)입니다. MySQL의 InnoDB 스토리지 엔진은 이를 기가 막히게 구현했습니다.
sequenceDiagram
autonumber
participant TxA as 트랜잭션 A (SELECT)
participant Buffer as 메모리 버퍼 풀
participant Undo as Undo 로그 (이전 버전 스냅샷)
participant TxB as 트랜잭션 B (UPDATE)
Note over Buffer: 원본: [ID: 1, Name: '철수']
TxB->>Buffer: 1. Name을 '영희'로 UPDATE (커밋 전)
Buffer->>Undo: 2. 옛날 데이터 [ID: 1, Name: '철수']를 Undo 세그먼트에 복사
Note over Buffer: 메모리에는 [ID: 1, Name: '영희']가 기록됨
TxA->>Buffer: 3. ID 1번 회원 조회 요청
Note over TxA,Undo: 4. TxA는 자신의 시작 시점(Read View)보다 나중에 수정된 데이터를 무시함!
Undo-->>TxA: 5. Undo 영역의 옛날 버전 [Name: '철수']를 읽어 반환!
- 핵심 원리: 읽기 작업은 락(Lock)을 걸지 않습니다. 대신 데이터가 수정되면 이전 상태를 Undo Log(언두 로그)에 복사해 두고, 조회 트랜잭션에는 자신이 시작된 시점의 스냅샷(Read View)을 보여줍니다.
- 결과: “쓰기 작업은 읽기 작업을 블로킹하지 않고, 읽기 작업도 쓰기 작업을 블로킹하지 않는다!” 이를 통해 대규모 동시 읽기 트래픽에서도 극강의 성능을 보장합니다.
5. 리두 로그(Redo) vs 언두 로그(Undo): 서버 전원이 나가도 데이터가 안 날아가는 이유
데이터베이스 서버의 전원 플러그가 갑자기 뽑히면(서버 크래시) 어떻게 될까요?
- Redo Log (리두 로그 - 복구와 지속성 Guarantee):
- “이미 저지른 일은 다시 해내서라도 지킨다.”
- 트랜잭션이 커밋될 때 무거운 디스크 데이터 파일(ibd)을 직접 쓰는 것은 너무 느립니다. 대신 가벼운 연속 로그 파일(WAL: Write-Ahead Logging)에 변경 내역만 순차적으로 기록(Redo Log Flush)합니다.
- 서버가 재부팅되면 Redo 로그를 읽어 미처 디스크에 저장되지 못한 변경 사항을 100% 복구(Roll-forward)합니다.
- Undo Log (언두 로그 - 취소와 롤백 Guarantee):
- “실패한 작업은 없었던 일로 되돌린다.”
- 트랜잭션이 실행되는 도중 에러가 나거나
ROLLBACK이 호출되면, Undo 로그에 백업해 두었던 이전 데이터를 복원하여 원래대로 되돌립니다. 동시에 앞서 살펴본 MVCC의 과거 스냅샷 역할도 담당합니다.
6. 요약: 백엔드 개발자가 지켜야 할 DB 운영 5계명
- WHERE 절 없는 UPDATE/DELETE는 재앙이다: 안전 모드(
sql_safe_updates)를 상시 켜두고 인덱스를 반드시 타게 하라. - 트랜잭션의 범위를 가능한 한 짧게 유지하라: 트랜잭션 안에서 외부 네트워크 API(PG사 결제 호출 등)를 호출하는 것은 서버 전체를 데드락으로 몰고 가는 자살 행위다.
- 격리 수준을 불필요하게 Serializable로 올리지 마라: MySQL 기본값인
Repeatable Read나 대부분의 실무 표준인Read Committed로도 비즈니스 로직(낙관적 락 결합)으로 안전하게 제어할 수 있다. - 인덱스 컬럼을 함수로 가공하지 마라:
WHERE YEAR(created_at) = 2026대신WHERE created_at >= '2026-01-01'형태로 작성해야 B+Tree 인덱스를 탈 수 있다. - Slow Query 로그를 주기적으로 분석하라: 인덱스 없는 풀 테이블 스캔(Full Table Scan)은 수백 명의 요청을 줄줄이 락 대기열로 몰아넣는다.
📚 참고 문헌 및 공식 출처
| 구분 | 자료명 | 주요 내용 | 링크 |
|---|---|---|---|
| MySQL 바이블 | Real MySQL 8.0 (1권 & 2권) | 백은빈·이성욱 저, InnoDB 스토리지 엔진 내부 아키텍처 및 MVCC 완벽 해설 | 위키북스 |
| 데이터베이스 이론 | Database System Concepts (Silberschatz 저) | 트랜잭션 스케줄, 2PL 프로토콜, ARIES 복구 알고리즘 학술적 기초 | Yale DB Group |
| 공식 문서 | MySQL 8.0 Reference Manual - InnoDB Locking and Transaction Model | 격리 수준별 잠금 동작 및 데드락 감지 알고리즘 | MySQL Official Docs |
| PostgreSQL 비교 | PostgreSQL Concurrency Control (MVCC) | PostgreSQL의 Tuple 힙 저장 및 VACUUM 아키텍처 비교 | PostgreSQL Docs |