infra
Platform

모듈 맵

[Database] 트랜잭션의 4대 속성(ACID)과 복구 원리

0 / 37 완료

펼치기
0 / 37 완료0%

데이터베이스 설계 & 튜닝 · 08 / 37

[Database] 트랜잭션의 4대 속성(ACID)과 복구 원리

트랜잭션의 ACID 속성을 이해하고 BEGIN/COMMIT/ROLLBACK으로 안전한 데이터 처리를 구현합니다

🚨INCIDENT ALERT
HIGH

입금과 출금처럼 여러 쿼리가 하나의 업무 단위로 묶이는 경우가 많습니다. 중간에 실패했는데 일부 쿼리만 반영되면 데이터는 바로 깨집니다. 트랜잭션과 ACID를 이해하면 실패해도 일관성을 지키는 코드를 작성할 수 있습니다.

이번 챕터에서 배울 것

ACID는 트랜잭션이 안전하게 처리되기 위한 4가지 보장입니다. 격리 수준은 성능과 일관성 사이의 트레이드오프를 조절합니다. 실무에서는 격리 수준의 기본값과 트랜잭션 범위를 올바르게 설계하는 것이 중요합니다.

  • 1BEGIN, COMMIT, ROLLBACK으로 트랜잭션을 제어할 수 있다
  • 2은행 이체 예시로 ACID 4가지 속성을 직관적으로 설명할 수 있다
  • 34단계 격리 수준을 구분하고 차이를 설명할 수 있다
  • 4Dirty Read, Non-repeatable Read, Phantom Read 문제와 해결책을 설명할 수 있다
  • 5SAVEPOINT로 트랜잭션 내 부분 롤백을 구현할 수 있다
  • 6실무에서 트랜잭션을 사용해야 하는 패턴을 식별하고 적용할 수 있다

트랜잭션과 ACID — 데이터 일관성이 왜 중요한가

은행에서 계좌 이체를 하는 상황을 상상해보세요. A 계좌에서 10만 원이 빠져나갔는데, 서버가 갑자기 다운되어 B 계좌에는 입금이 안 됐습니다. 10만 원이 사라진 것입니다. 트랜잭션과 ACID는 이런 상황이 절대 발생하지 않도록 데이터베이스가 보장하는 핵심 메커니즘입니다.


💡개념

ACID 4가지 속성 — 은행 이체 예시로 이해하기

A 계좌에서 100만원을 출금했는데 시스템이 B 계좌 입금 직전에 죽었습니다. 돈은 사라졌습니다. 트랜잭션이 없으면 이런 중간 상태가 DB에 그대로 남습니다. ACID는 이런 상황에서 "전부 성공하거나 전부 없던 일로 돌아가는" 것을 보장하는 4가지 속성입니다. 금융, 재고, 예약처럼 정확성이 중요한 시스템은 모두 이 보장에 의존합니다.

ACID 4가지 속성 — 은행 이체 예시로 이해하기확대

트랜잭션 기본 문법

트랜잭션은 BEGIN으로 시작해 COMMIT(확정) 또는 ROLLBACK(취소)으로 끝납니다. MySQL에서는 BEGIN 대신 START TRANSACTION을 사용하기도 합니다.

1기본 트랜잭션 — 계좌 이체 실행

BEGIN으로 트랜잭션을 시작하고 출금·입금 두 UPDATE를 하나의 단위로 묶습니다. 중간에 오류가 생기면 두 변경이 모두 취소됩니다.

SQL
BEGIN;

UPDATE accounts SET balance = balance - 100000 WHERE id = 1;
UPDATE accounts SET balance = balance + 100000 WHERE id = 2;

COMMIT;
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
BEGIN;
🔍실행 후 확인할 것
  • 먼저 두 UPDATE가 모두 '1행 영향'을 반환하는지 확인합니다. 하나라도 '0행 영향'이면 조건에 맞는 행이 없는 것입니다 — 이 경우 COMMIT하지 말고 ROLLBACK해야 합니다.
  • COMMIT 후 두 계좌 잔액의 합이 이체 전과 동일한지 검증합니다. 합계가 달라졌다면 원자성이 깨진 것입니다 — 정상적인 이체에서는 출금액 = 입금액이므로 총 잔액이 불변해야 합니다.
  • 오류 발생 시 트랜잭션 상태를 확인합니다: PostgreSQL에서 오류 후 COMMIT을 시도하면 'ERROR: current transaction is aborted'가 납니다 — 이 메시지가 보이면 반드시 ROLLBACK 후 다시 BEGIN해야 합니다.

