it 서적 독후감

[DDIA 7장] 트랜잭션 — ACID는 실제로 무엇을 보장하는가

데일리코딩 2026. 4. 3. 10:54

[DDIA 7장] 트랜잭션 — ACID는 실제로 무엇을 보장하는가

Designing Data-Intensive Applications (Martin Kleppmann) 를 읽으며 정리한 시리즈입니다.
1장 | 2장 | 3장 | 4장 | 5장 | 6장


들어가며

"트랜잭션 쓰면 ACID 보장됩니다."

면접에서도, 실무에서도 자주 듣는 말이다. 그런데 ACID가 구체적으로 무엇을 보장하는지, 격리 수준이 왜 여러 단계로 나뉘는지, @Transactional 하나 붙인다고 모든 동시성 문제가 해결되는지 물어보면 대답이 흐릿해진다.

7장은 트랜잭션의 가장 깊은 곳을 다룬다. ACID의 각 글자가 실제로 무엇을 의미하는지, 트랜잭션 없이 어떤 문제가 생기는지, 격리 수준마다 어떤 이상 현상을 허용하고 막는지를 낱낱이 설명한다.

이 장은 읽으면서 가장 많이 "아, 그래서 그랬구나"를 반복한 장이었다.


ACID — 각 글자가 실제로 의미하는 것

A — 원자성 (Atomicity)

트랜잭션 내의 연산들은 전부 성공하거나 전부 실패한다. 중간 상태가 없다.

계좌 이체를 예로 들면, A 계좌에서 출금하고 B 계좌에 입금하는 두 연산이 있다. 출금은 성공했는데 입금 도중 오류가 나면? 원자성이 보장되면 출금도 롤백된다. 돈이 사라지지 않는다.

원자성의 핵심은 "부분 실패 시 롤백" 이다. 성공 여부와 관계없이 중간 상태가 외부에 노출되지 않는다.

C — 일관성 (Consistency)

사실 ACID에서 가장 애매한 글자다.

책은 일관성이 사실 DB의 속성이 아니라 애플리케이션의 속성이라고 말한다. "잔액이 음수가 되면 안 된다" 같은 규칙은 DB가 알아서 보장하는 게 아니라 애플리케이션 코드와 DB 제약 조건이 함께 만들어내는 것이다.

C는 나머지 세 글자(A, I, D)의 결과로 얻어지는 것이지, DB가 독립적으로 제공하는 속성이 아니다. ACID에서 C는 조금 끼워 맞춘 글자라는 시각도 있다.

I — 격리성 (Isolation)

동시에 실행되는 트랜잭션들이 서로 영향을 주지 않는다. 각 트랜잭션은 혼자 실행되는 것처럼 동작해야 한다.

이게 가장 복잡하고 실무에서 문제가 가장 많이 생기는 부분이다. 완벽한 격리(직렬화 가능성)는 성능이 너무 나쁘기 때문에, 현실에서는 격리 수준을 낮춰서 성능과 정확성을 맞바꾼다.

D — 지속성 (Durability)

트랜잭션이 커밋되면 그 데이터는 영구적으로 저장된다. 이후 시스템이 크래시 나도 유실되지 않는다.

3장에서 나온 WAL(Write-Ahead Log)이 지속성을 구현하는 핵심 메커니즘이다. 데이터를 실제로 쓰기 전에 로그를 먼저 기록하고, 크래시 후 재시작 시 로그를 보고 복구한다.


트랜잭션 없이 생기는 문제들

격리성 얘기를 하기 전에, 격리가 없으면 어떤 일이 생기는지를 먼저 보자. 책은 이를 경쟁 조건(Race Condition) 으로 정리한다.

더티 읽기 (Dirty Read)

아직 커밋되지 않은 데이터를 다른 트랜잭션이 읽는 상황이다.

트랜잭션 A: 잔액 1000 → 500으로 업데이트 (아직 커밋 안 함)
트랜잭션 B: 잔액 읽기 → 500 읽음
트랜잭션 A: 오류 발생, 롤백 → 잔액 다시 1000
트랜잭션 B: 존재하지 않는 500이라는 값을 기반으로 로직 수행

더티 쓰기 (Dirty Write)

아직 커밋되지 않은 데이터를 다른 트랜잭션이 덮어쓰는 상황이다.

트랜잭션 A: 매물 구매자를 "Alice"로 업데이트 (커밋 안 함)
트랜잭션 B: 매물 구매자를 "Bob"으로 업데이트 (커밋)
트랜잭션 A: 커밋

결과: 매물 구매자는 "Alice"
      하지만 청구서는 "Bob"에게 발송 (B가 먼저 커밋한 다른 테이블 업데이트 기준)

