PostgreSQL 심화 — 트랜잭션·인덱스·JSONB·파티션 · 트랜잭션과 잠금 · 이론
트랜잭션 격리와 잠금 — 동시성이 어긋나는 순간
한 줄 요약
같은 행을 두 사람이 동시에 고치면 무슨 일이 일어나는지는 격리 수준이 정한다. READ COMMITTED 는 갱신을 조용히 잃고, REPEATABLE READ 는 잃는 대신 오류를 낸다. 그 차이를 모르면 은행 잔액이 틀려도 아무도 신고하지 않는다.
왜 이게 필요했나
잔액을 읽어 100을 더해 다시 쓰는 코드가 있다. 평소에는 맞다. 그런데 같은 계좌에 두 요청이 겹친 날, 두 번 입금했는데 잔액은 한 번만 늘어 있다. 오류 로그는 깨끗하고, 코드에도 버그가 없다.
문제는 코드가 아니라 격리 수준이다. 기본값인 READ COMMITTED 에서 두 트랜잭션은 서로가 커밋하기 전의 값을 읽는다. 둘 다 1000을 읽고, 둘 다 1100을 쓴다. 한 번의 입금이 사라진 것이다 — 이것을 갱신 손실이라 한다.
어떻게 동작하나
PostgreSQL 의 격리 수준은 세 가지가 실무의 거의 전부다.
| 수준 | 무엇을 보장하나 | 무엇을 못 막나 |
| --- | --- | --- |
| READ COMMITTED | 커밋된 값만 읽는다(기본) | 같은 행을 두 번 읽으면 값이 달라질 수 있고, 갱신 손실이 난다 |
| REPEATABLE READ | 트랜잭션 내내 같은 스냅샷을 본다 | 뒤늦게 커밋하려는 쪽을 직렬화 오류로 되돌린다 |
| SERIALIZABLE | 동시 실행이 순차 실행과 같음을 보장 | 충돌하면 역시 오류로 되돌린다 |
읽어서-고쳐-쓰는 흐름을 안전하게 만드는 열쇠는 격리 수준을 올리는 것이거나, SELECT ... FOR UPDATE 로 읽는 순간 행을 잠그는 것이다. 잠긴 행은 다른 세션이 줄을 서서 기다리므로, 각자 최신 값을 읽고 고친다. 손실이 사라진다.
잠금은 대기 줄을 만든다
잠금은 안전을 주지만 대기를 만든다. 두 세션이 서로가 쥔 행을 반대 순서로 기다리면 교착이 되고, PostgreSQL 이 한쪽을 희생자로 골라 되돌린다. 트랜잭션을 열어 놓고 노는 세션은 커넥션을 문 채 남을 막는다.
그래서 두 개의 안전장치가 있다. lock_timeout 은 '기다리는' 한도라 무한 대기를 끊고, idle_in_transaction_session_timeout 은 '열어 놓고 노는' 세션을 끊는다. SELECT ... FOR UPDATE SKIP LOCKED 는 잠긴 행을 건너뛰고 다음 행을 집어, 여러 워커가 같은 작업 대기열을 겹치지 않게 나눠 처리하게 한다.
실무에서 진짜 중요한 것
첫째, 읽어서-고쳐-쓰는 코드에는 격리 수준이나 행 잠금을 반드시 붙인다. 기본값 READ COMMITTED 는 그 흐름을 지켜 주지 않는다. 잔액·재고·카운터처럼 남이 동시에 건드리는 값이면, FOR UPDATE 로 읽거나 REPEATABLE READ 로 올려 오류를 받고 재시도한다.
둘째, 한도는 커넥션에 건다. 쿼리마다 set lock_timeout 을 넣는 방식은 언젠가 빠뜨린다. 풀이 커넥션을 빌려줄 때 세션 기본값으로 걸어 두면 빠뜨릴 자리가 없다.
셋째, 작업 대기열은 SKIP LOCKED 로 뽑는다. 워커 여럿이 같은 큐를 처리할 때, 이것이 없으면 한 워커가 잠근 행을 다른 워커가 기다리며 병렬성이 사라진다. 다음 실습에서 이 현상들을 직접 재현한다.