ACID 4가지 속성

ACID는 Atomicity(원자성), Consistency(일관성), Isolation(격리성), Durability(내구성)의 약자입니다. 각 속성이 무엇을 보장하는지 은행 이체 예시로 살펴봅니다.

A — Atomicity (원자성): 전부 아니면 전무

원자성은 트랜잭션 내의 모든 작업이 하나의 단위로 처리된다는 보장입니다. 이체 중 서버가 다운되더라도 출금만 되고 입금이 안 되는 상황은 발생하지 않습니다. 두 작업이 모두 성공해야만 COMMIT되고, 하나라도 실패하면 전체가 ROLLBACK됩니다.

SQL
BEGIN;

UPDATE accounts
SET balance = balance - 100000
WHERE id = 1 AND balance >= 100000;

UPDATE accounts
SET balance = balance + 100000
WHERE id = 2;

COMMIT;

C — Consistency (일관성): 제약조건 항상 유지

일관성은 트랜잭션 실행 전후에 데이터베이스가 정의된 규칙(제약조건)을 항상 만족해야 한다는 보장입니다. 잔액이 음수가 되는 이체 시도는 CHECK 제약 위반으로 자동 롤백됩니다.

SQL
CREATE TABLE accounts (
    id      BIGINT PRIMARY KEY,
    name    VARCHAR(100) NOT NULL,
    balance NUMERIC(15, 2) NOT NULL DEFAULT 0,
    CONSTRAINT chk_balance_positive CHECK (balance >= 0)
);

BEGIN;
UPDATE accounts SET balance = balance - 1000000 WHERE id = 1;
COMMIT;

잔액이 음수가 되는 순간 CHECK 제약 위반으로 ERROR: new row for relation "accounts" violates check constraint "chk_balance_positive" 오류가 발생하고 트랜잭션이 롤백됩니다.

I — Isolation (격리성): 트랜잭션 간 간섭 방지

격리성은 동시에 실행 중인 트랜잭션들이 서로의 중간 상태를 볼 수 없도록 격리해야 한다는 보장입니다. 어느 정도까지 격리할지는 "격리 수준"으로 조정하며, 다음 섹션에서 자세히 다룹니다.

SQL
BEGIN;
UPDATE accounts SET balance = balance - 50000 WHERE id = 1;
-- 아직 COMMIT하지 않은 상태

SELECT balance FROM accounts WHERE id = 1;
-- 격리 수준에 따라 다른 트랜잭션에서 변경 전 값을 보거나 변경 후 값을 봄
COMMIT;

D — Durability (내구성): 커밋된 데이터는 영구 보존

내구성은 COMMIT된 데이터는 이후 시스템 장애(전원 차단, 서버 크래시)가 발생해도 손실되지 않는다는 보장입니다.

데이터베이스는 WAL(Write-Ahead Log) 메커니즘으로 내구성을 구현합니다. COMMIT 시 실제 데이터 파일을 수정하기 전에 변경 내용을 WAL 로그에 먼저 디스크에 기록합니다. 서버가 다운되더라도 재시작 시 WAL 로그를 재실행해 커밋된 데이터를 복구합니다.

커밋 과정 요약:

  1. 변경 내용을 WAL에 먼저 기록하고 디스크에 동기화
  2. 실제 데이터 파일에 변경 적용
  3. 서버 재시작 시 WAL을 재실행해 복구

실전 트랜잭션 패턴

애플리케이션 코드에서 트랜잭션을 사용할 때는 예외 발생 시 ROLLBACK이 자동으로 이루어지도록 try/catch(또는 언어별 동등 구조)와 함께 사용합니다. 아래는 재고 차감과 주문 생성을 하나의 트랜잭션으로 처리하는 표준 패턴입니다.

2재고 차감 + 주문 생성을 트랜잭션으로 묶기

재고 UPDATE와 주문 INSERT를 하나의 트랜잭션으로 묶어 원자성을 보장합니다. 재고가 없으면 주문도 생성되지 않습니다.

