트랜잭션 ACID와 격리 수준: 두 세션으로 읽기 이상 확인하기


1장. 돈은 빠졌는데 상대 계좌에는 들어오지 않았다#

가람의 계좌에는 300원이 있습니다.

나래의 계좌에는 200원이 있습니다.

가람이 나래에게 100원을 이체합니다.

정상적인 결과는 다음과 같습니다.

계좌 이체 전 이체 후
가람 300 200
나래 200 300
합계 500 500

업무적으로는 매우 단순해 보입니다.

가람 -100
나래 +100

하지만 데이터베이스에서는 두 개의 변경 작업이 필요합니다.

UPDATE account
SET balance = balance - 100
WHERE account_id = 1;

UPDATE account
SET balance = balance + 100
WHERE account_id = 2;

문제는 첫 번째 UPDATE가 성공하고 두 번째 UPDATE가 실패하는 경우입니다.

그 상태가 그대로 확정되면:

계좌 중간 상태
가람 200
나래 200
합계 400

100원이 사라집니다.

트랜잭션이 필요한 이유는 바로 이런 업무적으로 불완전한 중간 상태를 최종 상태로 남기지 않기 위해서입니다.


2장. 트랜잭션은 SQL 여러 개를 묶는 문법 이상이다#

트랜잭션을 단순하게 설명하면 다음과 같습니다.

하나의 업무 단위로 처리되어야 하는 데이터베이스 작업의 묶음.

계좌 이체에서는:

출금
+
입금

두 작업이 하나의 업무입니다.

따라서:

BEGIN;

UPDATE account
SET balance = balance - 100
WHERE account_id = 1;

UPDATE account
SET balance = balance + 100
WHERE account_id = 2;

COMMIT;

처럼 하나의 트랜잭션으로 묶을 수 있습니다.

구조를 보면 다음과 같습니다.

flowchart LR
    B["BEGIN"] --> W1["가람 -100"]
    W1 --> W2["나래 +100"]
    W2 --> C["COMMIT"]
    C --> S["이체 확정"]

둘 중 하나라도 업무적으로 실패한다면 전체를 취소해야 합니다.

flowchart LR
    B["BEGIN"] --> W1["가람 -100"]
    W1 --> W2["나래 +100 실패"]
    W2 --> R["ROLLBACK"]
    R --> S["원래 상태 복원"]

3장. ACID는 트랜잭션의 네 가지 핵심 성질이다#

트랜잭션을 설명할 때 가장 많이 등장하는 개념이 ACID입니다.

각 문자는 다음을 의미합니다.

성질 의미
Atomicity 원자성
Consistency 일관성
Isolation 격리성
Durability 지속성

쉽게 정리하면 다음과 같습니다.

원자성
→ 전부 성공하거나 전부 취소

일관성
→ 정의한 데이터 규칙을 만족하는 상태 유지

격리성
→ 동시에 실행되는 트랜잭션 간 관찰과 충돌 제어

지속성
→ COMMIT된 결과가 장애 뒤에도 복구 가능해야 함

하지만 이 네 개념을 한 문장씩 외우는 것만으로는 실제 트랜잭션 문제를 해결하기 어렵습니다.


4장. 원자성은 “중간 과정이 존재하지 않는다”는 뜻이 아니다#

가람 계좌에서 먼저 100원을 뺍니다.

UPDATE account
SET balance = balance - 100
WHERE account_id = 1;

그 순간 트랜잭션 내부에서는 다음 상태가 존재할 수 있습니다.

가람 = 200
나래 = 200

즉 중간 상태 자체는 존재합니다.

원자성의 의미는:

중간 상태가 최종적으로 일부만 확정되지 않도록 한다.

입니다.

두 번째 UPDATE가 실패하면:

ROLLBACK;

하여 출금도 함께 취소합니다.

결과는 다시:

가람 = 300
나래 = 200

이 됩니다.


5장. 원자성은 업무 성공 조건까지 자동으로 판단해 주지 않는다#

다음 SQL을 사용한다고 하겠습니다.

UPDATE account
SET balance = balance - 100
WHERE account_id = 1
  AND balance >= 100;

잔액이 50원밖에 없다면 어떻게 될까요?

SQL 자체가 오류를 내는 것이 아니라:

UPDATE 0

이 될 수 있습니다.

즉 출금된 행이 없습니다.

그런데 프로그램이 이를 확인하지 않고 다음 SQL을 실행하면:

UPDATE account
SET balance = balance + 100
WHERE account_id = 2;

나래 계좌에는 100원이 추가됩니다.

결과적으로 돈이 새로 만들어집니다.

따라서 트랜잭션이 있다고 해서 업무 규칙까지 자동으로 올바르게 처리되는 것은 아닙니다.


6장. 변경된 행 수를 확인해야 하는 이유#

이체에서는 첫 UPDATE가 정확히 한 행을 수정해야 합니다.

1행 수정
→ 출금 성공

0행 수정
→ 잔액 부족 또는 계좌 없음

2행 이상
→ 식별 조건 자체가 잘못됐을 가능성

따라서 애플리케이션은 다음과 같은 로직이 필요합니다.

출금 UPDATE
↓
수정된 행 수 확인
↓
1행이 아니면 ROLLBACK
↓
입금 UPDATE
↓
수정된 행 수 확인
↓
정상일 때만 COMMIT