읽기 스큐 (Read Skew) — 반복 불가능한 읽기

같은 트랜잭션 내에서 같은 데이터를 두 번 읽었는데 결과가 다른 상황이다.

트랜잭션 A: 계좌1 잔액 읽기 → 500
트랜잭션 B: 계좌1에서 계좌2로 500 이체, 커밋
트랜잭션 A: 계좌2 잔액 읽기 → 500 (이체 후 금액)

결과: A는 계좌1 = 500, 계좌2 = 500으로 읽음 → 총합 1000
      실제로는 계좌1 = 0, 계좌2 = 500이어야 함 → 총합 500

팬텀 읽기 (Phantom Read)

같은 조건의 쿼리를 두 번 실행했는데 결과 집합이 달라지는 상황이다.

트랜잭션 A: "회의실 예약 내역 조회" → 3건
트랜잭션 B: 새 예약 추가, 커밋
트랜잭션 A: "회의실 예약 내역 조회" → 4건

처음 읽었을 때 없던 행이 두 번째 읽기에 나타났다.

갱신 손실 (Lost Update)

두 트랜잭션이 같은 값을 읽고 수정했을 때 하나의 수정이 사라지는 상황이다.

트랜잭션 A: 카운터 읽기 → 10
트랜잭션 B: 카운터 읽기 → 10
트랜잭션 A: 카운터 11로 업데이트, 커밋
트랜잭션 B: 카운터 11로 업데이트, 커밋

결과: 카운터 = 11 (두 번 증가해야 했으므로 12가 되어야 함)

쓰기 스큐 (Write Skew)

더 교묘한 형태의 경쟁 조건이다. 두 트랜잭션이 같은 데이터를 읽고, 각각 다른 데이터를 수정하는데, 합쳐졌을 때 제약 조건이 깨진다.

규칙: 병원에 최소 한 명의 의사가 당직이어야 한다.

트랜잭션 A (의사1): 당직 의사 조회 → 2명 (당직 가능)
트랜잭션 B (의사2): 당직 의사 조회 → 2명 (당직 가능)
트랜잭션 A: 의사1 당직 취소, 커밋
트랜잭션 B: 의사2 당직 취소, 커밋

결과: 당직 의사 0명 → 규칙 위반

A와 B는 각자 다른 행을 수정했다. 더티 쓰기도 갱신 손실도 아니다. 하지만 결과는 제약 조건 위반이다.


격리 수준 (Isolation Level)

완벽한 격리는 성능이 너무 나쁘다. 그래서 현실에서는 격리 수준을 선택할 수 있게 한다. 어떤 이상 현상을 허용할지 트레이드오프를 선택하는 것이다.

Read Uncommitted

커밋되지 않은 데이터도 읽을 수 있다. 더티 읽기가 발생한다. 거의 쓰지 않는다.

Read Committed

커밋된 데이터만 읽는다. 더티 읽기와 더티 쓰기를 방지한다.

PostgreSQL, Oracle, SQL Server의 기본 격리 수준이다. 대부분의 서비스에서 이 수준으로 운영한다.

구현 방식: 쓰기 잠금은 트랜잭션이 끝날 때까지 유지한다. 읽기는 잠금 대신 커밋된 가장 최신 값을 읽는다. 읽기 성능이 좋다.

단점: 읽기 스큐와 팬텀 읽기는 발생할 수 있다.

Repeatable Read

같은 트랜잭션 내에서 같은 데이터를 읽으면 항상 같은 결과를 보장한다. 더티 읽기, 읽기 스큐를 방지한다.

MySQL InnoDB의 기본 격리 수준이다.

구현 방식: MVCC(Multi-Version Concurrency Control). 각 트랜잭션이 시작될 때 스냅샷을 찍는다. 트랜잭션이 진행되는 동안 이 스냅샷 기준으로 읽는다. 다른 트랜잭션이 중간에 데이터를 수정해도 내가 보는 스냅샷은 바뀌지 않는다.

트랜잭션 A 시작 (스냅샷 타임스탬프: T1)
트랜잭션 B: 계좌 잔액 수정, 커밋 (T2)
트랜잭션 A: 계좌 잔액 읽기 → T1 기준의 값을 읽음 (T2 변경 무시)

MVCC는 읽기 성능이 매우 좋다. 읽기가 쓰기를 블로킹하지 않고, 쓰기가 읽기를 블로킹하지 않는다.

단점: 팬텀 읽기와 쓰기 스큐는 여전히 발생할 수 있다.

Serializable