SQL
BEGIN;

UPDATE inventory
SET stock = stock - 1
WHERE product_id = 42 AND stock > 0;

INSERT INTO orders (user_id, product_id, status)
VALUES (1, 42, 'confirmed');

COMMIT;
BEGIN;
🔍실행 후 확인할 것
  • 먼저 UPDATE 결과의 영향 행 수를 확인합니다. 1행이면 재고 차감 성공입니다. 0행이면 stock > 0 조건이 만족되지 않은 것(재고 부족)이므로 즉시 ROLLBACK해야 합니다.
  • COMMIT 후 inventory.stock을 SELECT로 검증합니다. 이전 값보다 정확히 1 줄어야 합니다. 줄지 않았다면 트랜잭션이 커밋되지 않은 것입니다.
  • 두 시그널을 조합합니다: UPDATE가 1행 성공했지만 INSERT가 외래키 오류로 실패했다면 → 트랜잭션 전체가 롤백되어 재고도 원래 값으로 돌아와야 합니다. 재고가 줄었지만 주문이 없다면 원자성이 깨진 것입니다.

재고가 부족해 UPDATE가 0행을 변경했다면 애플리케이션 레이어에서 이를 감지하고 ROLLBACK을 실행해야 합니다.

💡개념

COMMIT이 원자성·내구성을 지키는 법 — WAL 선기록부터 fsync 확정까지 5단계

앞에서 원자성(A)과 내구성(D)이 무엇을 보장하는지 봤습니다. 그런데 DB는 대체 어떤 장치로 "출금만 되고 입금은 안 된 중간 상태"를 막고, 전원이 나가도 커밋된 데이터를 지킬까요? 답은 변경을 데이터 파일보다 로그(WAL)에 먼저 쓰고, COMMIT 시점에 그 로그를 디스크에 확정하는 순서에 있습니다. 이 5단계를 알면 "커밋이 성공을 반환했다"가 정확히 무엇을 약속하는지 단계로 이해할 수 있습니다.

TEXT
[클라이언트]  BEGIN;
             UPDATE accounts SET balance = balance - 100000 WHERE id = 1;
             UPDATE accounts SET balance = balance + 100000 WHERE id = 2;
             COMMIT;
   │
   ① BEGIN           → 트랜잭션 ID 발급, 여기부터의 변경이 '한 단위'로 묶임
   │
   ② WAL 선기록       → 데이터 파일을 고치기 전에 "무엇을 어떻게 바꿀지"를
   │                   먼저 WAL(redo 로그)에 append (Write-Ahead)
   │
   ③ 버퍼 수정        → 메모리(shared_buffers)의 데이터 페이지만 변경.
   │                   디스크 데이터 파일은 아직 예전 상태
   │
   ④ COMMIT          → 이 트랜잭션의 WAL을 fsync로 디스크에 확정(내구성 확정 지점)
   │                   → 그 다음에야 클라이언트에 '커밋 성공' 반환
   │
   ⑤ 가시화 or 되돌림  → 커밋되면 다른 트랜잭션에 보임.
   │                   ROLLBACK·크래시면 미확정 변경은 되돌려짐(부분 반영 없음)
   ▼
[크래시 후 재시작]  WAL을 재실행(redo)해 커밋된 것 복구, 미커밋은 버림

각 단계에서 무슨 일이 일어나고, 무엇이 보장되나:

단계하는 일여기서 보장되는 것 · 어긋나면
① BEGIN변경 묶음의 시작 경계를 정함이 경계 덕에 여러 UPDATE가 '전부 아니면 전무'의 한 단위가 됨(A의 범위)
② WAL 선기록데이터 파일보다 로그를 먼저 기록크래시가 나도 이 로그로 재실행 가능 → 내구성(D)의 토대
③ 버퍼 수정메모리 페이지만 고치고 디스크 쓰기는 나중에(체크포인트)디스크 랜덤 쓰기를 미뤄 빠름 — 단, 이 시점엔 아직 영구 아님
④ COMMIT fsyncWAL을 디스크에 확정한 뒤 성공 반환이 지점을 넘겨야 진짜 커밋. 직전에 전원이 나가면 미커밋으로 취급돼 사라짐
⑤ 가시화/롤백커밋은 노출, 실패는 되돌림커밋 안 한 변경은 절대 부분 반영되지 않음 → 원자성(A)

