트랜잭션 안의 SELECT 두 번이 다른 값을 돌려주는 이유: PostgreSQL READ COMMITTED와 스냅샷
PostgreSQL 기본 격리 수준인 READ COMMITTED에서 한 트랜잭션의 SELECT 두 번이 다른 결과를 내는 이유를 공식 문서로 정리했습니다. 문장마다 잡는 스냅샷, UPDATE의 재평가, REPEATABLE READ와 재시도도 다룹니다.
같은 트랜잭션 안에서 똑같은 SELECT를 두 번 실행했는데 결과가 다르면 버그처럼 보이지만, PostgreSQL의 기본 격리 수준인 READ COMMITTED에서는 문서에 적힌 동작입니다. 2026년 10월 7일 기준 최신 정식 버전인 PostgreSQL 18 문서를 기준으로 했고, 같은 설명이 14 문서와 베타 단계인 19 문서에도 있습니다.
두 세션으로 보는 예
계좌 1의 잔액이 500일 때 두 세션이 이 순서로 실행된다고 해 보겠습니다.
-- 세션 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 500
-- 세션 B (BEGIN 없이 실행)
UPDATE accounts SET balance = 400 WHERE id = 1;
-- 세션 A
SELECT balance FROM accounts WHERE id = 1; -- 400
COMMIT;
BEGIN 없이 실행한 문장은 자동 커밋 모드에서 그 자체로 하나의 트랜잭션이 되고, 성공하면 끝날 때 커밋됩니다. 세션 B의 변경이 A의 두 SELECT 사이에 커밋되었으므로 A의 두 번째 SELECT는 400을 봅니다.
스냅샷을 문장마다 새로 잡는다
PostgreSQL은 다중 버전 동시성 제어(MVCC)로 각 SQL 문이 어느 시점의 스냅샷을 보게 합니다. READ COMMITTED에서 FOR UPDATE/FOR SHARE가 없는 SELECT는 그 쿼리가 시작되기 전에 커밋된 데이터만 봅니다. 커밋되지 않은 데이터도, 실행 중에 다른 트랜잭션이 커밋한 변경도 보지 않습니다. 자기 트랜잭션에서 앞서 한 변경은 커밋 전이라도 봅니다.
기준점이 트랜잭션이 아니라 쿼리의 시작이라는 점이 핵심입니다. 그래서 문서의 표에서 READ COMMITTED는 반복 불가능한 읽기(nonrepeatable read)와, 조건에 맞는 행의 집합이 바뀌는 팬텀 읽기(phantom read)가 가능한 수준입니다. 앞의 예는 반복 불가능한 읽기입니다.
표준과 PostgreSQL
PostgreSQL 문서에 따르면 SQL 표준은 SERIALIZABLE을 뺀 세 수준을 일어나면 안 되는 현상으로 정의하고, 최소한의 보호만 정하므로 더 강하게 구현해도 됩니다. 표준의 기본값은 SERIALIZABLE, PostgreSQL의 기본값은 READ COMMITTED입니다. READ UNCOMMITTED를 지정해도 READ COMMITTED처럼 동작하므로 더티 리드는 일어나지 않습니다. 같은 이름의 수준을 다른 데이터베이스는 다르게 구현할 수 있어, 이 글은 PostgreSQL에만 해당합니다.
UPDATE는 최신 행으로 다시 확인한다
UPDATE, DELETE, SELECT FOR UPDATE/FOR SHARE도 명령 시작 시점에 커밋된 행만 대상으로 찾습니다. 찾은 행을 다른 트랜잭션이 고치는 중이면 끝날 때까지 기다리고, 상대가 커밋하면 삭제된 행은 건너뛰고 갱신된 행에 WHERE 조건을 다시 평가해 여전히 맞을 때만 작업합니다.
이 UPDATE가 같은 행을 갱신하던 다른 트랜잭션을 기다렸고 그 트랜잭션이 커밋했다면, SET balance = balance - 100은 새로 커밋된 행 버전의 값으로 계산됩니다. 반면 앱이 앞서 읽은 500에서 100을 빼 SET balance = 400처럼 상수를 넣으면, WHERE 조건이 여전히 맞는 한 새로 커밋된 값을 그 상수로 덮어쓸 수 있습니다. 문서는 행을 동시 갱신에서 지키려면 SELECT FOR UPDATE, SELECT FOR SHARE나 알맞은 모드의 LOCK TABLE을 쓰라고 안내합니다. 다만 SELECT FOR UPDATE가 다른 트랜잭션을 막는 것은 잠금을 잡은 트랜잭션이 커밋하거나 롤백할 때까지입니다.
재평가가 뜻밖의 결과를 내기도 합니다. 문서의 예에서 hits가 9와 10인 두 행에 한 트랜잭션이 UPDATE website SET hits = hits + 1을 실행하고 아직 커밋하지 않았을 때, 다른 세션이 DELETE FROM website WHERE hits = 10을 실행하면 아무것도 지워지지 않습니다. 9였던 행은 처음에 조건에 맞지 않았고, 10이던 행은 잠금을 얻었을 때 이미 11이었기 때문입니다.
트랜잭션 내내 같은 데이터가 필요하면
REPEATABLE READ는 트랜잭션에서 처음 실행하는, 트랜잭션 제어문이 아닌 문장의 시작 시점에 스냅샷을 잡아 끝까지 씁니다. 그래서 SELECT를 반복해도 같은 데이터를 봅니다. 격리 수준은 첫 쿼리나 데이터 변경 문장 전에 정해야 합니다.
BEGIN ISOLATION LEVEL REPEATABLE READ;
대신 재시도를 준비해야 합니다. 갱신하거나 잠그려는 행을 트랜잭션 시작 뒤 다른 트랜잭션이 고치거나 지우고 커밋했다면 could not serialize access due to concurrent update 오류로 롤백됩니다. 직렬화 오류는 메시지가 달라도 SQLSTATE가 항상 40001이므로 코드로 판별하는 편이 안전합니다. 문서는 어떤 SQL을 어떤 값으로 보낼지 정하는 로직까지 포함해 트랜잭션 전체를 다시 실행하라고 하며, 그래서 자동 재시도 기능은 없습니다. 읽기만 하는 트랜잭션에는 이 충돌이 생기지 않습니다.
비슷하게 쿼리 하나의 결과가 직관과 다르게 나오는 경우는 NOT IN과 NULL에서 다뤘습니다.
참고한 문서
- PostgreSQL 18 — 13.1 Introduction: MVCC와 문장별 스냅샷
- PostgreSQL 18 — 13.2 Transaction Isolation: 13.2 도입부와 표 13.1, 13.2.1, 13.2.2
- PostgreSQL 18 — 13.4 Data Consistency Checks at the Application Level: 13.4.2
- PostgreSQL 18 — 13.5 Serialization Failure Handling
- PostgreSQL 18 — SET TRANSACTION: 설명, 호환성
- PostgreSQL 18 — BEGIN: 개요, 자동 커밋
- PostgreSQL 18 — Appendix A. Error Codes: Class 40
링크와 내용은 2026년 10월 7일에 확인했습니다.