트랜잭션과 업무 검증은 함께 설계해야 합니다.


7장. 일관성은 데이터베이스가 세상의 모든 규칙을 자동으로 아는 것이 아니다#

계좌 테이블에 다음 제약이 있다고 하겠습니다.

balance integer NOT NULL
    CHECK (balance >= 0)

이제 음수 잔액은 DBMS가 막을 수 있습니다.

하지만 다음 업무 규칙은 어떨까요?

송금 계좌에서 빠진 금액과 수취 계좌에 추가된 금액은 같아야 한다.

이 규칙은 단일 행의 CHECK만으로 표현하기 어렵습니다.

또 다음 규칙도 있습니다.

이체 금액은 0보다 커야 한다.

정지 계좌에는 송금할 수 없다.

하루 이체 한도를 넘을 수 없다.

이런 규칙은 데이터베이스 제약과 애플리케이션 로직을 적절히 조합해야 합니다.


8장. 일관성은 “항상 현실적으로 올바른 데이터”라는 마법이 아니다#

ACID의 Consistency를 다음처럼 이해하면 위험합니다.

트랜잭션만 사용하면 데이터가 항상 올바르다.

잘못된 SQL을 트랜잭션으로 묶어도 잘못된 결과를 정상적으로 COMMIT할 수 있습니다.

예를 들어:

BEGIN;

UPDATE account
SET balance = balance + 100
WHERE account_id = 2;

COMMIT;

이 SQL은 완전히 원자적으로 실행될 수 있습니다.

하지만 실제 송금 원인이 없다면 업무 데이터는 틀렸습니다.

따라서 일관성을 유지하려면 업무 규칙 자체가 시스템에 표현되어 있어야 합니다.


9장. 격리성은 동시에 실행되는 트랜잭션의 문제다#

한 사람만 시스템을 사용한다면 동시성 문제는 거의 없습니다.

문제는 여러 사용자가 동시에 같은 데이터를 읽고 변경할 때 발생합니다.

예를 들어 가람의 잔액이 500원이라고 하겠습니다.

세션 A와 B가 동시에 조회합니다.

A → 500 읽음
B → 500 읽음

A는 100원을 추가하려고 합니다.

B는 200원을 추가하려고 합니다.

애플리케이션이 각각 새로운 값을 계산합니다.

A
500 + 100 = 600

B
500 + 200 = 700

A가 먼저 600을 저장합니다.

그다음 B가 700을 저장합니다.

최종값은:

700

이 됩니다.

하지만 두 증가가 모두 반영됐다면 기대값은:

800

입니다.

A의 증가분 100이 사라졌습니다.


10장. 이것이 갱신 손실의 전형적인 형태다#

시간 순서로 보면 다음과 같습니다.

순서 세션 A 세션 B 저장값
1 500 읽음 500
2 500 읽음 500
3 600 저장 600
4 700 저장 700

최종값:

700

의도한 값:

800

한쪽 변경이 다른 UPDATE에 덮였습니다.

이런 문제를 갱신 손실로 설명할 수 있습니다.


11장. 데이터베이스 내부에서 상대적으로 갱신하는 방법#

다음처럼 애플리케이션에서 값을 먼저 읽고 절대값으로 저장하는 것보다:

SELECT balance
↓
애플리케이션에서 계산
↓
UPDATE balance = 계산값

다음처럼 DBMS 내부에서 직접 증가시키는 방법을 사용할 수 있습니다.

UPDATE account
SET balance = balance + 100
WHERE account_id = 1;

또는 출금이라면:

UPDATE account
SET balance = balance - 100
WHERE account_id = 1
  AND balance >= 100;

DBMS의 동시성 제어와 함께 사용하면 단순한 읽기-계산-덮어쓰기 패턴보다 안전합니다.

다만 여러 행에 걸친 업무 규칙은 추가적인 격리 설계가 필요할 수 있습니다.


12장. 격리 수준은 “다른 트랜잭션의 무엇을 볼 수 있는가”를 결정한다#

격리 수준을 이해할 때 다음 질문을 기준으로 보면 쉽습니다.

다른 트랜잭션이 아직 COMMIT하지 않은 값을 볼 수 있는가?

같은 행을 다시 읽었을 때 값이 바뀔 수 있는가?

같은 조건을 다시 실행했을 때 새로운 행이 나타날 수 있는가?

이 질문들이 각각 대표적인 읽기 이상과 연결됩니다.


13장. 대표적인 네 가지 격리 수준#

SQL에서 흔히 설명하는 격리 수준은 다음과 같습니다.

READ UNCOMMITTED

READ COMMITTED

REPEATABLE READ

SERIALIZABLE

일반적으로 아래로 갈수록 더 강한 격리를 목표로 합니다.

flowchart TD
    RU["READ UNCOMMITTED"] --> RC["READ COMMITTED"]
    RC --> RR["REPEATABLE READ"]
    RR --> S["SERIALIZABLE"]

하지만 실제 동작은 DBMS마다 차이가 있습니다.

격리 수준 이름만 보고 모든 제품이 완전히 동일하게 동작한다고 가정하면 안 됩니다.


14장. 더티 읽기는 확정되지 않은 값을 읽는 현상이다#

가람의 잔액이 300이라고 하겠습니다.

세션 A가 트랜잭션을 시작합니다.

BEGIN;