정리하면 **"커밋 성공을 반환받았다"는 곧 "④ WAL fsync를 넘겼다"**는 뜻이고, 그래서 그 직후 전원이 나가도 재시작 시 WAL 재실행으로 데이터가 살아남습니다(D). "A에서 출금됐는데 B에 입금 안 된" 중간 상태가 DB에 남지 않는 이유도 여기 있습니다 — ④를 넘기기 전에는 어떤 변경도 확정이 아니고, 롤백이나 크래시면 두 UPDATE가 통째로 되돌아가기 때문입니다(A). 단, 이 보장은 단일 노드에서 synchronous_commit이 켜진 기준이며, 복제까지 얽히면 "어디까지의 내구성인가"가 달라집니다 — 그 여백은 이 모듈 뒤쪽 심화에서 다룹니다.

💡개념

격리 수준(Isolation Level) — Dirty Read, Phantom Read 문제

두 트랜잭션이 동시에 실행 중입니다. 하나가 아직 커밋 전인 데이터를 다른 트랜잭션이 읽었습니다. 그 트랜잭션이 롤백되자 읽은 데이터가 존재하지 않는 데이터가 됩니다. 이게 Dirty Read입니다. 격리 수준이 낮으면 이런 문제가 생기고, 너무 높이면 성능이 떨어집니다. 각 수준이 어떤 문제를 막는지 알아야 서비스에 맞는 격리 수준을 선택할 수 있습니다.

격리 수준(Isolation Level) — Dirty Read, Phantom Read 문제확대

격리 수준 4단계와 발생 가능 문제

격리 수준이 높을수록 데이터 일관성은 높아지지만 동시성(처리량)은 낮아집니다. 실무에서는 대부분 기본값인 Read Committed 또는 Repeatable Read를 사용합니다.

DB별 기본 격리 수준이 다릅니다. PostgreSQL의 기본값은 Read Committed이고, MySQL(InnoDB)의 기본값은 Repeatable Read입니다. 다른 DB를 함께 사용하거나 이식성이 중요한 코드에서는 이 차이를 반드시 고려해야 합니다.

격리 수준Dirty ReadNon-repeatable ReadPhantom ReadPostgreSQL 기본MySQL 기본
Read Uncommitted발생 가능발생 가능발생 가능
Read Committed방지됨발생 가능발생 가능기본값
Repeatable Read방지됨방지됨발생 가능기본값
Serializable방지됨방지됨방지됨

3가지 동시성 문제 설명

Dirty Read — 미커밋 데이터 읽기

트랜잭션 A가 변경했지만 아직 커밋하지 않은 데이터를 트랜잭션 B가 읽는 현상입니다. A가 롤백하면 B는 실제로 존재하지 않는 데이터를 읽은 셈이 됩니다. Read Uncommitted 격리 수준에서만 발생합니다.

시간 흐름 (T1 → T5):

시간트랜잭션 A트랜잭션 B
T1BEGIN;
T2UPDATE users SET vip=true WHERE id=1; (미커밋)
T3SELECT vip FROM users WHERE id=1; → true 반환 (아직 커밋 안 됨!)
T4ROLLBACK; (취소!)
T5B는 존재하지 않는 데이터를 읽었음

Non-repeatable Read — 같은 쿼리, 다른 결과

같은 트랜잭션 내에서 동일한 SELECT를 두 번 실행했을 때 결과가 달라지는 현상입니다. 다른 트랜잭션이 중간에 해당 행을 수정하고 커밋했기 때문입니다.

시간트랜잭션 A트랜잭션 B
T1BEGIN;
T2SELECT price FROM products WHERE id=1; → 10000
T3UPDATE products SET price=20000 WHERE id=1; COMMIT;
T4SELECT price FROM products WHERE id=1; → 20000 (변경됨!)

Phantom Read — 새로운 행이 나타남

같은 트랜잭션 내에서 동일한 범위 조회를 두 번 했을 때 처음에 없던 행이 나타나는 현상입니다. 다른 트랜잭션이 중간에 새 행을 INSERT하고 커밋했기 때문입니다.

