- 들어가며
- 1. PostgreSQL이 실제로 구현한 것
- 2. 어떤 수준을 고를 것인가
- 3. 직렬화 실패 재시도 계층
- 4. 행 잠금 사다리와 SKIP LOCKED
- 5. 어드바이저리 락 — 데이터 밖의 상호배제
- 6. 긴 트랜잭션이라는 진짜 비용
- 7. MySQL 8.4 InnoDB와 다른 지점
- 8. 관측 — 무엇을 상시로 봐야 하는가
- 퀴즈: 실력을 확인해 보세요
- 마치며
- 참고 자료
- 이어서 읽기
들어가며
이 블로그에는 이미 트랜잭션 격리 수준과 실제 이상 현상이 있습니다. 표준의 네 수준과 세 가지 이상 현상, 스냅샷 격리, write skew를 두 세션 SQL로 보여 주는 글입니다. 격리 수준의 이론을 다룬 글이라고 부를 수 있습니다.
이 글은 그 위에 얹는 운영 계약 편입니다. 격리 수준을 정확히 이해한 팀도 서비스에서는 여전히 사고를 냅니다. 이유는 격리 수준이 데이터베이스 쪽 절반만 책임지기 때문입니다. 나머지 절반은 애플리케이션의 몫입니다. 직렬화 실패가 났을 때 누가 재시도하는가, 잠금은 어느 강도로 걸어야 하는가, 큐 테이블은 어떻게 여러 소비자가 나눠 갖는가, 그리고 트랜잭션을 오래 열어 두면 데이터베이스에 무슨 일이 벌어지는가. 이 글은 그 목록을 다룹니다.
기준 엔진은 PostgreSQL 18 입니다. 격리 수준의 실제 동작은 엔진마다 크게 다르므로, MySQL 8.4 InnoDB와 다른 지점은 7절에서 따로 엔진 이름을 붙여 구분했습니다. 두 엔진을 뭉뚱그린 설명은 없느니만 못합니다.
1. PostgreSQL이 실제로 구현한 것
먼저 사실관계를 정확히 못박고 시작합니다. 여기 적은 내용은 모두 PostgreSQL 18 문서에서 확인한 것입니다.
PostgreSQL은 표준의 네 가지 격리 수준을 모두 요청받을 수 있지만, 내부적으로는 세 가지만 구현합니다. 문서 표현 그대로 "PostgreSQL의 Read Uncommitted 모드는 Read Committed처럼 동작합니다." 이것이 다중 버전 동시성 제어 구조에 표준 격리 수준을 대응시키는 유일하게 합리적인 방법이기 때문입니다. 즉 PostgreSQL에서 dirty read는 어떤 설정으로도 발생하지 않습니다.
두 번째로 중요한 사실. 문서의 격리 수준 표는 팬텀 읽기 항목에 "표준상 허용되지만 PostgreSQL에서는 발생하지 않음"이라고 적어 두었습니다. 문서 본문은 이렇게 설명합니다. "PostgreSQL의 Repeatable Read 구현은 팬텀 읽기를 허용하지 않는다. 표준은 특정 격리 수준에서 어떤 이상 현상이 일어나지 않아야 하는지를 규정하므로, 더 강한 보장은 허용된다."
따라서 표준의 표를 그대로 외운 사람이 PostgreSQL에서 "Repeatable Read니까 팬텀이 생기겠지"라고 판단하면 틀립니다. 반대로 "Repeatable Read면 안전하겠지"라고 판단하는 것도 틀립니다. Repeatable Read가 막지 못하는 것은 팬텀이 아니라 직렬화 이상(serialization anomaly) 입니다. 문서의 정의는 이렇습니다. "여러 트랜잭션을 성공적으로 커밋한 결과가, 그 트랜잭션들을 한 번에 하나씩 실행하는 어떤 순서로도 설명되지 않는 상태."
| 수준 | dirty read | non-repeatable read | phantom read | serialization anomaly |
|---|---|---|---|---|
| Read Uncommitted | 발생 안 함 | 발생 가능 | 발생 가능 | 발생 가능 |
| Read Committed | 발생 안 함 | 발생 가능 | 발생 가능 | 발생 가능 |
| Repeatable Read | 발생 안 함 | 발생 안 함 | 발생 안 함 | 발생 가능 |
| Serializable | 발생 안 함 | 발생 안 함 | 발생 안 함 | 발생 안 함 |
기본 격리 수준은 default_transaction_isolation이 결정하며 기본값은 read committed 입니다.
2. 어떤 수준을 고를 것인가
세 가지 실질적 선택지가 있고, 각각의 계약이 다릅니다.
Read Committed — 각 문장이 자기 시작 시점의 스냅샷을 봅니다. 같은 트랜잭션 안에서도 문장마다 다른 데이터를 볼 수 있습니다. 대부분의 짧은 OLTP 트랜잭션에는 이것으로 충분하고, 재시도 부담이 없다는 것이 최대 장점입니다. 대신 "읽고 판단해서 쓰는" 로직을 이 수준에서 쓰려면 반드시 명시적 잠금이 필요합니다.
Repeatable Read — 트랜잭션 전체가 하나의 스냅샷을 봅니다. 여러 테이블을 읽어 하나의 일관된 보고서를 만드는 작업에 적합합니다. 대신 갱신 충돌이 나면 could not serialize access due to concurrent update 오류로 트랜잭션이 중단되고, 애플리케이션이 재시도해야 합니다.
Serializable — 문서 표현으로 "Serializable Snapshot Isolation"이라는 기법으로 구현되어 있으며, 스냅샷 격리 위에 직렬화 이상 검사를 얹은 것입니다. 불변식이 여러 행에 걸쳐 있는 도메인(잔고 합계, 좌석 중복, 재고 총량)에서 명시적 잠금 설계를 대체할 수 있습니다. 대가는 재시도 비율과 예측 곤란성입니다.
문서가 Serializable에 대해 권고하는 항목들이 사실상 사용 조건입니다. 가능하면 트랜잭션을 READ ONLY로 선언할 것, 트랜잭션을 짧게 유지할 것, idle in transaction 상태를 오래 두지 말 것, 커넥션 풀로 동시 커넥션 수를 통제할 것. 읽기 전용 보고서 트랜잭션이라면 SERIALIZABLE READ ONLY DEFERRABLE을 쓸 수 있는데, 이 트랜잭션은 충돌이 발생할 수 없다는 사실이 확립될 때까지 블록되었다가 시작하므로 직렬화 실패로 중단되지 않습니다.
-- 재시도 없이 완전히 일관된 스냅샷이 필요한 야간 보고서
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT ...;
COMMIT;
3. 직렬화 실패 재시도 계층
Repeatable Read 이상을 쓰기로 했다면 재시도는 선택이 아니라 필수입니다. 문서는 이 점을 두 번 강조합니다. "이 수준을 사용하는 애플리케이션은 직렬화 실패로 인한 트랜잭션 재시도를 준비해야 한다." 그리고 "직렬화 실패를 처리하는 일반화된 방법을 갖추는 것이 중요하다. 어떤 트랜잭션이 읽기·쓰기 의존 관계에 기여해 롤백되어야 할지 정확히 예측하기가 매우 어렵기 때문이다."
핵심 식별자는 SQLSTATE 40001 입니다. 직렬화 실패는 항상 이 값으로 돌아옵니다. 재시도 계층은 세 가지 성질을 가져야 합니다.
첫째, 트랜잭션 전체를 다시 실행해야 합니다. 실패한 문장만 다시 실행하는 것은 의미가 없습니다. 스냅샷 자체가 무효화되었기 때문입니다.
둘째, 재시도 횟수에 상한과 지수 백오프가 있어야 합니다. 상한이 없으면 경합이 심한 순간에 재시도가 재시도를 부르는 폭주가 발생합니다.
셋째, 부수 효과가 트랜잭션 밖에 있으면 안 됩니다. 트랜잭션 안에서 외부 API를 호출하거나 메시지를 발행했다면 재시도 때 중복 발생합니다. 외부 호출은 커밋 이후로 미루거나 아웃박스 패턴으로 트랜잭션 안의 테이블 쓰기로 바꿔야 합니다.
-- 재시도 대상 오류를 서버 쪽에서 확인하는 방법
-- 40001: serialization_failure, 40P01: deadlock_detected
DO $do$
BEGIN
-- 실제 애플리케이션에서는 이 로직이 클라이언트 계층에 있어야 한다
PERFORM 1;
EXCEPTION
WHEN serialization_failure OR deadlock_detected THEN
RAISE NOTICE 'retryable: %', SQLSTATE;
END;
$do$;
데드락(40P01)도 같은 재시도 계층에서 처리하는 것이 실용적입니다. 두 오류 모두 "다시 하면 성공할 수 있다"는 성질이 같습니다.
주의할 점 하나. 재시도 계층을 데이터베이스 함수 안에 두면 안 됩니다. 함수 안의 예외 처리 블록은 서브트랜잭션이므로, 바깥 트랜잭션의 스냅샷은 그대로입니다. 재시도는 반드시 트랜잭션을 시작한 계층, 즉 애플리케이션 코드에 있어야 합니다.
4. 행 잠금 사다리와 SKIP LOCKED
Read Committed에서 "읽고 판단해서 쓰는" 로직을 안전하게 만들려면 명시적 잠금이 필요합니다. PostgreSQL은 네 단계의 행 잠금을 제공하며, 문서의 구문은 FOR lock_strength [ OF from_reference ] [ NOWAIT | SKIP LOCKED ] 입니다.
강도 순서대로 정리하면 이렇습니다.
- FOR UPDATE — 가장 강한 잠금. 다른 트랜잭션의 UPDATE, DELETE와 네 종류 잠금 SELECT를 모두 막습니다.
- FOR NO KEY UPDATE — 약한 배타 잠금.
FOR KEY SHARE를 막지 않습니다. 유일 인덱스 컬럼을 건드리지 않는 UPDATE가 자동으로 취하는 잠금이기도 합니다. - FOR SHARE — 공유 잠금. UPDATE, DELETE,
FOR UPDATE,FOR NO KEY UPDATE는 막지만 다른FOR SHARE와FOR KEY SHARE는 막지 않습니다. - FOR KEY SHARE — 가장 약한 공유 잠금. DELETE와 키 값의 UPDATE만 막습니다. 외래 키 검사가 내부적으로 쓰는 잠금입니다.
행 잠금 충돌 관계를 표로 옮기면 이렇습니다. X 표시가 충돌입니다.
| 요청 → / 보유 ↓ | KEY SHARE | SHARE | NO KEY UPDATE | UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | X | |||
| FOR SHARE | X | X | ||
| FOR NO KEY UPDATE | X | X | X | |
| FOR UPDATE | X | X | X | X |
실무에서 가장 유용한 것은 강도가 아니라 대기 정책입니다. 문서 표현 그대로, NOWAIT는 선택된 행을 즉시 잠글 수 없으면 대기하지 않고 오류를 냅니다. SKIP LOCKED는 즉시 잠글 수 없는 행을 건너뜁니다. 문서는 SKIP LOCKED에 대해 "잠긴 행을 건너뛰는 것은 일관되지 않은 데이터 뷰를 제공하므로 범용 작업에는 적합하지 않지만, 여러 소비자가 큐 형태의 테이블에 접근할 때 잠금 경합을 피하는 데 쓸 수 있다"라고 명시합니다.
이것이 데이터베이스를 작업 큐로 쓰는 표준 패턴입니다.
-- 여러 워커가 서로 다른 작업을 집어 가는 큐
BEGIN;
WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'RUNNING', started_at = now()
FROM picked p
WHERE j.id = p.id
RETURNING j.id, j.payload;
COMMIT;
SKIP LOCKED가 없으면 워커 열 개가 모두 같은 첫 행에서 줄을 섭니다. 있으면 각자 다른 행을 집어 갑니다.
한 가지 함정도 문서에 적혀 있습니다. Read Committed에서 ORDER BY와 잠금 절을 함께 쓰면 결과가 정렬 순서를 벗어날 수 있습니다. 정렬이 먼저 적용된 뒤 잠금 대기가 발생하고, 대기가 풀렸을 때 정렬 컬럼 값이 이미 바뀌어 있을 수 있기 때문입니다. Repeatable Read 이상에서는 같은 상황이 40001 직렬화 실패가 됩니다.
5. 어드바이저리 락 — 데이터 밖의 상호배제
잠글 대상이 행이 아닌 경우가 있습니다. "이 배치는 동시에 한 번만 돌아야 한다", "이 외부 API 호출은 테넌트당 하나만" 같은 요건입니다. 이때 잠금 플래그 컬럼을 만들어 UPDATE로 표시하는 방식은 테이블 bloat를 만들고 실패 시 정리가 어렵습니다.
PostgreSQL의 어드바이저리 락은 이 용도로 설계되었습니다. 문서는 이를 "애플리케이션이 정의하는 의미를 갖는 잠금으로, 시스템이 사용법을 강제하지 않으므로 애플리케이션이 올바르게 써야 한다"라고 설명하며, 테이블 플래그보다 빠르고 테이블 bloat를 만들지 않으며 세션 종료 시 자동으로 정리된다는 장점을 듭니다.
두 가지 수준이 있습니다. 세션 수준은 명시적으로 해제하거나 세션이 끝날 때까지 유지되며 트랜잭션 롤백에도 살아남습니다. 트랜잭션 수준은 트랜잭션이 끝날 때 자동으로 해제됩니다. 짧은 상호배제에는 트랜잭션 수준이 안전합니다.
-- 트랜잭션 수준 어드바이저리 락: 획득 못 하면 즉시 false를 돌려준다
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('nightly-settlement'));
-- true면 진행, false면 다른 인스턴스가 이미 돌고 있으므로 종료
COMMIT;
문서가 경고하는 함정이 하나 있습니다. LIMIT와 함께 쓸 때 SQL 평가 순서 때문에 의도보다 많은 잠금을 얻을 수 있습니다. 반드시 서브쿼리에서 LIMIT를 먼저 적용한 뒤 바깥에서 잠금 함수를 호출해야 합니다.
6. 긴 트랜잭션이라는 진짜 비용
격리 수준 논의에서 가장 자주 빠지는 주제입니다. 트랜잭션을 오래 열어 두면 무슨 일이 벌어지는가.
PostgreSQL의 MVCC는 갱신할 때 기존 행 버전을 그대로 두고 새 버전을 만듭니다. 낡은 버전은 "이 버전을 볼 수 있는 트랜잭션이 하나도 없을 때" 비로소 회수 가능합니다. 그런데 열려 있는 트랜잭션이 하나 있으면, 그 트랜잭션의 스냅샷보다 나중에 죽은 모든 행 버전이 데이터베이스 전체에서 회수되지 못합니다.
결과는 이렇습니다. 아침에 열어 두고 점심 먹으러 간 psql 세션 하나 때문에, 전혀 관계없는 주문 테이블의 죽은 행이 쌓이고, 인덱스가 부풀고, 순차 스캔이 읽어야 할 페이지가 늘어나고, 쿼리가 전반적으로 느려집니다. 그리고 트랜잭션 ID 소모가 계속되면 wraparound 방지를 위한 강제 vacuum까지 걸립니다.
방어선은 세 개의 타임아웃입니다. PostgreSQL 18 문서 기준으로 세 값 모두 기본값이 0, 즉 비활성 입니다. 기본 설정을 그대로 쓰는 서버는 이 방어선이 아예 없다는 뜻입니다.
statement_timeout— 지정한 시간을 넘는 문장을 중단합니다.lock_timeout— 테이블, 인덱스, 행 등의 잠금을 얻으려 기다리는 시간이 지정 시간을 넘으면 문장을 중단합니다.idle_in_transaction_session_timeout— 트랜잭션을 열어 둔 채 클라이언트 명령을 기다리며 놀고 있는 세션을 종료합니다.
PostgreSQL 17부터는 transaction_timeout도 있습니다. 트랜잭션 전체가 지정 시간을 넘으면 세션을 종료하며, 기본값은 역시 0입니다.
-- 데이터베이스 단위 기본값. 배치 계정은 따로 완화한다
ALTER DATABASE appdb SET statement_timeout = '30s';
ALTER DATABASE appdb SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE batch_worker SET statement_timeout = '30min';
현재 열려 있는 오래된 트랜잭션은 이렇게 찾습니다.
SELECT pid, state, now() - xact_start AS xact_age,
now() - state_change AS state_age,
wait_event_type, wait_event, left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;
7. MySQL 8.4 InnoDB와 다른 지점
여기부터는 엔진 이름을 붙여 읽어야 합니다. 두 엔진의 차이는 사소하지 않습니다.
기본 격리 수준이 다릅니다. MySQL 8.4 문서 기준 InnoDB의 기본 격리 수준은 REPEATABLE READ 입니다. PostgreSQL 18의 기본값은 Read Committed 입니다. 즉 같은 애플리케이션 코드를 두 엔진에 붙이면 기본 동작이 다릅니다.
Repeatable Read의 잠금 동작이 다릅니다. InnoDB에서 SELECT ... FOR UPDATE, SELECT ... FOR SHARE, UPDATE, DELETE는 검색 조건에 따라 다르게 동작합니다. 유일 인덱스에 유일한 값을 지정한 검색이면 찾은 인덱스 레코드만 잠그고 그 앞의 간격은 잠그지 않습니다. 그 밖의 검색 조건이면 스캔한 인덱스 범위를 갭 락 또는 넥스트 키 락으로 잠가 다른 세션이 그 범위에 삽입하는 것을 막습니다. 즉 InnoDB는 잠금 읽기에서 갭을 잠가 팬텀을 막습니다. PostgreSQL은 갭 락이라는 개념 자체가 없고, 스냅샷으로 팬텀을 막습니다.
Read Committed의 동작도 다릅니다. InnoDB의 Read Committed에서는 갭 잠금이 비활성화되고, 외래 키 제약 검사와 중복 키 검사에만 남습니다. 또한 UPDATE 문에서 이미 잠긴 행을 만나면 "준일관 읽기(semi-consistent read)"를 수행해 최신 커밋 버전을 MySQL 계층에 돌려주고, 그 값으로 WHERE 조건 일치 여부를 판단합니다. PostgreSQL에는 이런 동작이 없습니다.
Read Uncommitted의 의미가 다릅니다. InnoDB의 Read Uncommitted에서 SELECT는 잠금 없이 수행되며 이전 버전의 행이 사용될 수 있습니다. 즉 더티 리드가 실제로 발생합니다. PostgreSQL에서는 발생하지 않습니다.
Serializable의 구현이 다릅니다. InnoDB의 Serializable은 autocommit이 꺼져 있을 때 모든 평범한 SELECT를 암묵적으로 SELECT ... FOR SHARE로 변환합니다. 즉 잠금 기반입니다. PostgreSQL의 Serializable은 잠금이 아니라 직렬화 이상 검사 기반이며, 충돌 시 40001로 중단됩니다.
격리 수준을 지정하는 시스템 변수 이름도 다릅니다. MySQL은 transaction_isolation, PostgreSQL은 default_transaction_isolation입니다.
8. 관측 — 무엇을 상시로 봐야 하는가
동시성 문제는 재현이 어려우므로 지표를 미리 켜 두는 편이 낫습니다.
대기 이벤트. pg_stat_activity의 wait_event_type은 백엔드가 무엇을 기다리는지 알려 줍니다. 문서가 정의한 값에는 Lock(SQL에서 보이는 객체에 대한 무거운 잠금), LWLock(내부 자료구조 보호용 경량 잠금), BufferPin, IO, IPC, Client, Timeout, Activity, Extension이 있습니다. 동시성 조사에서는 Lock이 핵심입니다.
데드락 검출. PostgreSQL은 잠금 대기마다 데드락을 검사하지 않습니다. 비싸기 때문입니다. 대신 deadlock_timeout만큼 기다린 뒤에 검사합니다. 기본값은 1초 입니다. log_lock_waits를 켜 두면 같은 시간 기준으로 잠금 대기 로그가 남으므로, 데드락에 이르지 않은 긴 대기까지 잡을 수 있습니다.
-- 잠금 대기를 로그에 남긴다 (deadlock_timeout 기준)
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
잠금 슬롯. max_locks_per_transaction의 기본값은 64입니다. 문서는 "64라는 기본값은 역사적으로 충분한 것으로 입증되었지만, 한 트랜잭션에서 많은 테이블을 건드리는 질의가 있다면 값을 올려야 할 수 있다"라고 하며 자식이 많은 부모 테이블 질의를 예로 듭니다. 파티션이 수백 개인 테이블을 조회하는 워크로드에서 실제로 부딪히는 한계입니다.
Serializable 전용 지표. Serializable을 쓴다면 술어 잠금(predicate lock) 관련 파라미터도 봐야 합니다. max_pred_locks_per_transaction의 기본값은 64, max_pred_locks_per_page의 기본값은 2, max_pred_locks_per_relation의 기본값은 -2입니다. 술어 잠금이 부족하면 잠금 단위가 페이지에서 관계 전체로 승격되면서 직렬화 실패가 급증합니다.
퀴즈: 실력을 확인해 보세요
퀴즈 1: PostgreSQL에서 READ UNCOMMITTED로 트랜잭션을 시작했습니다. 다른 트랜잭션이 커밋하지 않은 값을 읽을 수 있을까요?
정답: 읽을 수 없습니다. PostgreSQL의 Read Uncommitted는 Read Committed처럼 동작합니다.
설명: 문서는 "PostgreSQL에서는 네 가지 표준 격리 수준을 모두 요청할 수 있지만 내부적으로는 세 가지만 구현되어 있으며, Read Uncommitted 모드는 Read Committed처럼 동작한다"라고 명시합니다. 이유는 MVCC 구조에 표준 격리 수준을 대응시키는 유일하게 합리적인 방법이기 때문입니다. 다른 엔진과 다릅니다. MySQL 8.4 InnoDB의 Read Uncommitted에서는 SELECT가 잠금 없이 수행되고 이전 버전의 행을 볼 수 있으므로 더티 리드가 실제로 발생합니다. 이식성을 고려한다면 이 차이를 반드시 알고 있어야 합니다.
퀴즈 2: 아래 코드의 문제는 무엇인가요?
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
INSERT INTO orders (...) VALUES (...);
-- 여기서 결제 게이트웨이 HTTP 호출
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A-1';
COMMIT;
정답: 트랜잭션 안에 외부 API 호출이 들어 있습니다. 직렬화 실패로 재시도하면 결제가 중복됩니다. 게다가 HTTP 응답을 기다리는 동안 트랜잭션이 열려 있어 VACUUM을 막습니다.
설명: Serializable은 40001 오류로 트랜잭션이 중단될 수 있고, 애플리케이션은 트랜잭션 전체를 재시도해야 합니다. 외부 호출이 트랜잭션 안에 있으면 재시도할 때마다 다시 호출됩니다. 해결은 외부 호출을 트랜잭션 밖으로 빼거나, 아웃박스 테이블에 이벤트를 기록하고 커밋 후 별도 워커가 발송하는 구조로 바꾸는 것입니다. 두 번째 문제도 심각합니다. 네트워크 지연이 몇 초만 되어도 그동안 데이터베이스 전체의 죽은 행 회수가 막힙니다. 문서가 Serializable 사용 시 "필요 이상의 것을 한 트랜잭션에 넣지 말 것"과 "idle in transaction 상태를 필요 이상으로 두지 말 것"을 권고하는 이유가 이것입니다.
퀴즈 3: 워커 열 개가 같은 jobs 테이블에서 작업을 가져가는데 처리량이 워커 한 개일 때와 같습니다. 무엇이 빠졌을까요?
정답: SKIP LOCKED입니다.
설명: SELECT ... FOR UPDATE LIMIT 1만 쓰면 모든 워커가 같은 첫 번째 행을 잠그려고 줄을 섭니다. 첫 워커가 처리하는 동안 나머지 아홉은 대기하므로 사실상 직렬 실행이 됩니다. 문서는 SKIP LOCKED에 대해 "여러 소비자가 큐 형태의 테이블에 접근할 때 잠금 경합을 피하는 데 쓸 수 있다"라고 명시합니다.
SELECT id FROM jobs
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
다만 같은 문서가 경고하듯 SKIP LOCKED는 일관되지 않은 뷰를 제공하므로 범용 조회에는 쓰면 안 됩니다. 큐 소비처럼 "아무 행이나 하나 집어 가면 되는" 경우에만 적합합니다.
퀴즈 4: 개발 서버에서 잘 돌던 배치가 운영에서 다른 트랜잭션을 몇 분씩 막습니다. 격리 수준을 낮추면 해결될까요?
정답: 아닙니다. 격리 수준과 잠금 대기는 별개의 문제입니다.
설명: 격리 수준은 "무엇을 볼 수 있는가"를 결정하고, 잠금은 "무엇을 기다려야 하는가"를 결정합니다. Read Committed로 낮춰도 UPDATE가 같은 행을 건드리면 여전히 기다립니다. 진단 순서는 이렇습니다. 먼저 pg_stat_activity에서 wait_event_type = 'Lock'인 세션을 찾고, 그 세션이 무엇을 기다리는지 확인합니다. 대개 원인은 배치가 한 트랜잭션에서 너무 많은 행을 갱신하고 있는 것입니다. 대응은 배치를 청크로 나눠 각 청크를 별도 트랜잭션으로 커밋하는 것이고, 예방은 lock_timeout을 설정해 무한 대기를 막는 것입니다. 기본값 0은 무한 대기를 뜻합니다.
퀴즈 5: Serializable로 바꿨더니 40001 오류가 급증했습니다. 통계를 보니 대부분의 쿼리가 Seq Scan입니다. 관련이 있을까요?
정답: 있습니다. 순차 스캔은 관계 수준의 술어 잠금을 유발합니다.
설명: PostgreSQL의 Serializable은 읽은 데이터에 술어 잠금을 걸어 읽기·쓰기 의존을 추적합니다. 인덱스 스캔이면 잠금 단위가 좁지만, 순차 스캔이면 테이블 전체가 잠금 대상이 되므로 무관한 트랜잭션끼리도 충돌로 판정됩니다. 문서 자체가 Serializable 성능 권고 항목으로 "순차 스캔을 피하도록 실행 계획을 최적화할 것"을 들고 random_page_cost와 cpu_tuple_cost 조정을 언급합니다. 함께 볼 것은 술어 잠금 한도입니다. max_pred_locks_per_transaction의 기본값 64, max_pred_locks_per_page의 기본값 2를 넘기면 잠금 단위가 승격되어 충돌이 더 늘어납니다.
마치며
격리 수준은 "높이면 안전해지는 손잡이"가 아닙니다. 데이터베이스와 애플리케이션 사이의 계약입니다. 계약의 조항은 이렇습니다. 데이터베이스는 정해진 이상 현상이 발생하지 않도록 보장한다. 대신 애플리케이션은 직렬화 실패를 재시도하고, 트랜잭션을 짧게 유지하고, 부수 효과를 트랜잭션 경계 밖으로 내보내지 않는다.
이 계약의 애플리케이션 쪽 조항을 지키지 않으면 격리 수준을 아무리 올려도 사고는 계속 납니다. 반대로 조항을 지키면 Read Committed에 명시적 잠금 몇 줄만으로도 대부분의 도메인이 안전해집니다.
두 세션으로 실제 이상 현상을 재현해 보고 싶다면 Postgres 놀이터에서 실험해 볼 수 있습니다.
참고 자료
- PostgreSQL 18, Transaction Isolation: https://www.postgresql.org/docs/18/transaction-iso.html (2026-08-15 확인)
- PostgreSQL 18, Explicit Locking: https://www.postgresql.org/docs/18/explicit-locking.html (2026-08-15 확인)
- PostgreSQL 18, SELECT (Locking Clause): https://www.postgresql.org/docs/18/sql-select.html (2026-08-15 확인)
- PostgreSQL 18, Client Connection Defaults: https://www.postgresql.org/docs/18/runtime-config-client.html (2026-08-15 확인)
- PostgreSQL 18, Lock Management: https://www.postgresql.org/docs/18/runtime-config-locks.html (2026-08-15 확인)
- PostgreSQL 18, Monitoring Database Activity: https://www.postgresql.org/docs/18/monitoring-stats.html (2026-08-15 확인)
- MySQL 8.4, Transaction Isolation Levels: https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html (2026-08-15 확인)
이어서 읽기
- 이전 편: SQL 실행계획 완전 가이드 — 옵티마이저가 계획을 고르는 과정
- 다음 편: 무중단 스키마 변경 완전 가이드 — DDL이 잡는 잠금 등급
- 트랜잭션 격리 수준과 실제 이상 현상 — 이론과 이상 현상 재현
- 데드락 진단과 예방 — 로그에서 두 쿼리를 특정하는 법
- Postgres 놀이터 — 두 세션 동시성 실험
현재 단락 (1/154)
이 블로그에는 이미 [트랜잭션 격리 수준과 실제 이상 현상](/blog/database/2026-07-26-transaction-isolation-levels)이 있습니다. 표준의...