UPDATE account
SET balance = 200
WHERE account_id = 1;

아직 COMMIT하지 않았습니다.

그런데 세션 B가 이 200을 읽었다고 하겠습니다.

이후 세션 A가:

ROLLBACK;

합니다.

실제 확정된 잔액은 다시 300입니다.

세션 B는 실제로 존재한 적 없는 최종 상태 200을 사용한 것입니다.

이것이 더티 읽기입니다.


15장. 더티 읽기를 시간표로 보면#

시각 세션 A 세션 B 확정 잔액
1 BEGIN 300
2 300 → 200 수정 300
3 200 읽음 300
4 ROLLBACK 300

세션 B가 읽은 200은 최종적으로 COMMIT된 적이 없습니다.

따라서 더티한 값입니다.


16장. PostgreSQL에서는 READ UNCOMMITTED도 READ COMMITTED처럼 동작한다#

SQL 표준 관점에서는 READ UNCOMMITTED에서 더티 읽기를 허용할 수 있습니다.

하지만 PostgreSQL에서는 READ UNCOMMITTED를 요청하더라도 실질적으로 READ COMMITTED 수준처럼 처리됩니다.

즉 PostgreSQL 실습에서 더티 읽기가 재현되지 않는다고 해서 개념 자체가 잘못된 것은 아닙니다.

표준의 격리 수준 개념과 특정 DBMS의 실제 구현을 구분해야 합니다.


17장. 비반복 읽기는 같은 행을 다시 읽었는데 값이 달라지는 현상이다#

가람 계좌 잔액이 100이라고 하겠습니다.

세션 A가 읽습니다.

A 첫 번째 조회
→ 100

그 사이 세션 B가:

UPDATE account
SET balance = 80
WHERE account_id = 1;

COMMIT;

합니다.

세션 A가 같은 행을 다시 읽습니다.

A 두 번째 조회
→ 80

같은 트랜잭션 안에서 같은 행을 읽었는데 값이 달라졌습니다.

이것이 비반복 읽기입니다.


18장. PostgreSQL READ COMMITTED에서 두 세션으로 확인하기#

먼저 테스트 데이터를 만듭니다.

CREATE TABLE isolation_test (
    id integer PRIMARY KEY,
    balance integer NOT NULL
);

INSERT INTO isolation_test
VALUES (1, 100);

세션 A:

BEGIN ISOLATION LEVEL READ COMMITTED;

SELECT balance
FROM isolation_test
WHERE id = 1;

결과:

100

세션 A는 그대로 둡니다.

세션 B:

BEGIN;

UPDATE isolation_test
SET balance = 80
WHERE id = 1;

COMMIT;

다시 세션 A에서 조회합니다.

SELECT balance
FROM isolation_test
WHERE id = 1;

이번에는:

80

을 볼 수 있습니다.


19장. READ COMMITTED에서는 문장마다 새로운 스냅샷을 볼 수 있다#

PostgreSQL의 READ COMMITTED에서는 각 명령이 시작할 때 볼 수 있는 데이터 상태가 달라질 수 있습니다.

개념적으로:

세션 A SELECT 1
↓
스냅샷 1

세션 B COMMIT

세션 A SELECT 2
↓
스냅샷 2

입니다.

따라서 같은 트랜잭션 안에서도 두 SELECT 결과가 달라질 수 있습니다.


20장. REPEATABLE READ에서는 같은 스냅샷을 계속 본다#

이번에는 잔액을 다시 100으로 초기화합니다.

세션 A:

BEGIN ISOLATION LEVEL REPEATABLE READ;

SELECT balance
FROM isolation_test
WHERE id = 1;

결과:

100

세션 B에서:

UPDATE isolation_test
SET balance = 80
WHERE id = 1;

COMMIT;

합니다.

세션 A에서 같은 SELECT를 다시 실행합니다.

SELECT balance
FROM isolation_test
WHERE id = 1;

PostgreSQL의 REPEATABLE READ에서는 기존 스냅샷을 계속 사용하므로 100을 볼 수 있습니다.


21장. 비반복 읽기는 “한 행의 값 변화”에 초점을 둔다#

비반복 읽기의 핵심은 다음입니다.

같은 행을 읽음
↓
다른 트랜잭션이 수정·COMMIT
↓
같은 행을 다시 읽음
↓
값이 달라짐

예:

상품 가격
1차 조회 → 1,000원

다른 트랜잭션 UPDATE + COMMIT

2차 조회 → 1,200원

동일한 식별자의 값이 바뀌는 문제입니다.


22장. 팬텀 읽기는 행 하나가 아니라 조건 결과 집합이 달라지는 문제다#

직원 테이블에서 개발부서 직원 수를 조회한다고 하겠습니다.

세션 A:

SELECT *
FROM employee
WHERE dept_id = 10;

결과가 5행입니다.

그 사이 세션 B가 개발부서 직원을 한 명 추가합니다.

INSERT INTO employee (...)
VALUES (..., 10, ...);

COMMIT;

세션 A가 같은 조건을 다시 실행합니다.

SELECT *
FROM employee
WHERE dept_id = 10;

이번에는 6행입니다.

마치 없던 행이 유령처럼 새로 나타났다고 해서 팬텀 읽기라고 부릅니다.


23장. 비반복 읽기와 팬텀 읽기를 구분하면#

비반복 읽기#

