동시성 제어 — 2PL·MVCC·락·데드락 (PostgreSQL·MySQL)
두 갈래의 큰 그림
[04 트랜잭션]에서 격리수준이 "무엇을" 막는지를 봤다면, 이 장은 "어떻게" 막는지 — 엔진 내부다. 수많은 트랜잭션이 같은 데이터를 동시에 읽고 쓰는데도 서로 안 보이는 것처럼 만드는 방법은 두 갈래다. 하나는 락(2PL) — 충돌할 것 같으면 잠가서 줄 세우는 비관적 방법. 다른 하나는 MVCC — 한 데이터의 여러 버전을 두고, 읽기와 쓰기가 서로 다른 버전을 보게 해 아예 안 부딪히게 하는 방법. 현대 DB(PostgreSQL·MySQL InnoDB)는 둘을 섞는다 — 읽기는 MVCC 스냅샷(락 없이), 쓰기는 락. 이 한 문장이 이 장 전체의 뼈대다. (출처: PostgreSQL — Concurrency Control / MySQL 8.4 — InnoDB Locking.)
2PL — 락으로 직렬성을 보장한다
가장 고전적인 방법은 락이다. **2단계 잠금(Two-Phase Locking)**은 직렬 가능성(여러 트랜잭션을 한 줄로 세운 것과 같은 결과)을 보장하는 규칙이다 — 트랜잭션이 락을 얻기만 하는 확장 단계(growing) 뒤에 *풀기만 하는 수축 단계(shrinking)*로 나뉘고, 한 번 락을 풀기 시작하면 새 락을 못 얻는다. 실무 DB는 Strict 2PL을 써 모든 쓰기 락을 커밋 시점까지 쥔다(중간에 풀면 남이 미완성 데이터를 읽는 dirty read·연쇄 롤백이 생기므로).
락은 두 종류다. **공유 락(S, shared)**은 읽기용이라 여럿이 동시에 잡을 수 있고, **배타 락(X, exclusive)**은 쓰기용이라 혼자만 잡는다. 거기에 행 락·테이블 락과, *"이 테이블 안 어딘가에 행 락을 걸 것"*을 미리 표시해 충돌 판단을 빠르게 하는 **의도 락(IS/IX)**이 있다. 락끼리의 양립 가능성은 한 표로 외운다 — 공유끼리만 같이 잡힌다.
| 요청 ↓ \ 보유 → | S(공유) | X(배타) |
|---|---|---|
| S(공유) | ✅ 가능 | ❌ 대기 |
| X(배타) | ❌ 대기 | ❌ 대기 |
여기서 순수 2PL의 치명적 한계가 드러난다 — 위 표의 ❌들이 말하듯, 읽기(S락)가 쓰기(X락)를 막고, 쓰기가 읽기를 막는다. 즉 한 사람이 어떤 행을 읽는 동안 아무도 그 행을 못 바꾸고, 누가 바꾸는 동안 아무도 못 읽는다 — 동시성이 극도로 떨어진다. 보고서를 만드느라 큰 테이블을 읽는 트랜잭션이 그 테이블 전체의 쓰기를 막아 버리는 식이다. 이 한계를 깬 게 MVCC다.
MVCC — 읽기와 쓰기가 서로 다른 버전을 본다
**다중 버전 동시성 제어(MVCC)**의 핵심 약속은 단 한 문장 — 읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않는다. 어떻게 가능할까? 비결은 한 행의 여러 버전을 동시에 유지하고, *각 트랜잭션에 "자기가 시작한 시점의 일관된 스냅샷"*을 보여주는 것이다. 은행 명세서에 비유하면 — 내가 잔액을 읽기 시작한 순간의 "사진"을 보고, 그 사이 다른 사람이 입금해 잔액이 바뀌어도 내 사진은 안 흔들린다. UPDATE는 기존 행을 덮어쓰지 않고 새 버전을 만들고, 옛 버전은 아직 그걸 봐야 할 트랜잭션을 위해 잠시 남는다 — 그래서 읽는 사람은 옛 버전을, 쓰는 사람은 새 버전을 봐 서로 안 막는다.
PostgreSQL의 MVCC — 힙에 버전을 쌓고 VACUUM으로 청소
PostgreSQL은 버전(튜플)을 테이블(힙) 안에 직접 쌓는다. 각 튜플엔 *xmin(만든 트랜잭션 ID)·xmax(지운/갱신한 트랜잭션 ID)*가 있어, 트랜잭션은 자기 스냅샷에서 보여야 할 버전만 고른다. UPDATE는 옛 튜플을 "죽은 튜플(dead tuple)"로 표시하고 새 튜플을 추가한다.
문제는 — 죽은 튜플이 계속 쌓이면 테이블이 부푼다(bloat). 그래서 VACUUM(보통 autovacuum)이 더는 어떤 스냅샷도 안 보는 죽은 튜플의 공간을 회수한다. 여기서 [13 실무사례]의 사고가 나온다 — 아주 오래 열린 트랜잭션(배치·안 닫은 커넥션)이 있으면, VACUUM이 "그 트랜잭션이 아직 볼 수도 있는" 죽은 튜플을 못 지워서 테이블·인덱스가 계속 부풀고 느려진다. 짧은 트랜잭션이 중요한 근본 이유다. (출처: PostgreSQL — Routine Vacuuming.)
MySQL InnoDB의 MVCC — undo log로 과거를 재구성
InnoDB는 버전을 행에 쌓는 대신 undo log로 관리한다. 각 행엔 숨은 칼럼 *DB_TRX_ID(마지막 변경 트랜잭션)·DB_ROLL_PTR(undo log 포인터)*가 있고, 옛 버전이 필요하면 undo log를 거꾸로 따라가 그 시점을 재구성한다. 트랜잭션은 read view(자기 시점에 보이는 트랜잭션 ID 집합)로 가시성을 판단하고, 더는 필요 없는 undo는 purge 스레드가 정리한다 — 긴 트랜잭션이 purge를 막아 undo 히스토리가 길어지는 문제는 PostgreSQL의 bloat와 판박이다.
락과 MVCC가 만나는 곳 — 쓰기와 갭 락
읽기는 MVCC로 락 없이 되지만, 쓰기끼리는 여전히 충돌한다. UPDATE/DELETE/SELECT ... FOR UPDATE는 행에 배타 락을 걸어, 같은 행을 동시에 바꾸려는 뒤엣것이 기다린다.
MySQL InnoDB가 Repeatable Read에서 phantom까지 막는 비결이 갭 락(gap lock)·**넥스트키 락(행 락+갭 락)**이다. 범위 조건으로 잠그면 존재하는 행뿐 아니라 행 사이의 '틈'까지 잠가, 그 틈에 새 행이 INSERT되는 것(=phantom)을 막는다.
-- InnoDB(RR): 100~200 범위의 '틈'까지 잠가 새 INSERT를 차단(phantom 방지)
SELECT * FROM orders WHERE amount BETWEEN 100 AND 200 FOR UPDATE;
PostgreSQL은 갭 락이 없고 Repeatable Read=스냅샷으로 phantom을 막되 *write skew는 Serializable(SSI)*에서 잡는다 — 같은 목표, 다른 메커니즘이다([04]).
데드락 — 서로의 락을 기다리며 멈춤
두 트랜잭션이 서로가 쥔 락을 기다리면 영원히 멈춘다 — 데드락이다. 전형은 락 순서가 엇갈릴 때다.
-- T1 -- T2
UPDATE acct SET bal=bal-1 UPDATE acct SET bal=bal-1
WHERE id='A'; -- A 잠금 WHERE id='B'; -- B 잠금
UPDATE acct SET bal=bal+1 UPDATE acct SET bal=bal+1
WHERE id='B'; -- B 대기(T2 보유) WHERE id='A'; -- A 대기(T1 보유) → 순환 대기
PostgreSQL·InnoDB 둘 다 데드락을 자동 탐지해 한쪽을 희생자로 롤백시킨다(InnoDB ERROR 1213, PostgreSQL deadlock detected). 그래서 애플리케이션은 데드락을 정상 상황으로 보고 재시도해야 한다. 줄이는 법은 고전적이다 — (1) 항상 같은 순서로 락(A→B로 통일하면 순환이 안 생긴다), (2) 트랜잭션을 짧게, (3) 필요한 행만 좁게 잠근다, (4) 적절한 인덱스로 락 범위 축소(인덱스가 없으면 더 많은 행·갭을 잠가 데드락 확률↑).
정리 — 읽기는 버전, 쓰기는 락
동시성 제어는 읽기는 MVCC 스냅샷(락 없이)·쓰기는 락이라는 분업이다. PostgreSQL(힙 버전+VACUUM)과 MySQL InnoDB(undo log+purge·갭락)는 구현은 달라도 원리는 같다. 그리고 긴 트랜잭션은 두 엔진 모두에서 독이다 — 버전 청소를 막아 시스템을 부풀린다. 데드락은 피할 수 없으니 자동 탐지+재시도로 다루되, 락 순서 통일·짧은 트랜잭션·좋은 인덱스로 빈도를 낮춘다.
한 줄 요약 — 2PL은 락으로 직렬성 보장(S/X·의도락, 그러나 읽기↔쓰기가 서로 막아 동시성↓). MVCC는 버전을 나눠 읽기는 옛 버전·쓰기는 새 버전을 봐 안 부딪힘. 현대 DB=읽기 MVCC + 쓰기 락 혼합. PostgreSQL은 힙 튜플(xmin/xmax)+VACUUM, InnoDB는 undo log+purge·갭락(phantom 차단). 긴 트랜잭션은 독(버전 청소 방해). 데드락은 자동 탐지·롤백→재시도, 줄이려면 락 순서 통일·짧게·좋은 인덱스.
(출처: PostgreSQL 17 — MVCC·Routine Vacuuming / MySQL 8.4 — InnoDB Locking & MVCC / Silberschatz DBSC 7e Ch 18.)