icon

안동민 개발노트

11장 : 동시성 제어

락과 2단계 잠금


격리 수준을 구현하는 가장 기본적인 메커니즘이 락(Lock)입니다. 누가 데이터를 사용하고 있으면 다른 사람은 기다리는 방식입니다. 락의 종류, 적용 범위, 데드락 처리까지 이해해야 실무에서 동시성 문제를 진단할 수 있습니다.


공유 락과 배타 락

락 유형약어용도호환성
공유 락 (Shared Lock)S-Lock읽기 (SELECT)S-Lock끼리 호환 (동시 읽기 가능)
배타 락 (Exclusive Lock)X-Lock쓰기 (UPDATE, DELETE)다른 모든 락과 불호환
락 호환 매트릭스
              이미 걸린 락
             S-Lock   X-Lock
요청하는 락
  S-Lock      ✓ 허용    ✗ 대기
  X-Lock      ✗ 대기    ✗ 대기

의미:
  S + S = 허용 → 여러 트랜잭션이 동시에 읽기 가능
  S + X = 대기 → 누군가 읽고 있으면 쓰기 대기
  X + S = 대기 → 누군가 쓰고 있으면 읽기 대기
  X + X = 대기 → 누군가 쓰고 있으면 쓰기 대기
락 동작 예시
T1: SELECT * FROM orders WHERE id = 1;  → S-Lock(id=1)
T2: SELECT * FROM orders WHERE id = 1;  → S-Lock(id=1)  ✓ 허용
T3: UPDATE orders SET status = 'X' WHERE id = 1; → X-Lock 요청
    → T1, T2가 S-Lock을 해제할 때까지 대기!

참고: MVCC 환경에서는 일반 SELECT가 S-Lock을 걸지 않음
  * Oracle: SELECT는 항상 무잠금 (Undo 버전 읽기)
  * MySQL InnoDB: 일반 SELECT는 무잠금 (Snapshot Read)
  * 락이 걸리는 경우: SELECT ... FOR SHARE / FOR UPDATE

의도 잠금 (Intent Lock)

행 수준 잠금이 설정될 때, 상위 수준(테이블)에 의도 잠금이 함께 설정됩니다.

의도 잠금과 잠금 에스컬레이션
  테이블 수준 잠금        행 수준 잠금
  IS (Intent Shared)    → 하위에 S-Lock이 있음을 표시
  IX (Intent Exclusive) → 하위에 X-Lock이 있음을 표시

목적
  다른 트랜잭션이 테이블 수준 잠금(LOCK TABLE)을 걸려고 할 때
  모든 행을 확인하지 않고 테이블 의도 잠금만 확인하면 됨

예시
  T1: UPDATE orders SET status='X' WHERE id=1;
      → 행: X-Lock(id=1), 테이블: IX-Lock(orders)

  T2: LOCK TABLE orders IN EXCLUSIVE MODE;
      → orders에 IX가 있으므로 바로 대기! (모든 행 검사 불필요)
테이블 수준 잠금 호환 매트릭스
요청 \ 보유   IS    IX    S    SIX    X
  IS          ✓     ✓     ✓    ✓      ✗
  IX          ✓     ✓     ✗    ✗      ✗
  S           ✓     ✗     ✓    ✗      ✗
  SIX         ✓     ✗     ✗    ✗      ✗
  X           ✗     ✗     ✗    ✗      ✗

S   = Shared (테이블 전체 공유 잠금)
X   = Exclusive (테이블 전체 배타 잠금)
IS  = Intent Shared
IX  = Intent Exclusive
SIX = S + IX (테이블 공유 + 일부 행 배타)

잠금 범위: 행, 페이지, 테이블

잠금 단위(Granularity)
세밀 ←───────────────────────────────────────→ 거침
  행(Row)   페이지(Page)   테이블(Table)   데이터베이스

행 수준 잠금
  * Oracle, MySQL InnoDB, PostgreSQL 기본
  * 동시성 높음, 잠금 관리 오버헤드 큼
  * 대부분의 OLTP에 적합

페이지 수준 잠금
  * SQL Server가 사용 (행 또는 페이지 선택)
  * 행보다 거칠지만 테이블보다 세밀

테이블 수준 잠금
  * MySQL MyISAM 엔진 기본
  * 동시성 매우 낮음, 관리 오버헤드 작음
  * LOCK TABLE 명령으로 명시적 사용