같은 행
값이 달라짐

예:

직원 1001 급여
500만 → 550만

팬텀 읽기#

같은 조건
행 집합이 달라짐

예:

dept_id = 10

5행 → 6행

초점이 다릅니다.


24장. PostgreSQL REPEATABLE READ에서는 팬텀도 같은 스냅샷으로 막힌다#

SQL 표준의 전통적인 격리 수준 표에서는 REPEATABLE READ에서 팬텀을 허용할 수 있다고 설명합니다.

하지만 PostgreSQL의 REPEATABLE READ는 트랜잭션 스냅샷을 사용하므로 다른 트랜잭션이 새 행을 INSERT하고 COMMIT하더라도 기존 트랜잭션에서 새 행이 보이지 않습니다.

따라서 PostgreSQL에서는 위와 같은 단순한 팬텀 읽기가 REPEATABLE READ에서 발생하지 않습니다.


25장. 격리 수준을 표준 정의와 PostgreSQL 동작으로 나눠 보면#

격리 수준 전통적인 설명 PostgreSQL에서의 특징
READ UNCOMMITTED 더티 읽기 가능 READ COMMITTED처럼 동작
READ COMMITTED 더티 읽기 방지 문장마다 새 스냅샷
REPEATABLE READ 비반복 읽기 방지 같은 스냅샷 유지, 팬텀도 방지
SERIALIZABLE 직렬 실행과 동등한 결과 목표 직렬화 충돌 시 실패와 재시도 가능

제품마다 실제 구현이 다르므로 사용하는 DBMS 문서를 기준으로 확인해야 합니다.


26장. SERIALIZABLE은 모든 트랜잭션을 실제로 한 줄로 실행한다는 뜻은 아니다#

다음 두 트랜잭션이 동시에 실행될 수 있습니다.

트랜잭션 A

트랜잭션 B

SERIALIZABLE의 목표는 두 트랜잭션을 실제로 반드시 순차 실행시키는 것이 아니라, 결과가 어떤 직렬 실행 순서와 동등하도록 보장하는 것입니다.

예:

A 먼저 완료
그다음 B

또는:

B 먼저 완료
그다음 A

중 하나와 모순되지 않는 결과를 만들어야 합니다.


27장. SERIALIZABLE에서는 정상적인 SQL도 실패할 수 있다#

동시에 실행된 두 트랜잭션이 직렬 실행으로 설명하기 어려운 상태를 만들려고 하면 DBMS가 한쪽을 중단시킬 수 있습니다.

PostgreSQL에서는 직렬화 실패가 발생할 수 있습니다.

예를 들어 SQLSTATE:

40001

같은 오류를 받을 수 있습니다.

이 경우 애플리케이션은 트랜잭션 전체를 다시 시도할 수 있도록 설계해야 합니다.


28장. 높은 격리 수준은 “오류가 없는 모드”가 아니다#

SERIALIZABLE을 사용하면:

동시성 오류가 완전히 사라짐

이라고 생각하면 안 됩니다.

오히려 충돌을 감지해 트랜잭션이 실패할 수 있습니다.

따라서 애플리케이션은 다음을 고려해야 합니다.

직렬화 실패
↓
ROLLBACK
↓
적절한 재시도

격리 수준을 높이는 것은 동시성 문제를 없애는 것이 아니라 허용하지 않을 이상 현상을 DBMS가 감지하고 제어하는 범위를 늘리는 것에 가깝습니다.


29장. REPEATABLE READ라고 모든 업무 규칙이 안전한 것도 아니다#

두 의사가 당직 상태라고 하겠습니다.

업무 규칙은:

최소 한 명은 반드시 당직이어야 한다.

현재:

의사 A = 당직
의사 B = 당직

트랜잭션 A는 B가 당직인지 확인합니다.

B = 당직

이라고 보고 자신을 당직에서 해제합니다.

동시에 트랜잭션 B도 A가 당직인지 확인합니다.

A = 당직

이라고 보고 자신을 해제합니다.

둘이 서로 다른 행을 수정하기 때문에 단순 행 충돌이 없을 수 있습니다.

최종적으로:

A = 비당직
B = 비당직

이 될 수 있습니다.

업무 규칙이 깨졌습니다.

이런 문제를 쓰기 왜곡으로 설명합니다.


30장. 스냅샷이 일관되어도 업무 불변식이 깨질 수 있다#

각 트랜잭션 입장에서는 읽은 데이터가 일관되어 있습니다.

A는:

B가 당직

인 스냅샷을 봤습니다.

B는:

A가 당직

인 스냅샷을 봤습니다.

각자 판단은 독립적으로는 맞아 보입니다.

문제는 두 결과를 동시에 합쳤을 때입니다.

당직자 수 = 0

이 됩니다.

따라서 다음은 서로 다릅니다.

일관된 스냅샷을 읽는다.

업무의 모든 다중 행 규칙이 자동으로 보장된다.

31장. 격리 수준은 업무 불변식과 함께 선택해야 한다#

다음 업무는 비교적 단순합니다.

한 행의 잔액 감소

하지만 다음 업무는 여러 행과 조건을 함께 봅니다.

최소 한 명의 당직자 유지

좌석 총합 제한

하루 전체 이체 한도

재고 총량과 예약 수량 관계

이런 규칙은 단일 행 잠금만으로 충분하지 않을 수 있습니다.

