입금과 출금처럼 여러 쿼리가 하나의 업무 단위로 묶이는 경우가 많습니다. 중간에 실패했는데 일부 쿼리만 반영되면 데이터는 바로 깨집니다. 트랜잭션과 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가지 속성입니다. 금융, 재고, 예약처럼 정확성이 중요한 시스템은 모두 이 보장에 의존합니다.
확대
트랜잭션 기본 문법
트랜잭션은 BEGIN으로 시작해 COMMIT(확정) 또는 ROLLBACK(취소)으로 끝납니다. MySQL에서는 BEGIN 대신 START TRANSACTION을 사용하기도 합니다.
BEGIN으로 트랜잭션을 시작하고 출금·입금 두 UPDATE를 하나의 단위로 묶습니다. 중간에 오류가 생기면 두 변경이 모두 취소됩니다.
BEGIN;
UPDATE accounts SET balance = balance - 100000 WHERE id = 1;
UPDATE accounts SET balance = balance + 100000 WHERE id = 2;
COMMIT;
실행 완료 또는 조회 결과가 표시됩니다.
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됩니다.
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 제약 위반으로 자동 롤백됩니다.
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 (격리성): 트랜잭션 간 간섭 방지
격리성은 동시에 실행 중인 트랜잭션들이 서로의 중간 상태를 볼 수 없도록 격리해야 한다는 보장입니다. 어느 정도까지 격리할지는 "격리 수준"으로 조정하며, 다음 섹션에서 자세히 다룹니다.
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 로그를 재실행해 커밋된 데이터를 복구합니다.
커밋 과정 요약:
- 변경 내용을 WAL에 먼저 기록하고 디스크에 동기화
- 실제 데이터 파일에 변경 적용
- 서버 재시작 시 WAL을 재실행해 복구
실전 트랜잭션 패턴
애플리케이션 코드에서 트랜잭션을 사용할 때는 예외 발생 시 ROLLBACK이 자동으로 이루어지도록 try/catch(또는 언어별 동등 구조)와 함께 사용합니다. 아래는 재고 차감과 주문 생성을 하나의 트랜잭션으로 처리하는 표준 패턴입니다.
재고 UPDATE와 주문 INSERT를 하나의 트랜잭션으로 묶어 원자성을 보장합니다. 재고가 없으면 주문도 생성되지 않습니다.
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단계를 알면 "커밋이 성공을 반환했다"가 정확히 무엇을 약속하는지 단계로 이해할 수 있습니다.
[클라이언트] 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 fsync | WAL을 디스크에 확정한 뒤 성공 반환 | 이 지점을 넘겨야 진짜 커밋. 직전에 전원이 나가면 미커밋으로 취급돼 사라짐 |
| ⑤ 가시화/롤백 | 커밋은 노출, 실패는 되돌림 | 커밋 안 한 변경은 절대 부분 반영되지 않음 → 원자성(A) |
정리하면 **"커밋 성공을 반환받았다"는 곧 "④ WAL fsync를 넘겼다"**는 뜻이고, 그래서 그 직후 전원이 나가도 재시작 시 WAL 재실행으로 데이터가 살아남습니다(D). "A에서 출금됐는데 B에 입금 안 된" 중간 상태가 DB에 남지 않는 이유도 여기 있습니다 — ④를 넘기기 전에는 어떤 변경도 확정이 아니고, 롤백이나 크래시면 두 UPDATE가 통째로 되돌아가기 때문입니다(A). 단, 이 보장은 단일 노드에서 synchronous_commit이 켜진 기준이며, 복제까지 얽히면 "어디까지의 내구성인가"가 달라집니다 — 그 여백은 이 모듈 뒤쪽 심화에서 다룹니다.
격리 수준(Isolation Level) — Dirty Read, Phantom Read 문제
두 트랜잭션이 동시에 실행 중입니다. 하나가 아직 커밋 전인 데이터를 다른 트랜잭션이 읽었습니다. 그 트랜잭션이 롤백되자 읽은 데이터가 존재하지 않는 데이터가 됩니다. 이게 Dirty Read입니다. 격리 수준이 낮으면 이런 문제가 생기고, 너무 높이면 성능이 떨어집니다. 각 수준이 어떤 문제를 막는지 알아야 서비스에 맞는 격리 수준을 선택할 수 있습니다.
확대
격리 수준 4단계와 발생 가능 문제
격리 수준이 높을수록 데이터 일관성은 높아지지만 동시성(처리량)은 낮아집니다. 실무에서는 대부분 기본값인 Read Committed 또는 Repeatable Read를 사용합니다.
DB별 기본 격리 수준이 다릅니다. PostgreSQL의 기본값은 Read Committed이고, MySQL(InnoDB)의 기본값은 Repeatable Read입니다. 다른 DB를 함께 사용하거나 이식성이 중요한 코드에서는 이 차이를 반드시 고려해야 합니다.
| 격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read | PostgreSQL 기본 | MySQL 기본 |
|---|---|---|---|---|---|
| Read Uncommitted | 발생 가능 | 발생 가능 | 발생 가능 | ||
| Read Committed | 방지됨 | 발생 가능 | 발생 가능 | 기본값 | |
| Repeatable Read | 방지됨 | 방지됨 | 발생 가능 | 기본값 | |
| Serializable | 방지됨 | 방지됨 | 방지됨 |
3가지 동시성 문제 설명
Dirty Read — 미커밋 데이터 읽기
트랜잭션 A가 변경했지만 아직 커밋하지 않은 데이터를 트랜잭션 B가 읽는 현상입니다. A가 롤백하면 B는 실제로 존재하지 않는 데이터를 읽은 셈이 됩니다. Read Uncommitted 격리 수준에서만 발생합니다.
시간 흐름 (T1 → T5):
| 시간 | 트랜잭션 A | 트랜잭션 B |
|---|---|---|
| T1 | BEGIN; | |
| T2 | UPDATE users SET vip=true WHERE id=1; (미커밋) | |
| T3 | SELECT vip FROM users WHERE id=1; → true 반환 (아직 커밋 안 됨!) | |
| T4 | ROLLBACK; (취소!) | |
| T5 | B는 존재하지 않는 데이터를 읽었음 |
Non-repeatable Read — 같은 쿼리, 다른 결과
같은 트랜잭션 내에서 동일한 SELECT를 두 번 실행했을 때 결과가 달라지는 현상입니다. 다른 트랜잭션이 중간에 해당 행을 수정하고 커밋했기 때문입니다.
| 시간 | 트랜잭션 A | 트랜잭션 B |
|---|---|---|
| T1 | BEGIN; | |
| T2 | SELECT price FROM products WHERE id=1; → 10000 | |
| T3 | UPDATE products SET price=20000 WHERE id=1; COMMIT; | |
| T4 | SELECT price FROM products WHERE id=1; → 20000 (변경됨!) |
Phantom Read — 새로운 행이 나타남
같은 트랜잭션 내에서 동일한 범위 조회를 두 번 했을 때 처음에 없던 행이 나타나는 현상입니다. 다른 트랜잭션이 중간에 새 행을 INSERT하고 커밋했기 때문입니다.
| 시간 | 트랜잭션 A | 트랜잭션 B |
|---|---|---|
| T1 | BEGIN; | |
| T2 | SELECT COUNT(*) FROM orders WHERE user_id=1; → 5건 | |
| T3 | INSERT INTO orders (user_id, ...) VALUES (1, ...); COMMIT; | |
| T4 | SELECT COUNT(*) FROM orders WHERE user_id=1; → 6건 (유령!) |
격리 수준 설정
세션 전체 또는 개별 트랜잭션에 격리 수준을 지정할 수 있습니다. 금융 거래처럼 완벽한 일관성이 필요한 경우에는 Serializable을 사용하고, FOR UPDATE로 조회한 행에 잠금을 겁니다.
세션 또는 개별 트랜잭션에 격리 수준을 설정합니다. FOR UPDATE는 조회한 행을 잠궈 다른 트랜잭션이 동시에 수정하지 못하게 합니다.
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로 전체 트랜잭션을 취소하지 않고 특정 지점까지만 되돌릴 수 있습니다. 쿠폰 적용처럼 "실패해도 주문 자체는 유지해야 하는" 선택적 단계에 유용합니다.
SAVEPOINT를 활용해 쿠폰 적용 실패 시 주문·주문항목은 유지하고 쿠폰 UPDATE만 되돌립니다.
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 이상으로 설정합니다
- 낙관적 락(버전 컬럼 비교) 패턴으로 충돌을 감지하고 재시도합니다
이 패턴은 이커머스 결제, 좌석 예약, 수강신청 시스템 등 동시 요청이 많고 데이터 정합성이 핵심인 모든 도메인에 적용됩니다.
심화 — '커밋 성공'과 '영원히 안전'은 다른 약속이다
심화: 내구성의 여백 — 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(최신 커밋 유실 위험) |
관련 모듈로 더 깊이:
- 트랜잭션 격리 수준(Isolation Level)과 이상 현상 제어 — ACID의 격리(Isolation)를 수준별로 깊이 파는 모듈
- 낙관적 락(Optimistic Lock) vs 비관적 락 동시성 충돌 제어 — 동시성 충돌을 잠금 없이 감지/재시도하는 전략
- 데이터 CRUD를 위한 SELECT, INSERT, UPDATE, DELETE 핵심 기초 — BEGIN/COMMIT/ROLLBACK이 쓰이는 기본 SQL 구문
다음 모듈에서는 B-Tree 인덱스의 작동 원리와 쿼리 성능을 좌우하는 인덱스 설계 조건을 다룹니다.