잠금 에스컬레이션 (Lock Escalation)
  * SQL Server에서 발생
  * 한 트랜잭션이 너무 많은 행 잠금을 보유하면
  * 자동으로 테이블 잠금으로 승격
  * 잠금 관리 메모리 절약, but 동시성 저하

2단계 잠금 프로토콜 (2PL)

2단계 잠금(Two-Phase Locking, 2PL)은 직렬 가능성을 보장하는 잠금 프로토콜입니다.

2PL의 두 단계
확장 단계 (Growing Phase)
  → 락을 획득만 할 수 있고, 해제할 수 없음

축소 단계 (Shrinking Phase)
  → 락을 해제만 할 수 있고, 새로 획득할 수 없음

시간 →      
락 수 ↑    / \
     │    /   \
     │   /     \
     │  / 확장  \ 축소
     │ /  단계   \ 단계
     │/           \
     └─────────────→ 시간

2PL을 지키면 충돌 직렬 가능성(Conflict Serializability)이 보장됩니다. 하지만 데드락이 발생할 수 있습니다.

2PL의 변형

2PL 변형들
Basic 2PL
  * 축소 단계에서 락 해제 → 다른 트랜잭션이 읽음
  * 그 후 원래 트랜잭션이 ROLLBACK → 연쇄 복귀(Cascading Rollback)!

Strict 2PL (S2PL)
  * X-Lock은 트랜잭션 종료(COMMIT/ROLLBACK) 시까지 유지
  * 연쇄 복귀 방지
  * 대부분의 DBMS가 사용하는 방식

Rigorous 2PL
  * S-Lock, X-Lock 모두 트랜잭션 종료 시까지 유지
  * 가장 엄격, 가장 안전

실제 DBMS
  Oracle: S2PL (읽기는 MVCC이므로 S-Lock 자체를 잘 안 씀)
  MySQL InnoDB: S2PL + MVCC 결합
  PostgreSQL: S2PL + MVCC + SSI(Serializable)
연쇄 복귀(Cascading Rollback) 문제
Basic 2PL에서
  T1: WRITE(A)  →  A에 X-Lock
  T1: UNLOCK(A) →  A 잠금 해제 (축소 단계 진입)
  T2: READ(A)   →  T1이 쓴 값을 읽음
  T1: ROLLBACK  →  T1의 변경 취소
  T2: ???       →  T2가 읽은 값이 무효화됨!
                →  T2도 ROLLBACK해야 함 (연쇄 복귀)

Strict 2PL에서
  T1: WRITE(A)  →  X-Lock, COMMIT까지 유지
  T2: READ(A)   →  대기... (T1이 끝날 때까지)
  T1: ROLLBACK  →  X-Lock 해제
  T2: READ(A)   →  원래 값을 읽음 (문제 없음!)

데드락

데드락(Deadlock, 교착 상태)은 두 트랜잭션이 서로가 가진 락을 기다리며 영원히 대기하는 상태입니다.

데드락 발생
T1                           T2
─────────────                ─────────────
LOCK(A) ✓
                             LOCK(B) ✓
LOCK(B) → 대기 (T2가 B를 잡고 있음)
                             LOCK(A) → 대기 (T1이 A를 잡고 있음)

→ T1은 B를 기다리고, T2는 A를 기다림
→ 영원히 끝나지 않음 = 데드락!

데드락 탐지

DBMS는 데드락을 탐지(Detection)하여 해결합니다. 주기적으로 대기 그래프(Wait-For Graph)를 검사하고, 사이클이 발견되면 한 트랜잭션을 강제 ROLLBACK합니다.

데드락 탐지 — 대기 그래프
T1 → T2 → T1  (사이클 발견!)
→ T1 또는 T2 중 하나를 의 ROLLBACK (희생자 선택)

희생자 선택 기준 (DBMS별 차이)
  * Oracle: 마지막에 데드락을 발생시킨 트랜잭션
  * MySQL InnoDB: Undo 레코드가 적은 (변경이 적은) 트랜잭션
  * PostgreSQL: 마지막에 데드락을 발생시킨 트랜잭션
  * SQL Server: 비용이 가장 적은 트랜잭션 (DEADLOCK_PRIORITY 설정 가능)

DBMS별 데드락 처리