필요하면:

SERIALIZABLE

명시적 잠금

업무용 집계 행 잠금

제약 구조 변경

등을 검토할 수 있습니다.


32장. ACID의 지속성은 COMMIT된 결과를 장애 후에도 복구할 수 있다는 의미다#

가람에서 나래에게 이체가 완료되었습니다.

애플리케이션은 COMMIT 성공 응답을 받았습니다.

그 직후 서버가 장애를 일으켰다고 하겠습니다.

재시작 후 이체 결과가 완전히 사라진다면 사용자가 받은 COMMIT의 의미가 무너집니다.

지속성은 성공적으로 확정된 변경을 장애 후에도 복구할 수 있도록 하는 성질입니다.


33장. 지속성은 메모리 페이지를 즉시 데이터파일에 썼다는 뜻과는 다르다#

현대 DBMS는 일반적으로 로그 기반 복구 구조를 사용합니다.

개념적으로:

flowchart LR
    U["데이터 변경"] --> B["버퍼 페이지 변경"]
    U --> L["WAL / 로그 기록"]
    L --> C["COMMIT"]
    B --> D["데이터 페이지는 적절한 시점에 저장"]

데이터 페이지 자체가 COMMIT 순간 즉시 데이터파일에 반영되지 않았더라도 필요한 로그가 안전하게 기록되어 있다면 장애 후 복구할 수 있습니다.

구체적인 방식은 DBMS마다 다릅니다.


34장. COMMIT 요청 뒤 연결이 끊기면 더 어려운 문제가 생긴다#

이체 요청 ID가 TX-1001이라고 하겠습니다.

프로그램이:

COMMIT;

을 전송했습니다.

그런데 응답을 받기 전에 네트워크 연결이 끊겼습니다.

클라이언트는 무엇을 알고 있을까요?

COMMIT이 실패했다?

확실하지 않습니다.

서버에서는 이미 COMMIT했는데 응답만 유실됐을 수도 있습니다.


35장. COMMIT 결과를 모르는 상태에서 무조건 재시도하면 중복 이체가 발생할 수 있다#

첫 요청이 실제로 성공했다고 하겠습니다.

가람 -100
나래 +100

클라이언트는 응답을 받지 못했습니다.

같은 이체를 다시 보냅니다.

가람 -100
나래 +100

최종적으로 200원이 이체될 수 있습니다.

따라서 금융·주문 같은 중요 업무에서는 요청 자체를 식별할 수 있는 키가 필요합니다.


36장. 업무 요청 ID로 중복 처리를 막을 수 있다#

예를 들어:

transfer_request_id
=
TX-1001

을 둡니다.

첫 요청이 성공했으면 해당 ID의 처리 결과를 저장합니다.

같은 ID로 재요청이 들어오면:

새 이체 실행

이 아니라:

기존 처리 결과 조회

로 처리할 수 있습니다.

이런 성질을 멱등성과 연결해 설계할 수 있습니다.


37장. 트랜잭션 성공과 사용자에게 알리는 시점도 중요하다#

다음 흐름을 생각해 보겠습니다.

출금
↓
입금
↓
문자 발송
↓
COMMIT

문자는 성공적으로 발송됐습니다.

그런데 COMMIT 직전에 데이터베이스 오류가 발생해 트랜잭션이 ROLLBACK됐습니다.

고객에게는:

이체가 완료되었습니다.

라는 메시지가 왔지만 실제 이체는 없습니다.

DB 트랜잭션의 원자성은 외부 문자 시스템까지 자동으로 롤백해 주지 않습니다.


38장. COMMIT 후 문자를 보내도 또 다른 문제가 생긴다#

이번에는 순서를 바꿉니다.

출금
↓
입금
↓
COMMIT
↓
문자 발송

데이터베이스 이체는 성공했습니다.

그런데 문자 발송 시스템이 장애입니다.

결과:

이체는 완료됨
문자는 오지 않음

데이터베이스와 외부 시스템 사이에는 별도의 실패 가능성이 있습니다.


39장. 트랜잭셔널 아웃박스 형태로 간격을 줄일 수 있다#

한 가지 방법은 이체와 알림 요청을 같은 데이터베이스 트랜잭션에 기록하는 것입니다.

flowchart TD
    B["BEGIN"] --> T["이체 처리"]
    T --> O["OUTBOX에 알림 이벤트 INSERT"]
    O --> C["COMMIT"]
    C --> W["별도 Worker"]
    W --> S["문자·이메일 전송"]

DB 트랜잭션 안에서는:

잔액 변경
+
알림 이벤트 저장

을 함께 확정합니다.

외부 전송은 COMMIT된 이벤트를 기반으로 별도 처리합니다.


40장. 아웃박스도 중복 전송 문제까지 자동 해결하지는 않는다#

Worker가 문자를 발송했습니다.

그 직후 처리 완료 상태를 저장하기 전에 장애가 발생했습니다.

Worker가 다시 실행되면 같은 메시지를 재전송할 수 있습니다.

따라서 외부 처리에서도:

event_id

처리 상태

중복 방지

재시도 정책

등을 설계해야 합니다.

트랜잭션 경계를 확장한다고 모든 외부 문제까지 자동으로 사라지는 것은 아닙니다.


41장. 격리 수준을 높이면 항상 느려진다고 단정할 수는 없다#

