PostgreSQL 기초 강좌 #6 트랜잭션과 MVCC: 동시성의 기본 원리
지금까지는 혼자 쓰는 DB였지만, 실제 서비스에서는 수십, 수백 개의 접속이 같은 테이블을 동시에 읽고 씁니다. 그런데도 잔액이 두 번 빠지거나 반쯤 저장된 주문이 보이지 않는 이유가 트랜잭션과 MVCC입니다. 이 원리는 실전 편의 VACUUM, 잠금, 복제가 전부 딛고 서는 바닥이라, 기초의 마지막 고비로 여기서 다져 두겠습니다.
트랜잭션: 전부 되거나, 전부 안 되거나 #
BEGIN;
UPDATE accounts SET balance = balance - 50000 WHERE id = 1;
UPDATE accounts SET balance = balance + 50000 WHERE id = 2;
COMMIT; -- 두 UPDATE가 한 묶음으로 확정. 중간에 문제가 있으면 ROLLBACK;계좌 이체의 두 UPDATE 중 하나만 반영되는 세상은 없어야 합니다. BEGIN과 COMMIT 사이의 작업은 하나의 단위로 묶여, 전부 확정되거나(COMMIT) 전부 없던 일이 되거나(ROLLBACK) 둘 중 하나입니다. 중간에 에러가 나거나 접속이 끊겨도 자동으로 ROLLBACK입니다. 참고로 명시적 BEGIN 없이 실행한 SQL 한 문장도 각각 자체 트랜잭션입니다(자동 커밋). “여러 문장이 한 덩어리여야 하는가"가 BEGIN을 쓸지의 판단 기준입니다.
MVCC: 읽기는 쓰기를 막지 않습니다 #
동시성의 소박한 구현은 잠금입니다. 누가 읽는 동안 다른 쓰기를 막으면 안전하지만, 전부가 서로를 기다리는 DB가 됩니다. PostgreSQL의 답은 MVCC(Multi-Version Concurrency Control)입니다. 데이터를 덮어쓰는 대신 버전을 여러 개 유지하고, 각 트랜잭션은 자기 시작 시점에 유효한 버전(스냅샷)을 봅니다.
- 트랜잭션 A가 긴 집계 쿼리를 도는 동안, 트랜잭션 B가 같은 테이블에 UPDATE를 해도 서로 기다리지 않습니다. A는 자기 스냅샷의 옛 버전을, B는 새 버전을 만들 뿐입니다.
- 그래서 PostgreSQL에서 읽기와 쓰기는 서로를 막지 않습니다. 서로 막는 것은 같은 행에 대한 쓰기와 쓰기뿐입니다(이 잠금 이야기는 실전 편에서).
여기에는 중요한 귀결이 하나 있습니다. UPDATE는 제자리 수정이 아니라 “새 버전 행을 추가하고 옛 버전을 폐기 예정으로 표시"하는 작업입니다. DELETE도 실제로 지우는 것이 아니라 표시만 합니다. 이렇게 쌓이는 옛 버전(dead tuple)을 치우는 청소부가 VACUUM이고, 이것이 실전 편에서 VACUUM이 큰 주제로 등장하는 이유입니다.
격리 수준: 기본값 Read Committed의 의미 #
스냅샷을 “언제” 찍는지가 격리 수준입니다. PostgreSQL의 기본값은 Read Committed로, 문장 하나하나가 시작될 때마다 새 스냅샷을 찍습니다. 커밋된 남의 변경이 다음 문장부터 보인다는 뜻입니다. 대부분의 웹 애플리케이션에는 이것으로 충분하지만, 함정이 하나 있습니다. 한 트랜잭션 안의 두 SELECT가 서로 다른 결과를 볼 수 있습니다(사이에 남의 커밋이 끼어들면). 읽고 → 판단하고 → 쓰는 로직이라면 이 틈이 문제가 됩니다.
| 격리 수준 | 스냅샷 시점 | 쓰는 경우 |
|---|---|---|
| Read Committed(기본) | 문장마다 | 일반적인 OLTP 대부분 |
| Repeatable Read | 트랜잭션 시작 시 1회 | 리포트·정합성 검사처럼 “한 장의 일관된 사진"이 필요할 때 |
| Serializable | 시작 시 1회 + 직렬 실행 검증 | 동시 실행의 상호작용까지 막아야 하는 돈 계산 등 |
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 이 안의 모든 SELECT는 같은 스냅샷을 봅니다
COMMIT;올려 쓸 때의 대가도 알아야 합니다. Repeatable Read 이상에서는 동시 수정과 충돌하면 에러(직렬화 실패)가 나며, 애플리케이션이 트랜잭션을 재시도할 준비가 되어 있어야 합니다. 안전을 공짜로 주는 것이 아니라 “충돌을 조용히 넘기는 대신 에러로 알려 주는” 방식입니다.
한편 “읽고 판단해서 쓰기"의 더 흔한 실무 해법은 격리 수준을 올리는 것보다 원자적 문장 하나로 합치는 것입니다. UPDATE accounts SET balance = balance - 50000 WHERE id = 1 AND balance >= 50000처럼 조건과 갱신을 한 문장에 담으면, 격리 수준과 무관하게 잔액 검사와 차감 사이에 틈이 없습니다.
정리 #
- 트랜잭션은 전부 아니면 전무입니다. 여러 문장이 한 덩어리여야 하면 BEGIN/COMMIT으로 묶고, 에러 시에는 자동 ROLLBACK입니다.
- MVCC 덕에 읽기와 쓰기는 서로를 막지 않습니다. 각 트랜잭션은 자기 스냅샷을 봅니다.
- UPDATE·DELETE는 새 버전 추가와 폐기 표시입니다. 여기서 생기는 dead tuple 청소(VACUUM)가 실전 편의 큰 주제입니다.
- 기본 격리 수준 Read Committed는 문장마다 스냅샷입니다. 한 트랜잭션 안에서도 두 SELECT의 결과가 다를 수 있습니다.
- 일관된 스냅샷이 필요하면 Repeatable Read, 완전한 직렬성이 필요하면 Serializable을 쓰되 재시도를 준비합니다. 읽고-판단-쓰기는 원자적 한 문장으로 합치는 것이 먼저입니다.