락과 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 (테이블 공유 + 일부 행 배타)잠금 범위: 행, 페이지, 테이블
세밀 ←───────────────────────────────────────→ 거침
행(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)은 직렬 가능성을 보장하는 잠금 프로토콜입니다.
확장 단계 (Growing Phase)
→ 락을 획득만 할 수 있고, 해제할 수 없음
축소 단계 (Shrinking Phase)
→ 락을 해제만 할 수 있고, 새로 획득할 수 없음
시간 →
락 수 ↑ / \
│ / \
│ / \
│ / 확장 \ 축소
│ / 단계 \ 단계
│/ \
└─────────────→ 시간2PL을 지키면 충돌 직렬 가능성(Conflict Serializability)이 보장됩니다. 하지만 데드락이 발생할 수 있습니다.
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)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별 데드락 처리
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)을 사용합니다.
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) |
-- 행 수준 락 (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;-- 테이블 전체 공유 잠금
LOCK TABLE orders IN SHARE MODE;
-- 테이블 전체 배타 잠금 (DDL 보호 등)
LOCK TABLE orders IN EXCLUSIVE MODE;
-- NOWAIT으로 즉시 실패
LOCK TABLE orders IN EXCLUSIVE MODE NOWAIT;Oracle에서 SELECT는 락을 걸지 않습니다 (MVCC 덕분). 읽기는 절대 쓰기를 차단하지 않고, 쓰기도 읽기를 차단하지 않습니다.
잠금 모니터링
-- 현재 잠금 대기 확인
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 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 섹션 확인-- 잠금 대기 현황
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';잠금 타임아웃 설정
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 패턴 (큐 처리)
-- 여러 워커가 동시에 작업을 처리하는 큐 패턴
-- 워커 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이 잠근 행을 건너뛰고 다음 행 처리
-- → 대기 없이 병렬 처리 가능!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 2PL | X-Lock을 COMMIT까지 유지, 연쇄 복귀 방지 |
| 데드락 | 순환 대기, DBMS가 탐지 후 희생자 롤백 |
| Gap Lock | InnoDB의 범위 잠금, Phantom Read 방지 |
| Next-Key Lock | Record + 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를 다루겠습니다.