DBMS별 데드락 탐지 주기
Oracle:
  * 데드락 발생 즉시 탐지 (대기 중인 트랜잭션이 스스로 확인)
  * 하나의 DML 문만 롤백 (전체 트랜잭션은 아님!)
  * ORA-00060: deadlock detected 에러 발생
  * 애플리케이션에서 명시적 ROLLBACK 또는 재시도 필요

MySQL InnoDB:
  * 5초마다 또는 대기 시작 시 탐지
  * 전체 트랜잭션 롤백
  * ERROR 1213: Deadlock found 에러
  * innodb_lock_wait_timeout (기본 50초)

PostgreSQL:
  * 1초마다 탐지 (deadlock_timeout, 기본 1초)
  * 전체 트랜잭션 롤백
  * ERROR: deadlock detected

SQL Server:
  * 5초마다 탐지
  * 전체 트랜잭션 롤백
  * SET DEADLOCK_PRIORITY로 희생자 우선순위 설정 가능

데드락 예방과 대응

방법설명
일관된 접근 순서모든 트랜잭션이 테이블 A → B 순서로 접근
트랜잭션 짧게락 점유 시간을 최소화
타임아웃 설정대기 시간 초과 시 롤백
인덱스 최적화인덱스 사용으로 락 범위 축소
NOWAIT 사용대기 대신 즉시 실패 처리
데드락 예방 실무 패턴
1. 접근 순서 통일:
   모든 트랜잭션에서 accounts → orders → items 순서로 접근
   → 순환 대기 불가능 → 데드락 원천 차단

2. SELECT FOR UPDATE NOWAIT:
   잠금 획득 실패 시 즉시 에러 → 애플리케이션에서 재시도
   → 대기 없이 빠른 실패

3. 인덱스를 통한 잠금 범위 최소화:
   인덱스 없이: UPDATE WHERE status='ACTIVE' → 전체 테이블 스캔
   → 불필요한 행까지 잠금!
   인덱스 있으면: 해당 행만 잠금 → 데드락 확률 감소

MySQL InnoDB의 갭 락과 넥스트키 락

MySQL InnoDB는 행 수준 잠금 외에 갭 락(Gap Lock)넥스트키 락(Next-Key Lock)을 사용합니다.

InnoDB 잠금 유형
Record Lock 
  * 인덱스 레코드에 대한 잠금
  * WHERE id = 10 → id=10 레코드만 잠금

Gap Lock
  * 인덱스 레코드 사이의 빈 공간에 대한 잠금
  * 해당 범위에 새 행 INSERT를 차단
  * REPEATABLE READ 이상에서 사용

Next-Key Lock
  * Record Lock + Gap Lock 결합
  * 레코드 자체 + 그 앞의 갭을 함께 잠금
  * InnoDB의 기본 잠금 방식
갭 락 예시
인덱스에 존재하는 값: 10, 20, 30

SELECT * FROM t WHERE id BETWEEN 15 AND 25 FOR UPDATE;

잠긴 범위
  (10, 20] → Next-Key Lock (20 레코드 + 10~20 갭)
  (20, 30] → Next-Key Lock (30 레코드 + 20~30 갭)

결과
  INSERT INTO t (id) VALUES (12); → Gap Lock에 의해 차단!
  INSERT INTO t (id) VALUES (22); → Gap Lock에 의해 차단!
  INSERT INTO t (id) VALUES (35); → 허용 (범위 밖)
  → Phantom Read 방지!
갭 락 관련 주의사항
갭 락으로 인한 의외의 대기
  T1: DELETE FROM t WHERE id = 15;
  → id=15가 존재하지 않으면 (10, 20) 갭에 Gap Lock!
  T2: INSERT INTO t (id) VALUES (12);
  → 갭 락에 의해 대기!

  → "없는 행 삭제"가 갭 락을 유발할 수 있음

갭 락 비활성화 (주의해서 사용)
  * 격리 수준을 READ COMMITTED로 변경
    → 갭 락 사용 안 함
  * innodb_locks_unsafe_for_binlog = 1 (deprecated)

Oracle의 락 체계

Oracle은 행 수준 락(Row-Level Lock)을 기본으로 사용합니다.

락 유형설명
TX 락 (Row Lock)행 수준의 배타 락, DML 시 자동 획득
TM 락 (Table Lock)테이블 수준의 의도 락 (RS, RX, S, SRX, X)
Oracle 명시적 락
-- 행 수준 락 (SELECT FOR UPDATE)
SELECT * FROM products WHERE id = 101 FOR UPDATE;
-- → 해당 행에 X-Lock, 다른 트랜잭션은 수정 대기

