트랜잭션과 격리 수준

동시 트랜잭션 간섭을 막는 4단계 보호

트랜잭션은 여러 SQL을 한 단위로 묶어 ACID(원자성·일관성·격리성·지속성)를 보장하는 단위다. 동시에 도는 트랜잭션끼리 간섭하면 Dirty Read, Non-Repeatable Read, Phantom Read, Lost Update 같은 이상 현상이 생긴다. 격리 수준 4단계(Read Uncommitted~Serializable)는 어떤 이상 현상까지 막을지를 정하는 다이얼이고, 위로 갈수록 안전하지만 느려진다.

Transaction

개념

트랜잭션은 여러 SQL을 묶어서 하나의 단위로 성공하거나 실패하게 만드는 단위다. 계좌이체처럼 "A에서 빼기 + B에 넣기"가 따로 따로 성공하면 돈이 증발하므로, 둘을 한 묶음으로 보장해야 한다. 이걸 ACID 4가지로 정리한다.

ACID 중 A(원자성), **D(지속성)**는 DB 엔진이 알아서 해주고 **C(일관성)**는 사실 앱 책임에 가깝다. 실무 트러블의 거의 전부는 **I(격리성)**에서 나온다. 동시에 도는 트랜잭션 수백~수천 개가 서로를 어떻게 보고 어떻게 안 보는지를 다루는 영역.

ACID 4글자

글자 의미 누가 보장
Atomicity (원자성) 전부 성공 or 전부 실패. 중간 상태 없음 DB (undo log)
Consistency (일관성) 트랜잭션 끝나도 DB 제약·비즈니스 규칙 안 깨짐 DB(제약 조건) + 앱(비즈니스)
Isolation (격리성) 동시 트랜잭션이 서로 간섭 안 함 DB (격리 수준 + 락/MVCC)
Durability (지속성) COMMIT 후엔 전원 나가도 살아남음 DB (WAL fsync)

A — 원자성

계좌이체 중간에 서버가 죽으면 "A에서 1만원 빠졌는데 B엔 안 들어감" 사태가 난다. 원자성이 있으면 둘 다 성공 or 둘 다 ROLLBACK. 되돌리려면 "바꾸기 전 값"을 어딘가 저장해둬야 하므로 undo log가 필요하다. MySQL InnoDB는 별도 undo log를, PostgreSQL은 MVCC의 옛 버전을 undo 대신 사용한다.

C — 일관성 (가장 약한 글자)

DB가 강제하는 부분은 NOT NULL, FK, UNIQUE, CHECK 같은 제약 조건뿐이다. "이체 후 총액이 보존된다"는 비즈니스 규칙은 앱이 직접 만들어야 한다. ACID에 끼워넣은 건 첫 글자 맞추기에 가깝다는 시각이 있다 (DDIA, Kleppmann).

D — 지속성

COMMIT 응답이 떨어진 트랜잭션은 디스크에 살아남는다. 이걸 보장하는 메커니즘이 WAL fsync. 자세한 흐름은 WAL과-버퍼-풀 참고. 매 COMMIT마다 WAL 한 번만 디스크에 쓰면 되도록 설계해서 D를 거의 공짜로 만든 게 핵심.

격리성 — 동시 트랜잭션의 4가지 이상 현상

격리 수준이 약하면 다음 현상들이 생긴다. 위에서 아래로 갈수록 미묘해진다.

1. Dirty Read — 확정 안 된 값 읽기

T1: BEGIN; UPDATE accounts SET balance = 60000 WHERE id='me';
                                    T2: SELECT balance → 60000 봄
T1: ROLLBACK
                                    T2: 존재한 적 없는 값을 본 것

T1이 ROLLBACK 됐는데 T2는 그 사이의 임시 값을 봤다. 사용자에게 "잔액 60000원" 보여주고 결정하게 하면 사고난다. 거의 모든 DB가 기본 설정으로 막는다.

2. Non-Repeatable Read — 같은 행, 두 번 읽었더니 값이 다름

T1: SELECT balance → 50000
                                    T2: UPDATE balance = 60000; COMMIT;
T1: SELECT balance → 60000   ← 같은 트랜잭션 안인데 값 바뀜

T1이 한 트랜잭션 안에서 같은 데이터를 두 번 읽었는데 다른 값이 나온다. 보고서나 집계 도중에 일어나면 모순된 결과가 나옴. Repeatable Read 수준부터 막힌다.

3. Phantom Read — 행의 존재 자체가 새로 생기/사라짐

T1: SELECT COUNT(*) WHERE balance >= 5000 → 100건
                                    T2: INSERT (balance=10000); COMMIT;
T1: SELECT COUNT(*) WHERE balance >= 5000 → 101건  ← 유령처럼 새 행 등장

