Lost Updates, Phantom Rows, 그리고 이를 방지하는 격리 수준 (Isolation Level)
요약
데이터베이스 트랜잭션 격리 수준(Isolation Level)과 그 과정에서 발생할 수 있는 네 가지 주요 이상 현상을 설명합니다. Lost Update, Dirty Read, Non-repeatable Read, Phantom Read의 개념을 통해 데이터 일관성 보장의 중요성을 다룹니다.
핵심 포인트
- 트랜잭션 격리 수준은 동시성 제어와 데이터 일관성 사이의 계약임
- Dirty Read, Non-repeatable Read, Phantom Read, Lost Update가 주요 이상 현상임
- 격리 수준이 높을수록 데이터는 안전하지만 성능 트레이드오프가 발생함
- 기본 설정된 격리 수준이 항상 가장 안전한 수준은 아님을 인지해야 함
두 번의 업데이트, 하나의 행, 합의되지 않은 결과
두 개의 애플리케이션 서버가 동일한 순간에 동일한 고객 액션을 수신합니다. 두 서버 모두 고객의 잔액을 읽고, 둘 다 크레딧을 더하고, 둘 다 결과를 기록합니다. 하나의 크레딧이 사라집니다. 각 서버의 관점에서는 어느 쪽도 잘못한 것이 없습니다. 각 서버는 일관된 값을 읽었고, 새로운 값을 기록했으며, 데이터베이스는 불만 없이 두 번의 쓰기(write)를 모두 수락했습니다.
이것이 바로 Lost Update (갱신 손실)이며, 일반적인 의미에서의 애플리케이션 버그는 아닙니다. 애플리케이션 코드는 단일 작성자(writer)가 존재하는 세상에서는 올바르게 작동했습니다. 하지만 데이터베이스는 기본적으로 그러한 세상을 보장하지 않습니다. 데이터베이스는 각 개별 문장(statement)이 일관된 스냅샷 (snapshot)을 볼 수 있음을 보장할 뿐이며, 이 약속은 코드가 가정했던 것보다 엄격하게 약합니다. 이 두 지점 사이의 간극이 바로 트랜잭션 격리 수준 (transaction isolation levels)의 핵심 주제입니다.
격리 수준 (Isolation levels)은 트랜잭션이 관찰할 수 있는 동시 활동 (concurrent activity)에 대해 애플리케이션과 데이터베이스 간에 맺는 계약입니다. SQL 표준은 네 가지 격리 수준을 정의하며, 모든 진지한 데이터베이스는 최소 세 가지를 구현합니다. 실질적인 질문은 어떤 수준이 "최선"인가가 아니라, 각 수준이 실제로 무엇을 보장하는가입니다. 왜냐하면 그 보장 범위는 대부분의 개발자가 믿는 것보다 좁으며, 기본 설정된 수준이 가장 안전한 수준도 아니기 때문입니다.
격리가 방지해야 하는 네 가지 이상 현상 (Anomalies)
표준은 격리 수준을 허용하거나 방지하는 이상 현상 (anomalies)에 따라 정의합니다. 실무에서는 네 가지 이상 현상이 중요합니다.
Dirty Read (오염된 읽기)는 아직 커밋 (commit)되지 않은 트랜잭션이 작성한 행을 읽는 것입니다. 위험한 점은 읽기 자체의 문제가 아니라, 쓰기 트랜잭션이 롤백 (rollback)될 수 있으며, 읽는 쪽은 존재하지도 않았던 값을 바탕으로 이미 로직을 구축해 버렸다는 점입니다.
A Non-repeatable Read (비반복 읽기)는 하나의 트랜잭션 내에서 동일한 행을 두 번 읽었을 때, 그 사이에 다른 트랜잭션이 변경 사항을 커밋하여 서로 다른 값을 얻게 되는 현상입니다. 첫 번째 읽기는 커밋된 값을 읽었고, 두 번째 역시 커밋된 값을 읽었습니다. 단지 두 값이 서로 일치하지 않을 뿐입니다.
팬텀 읽기 (Phantom Read)는 동일한 문제의 행 수 (row-count) 버전입니다. 필터가 포함된 쿼리가 두 번째 실행될 때 다른 행 집합을 반환하는데, 이는 두 실행 사이에 다른 트랜잭션이 필터와 일치하는 행을 삽입하거나 삭제했기 때문입니다.
갱신 손실 (Lost Update)은 두 트랜잭션이 동일한 값을 읽고, 읽은 값을 바탕으로 둘 다 수정하며, 두 번째 쓰기가 첫 번째 쓰기를 소리 없이 덮어쓸 때 발생합니다. 오류가 발생하지 않습니다. 제약 조건 (constraint)도 위반되지 않습니다. 데이터베이스는 단순히 마지막 쓰기를 유지하며, 이전 트랜잭션의 작업은 증발해 버립니다.
각 격리 수준 (Isolation Level)은 스펙트럼 상의 한 지점입니다. 즉, 데이터베이스가 이 네 가지 이상 현상 (anomalies) 중 어떤 것을 허용할 것인가에 대한 위치입니다. 가장 안전한 수준은 네 가지 모두를 방지합니다. 가장 약한 수준은 첫 번째 것만 방지합니다. 그 사이의 모든 것은 트레이드오프 (trade-off)입니다.
Read Uncommitted: Postgres가 제공하기를 거부하는 수준
가장 약한 표준 수준인 Read Uncommitted는 dirty read (dirty reads)를 허용합니다. 이 수준의 트랜잭션은 다른 트랜잭션이 수정했지만 커밋하지 않은 행을 볼 수 있으며, 여기에는 잠시 후 롤백 (rolled back)될 행도 포함됩니다.
PostgreSQL은 이를 구현하지 않습니다. 이는 실수(oversight)가 아니라 아키텍처상의 결과입니다. PostgreSQL의 동시성 제어 (concurrency control)는 문장이 시작되는 시점에 찍힌 스냅샷 (snapshots)을 기반으로 구축되었으며, 구조적으로 스냅샷은 스냅샷이 찍히기 전에 커밋된 행만을 포함합니다. 트랜잭션에 커밋되지 않은 행을 보여주는 코드 경로(code path)는 존재하지 않는데, 스냅샷 외부를 읽는 코드 경로 자체가 없기 때문입니다.
PostgreSQL에서 격리 수준을 Read Uncommitted로 설정하는 것은 문법적으로 허용되지만, 내부적으로는 Read Committed와 동일하게 동작합니다. 이 사실을 아는 것은 중요합니다. 왜냐하면 다른 데이터베이스의 문서나 조언을 읽는 방식이 달라지기 때문입니다. MySQL에서 Read Uncommitted는 실제로 존재하며 도달 가능한 수준이지만, PostgreSQL에서는 아무런 효과가 없는 라벨일 뿐입니다. 잠금 (locking) 문제를 우회하기 위해 커밋되지 않은 데이터를 보는 것에 의존하는 코드는 여기서 작동하지 않을 것이며, 해결책은 더 낮은 수준을 찾는 것이 아닙니다. 더 낮은 수준은 존재하지 않기 때문입니다.
Read Committed: 당신이 이미 실행 중인 기본값
Read Committed는 PostgreSQL의 기본값이며, 대부분의 애플리케이션이 별다른 생각 없이 사용하는 수준입니다. 각 문장(statement)은 해당 문장이 시작될 때 찍힌 자신만의 스냅샷(snapshot)을 가집니다. 해당 문장은 그 시점 이전에 커밋(commit)된 모든 행은 볼 수 있지만, 그 이후에 커밋된 것은 볼 수 없습니다.
이 보장은 트랜잭션(transaction) 단위가 아닌 문장(statement) 단위로 이루어집니다. 동일한 트랜잭션 내의 두 SELECT 문이라도, 그 사이에 다른 트랜잭션이 커밋을 완료한다면 서로 다른 결과를 반환할 수 있습니다. 이것이 비반복 읽기 (non-repeatable reads)의 원인입니다. 하지만 대부분의 트랜잭션은 짧고, 대부분의 애플리케이션은 하나의 트랜잭션 내에서 동일한 필터로 동일한 행을 다시 읽지 않기 때문에 대개의 경우 이 현상은 드러나지 않습니다.
Read Committed는 또한 갱신 손실 (lost updates)을 방지하지 못합니다. 두 트랜잭션이 모두 Read Committed 수준에서 행을 읽고, 각자 새로운 값을 계산한 뒤, 둘 다 이를 기록할 수 있습니다. 기록(write)은 직렬화 (serialize)되지만 읽기(read)는 그렇지 않기 때문에, 먼저 수행된 계산은 폐기됩니다. 이것은 거의 모든 PostgreSQL 설치 환경의 기본 동작이며, 이는 앞서 언급한 시나리오 — 두 번의 입금, 하나의 잔액 — 가 설정 오류가 아니라 일반적인 결과임을 의미합니다.
Read Committed 환경에서 갱신 손실 (lost updates)을 완화하는 방법은 다른 격리 수준 (isolation level)을 사용하는 것이 아닙니다. 업데이트 자체를 조건부로 만드는 것입니다. 즉, 문장(statement)이 자신의 스냅샷 내에서 행을 다시 읽고 현재 값으로부터 계산하도록 하여, 이 특정 패턴에 대한 읽기-수정-쓰기 (read-modify-write) 간극을 메우는 것입니다:
UPDATE accounts
SET balance = balance + 50
WHERE account_id = 42;
동일한 기법은 업서트 (upsert) 형태에도 적용됩니다. 업서트는 문장 실행 시점의 행 상태에 따라 삽입 (insert)과 업데이트 (update) 사이를 원자적 (atomically)으로 결정합니다:
INSERT INTO counters (key, value)
VALUES ('visits', 1)
ON CONFLICT (key)
...
두 형태 모두 갱신 손실 (lost updates)로부터 안전하기 위해 트랜잭션 경계 (transaction boundary)를 가질 필요가 없습니다. 충돌 확인 (conflict check)과 쓰기 (write)가 하나의 원자적 문장 (atomic statement) 내에서 일어나기 때문입니다. 애플리케이션이 작업을 SELECT 이후 별도의 UPDATE로 분리하는 순간, 다시 읽기-수정-쓰기 (read-modify-write) 경로로 돌아가게 되며, 이때부터는 격리 수준 (isolation level)이 다음에 일어날 일을 결정하게 됩니다.
Repeatable Read: 문장 (Statement)보다 오래 지속되는 스냅샷 (Snapshot)
Repeatable read 단계에서는 의미론적(semantics)인 변화가 질적으로 일어납니다. PostgreSQL은 트랜잭션의 첫 번째 문장 (statement)이 실행될 때 스냅샷 (snapshot)을 한 번 찍고, 트랜잭션 전체에 걸쳐 이를 재사용하는 방식으로 이를 구현합니다. 트랜잭션 내의 모든 문장은 동일한 커밋된 행 (committed rows) 세트와 해당 행들의 동일한 버전을 포함하여, 동일한 세계를 보게 됩니다.
Non-repeatable read (비반복 읽기) 현상이 사라집니다. 하나의 트랜잭션 내에서 동일한 행을 두 번 읽어도 단 하나의 스냅샷만 존재하기 때문에 동일한 값을 반환합니다. Phantom (팬텀) 현상 또한 같은 이유로 방지됩니다. 스냅샷이 찍힐 때 행 세트가 고정되므로, 그 사이에 다른 트랜잭션이 무엇을 커밋하든 상관없이 필터링된 쿼리는 매번 동일한 행을 반환합니다.
하지만 Lost update (갱신 손실)는 방지되는 것이 아니라 탐지됩니다. Repeatable read 수준의 트랜잭션이 동시에 커밋된 다른 트랜잭션에 의해 이미 업데이트된 행을 업데이트하려고 시도하면, PostgreSQL은 조용히 덮어쓰는 대신 직렬화 실패 (serialization failure)를 발생시킵니다. 애플리케이션은 에러를 전달받게 되며 트랜잭션을 재시도 (retry)해야 합니다. 이는 매우 중요한 차이점입니다. 이상 현상 (anomaly)이 조용히 통과되도록 허용하지는 않지만, 트랜잭션이 자동으로 안전하게 만들어지는 것도 아닙니다. 직렬화 실패를 전혀 처리하지 않는 코드는 데이터가 오염되기 직전의 바로 그 순간에 충돌(crash)하게 될 것입니다.
일관된 특정 시점의 뷰 (point-in-time view)를 집계해야 하는 보고용 쿼리나, 사용자가 계속 데이터를 쓰는 동안 안정적인 행 세트를 읽어야 하는 배치 작업(batch jobs)처럼 진정으로 Repeatable read 의미론이 필요한 애플리케이션은 의도적으로 이 수준을 설정하고 직렬화 실패에 대한 재시도 로직 (retry logic)을 추가해야 합니다. 기본 수준에서는 이러한 쿼리들이 서로 다른 시점의 데이터를 조용히 섞어버리기 때문입니다.
Serializable: 데이터베이스가 거절하기 시작하는 지점
Serializable는 가장 강력한 표준 수준입니다. 트랜잭션들이 실제로 동시에 실행되더라도, 마치 어떤 순서에 따라 하나씩 차례대로 실행된 것처럼 동작해야 합니다. PostgreSQL은 Serializable Snapshot Isolation (SSI)을 통해 이를 구현하며, 이는 Repeatable-read 스냅샷 하에서 트랜잭션을 실행하고 트랜잭션 간의 읽기-쓰기 의존성 (read-write dependencies)을 추적합니다. 의존성 그래프 (dependency graph)에 사이클 (cycle)이 발생하면 — 즉, 동시 실행 스케줄을 선형화 (linearize) 할 수 없음을 의미하면 — 데이터베이스는 Serialization failure (직렬화 실패) 오류와 함께 트랜잭션 중 하나를 중단(abort)시킵니다.
실질적인 형태는 Repeatable read에 더 엄격한 실패 감지 기능이 추가된 것과 동일합니다. 애플리케이션은 여전히 중단된 트랜잭션을 재시도 (retry)해야 합니다. 차이점은 중단 가능한 스케줄의 집합이 더 크다는 것입니다. Serializable은 두 트랜잭션이 겹치는 범위를 읽고 쓰는 방식이 어떤 직렬 순서에서도 발생할 수 없는 경우의 교차 실행 (interleavings)까지도 잡아내기 때문입니다.
이로 인한 정직한 대가는 처리량 (throughput)입니다. Serializable 트랜잭션은 더 자주 중단되며, 모든 중단은 애플리케이션이 작업을 다시 수행하도록 강제합니다. 기본 설정이 Serializable인 데이터베이스는 동시성 (concurrency)보다 정확성 (correctness)을 선택한 것입니다. PostgreSQL은 그 반대로 기본 설정되어 있으며, Serializable 동작을 원하는 운영자는 세션별 또는 트랜잭션별로 이를 설정하고, 이를 실행 가능하게 만드는 재시도 루프 (retry loop)를 구축해야 합니다.
비즈니스 불변량 (business invariant)의 정확성이 어떠한 교차 실행의 부재에 달려 있을 때 Serializable을 선택하는 것이 옳습니다. 예를 들어 좌석 배정, 재고 감소, 계좌 간 잔액 이체 등이 이에 해당합니다. 이러한 경우, 조용히 발생한 Lost update (유실된 업데이트) 이후에 따를 감사 (audit) 비용에 비하면 재시도 루프를 구축하는 비용은 저렴한 편입니다.
추측 없이 수준 선택하기
네 가지 수준은 실질적인 의사결정 트리 (decision tree)를 형성하며, 정답은 대개 두 가지 질문에 의해 결정됩니다: 트랜잭션이 이미 읽은 데이터를 다시 읽는가, 그리고 읽은 값으로부터 새로운 값을 계산하는가?
한 번 읽고 한 번 쓰는 짧은 트랜잭션의 경우, Read Committed (읽기 커밋)가 적절하며, 업데이트 시에는 읽기-수정-쓰기 (read-modify-write) 간극을 메울 수 있는 원자적 조건부 형식 (atomic conditional forms)을 사용해야 합니다. 동일한 행 또는 동일한 필터링된 집합을 여러 번 읽어야 하며 일관된 세계를 보아야 하는 트랜잭션의 경우에는 Repeatable Read (반복 가능 읽기)가 적절한 수준이며, 직렬화 실패 (serialization failure)에 대비한 재시도 핸들러 (retry handler)를 함께 사용해야 합니다. 다른 트랜잭션과의 인터리빙 (interleaving)이 비즈니스 불변성 (business invariant)을 손상시킬 수 있는 트랜잭션의 경우에는 Serializable (직렬화 가능) 수준이 적절하며, 동일한 재시도 핸들러를 더 자주 적용해야 합니다.
격리 수준은 문장(statement)마다 힌트를 뿌리는 방식이 아니라, 연결 문자열 (connection string)이나 세션 설정 (session configuration)에서 설정하십시오. 격리 수준은 트랜잭션의 속성이고, 트랜잭션은 연결의 속성이기 때문입니다:
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 이 트랜잭션의 모든 문장은 하나의 스냅샷 (snapshot)을 공유합니다
...
서로 다른 격리 수준에서 실행되는 트랜잭션들이 혼재된 애플리케이션은, 모든 코드 경로가 트랜잭션의 첫 번째 문장이 실행되기 전에 자체적인 수준을 설정할 때만 올바르게 작동합니다. 단 하나의 경로라도 이를 잊는 순간, 해당 경로는 조용히 기본값 (default)을 상속받게 됩니다.
현재 어떤 수준인지 보여주는 테스트
차이점을 내재화하는 가장 빠른 방법은 직접 관찰하는 것입니다. 두 개의 세션, 하나의 테이블, 하나의 행, 그리고 읽기와 쓰기 사이의 의도적인 일시 정지를 통해 각 보장 사항을 직접 확인할 수 있습니다.
세션 1에서 트랜잭션을 시작하고 행을 읽습니다. 세션 2에서 해당 행을 업데이트하고 커밋합니다. 세션 1에서 다시 읽습니다. Read Committed 환경에서는 두 번째 읽기에서 새로운 값이 나타나지만, Repeatable Read 환경에서는 이전 값이 나타납니다 — 스냅샷 (snapshot)이 승리하는 것입니다. 두 세션 모두 읽은 값을 바탕으로 동일한 행을 업데이트하는 동일한 시퀀스를 실행해 보면, Repeatable Read 환경에서는 두 번째 커밋 시 직렬화 실패 (serialization failure)가 발생하지만, Read Committed 환경에서는 조용히 성공하며 첫 번째 업데이트를 폐기(discard)합니다.
그러한 대조 — 에러 발생 대 침묵 — 가 바로 격리 스펙트럼 (isolation spectrum)을 높여가는 핵심적인 이유입니다. 데이터베이스는 동시 실행되는 로직 자체를 올바르게 만들 수는 없습니다. 데이터베이스는 단지 계약을 위반하게 될 인터리빙 (interleaving, 실행 순서 섞임)의 실행을 거부할 수 있을 뿐입니다. 낮은 격리 수준은 인터리빙이 발생하도록 허용하고 그 결과를 유지합니다. 높은 격리 수준은 트랜잭션을 중단 (abort)하고 애플리케이션에 재시도를 요청합니다. 당신이 선택하는 격리 수준 (isolation level)은 성능 조절 노브 (performance knob)가 아닙니다. 그것은 데이터베이스가 조용히 틀리는 것을 허용할 것인지에 대한 선언이며, 애플리케이션의 재시도 루프 (retry loop)는 그 질문에 '아니오'라고 답한 것에 대한 대가입니다.
원문은 Dispatch에 처음 게시되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기