-- NOWAIT 옵션 (락 획득 실패 시 즉시 에러)
SELECT * FROM products WHERE id = 101 FOR UPDATE NOWAIT;

-- WAIT 옵션 (최대 5초 대기)
SELECT * FROM products WHERE id = 101 FOR UPDATE WAIT 5;

-- SKIP LOCKED (잠긴 행 건너뛰기 — 큐 패턴)
SELECT * FROM jobs WHERE status = 'PENDING'
FOR UPDATE SKIP LOCKED FETCH FIRST 1 ROW ONLY;
Oracle 테이블 수준 잠금
-- 테이블 전체 공유 잠금
LOCK TABLE orders IN SHARE MODE;

-- 테이블 전체 배타 잠금 (DDL 보호 등)
LOCK TABLE orders IN EXCLUSIVE MODE;

-- NOWAIT으로 즉시 실패
LOCK TABLE orders IN EXCLUSIVE MODE NOWAIT;

Oracle에서 SELECT는 락을 걸지 않습니다 (MVCC 덕분). 읽기는 절대 쓰기를 차단하지 않고, 쓰기도 읽기를 차단하지 않습니다.


잠금 모니터링

Oracle 잠금 모니터링
-- 현재 잠금 대기 확인
SELECT l1.sid blocking_sid, l2.sid waiting_sid,
       l1.type lock_type
FROM v$lock l1, v$lock l2
WHERE l1.id1 = l2.id1 AND l1.block = 1 AND l2.request > 0;

-- 블로킹 세션 상세 정보
SELECT blocking_session, sid, serial#, 
       wait_class, seconds_in_wait
FROM v$session WHERE blocking_session IS NOT NULL;

-- 잠금 트리 (누가 누구를 블로킹하는지)
SELECT LPAD(' ', (LEVEL-1)*2) || sid sid_tree,
       blocking_session, event, seconds_in_wait
FROM v$session
START WITH blocking_session IS NULL
CONNECT BY PRIOR sid = blocking_session;
MySQL 잠금 모니터링
-- 현재 잠금 대기 확인 (MySQL 8.0+)
SELECT * FROM performance_schema.data_lock_waits;

-- 잠금 상세 정보
SELECT * FROM performance_schema.data_locks;

-- InnoDB 상태 (데드락 정보 포함)
SHOW ENGINE INNODB STATUS;

-- 최근 데드락 로그 확인
-- SHOW ENGINE INNODB STATUS 출력에서
-- LATEST DETECTED DEADLOCK 섹션 확인
PostgreSQL 잠금 모니터링
-- 잠금 대기 현황
SELECT blocked_locks.pid AS blocked_pid,
       blocking_locks.pid AS blocking_pid,
       blocked_activity.query AS blocked_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_locks blocking_locks 
  ON blocking_locks.locktype = blocked_locks.locktype
WHERE NOT blocked_locks.granted;

-- 오래 대기중인 쿼리
SELECT pid, age(clock_timestamp(), query_start), query
FROM pg_stat_activity
WHERE state != 'idle' AND query_start < NOW() - INTERVAL '5 minutes';

잠금 타임아웃 설정

DBMS별 잠금 타임아웃
Oracle:
  * DDL_LOCK_TIMEOUT (DDL 대기 시간)
    ALTER SESSION SET DDL_LOCK_TIMEOUT = 10;  -- 10초
  * DML은 FOR UPDATE WAIT n 으로 개별 설정
  * 기본적으로 DML은 무한 대기

MySQL:
  * innodb_lock_wait_timeout = 50 (기본 50초)
    SET innodb_lock_wait_timeout = 10;  -- 세션 단위
  * lock_wait_timeout = 31536000 (메타데이터 잠금 대기)

PostgreSQL:
  * lock_timeout = 0 (기본 무한 대기)
    SET lock_timeout = '10s';
  * statement_timeout (쿼리 전체 타임아웃)
    SET statement_timeout = '30s';

SQL Server:
  * SET LOCK_TIMEOUT 10000; (밀리초 단위, 10초)
  * -1 = 무한 대기 (기본)

잠금과 인덱스의 관계

인덱스 유무에 따른 잠금 범위 차이
테이블: users (id, name, status)
인덱스: PK(id)만 존재, status에는 인덱스 없음