다음 설명은 지나치게 단순합니다.

READ COMMITTED
→ 빠름

SERIALIZABLE
→ 느림

실제 성능에는 여러 요소가 영향을 줍니다.

충돌 빈도

읽기와 쓰기 비율

재시도 횟수

잠금 대기

MVCC 구현

인덱스

트랜잭션 길이

SERIALIZABLE에서도 충돌이 거의 없다면 비용 차이가 작을 수 있습니다.

반대로 낮은 격리 수준에서 애플리케이션이 복잡한 보정 로직을 수행하면 전체 비용이 더 커질 수도 있습니다.


42장. 트랜잭션은 가능한 한 불필요하게 길게 잡지 않는 것이 좋다#

다음 코드를 생각해 보겠습니다.

BEGIN

계좌 조회

사용자에게 화면 표시

사용자 입력 30초 대기

다른 API 호출

UPDATE

COMMIT

트랜잭션이 오랫동안 열려 있습니다.

이런 구조는:

잠금 유지 시간 증가

오래된 스냅샷 유지

충돌 가능성 증가

커넥션 점유

재시도 비용 증가

등의 문제를 만들 수 있습니다.

업무상 필요한 데이터베이스 작업 범위를 명확히 정하는 것이 중요합니다.


43장. “사용자가 버튼을 누른 전체 시간”이 트랜잭션 범위일 필요는 없다#

사용자가 주문 화면에 5분 동안 머물렀다고 해서 데이터베이스 트랜잭션도 5분 동안 유지할 필요는 없습니다.

보통 실제 변경을 수행하는 짧은 구간을 트랜잭션으로 설계합니다.

요청 수신
↓
현재 상태 재확인
↓
필요한 데이터 변경
↓
COMMIT
↓
응답

화면을 열어 둔 시간과 DB 트랜잭션 시간은 다른 개념입니다.


44장. 두 세션 실습은 반드시 서로 다른 연결에서 해야 한다#

동시성 문제를 실습할 때 가장 흔한 실수는 SQL을 한 창에서 순서대로 실행하는 것입니다.

BEGIN
SELECT
UPDATE
COMMIT
SELECT

한 연결에서 이런 순서로 실행하면 실제 동시 실행 상황을 관찰할 수 없습니다.

반드시:

세션 A

세션 B

두 개의 독립적인 데이터베이스 연결을 준비하는 것이 좋습니다.


45장. 비반복 읽기 실습 시간표#

PostgreSQL READ COMMITTED 예제를 다시 시간표로 정리해 보겠습니다.

초기값:

balance = 100
단계 세션 A 세션 B A 관찰값
1 BEGIN READ COMMITTED
2 SELECT balance 100
3 UPDATE balance=80
4 COMMIT
5 SELECT balance 80
6 COMMIT

A의 두 번의 SELECT 결과가 다릅니다.


46장. REPEATABLE READ 실습 시간표#

초기값을 다시:

balance = 100

으로 맞춥니다.

단계 세션 A 세션 B A 관찰값
1 BEGIN REPEATABLE READ
2 SELECT balance 100
3 UPDATE balance=80
4 COMMIT
5 SELECT balance 100
6 COMMIT

세션 B의 변경은 COMMIT됐지만 A는 자신의 스냅샷을 계속 봅니다.


47장. 스냅샷이 오래되었다고 데이터가 틀렸다는 뜻은 아니다#

REPEATABLE READ의 A가 100을 보고 있을 때 데이터베이스의 최신 확정값은 80일 수 있습니다.

그렇다면 A가 잘못된 데이터를 보는 것일까요?

아닙니다.

A는 자신의 트랜잭션이 시작한 일관된 시점의 데이터를 보고 있는 것입니다.

현재 최신값
=
80

A의 트랜잭션 스냅샷
=
100

두 값은 서로 다른 시간 기준의 사실입니다.


48장. 자신의 변경은 같은 트랜잭션에서 보인다#

REPEATABLE READ라고 해서 트랜잭션 시작 순간에 모든 값이 완전히 얼어붙는 것은 아닙니다.

세션 A가 직접 다음 UPDATE를 수행했다면:

UPDATE isolation_test
SET balance = 90
WHERE id = 1;

그 뒤 같은 세션에서 SELECT하면 자신의 변경 90을 볼 수 있습니다.

즉:

다른 트랜잭션의 이후 COMMIT
→ 기존 스냅샷에서 안 보일 수 있음

자기 자신의 변경
→ 자기 트랜잭션에서 보임

을 구분해야 합니다.


49장. 격리 수준과 잠금은 관련되지만 같은 개념은 아니다#

격리 수준은 다른 트랜잭션의 변경을 어떻게 관찰하고 충돌을 어떻게 처리할지를 정의합니다.

잠금은 동시 접근을 제어하는 하나의 메커니즘입니다.

MVCC 기반 DBMS에서는 모든 읽기가 단순한 공유 잠금으로 구현되는 것도 아닙니다.

따라서:

격리 수준
=
락 종류

라고 동일하게 보면 안 됩니다.


50장. SELECT FOR UPDATE는 읽으면서 변경 대상 행을 잠그는 데 사용할 수 있다#

잔액을 읽고 복잡한 계산을 한 뒤 수정해야 한다고 하겠습니다.

BEGIN;

SELECT balance
FROM account
WHERE account_id = 1
FOR UPDATE;