시간트랜잭션 A트랜잭션 B
T1BEGIN;
T2SELECT COUNT(*) FROM orders WHERE user_id=1; → 5건
T3INSERT INTO orders (user_id, ...) VALUES (1, ...); COMMIT;
T4SELECT COUNT(*) FROM orders WHERE user_id=1; → 6건 (유령!)

격리 수준 설정

세션 전체 또는 개별 트랜잭션에 격리 수준을 지정할 수 있습니다. 금융 거래처럼 완벽한 일관성이 필요한 경우에는 Serializable을 사용하고, FOR UPDATE로 조회한 행에 잠금을 겁니다.

3격리 수준 설정 및 SELECT FOR UPDATE

세션 또는 개별 트랜잭션에 격리 수준을 설정합니다. FOR UPDATE는 조회한 행을 잠궈 다른 트랜잭션이 동시에 수정하지 못하게 합니다.

SQL
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
UPDATE accounts SET balance = balance - 50000 WHERE id = 1;
COMMIT;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
🔍실행 후 확인할 것
  • 먼저 SHOW TRANSACTION ISOLATION LEVEL로 현재 격리 수준을 확인합니다. 의도한 수준과 다르다면 명시적으로 BEGIN ISOLATION LEVEL ...을 사용해야 합니다.
  • FOR UPDATE를 실행한 세션과 별도 세션을 열어 같은 행을 UPDATE 시도합니다. 잠금 대기(waiting)가 발생해야 정상입니다 — 즉시 실행된다면 FOR UPDATE가 효과가 없는 것입니다.
  • 잠금 대기 시간을 주시합니다. 30초 이상 대기가 지속되면 잠금을 잡은 트랜잭션이 COMMIT/ROLLBACK 없이 방치된 것입니다 — 이 경우 pg_locks 뷰로 대기 원인을 확인하고 KILL을 고려하세요.

SAVEPOINT — 부분 롤백

SAVEPOINT는 트랜잭션 중간에 체크포인트를 만드는 기능입니다. ROLLBACK TO SAVEPOINT로 전체 트랜잭션을 취소하지 않고 특정 지점까지만 되돌릴 수 있습니다. 쿠폰 적용처럼 "실패해도 주문 자체는 유지해야 하는" 선택적 단계에 유용합니다.

4SAVEPOINT로 부분 롤백

SAVEPOINT를 활용해 쿠폰 적용 실패 시 주문·주문항목은 유지하고 쿠폰 UPDATE만 되돌립니다.

SQL
BEGIN;

INSERT INTO orders (user_id, total) VALUES (1, 50000);
SAVEPOINT order_created;

INSERT INTO order_items (order_id, product_id, quantity)
VALUES (LASTVAL(), 42, 2);
SAVEPOINT items_added;

UPDATE coupons SET used_at = NOW() WHERE code = 'DISC10' AND used_at IS NULL;

ROLLBACK TO SAVEPOINT items_added;

COMMIT;
SAVEPOINT order_created;
🔍실행 후 확인할 것
  • 먼저 ROLLBACK TO SAVEPOINT 직후 orders와 order_items를 SELECT합니다. 두 행이 모두 남아있어야 정상입니다 — SAVEPOINT 이전 작업은 보존됩니다.
  • coupons.used_at이 NULL인지 확인합니다. ROLLBACK TO SAVEPOINT items_added가 쿠폰 UPDATE를 취소했기 때문에 NULL이어야 합니다. NULL이 아니라면 ROLLBACK이 예상 SAVEPOINT를 대상으로 하지 않은 것입니다.
  • COMMIT 이후를 최종 검증합니다: 주문 행이 존재하고 + 쿠폰 used_at이 NULL이면 → SAVEPOINT를 통한 선택적 롤백이 성공한 것입니다. 주문도 없고 쿠폰도 NULL이라면 SAVEPOINT 이전에 전체 ROLLBACK이 발생한 것입니다.

쿠폰 UPDATE가 0행을 변경하면(쿠폰 없음 또는 이미 사용됨) 애플리케이션에서 이를 감지하고 ROLLBACK TO SAVEPOINT items_added를 실행합니다. 주문과 주문 항목은 그대로 유지됩니다.

PostgreSQL의 기본 격리 수준은 Read Committed이고 MySQL(InnoDB)의 기본값은 Repeatable Read입니다. 두 DB를 모두 사용하는 팀에서는 이 차이가 버그의 원인이 됩니다.

