입금과 출금처럼 여러 쿼리가 하나의 업무 단위로 묶이는 경우가 많습니다. 중간에 실패했는데 일부 쿼리만 반영되면 데이터는 바로 깨집니다. 트랜잭션과 ACID를 이해하면 실패해도 일관성을 지키는 코드를 작성할 수 있습니다.
ACID는 트랜잭션이 안전하게 처리되기 위한 4가지 보장입니다. 격리 수준은 성능과 일관성 사이의 트레이드오프를 조절합니다. 실무에서는 격리 수준의 기본값과 트랜잭션 범위를 올바르게 설계하는 것이 중요합니다.
- 1트랜잭션이란 — BEGIN, COMMIT, ROLLBACK의 의미
- 2ACID 4가지 속성 — 은행 이체 예시로 직관적 이해
- 3격리 수준(Isolation Level) 4단계
- 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;- 커밋 단위—여러 SQL이 하나의 업무 단위로 함께 커밋되는지 확인합니다.
- 롤백 결과—중간 실패 후 부분 변경이 남지 않는지 봅니다.
- 정합성 규칙—잔액, 재고 같은 값이 트랜잭션 전후로 깨지지 않는지 점검합니다.
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건이면 재고 부족이므로 ROLLBACK합니다.
- COMMIT 후 inventory.stock이 실제로 1 감소했는지 SELECT로 검증합니다.
- 애플리케이션에서 영향 행 수(rowCount)를 체크해 0이면 ROLLBACK 로직을 추가합니다.
재고가 부족해 UPDATE가 0행을 변경했다면 애플리케이션 레이어에서 이를 감지하고 ROLLBACK을 실행해야 합니다.
격리 수준(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; 로 현재 설정값을 확인합니다.
- FOR UPDATE 실행 후 다른 세션에서 같은 행 UPDATE 시 잠금 대기가 발생하는지 봅니다.
- COMMIT 후 잠금이 해제되어 대기 트랜잭션이 진행되는지 확인합니다.
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로 확인합니다.
- coupons.used_at이 여전히 NULL인지 확인합니다 — 쿠폰 UPDATE가 취소된 것입니다.
- COMMIT 후 주문은 정상 생성되고 쿠폰만 미사용 상태임을 검증합니다.
쿠폰 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 이상으로 설정합니다
- 낙관적 락(버전 컬럼 비교) 패턴으로 충돌을 감지하고 재시도합니다
이 패턴은 이커머스 결제, 좌석 예약, 수강신청 시스템 등 동시 요청이 많고 데이터 정합성이 핵심인 모든 도메인에 적용됩니다.
다음 모듈에서는 B-Tree 인덱스의 작동 원리와 쿼리 성능을 좌우하는 인덱스 설계 조건을 다룹니다.