쿼리: UPDATE users SET name='X' WHERE status='ACTIVE';

인덱스 없음 (status)
  * Full Table Scan 발생
  * InnoDB: 스캔한 모든 행에 X-Lock! (ACTIVE가 아닌 행도!)
  * Oracle: ACTIVE인 행에만 X-Lock (MVCC 덕분)

인덱스 있음 (status)
  * Index Range Scan
  * ACTIVE인 행에만 정확하게 X-Lock
  * 불필요한 잠금 없음!

결론: 인덱스가 없으면 잠금 범위가 불필요하게 넓어질 수 있음
  → 잠금 경합 증가 → 데드락 확률 증가
  → WHERE 조건 컬럼에 적절한 인덱스 필수!

SKIP LOCKED 패턴 (큐 처리)

SKIP LOCKED를 이용한 작업 큐
-- 여러 워커가 동시에 작업을 처리하는 큐 패턴

-- 워커 1:
SELECT id, payload FROM job_queue
WHERE status = 'PENDING'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
FETCH FIRST 1 ROW ONLY;
-- → 잠긴 행(다른 워커가 처리 중)은 건너뛰고
-- → 잠금 가능한 첫 번째 행을 가져옴

UPDATE job_queue SET status = 'PROCESSING' WHERE id = ?;
COMMIT;

-- 워커 2:
-- 같은 쿼리 실행 → 워커1이 잠근 행을 건너뛰고 다음 행 처리
-- → 대기 없이 병렬 처리 가능!
SKIP LOCKED 지원 현황
Oracle: 12c+ (FOR UPDATE SKIP LOCKED)
MySQL: 8.0+ (FOR UPDATE SKIP LOCKED)
PostgreSQL: 9.5+ (FOR UPDATE SKIP LOCKED)
SQL Server: WITH (READPAST) 힌트로 유사 기능

정리

개념핵심 내용
공유 락 (S)읽기 잠금, 동시 읽기 허용
배타 락 (X)쓰기 잠금, 모든 다른 잠금 차단
의도 잠금테이블 수준에서 행 잠금 존재 표시
2PL확장 → 축소 단계로 직렬 가능성 보장
Strict 2PLX-Lock을 COMMIT까지 유지, 연쇄 복귀 방지
데드락순환 대기, DBMS가 탐지 후 희생자 롤백
Gap LockInnoDB의 범위 잠금, Phantom Read 방지
Next-Key LockRecord + Gap Lock 결합
SKIP LOCKED잠긴 행 건너뛰기, 큐 처리 패턴
잠금 타임아웃DBMS별 설정, 무한 대기 방지
잠금과 인덱스인덱스 없으면 불필요한 행까지 잠금

락은 데이터 일관성을 지키는 핵심 메커니즘이지만, 동시성을 저하시킬 수 있습니다. 현대 DBMS는 MVCC와 락을 결합하여 읽기-쓰기 차단을 제거하면서도 쓰기-쓰기 충돌은 락으로 제어합니다. 락의 범위를 줄이고, 트랜잭션을 짧게 유지하며, 접근 순서를 통일하는 것이 동시성 최적화의 핵심입니다.

잠금 관련 실무 체크리스트
1. 트랜잭션은 가능한 짧게 유지
   → 잠금 보유 시간 최소화 → 동시성 향상

2. WHERE 절 컬럼에 인덱스 확인
   → 인덱스 없으면 불필요한 행까지 잠금

3. 잠금 순서 통일
   → 모든 트랜잭션에서 동일한 순서로 테이블/행 접근
   → 데드락 원천 차단

4. 적절한 격리 수준 선택
   → 불필요하게 높은 격리 수준 = 불필요한 잠금

5. FOR UPDATE NOWAIT 또는 WAIT n 사용
   → 무한 대기 방지, 빠른 실패 후 재시도

6. 대량 DML은 배치 처리
   → 한 번에 100만 행 UPDATE → 잠금 폭발
   → 1000행씩 나눠서 COMMIT → 안전

7. 잠금 모니터링 쿼리 준비
   → 운영 환경에서 잠금 경합 즉시 감지

8. 데드락 로그 분석 습관화
   → 반복되는 데드락 패턴을 찾아 코드 수정

다음 절에서는 락 없이 동시성을 높이는 MVCC를 다루겠습니다.

목차