흔한 시나리오:

  • MySQL 환경에서 개발하고 테스트한 코드가 PostgreSQL 운영 환경에서 Non-repeatable Read를 일으킴
  • 트랜잭션 A가 잔액을 조회하고(10만 원), 다른 트랜잭션이 이체 후 커밋하고, A가 다시 조회하면 PostgreSQL(Read Committed)에서는 변경된 값(5만 원)을 반환하지만 MySQL(Repeatable Read)에서는 처음 값(10만 원)을 반환

해결 방법:

  • 중요 트랜잭션에는 격리 수준을 명시적으로 지정하세요: BEGIN ISOLATION LEVEL REPEATABLE READ;
  • 팀 코딩 가이드에 "격리 수준 의존 코드는 명시적 설정 필수" 규칙을 추가합니다
  • 서비스가 지원하는 DB 조합에 대해 통합 테스트를 작성합니다
💼
실무 맥락결제 서비스 — 잔액 조회 후 차감 시 이중 결제 방지
현업 패턴

결제 서비스에서 "잔액 확인 → 결제 차감"을 두 단계로 처리할 때 동시 요청이 들어오면 Dirty Read 또는 Non-repeatable Read가 발생해 이중 결제 또는 잔액 초과 차감이 일어날 수 있습니다.

대표적인 해결 방법:

  • SELECT ... FOR UPDATE로 잔액 조회 시 행 잠금을 걸어 다른 트랜잭션이 같은 행을 동시에 수정하지 못하게 합니다
  • 격리 수준을 Repeatable Read 이상으로 설정합니다
  • 낙관적 락(버전 컬럼 비교) 패턴으로 충돌을 감지하고 재시도합니다

이 패턴은 이커머스 결제, 좌석 예약, 수강신청 시스템 등 동시 요청이 많고 데이터 정합성이 핵심인 모든 도메인에 적용됩니다.

트랜잭션 격리 수준 선택 기준
일반 웹 서비스 — 내 트랜잭션이 커밋된 데이터만 읽으면 충분한 경우Read Committed (PostgreSQL 기본값)Dirty Read 방지, 성능과 일관성의 균형, 대부분의 OLTP 서비스에 적합
같은 트랜잭션 내에서 동일 쿼리를 두 번 실행했을 때 같은 결과가 보장되어야 하는 경우Repeatable Read (MySQL 기본값)Non-repeatable Read 방지, 재고 확인처럼 중간 변경이 개입하면 안 되는 경우
동시 접근이 많은 좌석 예약, 수강신청처럼 Phantom Row 추가도 막아야 하는 경우Serializable가장 강한 격리, 동시성 크게 감소, SELECT FOR UPDATE 또는 낙관적 락으로 대체 권장
재고 차감처럼 읽고 쓰는 사이 다른 트랜잭션이 끼어들면 안 되는 패턴SELECT FOR UPDATE (비관적 락)읽기 시점에 행 잠금, Repeatable Read + FOR UPDATE 조합으로 갱신 충돌 방지
동시 충돌이 드문 환경에서 성능을 최대화하면서 충돌만 감지하고 싶은 경우낙관적 락 (버전 컬럼 비교)잠금 없이 UPDATE rows = 0이면 애플리케이션에서 재시도, 처리량 높고 충돌 드문 경우에 유리

심화 — '커밋 성공'과 '영원히 안전'은 다른 약속이다

💡개념

심화: 내구성의 여백 — WAL fsync 너머의 synchronous_commit과 복제

