2단계 로킹 규약 완전 정복 — 2PL, Strict 2PL, 직렬 가능성까지

스터디·11분 읽기

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;

이 흐름은 보통 다음처럼 해석할 수 있습니다.

  1. 먼저 필요한 락을 모두 획득합니다
  2. 작업을 수행합니다
  3. 트랜잭션 종료 시점에 락을 해제합니다

즉, 실무에서 많이 보는 비관적 락 패턴은 대개 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

T2T1이 아직 커밋하지 않은 값을 읽었습니다. 만약 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 UPDATE
  • SELECT ... FOR SHARE
  • UPDATE
  • DELETE

이 경우 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 시작 전에 필요한 락을 모두 확보 데드락 방지 필요한 락 집합을 미리 알아야 함

정리

  1. 2PL은 락의 종류가 아니라 락의 사용 규칙입니다 — 한 번 풀기 시작하면 새로 잡지 않는다는 규칙이 핵심입니다
  2. 핵심 목적은 충돌 직렬가능성입니다 — 동시 실행 결과를 직렬 실행과 같은 결과로 보이게 만듭니다
  3. 실무에서는 Strict 2PL 감각이 더 중요합니다 — X 락을 트랜잭션 끝까지 유지해야 복구와 정합성 측면에서 다루기 쉽습니다
  4. 2PL만으로 데드락이 없어지지는 않습니다 — 락 순서 통일, 짧은 트랜잭션, 적절한 인덱스가 함께 필요합니다
  5. InnoDB는 순수 2PL이 아니라 MVCC와 락의 조합입니다 — 일반 SELECT와 잠금 읽기/쓰기를 분리해서 이해해야 합니다

핵심을 한 문장으로 줄이면 이렇습니다.

"락의 종류를 아는 것" 다음 단계는, 그 락을 어떤 순서와 규칙으로 운용해야 직렬 가능성과 복구 안정성을 얻는지 이해하는 것이고, 그 대표 규약이 바로 2PL입니다

관련 글