가장 강한 격리 수준. 모든 트랜잭션이 순차적으로 실행된 것과 동일한 결과를 보장한다. 모든 이상 현상을 방지한다.

성능이 가장 나쁘다. 구현 방식에 따라 다르지만 잠금 경합이 심해진다.

구현 방법은 세 가지다.

실제 직렬 실행: 트랜잭션을 진짜로 하나씩 순서대로 실행한다. 동시성이 없다. VoltDB가 이 방식이다. 트랜잭션이 매우 빠르고 짧을 때만 유효하다.

2단계 잠금 (2PL, Two-Phase Locking): 읽기는 공유 잠금, 쓰기는 배타 잠금을 사용한다. 잠금을 획득하는 단계와 해제하는 단계가 명확히 나뉜다. 전통적인 방식이지만 교착 상태(Deadlock)가 발생할 수 있고 잠금 경합으로 성능이 저하된다.

직렬화 가능한 스냅샷 격리 (SSI, Serializable Snapshot Isolation): MVCC 기반의 낙관적 동시성 제어. 트랜잭션을 일단 실행하고, 커밋 시점에 충돌을 감지하면 롤백한다. 충돌이 적은 워크로드에서 2PL보다 훨씬 성능이 좋다. PostgreSQL 9.1 이상에서 사용한다.

격리 수준별 이상 현상 정리

이상 현상 Read Uncommitted Read Committed Repeatable Read Serializable
더티 읽기 발생 방지 방지 방지
더티 쓰기 발생 방지 방지 방지
읽기 스큐 발생 발생 방지 방지
갱신 손실 발생 발생 대부분 방지 방지
팬텀 읽기 발생 발생 발생 방지
쓰기 스큐 발생 발생 발생 방지

갱신 손실 방지 — 실무에서 자주 쓰는 패턴

갱신 손실은 실무에서 자주 마주치는 문제라 해결책을 따로 정리한다.

원자적 쓰기 연산

DB가 제공하는 원자적 연산을 쓴다.

UPDATE counters SET value = value + 1 WHERE key = 'hits';

읽기-수정-쓰기 사이클 없이 DB 내부에서 원자적으로 처리한다. 애플리케이션에서 값을 읽어서 수정하고 다시 저장하는 패턴보다 안전하다.

명시적 잠금

SELECT * FROM items WHERE id = 1 FOR UPDATE;

FOR UPDATE로 읽은 행에 배타 잠금을 건다. 다른 트랜잭션은 이 잠금이 해제될 때까지 같은 행을 수정하지 못한다. 게임의 아이템 획득, 좌석 예약처럼 "먼저 읽고 조건 확인 후 수정"하는 패턴에 쓴다.

낙관적 잠금 (Optimistic Locking)

충돌이 드물다고 가정하고 잠금 없이 진행하다가, 커밋 시점에 충돌을 감지하면 재시도한다. 버전 번호나 타임스탬프를 활용한다.

UPDATE items
SET quantity = quantity - 1, version = version + 1
WHERE id = 1 AND version = 5;
-- 영향받은 행이 0이면 다른 트랜잭션이 먼저 수정한 것 → 재시도

JPA의 @Version이 이 패턴을 구현한다.


마치며 — 내 실무와 연결되는 점

이 장을 읽고 나서 @Transactional을 바라보는 시각이 완전히 바뀌었다.

@Transactional은 원자성과 일부 격리성을 제공하지만, 어떤 격리 수준인지, 어떤 이상 현상이 여전히 가능한지를 모르면 쓰기 스큐 같은 미묘한 버그를 만들 수 있다.

MySQL InnoDB의 기본 격리 수준이 Repeatable Read라는 걸 알았다. 팬텀 읽기나 쓰기 스큐가 문제가 될 수 있는 로직이라면 SERIALIZABLE로 올리거나, 명시적 잠금(FOR UPDATE)을 써야 한다.

그리고 카운터 증가 로직을 애플리케이션에서 "읽고 +1 하고 저장"하는 패턴으로 짜고 있었다면, 오늘부터 UPDATE counters SET value = value + 1로 바꾸는 게 맞다.

8장은 분산 시스템의 골칫거리다. 단일 노드에서는 신경 쓰지 않아도 됐던 문제들 — 네트워크 지연, 클럭 불일치, 프로세스 일시 정지 — 이 분산 환경에서 어떻게 예상치 못한 장애를 만드는지를 다룬다. 읽으면 분산 시스템이 왜 어려운지 뼈저리게 느끼게 되는 장이다.


읽어주셔서 감사합니다. 잘못된 내용이나 보완할 점이 있으면 댓글로 알려주세요.