이 모듈은 "COMMIT 시 WAL이 fsync로 디스크에 기록되므로 내구성(D)이 보장된다"고 배웠습니다. 단일 노드에서는 맞는 말이지만, 실제 운영은 대개 복제본을 두고 굴러가고, 거기서 내구성은 몇 개의 손잡이(knob)에 달려 있습니다. 한 단계 더 들어가 "COMMIT 성공"이 실제로 어디까지 보장하는지 봅니다.

  • synchronous_commit — 얼마나 기다렸다 성공이라 말하나: 기본값 on이면 COMMIT은 WAL이 로컬 디스크에 flush될 때까지 기다려, 주 노드가 크래시해도 커밋된 데이터가 남습니다. 반대로 off로 두면 처리량을 위해 flush 전에 성공을 반환합니다 — 크래시 시 최근 커밋 몇 건을 통째로 잃을 수 있습니다(부분 반영은 없고 정합성은 유지되며, '최신 커밋만' 사라짐). 이 손잡이의 의미를 모르고 켰다 끄면 내구성의 강도가 바뀝니다.
  • 비동기 복제 — 주는 복제본을 기다리지 않는다: 기본 스트리밍 복제는 비동기입니다. 주(primary)는 복제본이 WAL을 받았는지 확인하지 않고 COMMIT을 확정합니다. 평소엔 문제없지만, 주가 갑자기 죽어 아직 최신 WAL을 못 받은 복제본으로 페일오버하면 그 사이 커밋된 데이터가 사라집니다. 이 손실 구간이 바로 RPO(복구 시점 목표)입니다 — 비동기 복제의 RPO는 0이 아닙니다.
  • 무손실을 원하면 동기 복제: synchronous_standby_names로 동기 복제를 켜면, 최소 한 복제본이 WAL을 받은 뒤에야 COMMIT이 확정돼 페일오버 유실이 사라집니다. 대가는 커밋 지연 증가와 가용성 트레이드오프입니다 — 동기 복제본이 죽으면 커밋이 멈출 수 있어, 보통 복제본을 2대 이상 두고 quorum으로 설정합니다.
  • 그래서 내구성은 '설정된 약속'이다: "COMMIT이 성공을 반환했다"는 것과 "이 데이터는 어떤 장애에도 살아남는다"는 다른 문장입니다. 팀은 데이터 중요도에 따라 결제·정산은 동기(또는 최소 지연 감시), 로그·통계는 비동기처럼 나눠 설계합니다.

정리하면, WAL은 내구성의 '메커니즘'이고 synchronous_commit·복제 모드는 그 '강도'를 정하는 손잡이입니다. 어느 손잡이가 어디에 걸려 있는지 아는 것이 진짜 내구성 설계입니다.

상황: 주 DB 하드웨어 장애로 대기 복제본을 승격(promote)해 서비스를 복구했습니다. 그런데 장애 직전 몇 분 사이 결제를 완료한 고객들의 주문 일부가 새 주 DB에 없습니다. 고객 화면에는 분명히 '결제 완료'로 떴는데, DB에는 그 행이 없습니다.

원인: 복제가 비동기였습니다. 주는 복제본의 수신을 기다리지 않고 COMMIT을 확정했고, 클라이언트에도 성공을 반환했습니다. 그런데 그 커밋에 해당하는 WAL이 복제본에 도착하기 전에 주가 죽었습니다. 아직 전달되지 못한 WAL 구간(복제 지연만큼)은 승격된 복제본에 존재하지 않으므로, 그 커밋들은 통째로 사라진 것입니다. 내구성이 깨진 게 아니라, '어디까지의 내구성인가'가 비동기 복제의 RPO만큼 뒤로 물러나 있었던 것입니다.

진단: 페일오버 전 주의 마지막 WAL 위치와 복제본이 수신한 위치(pg_last_wal_receive_lsn 등)의 차이가 곧 유실 구간입니다. 모니터링의 복제 지연(replication lag) 그래프에서 장애 시점에 지연이 얼마였는지 보면 손실량이 드러납니다. 사라진 주문의 결제 시각이 그 지연 구간에 들어오는지 대조합니다.

해결: 무손실이 절대적인 결제·정산 데이터는 동기 복제로 바꿔 최소 한 복제본이 WAL을 받은 뒤 COMMIT을 확정하게 합니다(대신 커밋 지연·가용성 트레이드오프를 수용, 보통 동기 복제본 2대 이상). 무손실까지는 아니어도 손실을 줄이려면 복제 지연을 상시 감시하고 임계 초과 시 알림을 겁니다. 근본적으로는 결제 성공을 DB 커밋 하나에만 의존하지 말고, 외부 결제사와의 상태 대사(reconciliation)와 idempotency 키로 승격 후 누락을 복구할 수 있게 설계합니다. '커밋 성공'과 '영원히 안전'을 같은 것으로 다루지 않는 게 핵심입니다.


명령어·구문 빠른 참조

