쓰기 스큐 — 각자는 옳은데 합치면 틀리다
당직은 최소 한 명이 남아야 하는데, 두 사람이 동시에 해제를 눌렀더니 0명이 됐어요. 검사도 트랜잭션도 있었는데요. 각자는 옳은데 합치면 틀리는 문제와 이를 막는 방법을 다뤄요.
당직은 최소 한 명이 남아야 해요. 그래서 당직 해제 API 는 "나 말고 다른 당직자가 있는지" 확인한 뒤에만 해제하도록 짰어요. 조건 검사도 있고 트랜잭션도 걸었어요.
그런데 어느 날 당직이 0명이 됐어요. 로그를 보면 두 사람이 거의 동시에 해제를 눌렀고, 각자의 트랜잭션은 확인 시점에 "상대가 남아 있다"를 보고 통과했어요.
각각의 트랜잭션은 규칙을 어기지 않았어요. 자기 시점에서는 조건이 참이었으니까요. 그런데 둘을 합치니 규칙이 깨졌습니다.
코드를 아무리 들여다봐도 잘못된 곳이 안 보이는 게 이 문제의 특징이에요. 검사도 있고 트랜잭션도 있으니까요.
왜 그런가
잃어버린 갱신은 같은 행을 두 번 쓰는 문제였어요. 쓰기 스큐는 다릅니다. 두 트랜잭션이 서로 다른 행을 쓰는데, 판단의 근거로 삼은 데이터가 겹칠 때 생겨요.
당직 예에서 A 는 자기 행을 해제하고 B 는 자기 행을 해제해요. 쓰는 행이 다르니 충돌하지 않아요. 그런데 둘 다 "다른 당직자가 있는가"를 확인하려고 같은 집합을 읽었어요. 그 집합이 각자의 쓰기로 인해 바뀌었는데, 서로의 변경을 못 본 거예요.
이게 팬텀 리드와 관련이 있어요. 조건에 맞는 행의 집합이 트랜잭션 도중에 바뀌는 상황이요. 그런데 표준의 이상 현상 목록에 쓰기 스큐는 없어요. 격리 수준의 정의가 이 문제를 다루지 않는다는 뜻이에요.
그래서 REPEATABLE READ 로 올려도 못 막아요. 스냅샷을 고정하는 건 오히려 이 문제를 만드는 조건이에요 — 각자가 자기 스냅샷 안에서는 계속 조건이 참이거든요.
막을 수 있는 건 SERIALIZABLE 뿐이에요. 트랜잭션들이 어떤 순서로든 하나씩 실행된 것과 같은 결과를 보장하니까요. A 다음에 B 였다면 B 는 조건을 통과 못 했을 거고, 그게 직렬화의 의미예요.
코드로 보면
쓰는 행이 달라도 근거가 겹치면 위험하다:
· 두 세션이 거의 동시에
BEGIN;
SELECT count(*) FROM oncall WHERE on_duty = true; -- 둘 다 2 를 본다
-- 2 >= 2 이므로 나 하나는 빠져도 된다고 판단
UPDATE oncall SET on_duty = false WHERE id = :me;
COMMIT;
→ 둘 다 통과, 당직 0명
· 1) 판단 근거를 한 문장에 넣는다 (조건부 UPDATE)
UPDATE oncall SET on_duty = false
WHERE id = :me
AND (SELECT count(*) FROM oncall WHERE on_duty) >= 2;
-- 영향받은 행이 0 이면 마지막 당직자였다
· 2) 또는 직렬화 수준으로 올리고 재시도한다
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
실패 시 재시도는 필수✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
쓰기 스큐가 나타나는 자리는 패턴이 있어요. "집합에 대한 조건을 검사하고, 그 집합의 일부를 바꾸는" 코드예요.
당직 최소 인원, 회의실 중복 예약, 계좌의 합계 잔액 제약, 재고 총량 제한, 좌석 배정. 전부 같은 구조예요. 단일 행의 제약이면 DB 가 막아 주지만, 여러 행에 걸친 제약은 DB 가 모릅니다.
해법이 세 갈래예요.
첫째, 판단과 쓰기를 한 문장으로 합치기. 조건을 UPDATE 의 WHERE 에 넣으면 DB 가 원자적으로 평가해요. 가장 싸고 확실한 방법이라 가능하면 이걸 먼저 검토하세요.
둘째, 물질화된 충돌 지점 만들기. 경쟁하는 트랜잭션들이 같은 행을 잠그도록 인위적인 행을 두는 방법이에요. 당직 예라면 "당직 조" 행을 하나 두고 해제하려는 사람이 그 행을 FOR UPDATE 로 잠그게 해요. 쓰는 행은 달라도 이 행을 통해 직렬화됩니다.
셋째, SERIALIZABLE 로 올리기. PostgreSQL 은 직렬화 가능 스냅샷 격리를 쓰는데, 락 대신 충돌을 감지해서 문제가 되는 트랜잭션을 실패시켜요. 정확하지만 재시도 구현이 필수고, 경합이 심하면 실패율이 올라가요.
여기서 중요한 판단이 있어요. SERIALIZABLE 은 만능처럼 보이지만 비용이 있어요. 충돌 감지를 위한 추적 부담이 있고, 실패한 트랜잭션은 처음부터 다시 해야 하고, 앱 전체를 그 수준으로 올리면 예상 못 한 곳에서 실패가 쏟아져요. 그래서 문제가 되는 트랜잭션만 선택적으로 올리는 게 실무적입니다.
그리고 제약 조건으로 표현할 수 있는지 먼저 보세요. 회의실 중복 예약은 배제 제약으로 DB 수준에서 막을 수 있어요. 시간 범위가 겹치는 행을 못 넣게 하는 거죠. 애플리케이션 로직으로 지키는 것보다 훨씬 안전합니다.
처방
- 판단 근거와 쓰기를 한 문장으로 합치세요. 조건을 UPDATE 의 WHERE 에 넣고 영향받은 행 수로 성공을 판단하세요. 가장 싸고 확실해요.
- DB 제약으로 표현할 수 있는지 보세요. 시간 겹침이면 배제 제약, 유일성이면 유니크 인덱스. 애플리케이션이 지키는 규칙보다 DB 가 지키는 규칙이 훨씬 강합니다.
- 안 되면 인위적인 충돌 지점을 만드세요. 경쟁자들이 공통으로 잠그는 행 하나를 두면 직렬화됩니다. 상위 개체(팀, 회의실, 계좌 그룹)의 행이 자연스러운 후보예요.
- 그래도 안 되면 해당 트랜잭션만 SERIALIZABLE 로 올리고 재시도를 구현하세요. 앱 전체를 올리지는 마세요.
- 이 패턴을 알아보는 눈을 팀에 공유하세요. "집합 조건을 검사하고 그 집합의 일부를 바꾼다"가 신호예요. 코드 리뷰에서 이 구조를 보면 멈춰서 확인하는 습관이 가장 효과적입니다.
- 정합성 검사 배치를 두세요. 당직 0명, 마이너스 잔액, 중복 예약 같은 위반 상태를 주기적으로 찾아 알리세요. 막지 못한 것을 빨리 발견하는 장치예요.
지금 확인해 보세요
위반 상태를 주기적으로 찾는다:
· 규칙이 실제로 지켜지고 있는가 — 정합성 배치
SELECT team_id, count(*) FILTER (WHERE on_duty) AS on_duty
FROM oncall GROUP BY team_id
HAVING count(*) FILTER (WHERE on_duty) < 1;
· 직렬화 실패가 얼마나 나는지 (PG)
로그에서 'could not serialize access' 를 센다
재시도 성공률까지 지표로 두면 경합 수준이 보인다
· 겹침을 DB 로 막을 수 있는 경우 — 배제 제약
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (
room_id WITH =, during WITH &&);
· 애플리케이션 로직보다 훨씬 강하다핵심 한 줄각자 옳아도 합치면 틀릴 수 있다. 집합을 보고 집합을 바꾸면 의심하라.