2단계 로킹 규약 완전 정복 — 2PL, Strict 2PL, 직렬 가능성까지
2PL, 왜 따로 알아야 하나요?
데이터베이스 락 글에서 공유 락(S), 배타 락(X), 갭 락, 넥스트 키 락을 정리했고, SELECT ... FOR UPDATE 글에서는 비관적 락이 언제 필요한지도 살펴봤습니다. 그런데 락 종류를 안다고 해서 동시성 제어를 다 이해한 것은 아닙니다.
실제로는 이런 질문이 남습니다.
- 락을 어떤 순서로 획득하고 해제해야 안전할까요?
- 왜 어떤 스케줄은 직렬 실행한 것처럼 안전하고, 어떤 스케줄은 꼬일까요?
Strict 2PL이 왜 복구와 데드락 이야기에서 자주 같이 나올까요?
이 질문에 답하는 것이 2단계 로킹 규약(2PL, Two-Phase Locking) 입니다. 이 글에서는 교과서적인 정의만 나열하지 않고, 직렬 가능성, 데드락, MySQL InnoDB의 실제 동작까지 연결해서 보겠습니다.
먼저 가장 짧은 답부터 보면
- 2PL은 트랜잭션이 락을 다루는 과정을 확장 단계와 축소 단계로 나누는 규약입니다
- 핵심 규칙은 단순합니다. 한 번 락을 해제하기 시작하면, 그 뒤로는 새로운 락을 얻을 수 없습니다
- 이 규약의 목적은 동시 실행 결과를 충돌 직렬가능(conflict-serializable) 하게 만드는 것입니다
- Strict 2PL은 배타 락을 커밋/롤백까지 유지해서 cascading abort를 막고 복구를 단순하게 만듭니다
- 다만 2PL이 곧 데드락 방지를 뜻하는 것은 아닙니다. 데드락은 여전히 생길 수 있습니다
- 그리고 MySQL InnoDB는 순수한 2PL 엔진이 아닙니다. 일반
SELECT는 MVCC 스냅샷 읽기로 처리하고, 잠금 읽기와 쓰기에서 락을 적극적으로 사용합니다
Phase 1. 락 종류, 전략, 규약은 서로 다릅니다
이 셋을 섞어서 이해하면 2PL이 애매해집니다.
1. 락 종류
무엇을 어떤 방식으로 잠글지를 말합니다.
- 공유 락(S)
- 배타 락(X)
- 갭 락
- 넥스트 키 락
즉, 락의 모양에 대한 이야기입니다.
2. 락 전략
충돌을 언제 처리할지에 대한 이야기입니다.
- 비관적 락 — 충돌이 날 것이라 보고 먼저 잠급니다
- 낙관적 락 — 끝에서 충돌을 검증합니다
즉, 운영 방식에 대한 이야기입니다.
3. 락 규약
트랜잭션이 언제 락을 얻고, 언제 풀 수 있는지에 대한 규칙입니다. 2PL은 여기에 속합니다.
즉, 2PL은 "S 락을 쓸까 X 락을 쓸까"의 문제가 아니라, 락 획득과 해제의 순서를 어떻게 제한할 것인가의 문제입니다.
Phase 2. 2PL의 핵심 규칙 — Growing phase와 Shrinking phase
2PL은 트랜잭션의 락 동작을 두 단계로 나눕니다.
확장 단계 (Growing Phase)
- 새로운 락을 획득할 수 있습니다
- 이미 가진 락의 업그레이드도 가능합니다
- 하지만 락을 해제할 수는 없습니다
축소 단계 (Shrinking Phase)
- 락을 해제할 수 있습니다
- 하지만 새로운 락을 획득할 수는 없습니다
가장 중요한 규칙은 이것입니다.
첫 번째
UNLOCK이후에는 새로운LOCK이 나오면 안 됩니다
간단히 쓰면 이런 모양입니다.
확장 단계: LOCK(A) -> LOCK(B) -> LOCK(C)
축소 단계: UNLOCK(C) -> UNLOCK(B) -> UNLOCK(A)
허용되지 않는 형태:
LOCK(A) -> UNLOCK(A) -> LOCK(B)
마지막 예시가 바로 2PL 위반입니다. A를 풀기 시작한 뒤에 B를 새로 잡으려 했기 때문입니다.
예시로 보면 더 직관적입니다
계좌 이체를 처리하는 트랜잭션이 from_account, to_account 두 행을 모두 수정해야 한다고 가정해 보겠습니다.
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT;
이 흐름은 보통 다음처럼 해석할 수 있습니다.
- 먼저 필요한 락을 모두 획득합니다
- 작업을 수행합니다
- 트랜잭션 종료 시점에 락을 해제합니다
즉, 실무에서 많이 보는 비관적 락 패턴은 대개 Strict 2PL에 가까운 형태입니다.
Phase 3. 왜 2PL이면 직렬 가능성이 생길까?
교과서에서는 보통 락 포인트(lock point) 라는 개념으로 설명합니다.
락 포인트는 각 트랜잭션이 마지막 락을 획득한 순간입니다. 2PL을 지키는 트랜잭션은 이 시점을 지나면 더 이상 새 락을 잡지 못하고, 이제는 해제만 할 수 있습니다.
직관은 이렇습니다.
- 트랜잭션 A가 어떤 데이터에 필요한 락을 모두 잡았습니다
- 그 시점 이후에는 A가 갑자기 새로운 충돌 지점을 만들어 내지 못합니다
- 그래서 여러 트랜잭션의 동시 실행을 각 트랜잭션의 락 포인트 순서로 직렬 실행한 것처럼 재배열할 수 있습니다
이 때문에 모든 트랜잭션이 2PL을 따르면 그 스케줄은 충돌 직렬가능합니다.
그림으로 보면
트랜잭션 T1: LOCK-X(A) -> LOCK-X(B) -> [lock point] -> UNLOCK(B) -> UNLOCK(A)
트랜잭션 T2: LOCK-S(C) -> [lock point] -> UNLOCK(C)
위 스케줄에서 T1의 락 포인트가 T2보다 먼저라면, 전체 실행 결과를 T1 -> T2 순서로 직렬 실행한 것과 같은 결과로 해석할 수 있습니다.
여기서 중요한 점은, 2PL이 실제로 한 번에 하나씩 실행한다는 뜻은 아니라는 것입니다. 동시 실행은 유지하되, 결과가 직렬 실행과 충돌 관점에서 동일하도록 제한하는 것입니다.
Phase 4. 2PL의 변형 — Basic, Strict, Rigorous, Conservative
실제로는 "2PL"이라고 해도 한 종류만 있는 것이 아닙니다. 자주 나오는 네 가지를 구분해 두는 편이 좋습니다.
Basic 2PL
가장 기본 규칙만 지킵니다.
- 락을 획득하는 구간과 해제하는 구간을 섞지 않습니다
- 한 번 해제를 시작하면 새 락을 잡지 않습니다
장점은 충돌 직렬가능성을 보장한다는 점입니다. 하지만 이것만으로는 복구가 깔끔하지 않을 수 있습니다.
예를 들어 이런 상황이 가능합니다.
T1: X-lock(A) -> WRITE(A=100) -> UNLOCK(A)
T2: S-lock(A) -> READ(A=100)
T1: ROLLBACK
T2는 T1이 아직 커밋하지 않은 값을 읽었습니다. 만약 T1이 롤백되면 T2도 함께 문제가 될 수 있습니다. 이것이 cascading abort 문제입니다.
Strict 2PL
배타 락(X lock)을 트랜잭션 종료 시점까지 유지합니다.
- 새 락 획득/해제 규칙은 여전히 2PL을 따릅니다
- 여기에 더해, 쓴 데이터에 대한 X 락은 커밋/롤백 전까지 풀지 않습니다
이렇게 하면 다른 트랜잭션이 아직 커밋되지 않은 쓰기 결과를 덮어쓰거나, 그 결과에 의존해 연쇄적으로 꼬이는 일을 막기 쉬워집니다. 그래서:
- Dirty Write
- Cascading Abort
를 막는 데 특히 유리합니다. 그리고 읽기도 락으로 제어하는 모델에서는 Dirty Read 방지와도 자연스럽게 연결됩니다. 실무에서 "DB 락은 보통 트랜잭션 끝까지 들고 간다"는 감각은 대부분 이 계열에서 옵니다.
Rigorous 2PL
공유 락과 배타 락을 모두 트랜잭션 종료 시점까지 유지합니다.
- X 락만이 아니라 S 락도 커밋까지 유지합니다
- 가장 이해하기 쉬운 형태의 락 기반 직렬화입니다
대신 블로킹이 더 많습니다. 읽기 락도 오래 들고 있으니 동시성이 더 떨어질 수 있습니다.
Conservative 2PL
필요한 락을 시작 전에 모두 확보한 뒤에만 실행합니다. Static 2PL이라고도 부릅니다.
트랜잭션 시작 전:
LOCK-X(A), LOCK-X(B)를 모두 얻을 수 있으면 시작
둘 중 하나라도 못 얻으면 아예 시작하지 않고 대기
이 방식의 핵심은 일부 락을 쥔 채 나머지 락을 기다리지 않는다는 점입니다. 그래서 데드락을 원천적으로 방지할 수 있습니다.
대신 단점도 분명합니다.
- 미리 어떤 락이 필요한지 알아야 합니다
- 시작 전에 많이 기다릴 수 있습니다
- 동시성이 떨어집니다
Phase 5. 2PL이 해결하는 것과 못 하는 것
2PL은 강력하지만, 만능은 아닙니다.
1. 해결하는 것: 충돌 직렬가능성
가장 핵심적인 보장은 이것입니다.
- 여러 트랜잭션이 동시에 실행되어도
- 락 충돌 관점에서는
- 어떤 직렬 실행 순서와 같은 결과를 만들 수 있습니다
즉, "결과가 마치 순서대로 실행한 것처럼 보이게 만든다" 가 2PL의 핵심 가치입니다.
2. 못 하는 것: 데드락 제거
Basic 2PL이나 Strict 2PL에서도 데드락은 충분히 생길 수 있습니다.
트랜잭션 A 트랜잭션 B
─────────────────────────────────────────────────
LOCK-X(행 1)
LOCK-X(행 2)
LOCK-X(행 2) -> 대기
LOCK-X(행 1) -> 대기
둘 다 아직 확장 단계에 있습니다. 즉, 2PL을 위반한 것도 아닙니다. 그럼에도 순환 대기가 만들어져 데드락이 됩니다.
그래서 실무에서는 2PL을 안다는 것만으로는 부족하고, 데이터베이스 락 글에서 본 것처럼:
- 락 획득 순서를 통일하고
- 트랜잭션을 짧게 유지하고
- 적절한 인덱스로 잠금 범위를 좁히는 습관
이 함께 필요합니다.
3. 못 하는 것: 잘못된 잠금 대상 보완
2PL은 언제 잠글지에 대한 규칙이지, 무엇을 잠글지를 자동으로 정해 주지는 않습니다.
예를 들어 팬텀 문제를 막으려면 단순히 기존 행만 잠그는 것으로는 부족하고, 검색 범위 자체를 잠가야 할 수 있습니다. MySQL InnoDB가 트랜잭션 격리 수준과 데이터베이스 락에서 본 갭 락과 넥스트 키 락을 사용하는 이유가 여기에 있습니다.
즉, 2PL + 적절한 잠금 단위가 함께 가야 합니다.
Phase 6. 실무에서는 어떻게 보이나?
이론을 실제 코드에 연결하면 보통 이렇게 보입니다.
1. SELECT ... FOR UPDATE는 Strict 2PL에 가까운 패턴입니다
START TRANSACTION;
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 재고 확인
-- 비즈니스 로직 수행
UPDATE products
SET stock = stock - 1
WHERE id = 1;
COMMIT;
이 패턴은:
- 읽기 시점에 X 락을 잡고
- 트랜잭션 끝까지 유지한 뒤
- 커밋 시점에 락을 해제합니다
즉, 실무 감각으로 보면 Strict 2PL 스타일의 비관적 락 사용에 가깝습니다.
2. 여러 행을 잠글 때는 항상 순서를 맞추는 편이 좋습니다
2PL은 직렬 가능성을 높여 주지만, 락 순서를 통일해 주지는 않습니다. 예를 들어 송금처럼 두 계좌를 동시에 잠가야 한다면 보통 id 오름차순으로 접근 규칙을 맞춥니다.
항상 작은 id를 먼저 잠금
id=1 -> id=2
이렇게 해야 서로 반대 순서로 들어가며 데드락을 만드는 일을 줄일 수 있습니다.
3. 락으로 해결할 수 있어도, 더 짧은 SQL이 있으면 먼저 봐야 합니다
모든 경쟁 구간을 FOR UPDATE로 감쌀 필요는 없습니다.
UPDATE products
SET stock = stock - 1
WHERE id = 1
AND stock > 0;
이런 쿼리로 규칙을 한 문장에 담을 수 있다면:
- 락 보유 시간이 짧고
- 코드가 단순하며
- 충돌 면적이 줄어듭니다
즉, 2PL은 중요한 이론이지만, 실무에서 항상 가장 좋은 구현 방식이 장시간 락 보유라는 뜻은 아닙니다.
Phase 7. MySQL InnoDB를 2PL로만 보면 안 되는 이유
실무에서 가장 자주 헷갈리는 지점입니다.
일반 SELECT는 보통 S 락을 잡지 않습니다
InnoDB의 일반 SELECT는 대개 MVCC 기반 스냅샷 읽기입니다. 즉:
- 다른 트랜잭션이 쓰고 있어도
- 이전 버전을 읽고
- 블로킹 없이 진행할 수 있습니다
이 부분은 MVCC 글에서 본 것처럼 락 기반 직렬화와는 다른 축입니다.
잠금 읽기와 쓰기에서는 락 규약이 중요합니다
반면 다음 연산은 락을 직접 씁니다.
SELECT ... FOR UPDATESELECT ... FOR SHAREUPDATEDELETE
이 경우 InnoDB는 레코드 락, 갭 락, 넥스트 키 락 등을 잡고, 공식 문서 기준으로 커밋이나 롤백 시점까지 유지되는 잠금을 중심으로 동작합니다. 다만 READ COMMITTED에서는 InnoDB가 WHERE 조건에 일치하지 않았던 스캔 행의 레코드 락을 구문(statement) 완료 후 해제합니다. 이런 early release 예외는 있지만, 갱신된 행에 대한 잠금은 여전히 트랜잭션이 끝날 때까지 유지되므로, 이 구간은 실무적으로 Strict 2PL에 가까운 느낌으로 이해하는 편이 맞습니다.
그래서 InnoDB는 하이브리드로 봐야 합니다
정리하면 InnoDB는:
- 일반 읽기에는 MVCC
- 잠금 읽기와 쓰기에는 락
을 함께 사용합니다.
즉, "InnoDB는 2PL 엔진이다"라고만 보면 MVCC를 놓치고, 반대로 "MVCC라서 락은 중요하지 않다"라고 보면 FOR UPDATE, 데드락, 넥스트 키 락 문제를 놓치게 됩니다.
실무에서는 MVCC + 락 기반 제어가 같이 동작하는 하이브리드 모델로 이해하는 편이 가장 안전합니다.
한눈에 보는 2PL 변형
| 규약 | 핵심 규칙 | 장점 | 주의점 |
|---|---|---|---|
| Basic 2PL | 해제 시작 후 새 락 획득 금지 | 충돌 직렬가능성 보장 | 데드락, cascading abort 가능 |
| Strict 2PL | X 락을 커밋/롤백까지 유지 | 복구 단순, Dirty Write/Cascading Abort 방지 | 대기 시간 증가 |
| Rigorous 2PL | S/X 락 모두 종료 시점까지 유지 | 가장 이해하기 쉬운 형태 | 블로킹 증가 |
| Conservative 2PL | 시작 전에 필요한 락을 모두 확보 | 데드락 방지 | 필요한 락 집합을 미리 알아야 함 |
정리
- 2PL은 락의 종류가 아니라 락의 사용 규칙입니다 — 한 번 풀기 시작하면 새로 잡지 않는다는 규칙이 핵심입니다
- 핵심 목적은 충돌 직렬가능성입니다 — 동시 실행 결과를 직렬 실행과 같은 결과로 보이게 만듭니다
- 실무에서는 Strict 2PL 감각이 더 중요합니다 — X 락을 트랜잭션 끝까지 유지해야 복구와 정합성 측면에서 다루기 쉽습니다
- 2PL만으로 데드락이 없어지지는 않습니다 — 락 순서 통일, 짧은 트랜잭션, 적절한 인덱스가 함께 필요합니다
- InnoDB는 순수 2PL이 아니라 MVCC와 락의 조합입니다 — 일반
SELECT와 잠금 읽기/쓰기를 분리해서 이해해야 합니다
핵심을 한 문장으로 줄이면 이렇습니다.
"락의 종류를 아는 것" 다음 단계는, 그 락을 어떤 순서와 규칙으로 운용해야 직렬 가능성과 복구 안정성을 얻는지 이해하는 것이고, 그 대표 규약이 바로 2PL입니다
DNS 완전 정복 — 재귀 질의, `TTL`, `A`/`AAAA`/`CNAME`, 권한 DNS까지
다음 글비트 연산자 완전 정복 — `AND`, `OR`, `XOR`, `NOT`, 시프트를 이진수로 이해하기