이 모듈에서 다룬 트랜잭션 제어 구문을 실전 조합과 함께 모았습니다. "예" 열의 조합을 그대로 써도 됩니다.

구문/명령용도
BEGIN / START TRANSACTION트랜잭션 시작BEGIN; (MySQL은 START TRANSACTION;도 가능)
COMMIT변경 확정출금·입금 두 UPDATE를 묶은 뒤 COMMIT;
ROLLBACK변경 전체 취소UPDATE가 0행이면 COMMIT 대신 ROLLBACK;
SAVEPOINT트랜잭션 중간 체크포인트SAVEPOINT items_added;
ROLLBACK TO SAVEPOINT특정 지점까지만 부분 롤백ROLLBACK TO SAVEPOINT items_added; (주문은 유지, 쿠폰 UPDATE만 취소)
CHECK 제약일관성(C)을 DB 레벨에서 강제CONSTRAINT chk_balance_positive CHECK (balance >= 0)
SET SESSION TRANSACTION ISOLATION LEVEL세션 격리 수준 지정SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN ISOLATION LEVEL ...트랜잭션 단위 격리 지정BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT ... FOR UPDATE조회 행 잠금(이중 결제 방지)SELECT balance FROM accounts WHERE id=1 FOR UPDATE;
SHOW TRANSACTION ISOLATION LEVEL현재 격리 수준 확인의도와 다르면 명시적 BEGIN ISOLATION LEVEL ... 사용
LASTVAL()직전 INSERT의 시퀀스 값INSERT INTO order_items (...) VALUES (LASTVAL(), 42, 2);
synchronous_commit커밋 내구성(D) 강도 조절기본 on(WAL flush 대기) vs off(최신 커밋 유실 위험)

관련 모듈로 더 깊이:

다음 모듈에서는 B-Tree 인덱스의 작동 원리와 쿼리 성능을 좌우하는 인덱스 설계 조건을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

트랜잭션의 Atomicity(원자성)를 가장 잘 설명한 것은?

Q2

재고 5개 남은 상품을 두 고객이 동시에 구매 중입니다. 트랜잭션 A가 재고를 4개로 줄였지만 아직 커밋 전인데, 트랜잭션 B가 재고 4개를 읽고 주문을 진행했습니다. 이후 트랜잭션 A가 롤백됐습니다. 이 현상과 방지 방법은?

Q3

아래 코드를 실행했더니 같은 트랜잭션 안에서 SELECT를 두 번 했는데 결과가 달랐습니다. PostgreSQL 기본 설정에서 이것이 가능한 이유는? BEGIN; SELECT count(*) FROM orders WHERE status = 'pending'; -- 결과: 10 -- (다른 트랜잭션이 pending 주문 3건을 처리해서 COMMIT) SELECT count(*) FROM orders WHERE status = 'pending'; -- 결과: 7 COMMIT;

Q4

SAVEPOINT의 주요 용도는?

Q5

PostgreSQL 서버가 트랜잭션 커밋 직후 전원이 갑자기 나갔습니다. 재시작 후 해당 트랜잭션 데이터가 정상적으로 남아있었습니다. 이것이 가능한 이유는?

Q6

계좌 이체에서 'A에서 출금'과 'B에 입금'을 한 트랜잭션으로 처리할 때, ACID의 일관성(Consistency)이 보장하는 것은?

Q7

[심화] 이 모듈은 'COMMIT 시 WAL이 fsync로 디스크에 기록되므로 내구성(D)이 보장된다'고 배웠다. 그런데 운영에서 'COMMIT이 성공했는데도 장애 후 그 데이터가 사라지는' 일이 생길 수 있는 대표적 원인은?

Q8

[심화] 주 DB 장애로 비동기 복제본을 승격했더니, 고객이 '결제 완료'로 확인했던 최근 주문 몇 건이 새 주(primary)에 없다. 재발을 막기 위한 판단으로 가장 적절한 것은?

0 / 8 답변

🧪 실습으로 확인하기

PostgreSQL Lock·Deadlock — pg_stat_activity 진단

중급

pg_stat_activity와 pg_locks를 조인해 락 대기 세션을 찾아내고, 데드락을 재현해 로그에서 원인을 확인하고 해결한다.

40📋 4단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요