이렇게 하면 해당 행을 변경 대상으로 잠그는 형태를 사용할 수 있습니다.

그 뒤 업무 조건을 검사하고 UPDATE합니다.

UPDATE account
SET balance = balance - 100
WHERE account_id = 1;

COMMIT;

동시에 같은 행을 수정하려는 다른 트랜잭션은 대기하거나 다른 동작을 하게 될 수 있습니다.


51장. 잠금을 쓴다고 모든 다중 행 규칙이 자동으로 안전해지는 것도 아니다#

두 행을 검사하는 업무라고 하겠습니다.

A 상태
B 상태

A만 잠그고 B를 읽는다면 다른 트랜잭션이 B를 변경할 수 있습니다.

다중 행 업무 규칙에서는:

무엇을 잠가야 하는가?

어떤 순서로 잠가야 하는가?

조건에 아직 존재하지 않는 행까지 보호해야 하는가?

를 함께 생각해야 합니다.


52장. 장시간 트랜잭션은 MVCC 환경에서도 비용이 생길 수 있다#

MVCC에서는 과거 버전을 이용해 동시 읽기를 지원합니다.

하지만 매우 오래 실행되는 트랜잭션이 과거 스냅샷을 오래 유지하면 데이터 정리와 버전 관리에 부담을 줄 수 있습니다.

따라서 읽기 전용 트랜잭션이라도 무제한으로 오래 유지하는 것은 바람직하지 않을 수 있습니다.


53장. 트랜잭션 실패 후 재시도는 전체 업무 단위로 해야 한다#

SERIALIZABLE 트랜잭션이 직렬화 실패했다고 하겠습니다.

다음 세 작업이 있었습니다.

재고 확인

주문 INSERT

포인트 차감

마지막 포인트 차감 SQL만 다시 실행해서는 안 됩니다.

트랜잭션 전체가 실패했다면 일반적으로 업무의 시작 단계부터 새로운 트랜잭션으로 다시 시도해야 합니다.

왜냐하면 재고와 포인트 상태가 이미 달라졌을 수 있기 때문입니다.


54장. 재시도 가능하게 하려면 트랜잭션 내부 작업도 설계가 필요하다#

DB 트랜잭션 안에서 다음과 같은 외부 작업을 직접 수행한다고 하겠습니다.

결제 API 호출

이메일 발송

SMS 발송

트랜잭션이 실패해 다시 실행되면 외부 작업까지 중복될 수 있습니다.

따라서 재시도 가능한 구조에서는 DB 변경과 외부 부작용을 분리하거나 요청 식별자를 통해 중복 실행을 막는 것이 중요합니다.


55장. ACID 네 가지를 계좌 이체에 다시 대입하면#

원자성#

출금과 입금 중 하나만 확정되어서는 안 됩니다.

출금 성공
입금 실패
→ 전체 취소

일관성#

잔액 음수 금지와 이체 업무 규칙을 만족해야 합니다.

balance >= 0

같은 제약과 애플리케이션 검사가 필요합니다.

격리성#

동시에 여러 이체가 같은 계좌를 변경해도 허용하지 않은 중간 상태나 잘못된 결과가 발생하지 않아야 합니다.

지속성#

COMMIT 성공으로 알려준 이체 결과는 장애 이후에도 복구되어야 합니다.


56장. ACID와 격리 수준을 한 흐름으로 보면#

flowchart TD
    A["업무 시작"] --> B["트랜잭션 시작"]
    B --> C["업무 조건 확인"]
    C --> D["데이터 변경"]
    D --> E{"모든 단계 성공?"}
    E -->|아니오| R["ROLLBACK"]
    E -->|예| F["COMMIT"]
    F --> G["확정된 결과"]
    G --> H["외부 이벤트 처리"]

동시에 다른 트랜잭션이 실행될 때는 이 흐름 사이에서 어떤 데이터를 볼 수 있는지가 격리 수준에 의해 달라집니다.


57장. 업무별로 필요한 격리 수준은 다를 수 있다#

뉴스 목록 조회와 계좌 이체는 요구가 다릅니다.

뉴스 목록에서는 몇 초 사이에 새로운 글이 나타나도 큰 문제가 아닐 수 있습니다.

반면:

좌석 예약

재고 차감

계좌 잔액

이체 한도

중복 결제

처럼 여러 사용자가 같은 제한 조건을 경쟁하는 업무에서는 더 정밀한 동시성 설계가 필요합니다.

따라서 모든 트랜잭션을 무조건 SERIALIZABLE로 만드는 것이 정답도 아니고 모든 업무를 READ COMMITTED로 두는 것이 정답도 아닙니다.


58장. 격리 수준을 선택할 때 확인할 질문#

  1. 같은 행을 한 트랜잭션에서 여러 번 읽는가?
  2. 그 사이 다른 트랜잭션의 변경이 보이면 문제가 되는가?
  3. 조건에 맞는 행 수가 중간에 바뀌면 문제가 되는가?
  4. 여러 행의 합계나 개수를 업무 규칙으로 사용하는가?
  5. 동시에 같은 행을 변경할 가능성이 높은가?
  6. 직렬화 실패를 애플리케이션이 재시도할 수 있는가?
  7. 트랜잭션이 얼마나 오래 열려 있는가?
  8. 외부 API 호출이 트랜잭션 안에 포함되어 있는가?
  9. 중복 요청을 구별할 업무 ID가 있는가?
  10. 사용하는 DBMS의 실제 격리 구현은 무엇인가?

