MVCC와 트랜잭션 격리 수준

src/content/documents/data-analysis/mvcc-transaction-isolation-levels.json

MVCC는 여러 트랜잭션이 같은 데이터를 읽고 쓸 때 각 문장이 볼 수 있는 행 버전을 정한다. PostgreSQL에서는 격리 수준을 높일수록 단순히 락이 많아지는 것이 아니라 스냅샷의 수명과 허용하는 직렬화 이상이 달라진다. 마지막 안전장치는 SERIALIZABLE 오류를 정상 흐름으로 보고 트랜잭션 전체를 재시도하는 것이다.

MVCC 스냅샷을 한 문장으로 이해하기

PostgreSQL은 각 데이터 변경이 만든 행 버전과 트랜잭션 가시성 정보로 읽을 버전을 선택한다. 일반 SELECT는 읽는 동안 쓰기를 막지 않고, 쓰기도 일반 SELECT를 막지 않는다. 그러나 같은 행을 갱신하는 쓰기끼리는 기다리거나 충돌한다.

Visible(row,S)=committed(row)xmin(row)Sxmax(row)SVisible(row, S)=committed(row)\land xmin(row)\in S\land xmax(row)\notin S

이 식은 직관을 위한 단순화다. SS는 스냅샷, xmin은 행 버전을 만든 트랜잭션, xmax는 삭제·교체한 트랜잭션을 뜻한다. 실제 가시성 판정에는 진행 중 트랜잭션 집합과 현재 트랜잭션의 명령 순서 등 더 많은 규칙이 들어간다.

격리 수준과 이상 현상 표

현상

RC

RR

Serializable

Dirty read

커밋하지 않은 값을 읽음

불가

불가

불가

Nonrepeatable read

같은 행 재조회 값이 바뀜

가능

불가

불가

Phantom read

같은 조건 재조회 행 집합이 바뀜

가능

PostgreSQL에서는 불가

불가

Write skew

같은 조건을 읽고 서로 다른 행을 써 전체 규칙 위반

가능

가능

한 트랜잭션 중단

PostgreSQL의 READ UNCOMMITTED는 READ COMMITTED처럼 동작하므로 dirty read를 재현할 수 없다. REPEATABLE READ는 SQL 표준의 최소 요구보다 강해 팬텀도 방지하지만, 직렬 실행과 결과가 다른 serialization anomaly는 허용한다.

실습 테이블 준비

DROP TABLE IF EXISTS raid_member, shop_item;

CREATE TABLE shop_item (
  item_id bigint PRIMARY KEY,
  item_name text NOT NULL,
  stock integer NOT NULL CHECK (stock >= 0),
  price integer NOT NULL CHECK (price >= 0)
);

CREATE TABLE raid_member (
  raid_id bigint NOT NULL,
  player_id bigint NOT NULL,
  role text NOT NULL CHECK (role IN ('tank','healer','dealer')),
  is_ready boolean NOT NULL DEFAULT true,
  PRIMARY KEY (raid_id, player_id)
);

INSERT INTO shop_item VALUES (1, '전설 회복 물약', 1, 500);
INSERT INTO raid_member VALUES
(10, 101, 'healer', true),
(10, 102, 'healer', true),
(10, 103, 'dealer', true);

Dirty read는 모든 PostgreSQL 격리 수준에서 차단된다

두 터미널을 같은 데이터베이스에 연결한다. 세션 A가 가격을 바꾸고 커밋하지 않은 동안 세션 B는 이전 커밋 값 500을 읽는다.

BEGIN;
UPDATE shop_item SET price = 900 WHERE item_id = 1;
-- 아직 COMMIT하지 않고 세션 B를 실행한다.
BEGIN ISOLATION LEVEL READ UNCOMMITTED;
SELECT price FROM shop_item WHERE item_id = 1; -- 500
COMMIT;
ROLLBACK;

READ COMMITTED — 문장마다 새 스냅샷

기본 격리 수준 READ COMMITTED에서는 각 SELECT가 시작할 때 새 스냅샷을 얻는다. 같은 트랜잭션 안의 두 조회 사이에 다른 트랜잭션이 커밋하면 두 번째 조회가 새 값을 볼 수 있다.

비반복 읽기 재현

BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT stock FROM shop_item WHERE item_id = 1; -- 1
-- 여기서 세션 B 실행
SELECT stock FROM shop_item WHERE item_id = 1; -- 0
COMMIT;
BEGIN;
UPDATE shop_item SET stock = 0 WHERE item_id = 1;
COMMIT;

팬텀 읽기 재현

BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT COUNT(*) FROM raid_member
WHERE raid_id = 10 AND is_ready; -- 3
-- 여기서 세션 B 실행
SELECT COUNT(*) FROM raid_member
WHERE raid_id = 10 AND is_ready; -- 4
COMMIT;
BEGIN;
INSERT INTO raid_member VALUES (10, 104, 'tank', true);
COMMIT;

다음 실습 전에 seed 스크립트를 다시 실행한다. 팬텀은 기존 행 값이 바뀐 것이 아니라 WHERE 조건을 만족하는 새 행이 나타난 현상이다.

동시 아이템 구매는 원자적 조건부 UPDATE로

먼저 SELECT로 재고를 읽고 나중에 UPDATE하면 두 구매가 모두 재고 1을 확인할 수 있다. READ COMMITTED에서는 검사와 변경을 한 문장으로 합친다. 같은 행을 갱신하는 두 번째 문장은 첫 번째를 기다린 후 WHERE 조건을 다시 평가한다.

BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE shop_item
SET stock = stock - 1
WHERE item_id = 1 AND stock > 0
RETURNING item_id, stock; -- (1, 0)
-- 세션 B의 UPDATE가 대기하는 동안 COMMIT
COMMIT;
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE shop_item
SET stock = stock - 1
WHERE item_id = 1 AND stock > 0
RETURNING item_id, stock; -- 0 rows
ROLLBACK;

애플리케이션은 반환 행이 1개일 때만 결제·인벤토리 지급을 같은 트랜잭션에서 진행하고, 0개면 품절로 처리한다. REPEATABLE READ에서 동일 행이 스냅샷 이후 바뀌면 could not serialize access due to concurrent update가 발생할 수 있으므로 재시도가 필요하다.

REPEATABLE READ — 트랜잭션 스냅샷은 안정적이지만 write skew는 가능

PostgreSQL REPEATABLE READ는 첫 비트랜잭션 제어 문장이 만든 스냅샷을 트랜잭션 끝까지 사용한다. 따라서 앞의 비반복 읽기와 팬텀은 보이지 않는다. 그러나 서로 다른 행을 쓰면서 ‘준비된 힐러가 최소 1명’ 같은 여러 행 규칙을 깨는 write skew는 가능하다.

seed 상태에는 준비된 힐러가 2명이다. 두 세션이 동시에 2명을 확인한 뒤 각자 다른 힐러를 준비 해제하면 행 쓰기 충돌이 없어 둘 다 커밋할 수 있고 결과는 0명이다.

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM raid_member
WHERE raid_id = 10 AND role = 'healer' AND is_ready; -- 2
UPDATE raid_member SET is_ready = false
WHERE raid_id = 10 AND player_id = 101;
-- 세션 B의 SELECT와 UPDATE 후
COMMIT; -- 성공
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM raid_member
WHERE raid_id = 10 AND role = 'healer' AND is_ready; -- 2
UPDATE raid_member SET is_ready = false
WHERE raid_id = 10 AND player_id = 102;
COMMIT; -- 성공
SELECT COUNT(*) AS ready_healers FROM raid_member
WHERE raid_id = 10 AND role = 'healer' AND is_ready;
-- ready_healers = 0: 업무 규칙 위반

SERIALIZABLE — 직렬 실행처럼 보이게 하고 하나를 중단

seed를 다시 적용하고 위 두 스크립트의 격리 수준만 SERIALIZABLE로 바꾼다. 같은 순서로 실행하면 PostgreSQL의 Serializable Snapshot Isolation이 위험한 읽기·쓰기 의존성을 감지해 둘 중 하나의 COMMIT을 SQLSTATE 40001로 실패시킨다. 어느 세션이 실패할지는 실행 순서에 따라 달라질 수 있다.

BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 각 세션에서 write skew 예제와 같은 SELECT·UPDATE 실행
COMMIT;
-- 한 세션 예상 오류:
-- ERROR: could not serialize access due to read/write dependencies among transactions
-- SQLSTATE: 40001

SERIALIZABLE은 오류가 절대 나지 않는 모드가 아니다. 직렬화할 수 없는 실행을 커밋 전에 중단해 정확성을 지키는 모드다. 직렬화 실패는 BEGIN부터 모든 읽기·판단·쓰기를 새 트랜잭션으로 다시 실행한다.

충돌률과 재시도 예산

conflict rate=N40001+N40P01Ntransaction attemptsconflict\ rate=\frac{N_{40001}+N_{40P01}}{N_{transaction\ attempts}}

40001은 직렬화 실패, 40P01은 교착 상태다. 둘 다 전체 트랜잭션 재시도 후보지만, 제약조건 위반과 입력 오류는 그대로 재시도하지 않는다.

P(success within k retries)=1pk+1P(success\ within\ k\ retries)=1-p^{k+1}

각 시도 충돌 확률을 독립인 pp로 단순 가정하면 최초 시도와 k번 재시도 안에 성공할 확률이다. 실제 충돌은 독립이 아니므로 용량 계획의 근사치로만 쓴다.

delayi=min(Dmax,D02i)+U(0,J)delay_i=\min(D_{max},D_0 2^i)+U(0,J)

재시도 i회 전 지연은 지수 백오프에 무작위 지터를 더한다. 최대 시도 횟수와 전체 시간 제한을 두고, 결제·아이템 지급 같은 외부 효과에는 요청 ID 기반 멱등성을 적용한다. 트랜잭션 안에서 사용자 입력이나 외부 API를 기다리지 않아 충돌 창을 짧게 유지한다.

상황별 선택 규칙

  • 단일 행 재고 차감은 READ COMMITTED의 조건부 UPDATE와 반환 행 수 검사부터 시작한다.

  • 긴 리포트에서 같은 기준 시점이 필요하면 읽기 전용 REPEATABLE READ를 고려하되 오래 열린 트랜잭션이 정리를 방해하지 않게 제한한다.

  • 여러 행을 읽고 서로 다른 행을 써 전체 불변식을 지키려면 SERIALIZABLE과 전체 재시도를 우선 검토한다.

  • 명시적 행 잠금을 쓸 때는 모든 코드 경로가 같은 순서로 잠그고, 조건에 해당하는 행이 아직 존재하지 않는 문제까지 해결되는지 확인한다.

  • 부하 시험에서 처리량·p95 지연뿐 아니라 40001/40P01 비율, 잠금 대기, 평균 재시도 횟수, 최종 업무 불변식을 함께 검증한다.

참고 자료

댓글 0

댓글을 불러오는 중…