격리 수준과 잠금
REPEATABLE READ의 스냅샷과 SELECT FOR UPDATE의 최신 잠금 읽기를 구분하고 게시글 버전 충돌·NOWAIT·교착 상태를 안전하게 처리합니다.
트랜잭션 두 개가 모두 원자적이어도 같은 행을 동시에 읽고 바꾸면 예상하지 못한 순서가 생깁니다.
격리 수준은 다른 트랜잭션의 변경을 언제 보게 할지 정하고, 잠금 읽기는 앞으로 변경할 행을 현재 버전 기준으로 잠급니다.
MySQL InnoDB의 기본 격리 수준인 REPEATABLE READ에서 일반 SELECT는 일관된 스냅샷을 읽습니다.
반면 SELECT ... FOR UPDATE는 현재 읽기에서 사용 가능한 값을 읽고 배타 잠금을 잡습니다. 다른 트랜잭션의 미확정 변경에는 대기할 수 있으며 자기 트랜잭션의 변경도 보입니다.
두 읽기를 섞어 잔액 판단을 하면 서로 다른 시점을 볼 수 있으므로 목적을 구분합니다.
독립 fixture인 member_point_accounts에서 회원 1의 게시판 포인트를 두 DB 세션이 동시에 차감하려 합니다.
세션 A가 행 잠금을 가진 동안 세션 B는 NOWAIT로 오류를 즉시 반환하게 하여 대기 대신 재시도 정책을 적용합니다.
이 문서의 잠금 예제는 게시글 등록 트랜잭션과 분리된 작은 포인트 계정으로 행 경쟁만 관찰합니다.
부모 members의 회원 1과 2가 먼저 존재해야 합니다. 경쟁을 시작하기 전에 한 연결에서만 fixture를 만들거나 100·20으로 초기화합니다. 경쟁 도중 두 세션에서 초기화 UPDATE를 함께 실행하지 않습니다. 아래 순서와 결과는 소스의 예상 시나리오이며 새로 실행해 관측한 로그가 아닙니다.
DROP TABLE IF EXISTS member_point_accounts;
CREATE TABLE member_point_accounts (
member_id BIGINT UNSIGNED NOT NULL,
balance INT UNSIGNED NOT NULL,
PRIMARY KEY (member_id),
CONSTRAINT fk_point_account_member
FOREIGN KEY (member_id) REFERENCES members (id)
) ENGINE = InnoDB;
INSERT INTO member_point_accounts (member_id, balance)
VALUES (1, 100), (2, 20);그다음 터미널 A와 B를 열고, 아래 문장을 각 터미널에서 한 번씩 실행합니다.
SET SESSION은 서버 전체가 아니라 실행한 연결 하나에만 적용되므로 한 터미널에서 대신 실행할 수 없습니다.
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;표준 격리 수준
격리 수준은 동시 실행을 완전히 없애는 설정이 아니라 어떤 읽기 이상을 허용할지 정하는 단계입니다.
먼저 세 용어를 구분합니다.
- 더티 리드(Dirty Read)는 다른 트랜잭션이 아직 커밋하지 않은 값을 읽는 현상입니다. 상대가 롤백하면 읽었던 값은 확정된 적이 없는 값이 됩니다.
- 반복 불가능 읽기(Non-repeatable Read)는 같은 행을 두 번 읽는 사이 다른 트랜잭션이 커밋해 두 결과가 달라지는 현상입니다.
- 팬텀 리드(Phantom Read)는 같은 범위 조건을 다시 조회했을 때 새로 삽입되거나 삭제된 행 때문에 결과 집합의 행 수가 달라지는 현상입니다.
SQL 표준의 큰 틀은 다음과 같습니다.
발생 가능은 반드시 매번 일어난다는 뜻이 아니라 해당 격리 수준이 그 현상을 금지하지 않는다는 뜻입니다.
- READ UNCOMMITTED: 표준상 더티 리드·반복 불가능 읽기·팬텀을 허용합니다.
- READ COMMITTED: 더티 리드를 막지만 반복 불가능 읽기와 팬텀은 허용합니다.
- REPEATABLE READ: 더티 리드와 반복 불가능 읽기를 막고, 표준상 팬텀은 허용합니다.
- SERIALIZABLE: 세 읽기 이상을 모두 막습니다. 충돌하는 작업의 대기·재시도 비용도 고려합니다.
InnoDB의 일반 일관 읽기는 READ COMMITTED에서 문장마다 새 스냅샷을, REPEATABLE READ에서 첫 일관 읽기가 정한 스냅샷을 재사용합니다. 같은 트랜잭션의 자기 변경은 보이므로 결과가 항상 최초 모습과 같다는 뜻은 아닙니다.
MySQL InnoDB의 REPEATABLE READ는 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어) 스냅샷과 다음-키 잠금을 사용해 표준의 최소 보장보다 강한 동작을 보이는 경우가 많습니다.
MVCC는 읽기가 특정 시점의 행 버전을 보게 해 읽기와 쓰기의 불필요한 충돌을 줄이는 방식입니다.
그러나 일반 스냅샷 SELECT와 SELECT ... FOR UPDATE의 읽기 시점은 다르고, 조건식·인덱스·잠금 종류에 따라 동작도 달라집니다.
격리 수준 이름만으로 “MySQL에서는 팬텀이 절대 없다”고 단정하지 말고 실제 구문과 실행 계획을 함께 확인합니다.
현재 연결의 격리 수준은 다음처럼 조회하고 바꿀 수 있습니다.
SET SESSION은 다음 트랜잭션부터 현재 연결에 적용하므로 연결 풀에 반환하기 전에 원래 설정으로 복구해야 합니다.
SELECT @@transaction_isolation AS current_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT @@transaction_isolation AS isolation_in_transaction;
COMMIT;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;두 터미널에서 더티 리드를 작게 재현하면 각 수준의 차이가 분명해집니다.
먼저 세션 A가 값을 바꾸되 커밋하지 않습니다.
-- session A
START TRANSACTION;
UPDATE member_point_accounts
SET balance = 777
WHERE member_id = 1;
-- 아직 COMMIT하지 않는다.
-- session B: READ UNCOMMITTED에서 실행
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
SELECT balance FROM member_point_accounts WHERE member_id = 1;
COMMIT;
-- session A
ROLLBACK;
-- session B: READ COMMITTED에서 다시 실행
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM member_point_accounts WHERE member_id = 1;
COMMIT;
-- session B cleanup: 뒤 실습이 기본값에서 시작하게 복구한다.
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;READ UNCOMMITTED: 777을 볼 수 있음
세션 A ROLLBACK 뒤 확정 값: 원래 잔액
READ COMMITTED: 미확정 777을 보지 않음위 순서의 마지막 READ COMMITTED 조회는 A가 이미 롤백한 뒤입니다. 확정 값이 복구되었다는 확인에는 맞지만, 그 시점에는 READ UNCOMMITTED도 같은 확정 값을 읽을 수 있으므로 격리 수준의 차이를 분리한 비교는 아닙니다.
같은 미커밋 상태에서 다시 비교하기
초기 잔액 100을 사용하는 별도 실험에서 A가 다시 777로 바꾸고 커밋·롤백하지 않은 동안, B를 READ COMMITTED로 설정해 일반 SELECT를 실행합니다. A가 미커밋 상태라는 조건을 같게 두어야 B의 읽기 정책 차이를 볼 수 있습니다. 잠금을 얻는 FOR UPDATE가 아닌 일반 SELECT를 사용합니다.
-- session A: 초기 확정 balance=100인 상태에서 시작
START TRANSACTION;
UPDATE member_point_accounts SET balance = 777 WHERE member_id = 1;
-- 이 상태를 유지하고 session B로 이동한다.
-- session B
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM member_point_accounts WHERE member_id = 1;
-- 별도의 커밋 변경이 없다면 확정 버전100을 읽는다.
COMMIT;
-- session A: B의 관찰 뒤 정리
ROLLBACK;
-- session B: 다음 실습의 기본 수준 복구
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;A의 미커밋 777과 마지막 확정 100을 구분해 기록합니다. 다른 세션이 값을 추가로 커밋하지 않는 조건의 예상 결과이며 이번 작업에서 실행한 DB 로그는 아닙니다. MySQL 8.4 격리 수준 설명은 READ COMMITTED 일반 일관 읽기의 새 스냅샷과 READ UNCOMMITTED의 미확정 읽기를 구분합니다.
반복 불가능 읽기는 세션 B가 중간에 같은 행을 커밋하도록 하고 세션 A가 앞뒤로 같은 행을 읽어 비교합니다.
READ COMMITTED에서는 두 번째 값이 달라질 수 있고, REPEATABLE READ의 일반 SELECT는 같은 스냅샷 값을 유지합니다.
팬텀 리드는 단일 행 대신 WHERE balance >= 100 같은 범위를 두 번 세고 사이에 조건을 만족하는 행을 INSERT해 같은 방식으로 관찰합니다.
SERIALIZABLE은 이상 현상을 줄이는 대신 읽기·쓰기 대기와 교착 상태 가능성을 높이므로 정확성이 필요하다는 이유만으로 모든 요청에 일괄 적용하지 않습니다.
스냅샷 기반 갱신의 위험
A와 B가 모두 잔액 100을 읽고 각각 30, 50을 뺀 뒤 계산한 고정값 70과 50을 UPDATE합니다.
아래 순서에서는 B가 50을 쓴 뒤 A가 70으로 덮으므로 두 차감 80점 중 30점만 반영됩니다.
-- session A
START TRANSACTION;
SELECT balance FROM member_point_accounts WHERE member_id = 1; -- 100
-- session B
START TRANSACTION;
SELECT balance FROM member_point_accounts WHERE member_id = 1; -- 100
UPDATE member_point_accounts SET balance = 50 WHERE member_id = 1;
COMMIT;
-- session A
UPDATE member_point_accounts SET balance = 70 WHERE member_id = 1;
COMMIT;이 순서대로 모두 성공하면 최종 잔액은 70입니다.
제시한 구문 순서의 예상 결과initial balance: 100
session A intends: -30
session B intends: -50
expected serial result: 20
final absolute-write result: 70
lost change: session B's -50일반 SELECT와 나중의 UPDATE 사이에 다른 트랜잭션이 끼어들 수 있습니다.
REPEATABLE READ 스냅샷이 오래 같은 값을 보여 주는 것은 계산 결과를 자동 보호한다는 뜻이 아닙니다.
가능하면 SET balance = balance - ? WHERE balance >= ? 같은 상대 조건부 UPDATE 하나로 원자화합니다.
여러 행·추가 판단이 필요하면 FOR UPDATE로 최신 행을 잠그고 일관된 순서로 접근합니다.
두 세션의 읽기와 쓰기를 시간축에 놓기
- 두 세션 표 — 각 구문의 시작·대기·커밋 순서를 타임라인으로 적습니다.
- 읽기 종류 — 일반 스냅샷인지 잠금 현재 읽기인지 표시합니다.
- 쓰기 모양 — 고정값 덮어쓰기인지 상대 UPDATE인지 구분합니다.
- 재시도 조건 — 잠금 시간 초과·교착 상태 SQLState만 제한적으로 재시도합니다.
두 세션의 고정값 쓰기와 손실 갱신
100에서 30 차감을 계획한다. 낡은 70이 최신 50을 덮는다. 각 단계의 값과 전환 이유를 비교합니다.
CONCURRENCY · STALE ABSOLUTE WRITE
마지막 70은 최신 50을 덮어써 B의 차감을 잃는다
초기 잔액 100을 두 세션이 읽었습니다. B가 50으로 먼저 커밋한 뒤 A가 오래된 100에서 계산한 70을 쓰면 두 차감의 올바른 합계 20이 나오지 않습니다.
A와 B의 오래된 100이 같은 DB 행을 덮어쓴다
A는 30 차감, B는 50 차감을 계획합니다. B의 COMMIT으로 확정된 50을 A의 오래된 100에서 계산한 70이 덮어씁니다. 잘못된 결과는 70, 두 차감의 올바른 합계는 100−50−30=20입니다. 본문 예상 순서이며 새 실측 로그가 아닙니다.
원래 설명과 상세 기준
| 순서 | 세션 A · 30 차감 계획 | 세션 B · 50 차감 계획 | 확정 잔액 |
|---|---|---|---|
| 1 · A 읽기 | 100을 읽고 70 계산 | — | 100 |
| 2 · B 읽기 | 앞서 읽은 100 유지 | 100을 읽고 50 계산 | 100 |
| 3 · B 쓰기 | — | 50으로 UPDATE · COMMIT | 50 |
| 4 · A 쓰기 | 70으로 UPDATE · COMMIT | 차감 50이 덮어써짐 | 70 |
A · 100을 읽고 70 계산
확정 잔액은 아직 100입니다.
B · 100을 읽고 50 계산
A가 가진 이전 입력도 100입니다.
B · 50으로 UPDATE · COMMIT
확정 잔액 50
A · 70으로 UPDATE · COMMIT
확정 잔액 70 · B의 50 차감이 덮어써졌습니다.
이 순서의 잘못된 결과 · 70
100 − 30만 남습니다. B의 −50이 손실됩니다.
두 차감을 모두 반영한 결과 · 20
100 − 50 − 30 = 20
본문의 예상 구문 순서이며 새로 측정한 로그가 아닙니다. 단일 잔액은 조건부 상대 UPDATE로 원자화하고, 추가 판단·복수 행은 좁은 잠금 읽기와 오류 시 전체 재시도를 검토합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 A 읽기부터 A 커밋까지 진행합니다.
| 순서 | 세션 A · 30 차감 계획 | 세션 B · 50 차감 계획 | 확정 잔액 |
|---|---|---|---|
| 1 · A 읽기 | 100을 읽고 70 계산 | — | 100 |
| 2 · B 읽기 | 앞서 읽은 100 유지 | 100을 읽고 50 계산 | 100 |
| 3 · B 쓰기 | — | 50으로 UPDATE · COMMIT | 50 |
| 4 · A 쓰기 | 70으로 UPDATE · COMMIT | B의 50 차감이 반영되지 않음 | 70 |
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 두 세션의 고정값 쓰기와 손실 갱신 |
| 표의 값 | B의 50 차감이 반영되지 않음 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| A 읽기 100에서 30 차감을 계획한다. |
|
|
| B 읽기 같은 100에서 50 차감을 계획한다. |
|
|
| B 커밋 계산한 50을 그대로 쓴다. |
|
|
| A 커밋 낡은 70이 최신 50을 덮는다. |
|
|
두 세션의 고정값 쓰기와 손실 갱신
100에서 30 차감을 계획한다. 낡은 70이 최신 50을 덮는다. 각 단계의 값과 전환 이유를 비교합니다.
CONCURRENCY · STALE ABSOLUTE WRITE
마지막 70은 최신 50을 덮어써 B의 차감을 잃는다
초기 잔액 100을 두 세션이 읽었습니다. B가 50으로 먼저 커밋한 뒤 A가 오래된 100에서 계산한 70을 쓰면 두 차감의 올바른 합계 20이 나오지 않습니다.
A와 B의 오래된 100이 같은 DB 행을 덮어쓴다
A는 30 차감, B는 50 차감을 계획합니다. B의 COMMIT으로 확정된 50을 A의 오래된 100에서 계산한 70이 덮어씁니다. 잘못된 결과는 70, 두 차감의 올바른 합계는 100−50−30=20입니다. 본문 예상 순서이며 새 실측 로그가 아닙니다.
원래 설명과 상세 기준
| 순서 | 세션 A · 30 차감 계획 | 세션 B · 50 차감 계획 | 확정 잔액 |
|---|---|---|---|
| 1 · A 읽기 | 100을 읽고 70 계산 | — | 100 |
| 2 · B 읽기 | 앞서 읽은 100 유지 | 100을 읽고 50 계산 | 100 |
| 3 · B 쓰기 | — | 50으로 UPDATE · COMMIT | 50 |
| 4 · A 쓰기 | 70으로 UPDATE · COMMIT | 차감 50이 덮어써짐 | 70 |
A · 100을 읽고 70 계산
확정 잔액은 아직 100입니다.
B · 100을 읽고 50 계산
A가 가진 이전 입력도 100입니다.
B · 50으로 UPDATE · COMMIT
확정 잔액 50
A · 70으로 UPDATE · COMMIT
확정 잔액 70 · B의 50 차감이 덮어써졌습니다.
이 순서의 잘못된 결과 · 70
100 − 30만 남습니다. B의 −50이 손실됩니다.
두 차감을 모두 반영한 결과 · 20
100 − 50 − 30 = 20
본문의 예상 구문 순서이며 새로 측정한 로그가 아닙니다. 단일 잔액은 조건부 상대 UPDATE로 원자화하고, 추가 판단·복수 행은 좁은 잠금 읽기와 오류 시 전체 재시도를 검토합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 A 읽기부터 A 커밋까지 진행합니다.
| 순서 | 세션 A · 30 차감 계획 | 세션 B · 50 차감 계획 | 확정 잔액 |
|---|---|---|---|
| 1 · A 읽기 | 100을 읽고 70 계산 | — | 100 |
| 2 · B 읽기 | 앞서 읽은 100 유지 | 100을 읽고 50 계산 | 100 |
| 3 · B 쓰기 | — | 50으로 UPDATE · COMMIT | 50 |
| 4 · A 쓰기 | 70으로 UPDATE · COMMIT | B의 50 차감이 반영되지 않음 | 70 |
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 두 세션의 고정값 쓰기와 손실 갱신 |
| 표의 값 | B의 50 차감이 반영되지 않음 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| A 읽기 100에서 30 차감을 계획한다. |
|
|
| B 읽기 같은 100에서 50 차감을 계획한다. |
|
|
| B 커밋 계산한 50을 그대로 쓴다. |
|
|
| A 커밋 낡은 70이 최신 50을 덮는다. |
|
|
스냅샷 읽기와 잠금 읽기
일반 SELECT는 일관된 보고서·조회에 적합합니다.
FOR UPDATE와 FOR SHARE는 현재 읽기로 잠금을 얻어 후속 변경의 전제를 보호합니다. 자기 트랜잭션의 변경을 포함하며, 다른 트랜잭션의 미확정 변경이 있으면 잠금 규칙에 따라 대기합니다.
REPEATABLE READ는 같은 스냅샷에서 반복 불가능 읽기를 막지만 범위 잠금과 누락 구간 잠금 비용도 고려해야 합니다.
READ COMMITTED는 구문마다 새 스냅샷을 보므로 관찰 방식이 달라지지만 손실 갱신을 자동으로 모두 해결하지 않습니다.
격리 수준을 높이는 대신 쿼리를 원자화하거나 필요한 행만 명시적으로 잠그는 설계가 더 분명할 수 있습니다.
스냅샷과 현재 읽기를 구분하기
- 단일 행 — 조건부 상대 UPDATE 하나로 해결할 수 있는지 먼저 봅니다.
- 복수 행 — 항상 작은
member_id부터 같은 순서로 잠금을 획득합니다. - 대기 정책 — 짧게 기다릴지
NOWAIT로 오류를 즉시 반환할지 API SLA에 맞춥니다. - 재시도 — 전체 트랜잭션을 새 스냅샷에서 다시 실행하고 대기 간격·횟수 제한을 둡니다.
동시 갱신 직렬화
잔액 차감 하나는 원자 UPDATE로 처리합니다.
아래 SQL은 초기화 뒤 두 계정이 있고 차감에 필요한 잔액이 충분한 성공 경로입니다. 초기화는 경쟁 전에 한 번만 실행합니다. 잠금 SELECT에서 두 행·잔액을 확인하고, 각 UPDATE의 영향 행 수가 1인지 확인한 경우에만 다음 단계와 COMMIT으로 진행합니다. 부족·행 누락·SQL 오류가 있으면 즉시 중단하고 ROLLBACK합니다. 차감이 0행인데 입금을 계속하면 합계가 늘어날 수 있습니다.
모든 경로가 같은 PK 순서로 잠금을 얻도록 설계합니다. ORDER BY는 결과 순서이므로 이것만으로 실제 잠금 획득 순서까지 보장하지는 않습니다. 실행 계획을 확인하고, 순서를 확실히 지정해야 한다면 개별 PK 잠금 읽기를 같은 순서로 수행합니다.
-- 앞의 손실 갱신 예제가 바꾼 값을 다시 고정한다.
UPDATE member_point_accounts
SET balance = CASE member_id
WHEN 1 THEN 100
WHEN 2 THEN 20
END
WHERE member_id IN (1, 2);
START TRANSACTION;
SELECT member_id, balance
FROM member_point_accounts
WHERE member_id IN (1, 2)
ORDER BY member_id
FOR UPDATE;
UPDATE member_point_accounts
SET balance = balance - 60
WHERE member_id = 1 AND balance >= 60;
UPDATE member_point_accounts
SET balance = balance + 60
WHERE member_id = 2;
COMMIT;같은 계정의 잠금이 충돌하면 뒤 트랜잭션은 대기할 수 있습니다. 잠금을 얻은 뒤 읽은 잔액으로 조건을 다시 판단해야 하며, 잠금 자체가 부족한 잔액의 이동을 승인하지는 않습니다.
차감 0행에서 멈춰야 하는 이유
초기 두 잔액 100·20에서 위의 60점 이동이 성공하면 40·80으로 합계 120이 유지됩니다. 이어 같은 방향으로 60점을 더 옮기려는 요청은 balance >= 60을 만족하지 못해 차감이 0행입니다. 이때도 두 번째 UPDATE를 계속하면 40·140으로 합계 180이 되어 없던 60점을 만든 셈입니다. 조건부 차감의 0행을 성공으로 읽지 않고 전체 ROLLBACK해야 합니다. 각 UPDATE 직후 영향 행 수를 검사하는 것은 단순 로그가 아니라 다음 SQL 실행 여부를 결정하는 분기입니다.
아래는 지향하는 흐름입니다. 실제 대기·잠금 획득 순서를 새로 관측한 결과나, 위 SQL의 무조건 성공 보장은 아닙니다.
session A: locks member 1, 2 → updates → COMMIT
session B: waits at member 1 lock
session B after wake: reads latest committed balances
lost update: none
lock order: 1 then 2 in every transferNOWAIT를 붙이면 기다리지 않고 오류 3572를 즉시 반환합니다.
이는 데이터 오류가 아니라 동시성 충돌이므로 짧은 무작위 지연 대기 간격 후 전체 트랜잭션을 다시 시도할 수 있습니다.
FOR UPDATE 범위를 인덱스가 좁히지 못하면 예상보다 많은 기록·누락 구간을 잠글 수 있습니다.
PK나 적절한 유일 제약 키로 정확한 행을 찾고 트랜잭션 안에서 사용자 입력 대기나 외부 호출을 하지 않습니다.
NOWAIT부터 역방향 이동까지 재현하기
- 잠금 보유 — 세션 A가 FOR UPDATE 뒤 커밋하지 않은 상태를 만듭니다.
- NOWAIT 충돌 — 세션 B에서 FOR UPDATE NOWAIT를 실행해 3572를 확인합니다.
- 커밋 후 재시도 — A를 커밋하고 B 전체 트랜잭션을 처음부터 다시 실행합니다.
- 역방향 이동 — 2→1 이동도 1,2 순으로 잠가 교착 상태 빈도를 비교합니다.
NOWAIT와 잠금 확인
두 터미널을 열어 구문 순서를 고정합니다.
오류 문자열을 복사하기보다 세션·순서·SQLState·재시도 결과를 함께 기록합니다.
-- session A
START TRANSACTION;
SELECT member_id, balance
FROM member_point_accounts
WHERE member_id = 1
FOR UPDATE;
-- session B, A가 아직 열린 동안
START TRANSACTION;
SELECT member_id, balance
FROM member_point_accounts
WHERE member_id = 1
FOR UPDATE NOWAIT;
-- ERROR 3572, then ROLLBACK
-- session A
COMMIT;A가 해당 잠금을 계속 보유한 상태라면 B의 NOWAIT는 오류 3572 대상입니다. 코드의 ROLLBACK은 주석으로 안내되어 있으므로 B에서 실제 ROLLBACK을 실행해 열린 트랜잭션을 끝냅니다.
A의 값은 바뀌지 않으며 B는 새 트랜잭션으로 재시도합니다.
경쟁 제어 방식 비교
- 조건부 UPDATE: 단일 카운터·잔액을 한 문장으로 판단합니다. 여러 행의 복합 규칙은 별도 검증이 필요합니다.
- FOR UPDATE 대기: 짧은 잠금을 기다리며 순서대로 처리할 수 있지만 꼬리 지연이 늘어납니다.
- NOWAIT: 대기를 줄이고 빠르게 실패시키는 대신 재시도가 늘 수 있습니다.
- 낙관적 버전: 충돌률이 낮을 때 긴 사전 잠금을 피합니다. UPDATE의 잠금은 여전히 필요하며 충돌하면 다시 읽고 계산합니다.
게시글 버전으로 손실 갱신 막기
회원 게시판의 글 수정은 긴 잠금 대신 version을 비교하는 낙관적 제어가 적합할 수 있습니다.
아래는 id=9, version=4인 게시글이 이미 있다는 별도 전제입니다. 포인트 fixture는 이 게시글을 만들지 않습니다. 두 편집자가 버전 4를 읽었더라도 먼저 성공한 UPDATE만 버전을 5로 올립니다.
UPDATE posts
SET title = '첫 번째 편집',
content = '버전 4를 기준으로 저장',
updated_at = CURRENT_TIMESTAMP(6),
version = version + 1
WHERE id = 9
AND version = 4;이 게시글·버전 전제에서 첫 요청이 성공하면 ROW_COUNT()는 1이고, 뒤 요청은 같은 조건으로 0을 반환합니다.
0행을 성공으로 숨기지 말고 최신 글과 버전을 다시 읽어 충돌 응답을 만들거나 사용자가 병합하도록 합니다.
교착 상태와 다음-키 잠금의 실제 비용
교착 상태는 오류 1213으로 한 트랜잭션이 희생됩니다.
모든 교착 상태를 제거하기보다 잠금 순서와 범위를 줄이고, 멱등한 전체 트랜잭션 재시도를 준비합니다.
1213은 전체 트랜잭션을 롤백합니다. 1205는 기본 설정에서 시간 초과 문장만 롤백하며 innodb_rollback_on_timeout 설정에 따라 범위가 달라집니다. 이 실습의 재시도 정책은 남은 트랜잭션을 명시적으로 ROLLBACK한 뒤 업무 전체를 새 트랜잭션으로 시작하는 것입니다.
범위 쿼리의 다음-키 잠금은 팬텀을 막는 대신 동시 INSERT를 차단할 수 있습니다.
보고서에는 스냅샷 SELECT, 예약·할당에는 좁은 잠금 읽기처럼 목적별로 사용합니다.
SKIP LOCKED·장기 스냅샷의 경계
- SKIP LOCKED는 작업 큐에는 유용하지만 잠긴 업무 행을 조용히 누락하므로 일반 목록 정답에 사용하지 않습니다.
- FOR UPDATE를 자동 커밋 밖의 명시적 트랜잭션 없이 쓰면 잠금 수명이 기대와 달라질 수 있습니다.
- 인덱스 없는 조건식은 많은 행을 잠가 처리량을 급격히 낮출 수 있습니다.
- 장기 스냅샷은 영구 삭제를 지연시켜 언두 로그 이력을 키울 수 있으므로 보고 트랜잭션도 수명을 제한합니다.
잠금 대기를 운영 신호로 읽기
교착 상태 로그와 performance_schema의 data_locks·data_lock_waits를 수집하되 개인정보가 있는 SQL 매개변수 노출을 관리합니다.
재시도는 트랜잭션 전체를 대상으로 하고 최대 횟수·지수 대기 간격·무작위 지연을 둡니다.
부분 구문만 반복하면 스냅샷과 원장 선점이 어긋납니다.
두 터미널로 이상을 만들기
- 손실 갱신 — 고정값을 덮어쓰는 UPDATE와 상대 UPDATE를 두 세션에서 비교합니다.
- 격리 변경 —
READ COMMITTED와REPEATABLE READ의 일반 SELECT 두 번째 값을 기록합니다. - 교착 상태 — 두 계정을 반대 순서로 잠가 1213을 만든 뒤 정렬 순서로 고칩니다.
- 범위 잠금 — 인덱스 있는 범위와 없는 범위에서 동시 INSERT 대기를 비교합니다.
동시성 경계가 명확해도 트리거가 숨은 UPDATE를 수행하면 잠금 순서와 오류 위치를 예측하기 어렵습니다.
다음 문서에서 DB 로직의 가시성과 현대적 대안을 판단합니다.
격리 수준과 잠금 선택
| 판단 축 | 확인할 질문 |
|---|---|
| 읽기 시점 | 스냅샷과 현재 잠금 읽기를 구분했는가? |
| 원자화 | 읽기-수정-쓰기를 조건부 UPDATE로 줄일 수 있는가? |
| 잠금 범위 | 인덱스로 필요한 행만 정확히 잠그는가? |
| 순서 | 복수 행 잠금을 모든 경로에서 같은 순서로 얻는가? |
| 재시도 | NOWAIT·1213·1205별 전체 트랜잭션 정책이 있는가? |
보고 읽기는 스냅샷, 변경 전 판단은 좁은 잠금 읽기 또는 원자 UPDATE로 구분합니다.
격리 수준 이름만 높이는 대신 실제 이상과 잠금 범위를 재현합니다.
직렬 가능성과 2단계 잠금의 추상 모델
직렬 스케줄은 T1을 끝낸 뒤 T2처럼 트랜잭션 전체를 순서대로 실행하는 것입니다. 직렬 가능한 스케줄은 연산이 섞였어도 정한 의미에서 어떤 직렬 순서와 동등한 결과를 갖습니다. 우연히 최종 값 하나가 같다는 사실만으로 동등성을 증명하지 않습니다.
충돌 직렬 가능성은 서로 다른 트랜잭션이 같은 항목에 접근하고 적어도 하나가 쓸 때 순서 간선을 그립니다. w1(X) → r2(X)는 T1→T2, w2(Y) → r1(Y)는 T2→T1입니다. 두 간선이 함께 있으면 순환하여 이 스케줄은 충돌 직렬 가능하지 않습니다. 읽기–읽기만으로 충돌 간선을 만들지 않습니다.
2단계 잠금(2PL)의 확장 단계에서는 잠금을 얻고, 축소 단계에서 처음 해제한 뒤에는 새 잠금을 얻지 않습니다. 적합한 잠금 모델에서 충돌 직렬 가능성을 보장하지만 교착 상태가 불가능하다는 뜻은 아닙니다. strict 2PL은 쓰기 배타 잠금을 완료까지 유지해 미완료 값을 다른 트랜잭션이 이용하는 연쇄 철회를 막는 데 연결됩니다.
이는 교과서의 추상 잠금 규칙입니다. InnoDB의 일반 MVCC SELECT까지 모두 같은 공유 잠금을 얻는 단순 2PL 구현이라고 설명하지 않습니다. 실제 격리 동작은 앞의 스냅샷·잠금 현재 읽기·인덱스 범위로 검사합니다.
직접 문제: T1이 X 잠금을 얻은 뒤 T2가 Y 잠금을 얻고, 각각 상대 항목을 요청하면 2PL을 지키더라도 무엇이 생길 수 있나요?
힌트와 풀이
잠금을 아직 해제하지 않았다는 점과 서로 기다리는 점을 같이 보세요. 두 작업 모두 확장 단계의 규칙을 지켜도 순환 대기로 교착 상태가 생길 수 있습니다. 직렬 가능성 보장과 교착 상태 예방은 별개입니다.
연습 문제
두 편집자가 같은 게시글의 version=7을 읽고 서로 다른 제목으로 저장합니다.
먼저 저장한 변경을 뒤 요청이 덮어쓰지 않도록 조건부 UPDATE를 작성하세요.
해설과 예시 답안
버전을 WHERE 조건에 포함하고 성공할 때만 1 증가시킵니다.
먼저 id=9, version=7인 게시글을 준비한 뒤 두 DB 세션에서 같은 SQL을 실행해 한쪽만 1행을 바꾸는지 확인합니다.
UPDATE posts
SET title = '편집자 A의 제목',
updated_at = CURRENT_TIMESTAMP(6),
version = version + 1
WHERE id = 9
AND version = 7;
SELECT ROW_COUNT() AS changed_rows;편집자 B는 SET의 제목을 B의 다른 제목으로 바꾸되 WHERE의 기대 버전은 7로 둡니다. A가 먼저 커밋했다면 B의 changed_rows=0이어야 합니다. A의 성공 뒤 저장된 제목과 버전 8이 유지되는지도 확인합니다. SET의 제목은 새 내용이고 WHERE의 버전은 읽었을 때의 전제이므로 서로 역할이 다릅니다.
최신 버전을 다시 읽기 전에는 B의 제목으로 무조건 UPDATE하지 않습니다.
핵심 정리
- 일반 SELECT 스냅샷과 FOR UPDATE 현재 읽기는 목적이 다릅니다.
- 상대 조건부 UPDATE는 단일 행 경쟁을 간단히 원자화합니다.
- 게시글
version조건은 늦게 도착한 편집이 최신 내용을 덮어쓰지 못하게 합니다. - 여러 행은 일관된 순서로 좁게 잠가 교착 상태를 줄입니다.
- NOWAIT·교착 상태 재시도는 전체 트랜잭션과 멱등성을 전제로 합니다.
다음 문서에서는 저장 프로시저·함수·트리거가 적합한 경계와 숨은 로직의 함정을 살펴봅니다.