- 들어가며 — 트랜잭션은 무엇을 약속하는가
- ACID — 네 글자가 뜻하는 것
- 왜 격리가 어려운가 — 동시성이라는 근본 문제
- 이상 현상 네 가지 — 무엇이 잘못될 수 있나
- 네 가지 표준 격리 수준
- MVCC — 잠그지 않고 읽게 하는 마법
- 잠금 대 낙관적 동시성
- SELECT FOR UPDATE — 명시적 행 잠금
- PostgreSQL 대 MySQL — 기본값이 다르다
- 실무 지침 정리
- 마치며
- 참고 자료
들어가며 — 트랜잭션은 무엇을 약속하는가
데이터베이스에 돈이 오가면 무서운 질문이 생깁니다. "이체 도중에 서버가 죽으면 어떻게 되지?" 계좌 A에서 100원을 빼고 계좌 B에 100원을 더하는 두 문장 사이에서 프로세스가 죽으면, 돈은 사라질 수도 두 배가 될 수도 있습니다. 트랜잭션은 바로 이 공포를 없애기 위한 장치입니다.
트랜잭션의 약속은 단순합니다. "이 여러 문장을 하나의 덩어리로 취급하라. 전부 반영되거나, 하나도 반영되지 않거나 둘 중 하나다." 이 덩어리는 커밋(commit)되면 확정되고, 롤백(rollback)되면 통째로 없던 일이 됩니다. 그런데 이 단순한 약속을 여러 트랜잭션이 동시에 실행되는 환경에서 지키려면 놀랄 만큼 미묘한 문제들이 튀어나옵니다. 이 글은 그 미묘함을 정면으로 다룹니다.
개념을 직접 SQL로 실험해 보고 싶다면, 이 사이트의 SQL 놀이터, PostgreSQL 놀이터, DuckDB 놀이터에서 쿼리를 돌려 볼 수 있습니다.
ACID — 네 글자가 뜻하는 것
트랜잭션의 보장은 흔히 ACID라는 약자로 요약됩니다. 네 글자를 하나씩 정확히 봅시다.
- Atomicity(원자성): 트랜잭션은 나눌 수 없는 하나의 단위입니다. 전부 성공하거나 전부 실패합니다. 이체의 "빼기"만 반영되고 "더하기"는 빠지는 일은 없습니다.
- Consistency(일관성): 트랜잭션은 데이터베이스를 하나의 유효한 상태에서 또 다른 유효한 상태로 옮깁니다. 제약 조건(외래 키, 유니크, 체크)이 트랜잭션 경계에서 지켜집니다. 여기서의 일관성은 애플리케이션이 정의한 불변식을 뜻하며, 뒤에 나오는 분산 시스템의 "일관성(consistency)"과는 다른 개념입니다.
- Isolation(격리성): 동시에 실행되는 트랜잭션들이 서로의 중간 상태를 보지 못하게 합니다. 이 글의 절반이 바로 이 격리성 이야기입니다. 완벽한 격리는 비싸기 때문에, 실무는 여러 단계의 격리 수준 중에서 고릅니다.
- Durability(내구성): 커밋된 트랜잭션은 이후 시스템이 죽어도 살아남습니다. 보통 WAL(Write-Ahead Log, 미리 쓰기 로그)에 먼저 기록해 이를 보장합니다.
이 중 원자성과 내구성은 대체로 직관적입니다. 진짜 어려움은 격리성에 있습니다. "다른 트랜잭션의 중간 상태를 얼마나 보이게 할 것인가"라는 질문에는 하나의 정답이 없고, 성능과 정확성 사이의 스펙트럼만 있기 때문입니다.
왜 격리가 어려운가 — 동시성이라는 근본 문제
트랜잭션이 하나씩 순서대로 실행된다면 격리는 문제가 아닙니다. 어려움은 여러 트랜잭션이 겹쳐 실행될 때 생깁니다. 이론적으로 가장 안전한 상태는 "직렬 가능(serializable)"입니다. 즉 트랜잭션들이 실제로는 겹쳐 돌아도, 그 결과가 마치 어떤 순서로 하나씩 실행한 것과 똑같은 상태입니다.
문제는 이 완벽한 직렬성을 강제하면 비용이 크다는 것입니다. 트랜잭션들이 서로를 기다리며 동시성이 떨어지고, 처리량이 낮아집니다. 그래서 데이터베이스는 타협을 제공합니다. "이 정도 이상 현상까지는 허용할 테니, 그 대신 더 빠르게 돌아가겠다"는 단계들이 바로 **격리 수준(isolation level)**입니다.
각 격리 수준을 이해하는 열쇠는 "이 수준이 어떤 이상 현상을 막고, 어떤 것을 허용하는가"입니다. 그러니 먼저 이상 현상들부터 정확히 정의합시다.
이상 현상 네 가지 — 무엇이 잘못될 수 있나
격리가 약할 때 나타나는 대표적인 읽기 이상 현상은 다음과 같습니다. 각각을 구체적인 시나리오로 봅시다.
1. 더티 리드(dirty read) — 커밋되지 않은 값을 읽음. 트랜잭션 B가, 트랜잭션 A가 아직 커밋하지 않은 값을 읽습니다. 만약 A가 나중에 롤백하면, B는 존재한 적 없는 유령 값을 읽은 셈이 됩니다.
A: UPDATE balance = 200 (아직 커밋 안 함)
B: SELECT balance -> 200 (더티!)
A: ROLLBACK (200은 없던 값이 됨)
B: 존재하지 않았던 200을 근거로 판단해 버림
2. 반복 불가능한 읽기(non-repeatable read) — 같은 행을 두 번 읽었더니 값이 다름. 트랜잭션 B가 같은 행을 두 번 읽는데, 그 사이에 다른 트랜잭션 A가 그 행을 수정하고 커밋해서, 두 읽기의 값이 달라집니다.
B: SELECT balance -> 100
A: UPDATE balance = 200; COMMIT
B: SELECT balance -> 200 (같은 행인데 값이 바뀜)
3. 팬텀 리드(phantom read) — 같은 조건으로 두 번 조회했더니 행의 집합이 달라짐. B가 어떤 조건(예: "잔액 > 50")에 맞는 행들을 두 번 조회하는데, 그 사이 A가 그 조건에 맞는 새 행을 삽입(또는 삭제)해서, 결과 집합의 행 개수가 달라집니다. 반복 불가능한 읽기가 "이미 있던 행의 값 변화"라면, 팬텀은 "행 자체의 등장·소멸"입니다.
B: SELECT count(*) WHERE balance > 50 -> 3건
A: INSERT (balance = 500); COMMIT
B: SELECT count(*) WHERE balance > 50 -> 4건 (유령 행 등장)
4. 쓰기 왜곡(write skew) — 각자 본 값은 맞지만 합쳐 보면 규칙 위반. 이것은 더 미묘합니다. 두 트랜잭션이 서로 겹치지 않는 행을 읽고 각자 갱신하는데, 둘 다 개별적으로는 규칙을 지켰지만 합쳐진 결과가 불변식을 깨뜨립니다. 고전적인 예가 "의사 당직" 규칙입니다. 항상 최소 한 명은 당직이어야 하는데, 두 의사가 동시에 "다른 한 명이 당직이니 나는 빠져도 되겠다"고 판단하고 각자 빠지면, 결과적으로 당직이 0명이 됩니다.
규칙: 당직 의사는 항상 >= 1명
현재: 앨리스(당직), 밥(당직) — 2명
A(앨리스): SELECT count(당직) -> 2, "밥이 있으니 나는 빠져도 됨" -> 앨리스 off
B(밥): SELECT count(당직) -> 2, "앨리스가 있으니 나는 빠져도 됨" -> 밥 off
둘 다 커밋 -> 당직 0명. 규칙 위반!
쓰기 왜곡이 특히 무서운 이유는, 각 트랜잭션만 따로 보면 완벽히 정당해 보이기 때문입니다. 이 현상은 스냅샷 격리(뒤에 설명)에서도 발생할 수 있어서, "직렬 가능"이 왜 필요한지를 보여 주는 대표적 사례입니다.
네 가지 표준 격리 수준
SQL 표준은 위 이상 현상들을 기준으로 네 가지 격리 수준을 정의합니다. 낮은 수준일수록 더 많은 이상 현상을 허용하고 더 빠르며, 높은 수준일수록 더 안전하고 더 비쌉니다.
- READ UNCOMMITTED: 가장 약합니다. 더티 리드까지 허용합니다. 커밋되지 않은 값도 읽힐 수 있습니다. 실무에서 거의 쓰지 않습니다.
- READ COMMITTED: 더티 리드는 막습니다. 커밋된 값만 읽습니다. 하지만 반복 불가능한 읽기와 팬텀은 여전히 허용됩니다. 많은 데이터베이스의 실질적 기본값입니다.
- REPEATABLE READ: 반복 불가능한 읽기까지 막습니다. 트랜잭션이 시작할 때 본 행은 트랜잭션 내내 같은 값으로 보입니다. 표준상 팬텀은 허용되지만, 구현에 따라 팬텀까지 막기도 합니다.
- SERIALIZABLE: 가장 강합니다. 모든 이상 현상을 막습니다. 결과가 트랜잭션들을 어떤 순서로 하나씩 실행한 것과 동일하도록 보장합니다.
표준이 정의한 "수준 대 이상 현상" 관계를 표로 정리하면 이렇습니다.
| 격리 수준 | 더티 리드 | 반복 불가능한 읽기 | 팬텀 리드 |
|---|---|---|---|
| READ UNCOMMITTED | 허용 | 허용 | 허용 |
| READ COMMITTED | 방지 | 허용 | 허용 |
| REPEATABLE READ | 방지 | 방지 | 허용(표준) |
| SERIALIZABLE | 방지 | 방지 | 방지 |
한 가지 중요한 주의: 이 표는 SQL "표준"의 정의일 뿐이고, 실제 데이터베이스의 동작은 표보다 강하거나 미묘하게 다릅니다. 예를 들어 뒤에서 보듯 PostgreSQL의 REPEATABLE READ는 표준이 허용하는 팬텀까지 실제로는 막습니다. 그래서 "내 DB에서 이 수준이 실제로 무엇을 보장하는가"는 항상 그 DB의 문서로 확인해야 합니다.
MVCC — 잠그지 않고 읽게 하는 마법
격리를 구현하는 방식에는 크게 두 계열이 있습니다. 하나는 잠금(locking), 다른 하나는 **다중 버전 동시성 제어(MVCC, Multi-Version Concurrency Control)**입니다. 오늘날 PostgreSQL, MySQL(InnoDB), Oracle 같은 주요 데이터베이스는 모두 MVCC를 씁니다.
MVCC의 핵심 아이디어는 이렇습니다. 행을 덮어쓰지 않고, 새 버전을 만든다. 어떤 행이 갱신되면 옛 버전이 그대로 남고 새 버전이 추가됩니다. 각 버전에는 "어느 트랜잭션이 만들었는지"를 나타내는 정보가 붙습니다. 트랜잭션이 데이터를 읽을 때는, 자신의 시점 기준으로 "보여야 하는 버전"만 골라 봅니다.
행 x의 버전들 (시간 순):
v1 (트랜잭션 10이 생성)
v2 (트랜잭션 25가 생성) <- 최신
트랜잭션 20이 x를 읽으면:
-> 20은 25의 결과를 볼 수 없음 (아직 없던 미래)
-> v1을 본다 (자신의 스냅샷에 맞는 버전)
이 방식의 결정적 장점은 읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않는다는 것입니다. 읽는 쪽은 그냥 자기 시점에 맞는 옛 버전을 보면 되고, 쓰는 쪽은 새 버전을 만들면 됩니다. 서로 기다릴 필요가 없어 동시성이 크게 올라갑니다. 이렇게 트랜잭션이 시작 시점의 일관된 스냅샷을 보는 격리를 **스냅샷 격리(snapshot isolation)**라고 부릅니다.
대가도 있습니다. 옛 버전들이 쌓이므로 주기적으로 청소해야 합니다. PostgreSQL에서는 이 청소를 VACUUM이 담당하고, 청소가 밀리면 죽은 튜플이 쌓여 성능이 나빠집니다. 그리고 앞서 본 쓰기 왜곡은 순수한 스냅샷 격리만으로는 막히지 않습니다. 스냅샷은 각자 일관되지만, 두 스냅샷의 결정이 합쳐질 때의 충돌은 보지 못하기 때문입니다.
잠금 대 낙관적 동시성
동시 갱신 충돌을 다루는 전략은 크게 비관적(pessimistic)과 낙관적(optimistic) 두 가지로 나뉩니다.
비관적 잠금(pessimistic locking). "충돌이 일어날 것"이라고 가정하고, 데이터를 만질 때 먼저 잠급니다. 다른 트랜잭션은 잠금이 풀릴 때까지 기다립니다. 충돌이 잦은 상황에서 안전하지만, 대기와 교착 상태(deadlock)의 위험이 있습니다. 뒤에 나올 SELECT ... FOR UPDATE가 이 방식의 대표적 도구입니다.
낙관적 동시성 제어(optimistic concurrency control). "충돌은 드물 것"이라고 가정하고, 일단 잠그지 않고 진행합니다. 대신 커밋 직전에 "내가 읽은 이후에 이 데이터가 바뀌었는가"를 확인하고, 바뀌었으면 트랜잭션을 실패시켜 재시도하게 합니다. 흔한 구현은 버전 컬럼입니다. 행에 버전 번호를 두고, 갱신할 때 "내가 읽은 버전과 현재 버전이 같을 때만 갱신"하도록 조건을 겁니다.
-- 낙관적 잠금: 버전이 그대로일 때만 갱신
UPDATE accounts
SET balance = 200, version = version + 1
WHERE id = 42 AND version = 7;
-- 영향받은 행이 0이면 -> 그 사이 누군가 바꿨다는 뜻 -> 재시도
선택 기준은 충돌 빈도입니다. 충돌이 잦으면 비관적 잠금이 재시도 낭비를 줄여 유리하고, 충돌이 드물면 낙관적 방식이 대기를 없애 처리량을 높입니다. 웹 애플리케이션의 많은 갱신은 충돌이 드물어 낙관적 방식이 잘 맞습니다.
SELECT FOR UPDATE — 명시적 행 잠금
MVCC 덕분에 일반적인 읽기는 잠그지 않지만, 때로는 "내가 읽은 이 행을 아무도 못 건드리게 잠가 두고 싶다"가 필요합니다. 대표적인 경우가 읽고-검사하고-쓰기(read-modify-write) 패턴입니다. 잔액을 읽어서, 충분한지 검사하고, 차감하는 흐름이 그렇습니다.
이때 그냥 SELECT로 읽으면, 읽은 직후 다른 트랜잭션이 같은 행을 바꿔 버려 갱신 손실(lost update)이 날 수 있습니다. SELECT ... FOR UPDATE는 읽는 그 순간 해당 행에 쓰기 잠금을 걸어, 트랜잭션이 끝날 때까지 다른 트랜잭션이 그 행을 수정하지 못하게 합니다.
BEGIN;
-- 이 행을 잠근 채로 읽는다. 다른 트랜잭션은 이 행 수정 시 대기.
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- 애플리케이션에서 잔액 충분한지 검사 후
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT; -- 커밋 시점에 잠금 해제
FOR UPDATE의 변형도 알아 두면 좋습니다. FOR SHARE는 공유 잠금으로, 다른 읽기는 허용하되 쓰기는 막습니다. FOR UPDATE SKIP LOCKED는 이미 잠긴 행을 건너뛰어, 여러 워커가 큐에서 서로 다른 작업을 집어 가는 작업 큐 패턴에 유용합니다. FOR UPDATE NOWAIT는 잠금을 못 얻으면 기다리지 않고 즉시 오류를 냅니다.
한 가지 조심할 것은 교착 상태입니다. 두 트랜잭션이 서로가 잠근 행을 반대 순서로 잠그려 하면 서로를 영원히 기다립니다. 데이터베이스가 이를 감지해 한쪽을 강제로 실패시키므로, 애플리케이션은 이 오류를 잡아 재시도할 준비가 되어 있어야 합니다. 교착을 줄이는 실전 요령은 "항상 같은 순서로 잠그기"입니다.
PostgreSQL 대 MySQL — 기본값이 다르다
여기서 실무에 직접 영향을 주는 차이를 짚습니다. 두 데이터베이스의 기본 격리 수준이 다릅니다.
- PostgreSQL의 기본값은 READ COMMITTED입니다. 각 문장이 시작될 때의 최신 커밋 스냅샷을 봅니다. 그래서 한 트랜잭션 안에서도 문장마다 다른 스냅샷을 볼 수 있어, 반복 불가능한 읽기가 기본값에서 가능합니다.
- MySQL(InnoDB)의 기본값은 REPEATABLE READ입니다. 트랜잭션의 첫 읽기 시점에 스냅샷을 잡아 트랜잭션 내내 유지합니다.
더 흥미로운 것은 각자의 상위 수준이 실제로 어떻게 동작하느냐입니다.
- PostgreSQL의 REPEATABLE READ는 진정한 스냅샷 격리를 구현하며, 표준이 허용하는 팬텀 리드까지 실제로 막습니다. 단, 쓰기 왜곡은 여전히 가능합니다.
- PostgreSQL의 SERIALIZABLE은 SSI(Serializable Snapshot Isolation)라는 기법으로, 스냅샷 격리 위에 위험한 의존성을 감지해 충돌하는 트랜잭션을 실패시킵니다. 그래서 쓰기 왜곡까지 막지만, 그만큼 직렬화 실패(오류 40001류)가 날 수 있어 애플리케이션이 재시도해야 합니다.
- MySQL/InnoDB의 REPEATABLE READ는 일반 읽기에는 일관된 스냅샷을 주지만, 잠금 읽기(
FOR UPDATE등)에는 갭 잠금(gap lock)을 써서 팬텀 삽입을 막습니다. 순수한 스냅샷 격리와 잠금이 섞인 미묘한 동작이라, 같은 REPEATABLE READ라도 PostgreSQL과 세부 거동이 다릅니다.
이 차이가 실무에서 왜 중요할까요? 같은 애플리케이션 코드라도 어느 DB에 붙느냐에 따라 동시성 버그의 양상이 달라지기 때문입니다. PostgreSQL에서는 문제없던 코드가 MySQL에서 다르게 동작하거나 그 반대일 수 있습니다. 그래서 동시성이 걸린 로직은 반드시 실제 대상 DB의 기본 격리 수준을 알고, 필요하면 명시적으로 격리 수준을 올려서 검증해야 합니다.
실무 지침 정리
지금까지의 내용을 실전 관점에서 압축합니다.
먼저 기본 격리 수준을 정확히 알고 시작하세요. PostgreSQL은 READ COMMITTED, MySQL은 REPEATABLE READ가 기본입니다. 이걸 모르면 "왜 이 값이 트랜잭션 중간에 바뀌지?" 또는 그 반대의 혼란을 겪습니다.
다음으로 읽고-검사하고-쓰기 패턴을 조심하세요. 잔액 차감, 재고 감소, 좌석 예약처럼 "읽은 값을 근거로 쓰는" 로직은 갱신 손실의 온상입니다. 낙관적 버전 컬럼이나 SELECT ... FOR UPDATE로 명시적으로 보호하세요.
쓰기 왜곡을 기억하세요. 각 트랜잭션이 개별적으로 정당해 보여도, 합쳐지면 불변식을 깰 수 있습니다. 순수 스냅샷 격리로는 막히지 않으므로, 정말로 필요한 불변식이라면 SERIALIZABLE로 올리거나 명시적 잠금으로 강제하세요.
직렬화 실패와 교착에 대비한 재시도를 넣으세요. SERIALIZABLE이나 낙관적 방식, 잠금은 모두 "실패하면 다시 시도"를 전제로 합니다. 애플리케이션에 재시도 로직이 없으면 이 안전장치들이 오히려 장애가 됩니다.
마지막으로 트랜잭션을 짧게 유지하세요. 긴 트랜잭션은 잠금을 오래 쥐고, MVCC의 옛 버전 청소를 방해하며(PostgreSQL의 VACUUM 지연), 충돌 확률을 높입니다.
마치며
트랜잭션은 "여러 작업을 하나로 묶어 전부 또는 전무로 처리하라"는 단순한 약속에서 출발하지만, 여러 트랜잭션이 동시에 돌아가는 순간 격리라는 깊은 문제로 이어집니다. 더티 리드, 반복 불가능한 읽기, 팬텀, 쓰기 왜곡이라는 이상 현상들이 있고, 이를 얼마나 막을지가 네 가지 격리 수준으로 나뉩니다.
현대 데이터베이스는 대부분 MVCC로 "읽기가 쓰기를 막지 않는" 스냅샷 격리를 제공하고, 그 위에서 필요할 때 잠금이나 낙관적 버전으로 충돌을 다룹니다. 그리고 PostgreSQL과 MySQL은 기본 격리 수준부터 상위 수준의 세부 거동까지 다르므로, 실제 대상 DB의 동작을 아는 것이 무엇보다 중요합니다.
핵심은 이것입니다. 완벽한 격리는 비싸고, 완전히 격리를 포기하면 위험합니다. 그래서 우리는 "이 데이터에 어떤 이상 현상까지 허용할 수 있는가"를 정하고, 거기에 맞는 격리 수준과 동시성 전략을 고릅니다. 그 선택을 의식적으로 할 때, 트랜잭션은 공포의 대상이 아니라 신뢰할 수 있는 토대가 됩니다.
참고 자료
- PostgreSQL 공식 문서, "Transaction Isolation": https://www.postgresql.org/docs/current/transaction-iso.html
- MySQL 공식 문서, "Transaction Isolation Levels": https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html
- "A Critique of ANSI SQL Isolation Levels" (Berenson et al., 1995): https://www.microsoft.com/en-us/research/publication/a-critique-of-ansi-sql-isolation-levels/
- Martin Kleppmann, "Designing Data-Intensive Applications" (7장, Transactions)
- Jepsen, "Consistency models": https://jepsen.io/consistency
현재 단락 (1/97)
데이터베이스에 돈이 오가면 무서운 질문이 생깁니다. "이체 도중에 서버가 죽으면 어떻게 되지?" 계좌 A에서 100원을 빼고 계좌 B에 100원을 더하는 두 문장 사이에서 프로세스...