기존 행이 변경된 게 아니라 새 행이 추가/삭제된 경우. 행 단위 락만으론 못 막는다 — 존재하지 않던 걸 락 걸 수 없으니. SQL 표준은 Serializable부터 막힌다고 본다. 단 PostgreSQL의 Repeatable Read는 스냅샷 기반이라 자동으로 막아준다.

4. Lost Update — 두 트랜잭션의 변경 중 하나가 사라짐

T1: SELECT likes → 100;                       T2: SELECT likes → 100;
                                              T2: UPDATE likes = 101; COMMIT;
T1: UPDATE likes = 101; COMMIT;
                                              결과: 101 (T2의 +1이 사라짐)

좋아요 두 명이 동시에 눌렀는데 결과는 +1. 표준 SQL 격리 수준 표엔 안 나오지만 실무에서 가장 자주 보는 이상 현상.

UPDATE posts SET likes = likes + 1 같은 atomic update를 쓰면 안 생긴다. 문제는 read-modify-write 패턴이 필요한 경우 (좌석 예약, 재고 차감, 조건부 잔액 변경) — DB로 한 번에 못 풀고 앱이 읽고 판단하고 써야 할 때.

격리 수준 4단계

수준 Dirty Read Non-Repeatable Phantom Lost Update
Read Uncommitted 가능 가능 가능 가능
Read Committed 막음 가능 가능 가능
Repeatable Read 막음 막음 가능 (표준) DB마다 다름
Serializable 막음 막음 막음 막음

위로 갈수록 빠르고 느슨, 아래로 갈수록 느리고 안전.

DB별 기본값

DB 기본 격리 수준
PostgreSQL Read Committed
MySQL InnoDB Repeatable Read
Oracle Read Committed
SQL Server Read Committed

기본값이 다르므로 내가 쓰는 DB의 기본이 뭔지 확인해야 한다. PostgreSQL에서 Repeatable Read를 가정하고 코드를 짰다가 운영에서 Read Committed라 이상 동작하는 경우가 흔하다.

Lost Update를 막는 방법

Lost Update가 표에 따로 안 나오는 이유는 격리 수준만으로는 깔끔히 안 풀리기 때문. 실무에서 쓰는 4가지 패턴.

1. Atomic Update — DB가 직접 계산

-- 위험 (read-modify-write)
SELECT likes FROM posts WHERE id=1;  -- 100
UPDATE posts SET likes = 101 WHERE id=1;

-- 안전
UPDATE posts SET likes = likes + 1 WHERE id=1;

가능하면 항상 이걸 우선. 빠르고 단순함.

2. 비관적 락 — SELECT ... FOR UPDATE

BEGIN;
SELECT * FROM seats WHERE id=5 FOR UPDATE;  -- 락 걸고 읽음
-- 다른 트랜잭션은 여기서 대기
UPDATE seats SET status='BOOKED' WHERE id=5;
COMMIT;

"내가 끝낼 때까지 아무도 손대지 마." 충돌 자주 일어나는 경우(좌석 예약, 재고 차감)에 유리.

3. 낙관적 락 — version 컬럼

SELECT data, version FROM items WHERE id=1;  -- version=5
-- ... 앱에서 처리 ...
UPDATE items SET data=..., version=6 
  WHERE id=1 AND version=5;  -- 영향 행 0이면 충돌, 재시도

"일단 락 안 걸고 진행. 끝낼 때 누가 끼어들었는지 검사." 충돌 드문 경우에 유리. 락 비용 없음.

4. Serializable 격리

DB가 충돌을 자동 감지해서 한쪽을 abort. 가장 안전하지만 가장 느리고 재시도 로직 필요.

언제 어떤 락을 쓰나

상황 추천
단순 카운터·잔액 가감 Atomic Update
좌석/재고 등 충돌 잦은 read-modify-write 비관적 락 (FOR UPDATE)
충돌 드문 read-modify-write (예: 사용자 프로필 편집) 낙관적 락 (version)
절대 안전 우선 Serializable + 재시도

ORM이 트랜잭션을 추상화해줘도 이 4가지 중 어떤 패턴인지는 개발자가 명시해야 한다 (Django select_for_update(), SQLAlchemy with_for_update() 등). 모르고 안 부르면 production에서 동시성 버그가 잠복한다.

더 보기

  • WAL과-버퍼-풀 — D(지속성)를 만드는 메커니즘. COMMIT 시점에 디스크 가는 건 데이터 페이지가 아니라 WAL이라는 분리가 핵심
  • DB-스토리지-레이아웃 — 트랜잭션이 다루는 "페이지"의 정체
  • 동시성 — OS 레이어의 뮤텍스/세마포어. DB 락은 이 위에 올라간 추상화
sunshinemoon · 2026