격리 수준의 이름보다 이 질문에 대한 답이 더 중요합니다.


59장. 대표적인 동시성 이상을 정리하면#

현상 핵심 상황
더티 읽기 다른 트랜잭션의 미확정 값을 읽음
비반복 읽기 같은 행을 다시 읽었더니 값이 달라짐
팬텀 읽기 같은 조건을 다시 실행했더니 행 집합이 달라짐
갱신 손실 한 트랜잭션의 변경을 다른 변경이 덮어씀
쓰기 왜곡 서로 다른 행을 수정했지만 전체 업무 규칙이 깨짐

이름보다 실행 순서를 직접 그려 보는 것이 이해하기 쉽습니다.


60장. 동시성 문제는 시간표로 작성하면 원인이 보인다#

예를 들어 다음처럼 기록합니다.

순서 세션 A 세션 B
1 BEGIN
2 값 읽음
3 BEGIN
4 같은 값 읽음
5 UPDATE
6 COMMIT
7 UPDATE
8 COMMIT

그리고 각 단계에서:

A가 무엇을 보았는가?

B가 무엇을 보았는가?

어느 값이 미확정이었는가?

어느 시점에 COMMIT됐는가?

를 표시합니다.

격리 문제는 SQL만 보는 것보다 시간 순서를 함께 보는 것이 핵심입니다.


61장. 트랜잭션 설계 체크리스트#

실제 업무를 트랜잭션으로 만들 때 다음을 점검할 수 있습니다.

업무 범위#

어떤 SQL이 반드시 함께 성공해야 하는가?

실패 처리#

어떤 단계에서 실패하면 전체 ROLLBACK해야 하는가?

검증#

UPDATE된 행 수를 확인하는가?

동시성#

두 요청이 동시에 실행되면 업무 규칙이 깨지는가?

격리#

어떤 다른 트랜잭션의 변경까지 보여도 되는가?

재시도#

직렬화 실패나 교착 상태 후 전체 업무를 재실행할 수 있는가?

외부 작업#

문자·메일·결제 API 같은 외부 부작용을 어떻게 중복 방지할 것인가?

결과 확인#

COMMIT 응답이 유실되면 이미 처리된 요청인지 확인할 수 있는가?

62장. 핵심 정리#

트랜잭션은 단순히:

BEGIN

SQL 여러 개

COMMIT

을 작성하는 문법이 아닙니다.

업무에서 어떤 상태를 하나의 성공으로 볼 것인지 정의하는 경계입니다.

ACID의 네 가지 성질은 서로 다른 문제를 다룹니다.

Atomicity
→ 일부만 확정되지 않도록 한다.

Consistency
→ 정의한 데이터와 업무 규칙을 지킨다.

Isolation
→ 동시에 실행되는 트랜잭션의 관찰과 충돌을 제어한다.

Durability
→ 확정된 변경을 장애 뒤에도 복구할 수 있게 한다.

그리고 트랜잭션만 시작했다고 업무가 자동으로 안전해지는 것은 아닙니다.

출금 UPDATE가 0행을 수정했는데 입금을 계속한다면 잘못된 이체가 COMMIT될 수 있습니다.

따라서:

SQL 성공 여부

변경 행 수

업무 불변식

동시성 충돌

을 함께 검사해야 합니다.

격리 수준에서는 현상의 이름보다 실행 순서를 이해하는 것이 중요합니다.

더티 읽기
→ 미확정 값을 봄

비반복 읽기
→ 같은 행의 값이 바뀜

팬텀 읽기
→ 같은 조건의 행 집합이 바뀜

PostgreSQL의 READ COMMITTED에서는 명령마다 새로운 스냅샷을 볼 수 있고, REPEATABLE READ에서는 트랜잭션 동안 같은 스냅샷을 사용합니다.

하지만 같은 스냅샷을 본다고 모든 다중 행 업무 규칙이 자동으로 안전한 것은 아닙니다.

서로 다른 행을 수정하면서 전체 조건을 깨뜨리는 쓰기 왜곡 같은 문제가 남을 수 있습니다.

SERIALIZABLE은 이런 직렬화 이상까지 방지하려고 하지만 충돌 시 트랜잭션이 실패할 수 있으므로 애플리케이션의 재시도 설계가 필요합니다.

또한 데이터베이스의 COMMIT과 외부 문자·이메일·결제 시스템은 같은 트랜잭션이 아닙니다.

DB COMMIT 성공
≠
외부 알림 성공

이 간격을 다루기 위해 이벤트 기록, 아웃박스, 업무 요청 ID와 같은 설계가 필요할 수 있습니다.

트랜잭션을 설계할 때 가장 중요한 질문은 이것입니다.

이 업무에서 반드시 함께 성공하거나 함께 실패해야 하는 상태는 무엇인가?

그리고 동시성까지 고려한다면 한 가지 질문을 더 해야 합니다.

두 사용자가 동시에 같은 업무를 수행해도 그 규칙이 그대로 유지되는가?

이 두 질문에 답할 수 있어야 ACID와 격리 수준이 단순한 이론이 아니라 실제 데이터 무결성을 지키는 설계 원칙이 됩니다.

이 페이지의 목차