백엔드 데이터 인프라 17편. 트랜잭션의 ACID 보장과 BEGIN·COMMIT·ROLLBACK·SAVEPOINT 표준 패턴을 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 17편이에요. 13~14편 UPDATE·DELETE 에서 운영 5단계 (BEGIN-SELECT-UPDATE-검증-COMMIT) 를 봤는데, 아직 안 짚은 BEGIN·COMMIT 이 바로 트랜잭션이에요.
트랜잭션 한 줄 설명
여러 SQL을 한 묶음으로 처리하고 모두 성공하거나 모두 실패하게 만드는 게 트랜잭션이에요. 묶음 안 어떤 SQL이라도 실패하면 그 묶음의 변경은 전부 취소돼요.
전형 예 — 계좌 이체:
BEGIN;
UPDATE accounts SET balance = balance - 10000 WHERE id = 1; -- A 출금
UPDATE accounts SET balance = balance + 10000 WHERE id = 2; -- B 입금
COMMIT;
A에서 빼고 B에 더하는 두 SQL이 둘 다 성공해야 이체가 끝나요. 첫 번째만 성공하고 두 번째가 실패하면 A 돈이 사라지죠. 트랜잭션이 이 위험을 차단해줘요.
ACID — 트랜잭션의 4가지 약속
7편 관계형 모델 에서 짧게 소개한 ACID 를 좀 더 깊이 봐요.
| 이름 | 의미 | |
|---|---|---|
| A | Atomicity (원자성) | 모두 성공 또는 모두 실패 |
| C | Consistency (일관성) | 제약 조건 항상 만족 |
| I | Isolation (격리성) | 동시 트랜잭션 서로 안 보임 |
| D | Durability (영속성) | 커밋 후엔 사라지지 않음 |
PG는 ACID 를 100% 보장해요. DB가 약속해주는 안전망인 셈이죠.
기본 3가지 명령
BEGIN — 트랜잭션 시작
BEGIN;
-- 또는 START TRANSACTION;
이 시점부터 다음 COMMIT 또는 ROLLBACK 까지가 한 트랜잭션이에요.
COMMIT — 확정
COMMIT;
지금까지의 변경을 영구 적용해요. WAL (Write-Ahead Log, 변경 선기록 로그) 에 기록한 뒤 디스크로 내려가고, 그제야 다른 트랜잭션에 보이기 시작해요.
ROLLBACK — 취소
ROLLBACK;
지금까지의 변경을 모두 취소하고 트랜잭션 시작 전 상태로 되돌려요.
자동 커밋 vs 명시 트랜잭션
PG 기본은 자동 커밋이에요. 즉 각 SQL이 그대로 개별 트랜잭션이 돼요.
UPDATE users SET name = 'Alice' WHERE id = 1; -- 자동으로 BEGIN·COMMIT
명시적으로 묶고 싶으면 BEGIN 으로 열어요.
BEGIN;
UPDATE users SET name = 'Alice' WHERE id = 1;
UPDATE users SET email = 'new@example.com' WHERE id = 1;
COMMIT;
JPA (Java Persistence API, 자바 ORM 표준) @Transactional (자바 백엔드 입문 43편) 은 메서드에 자동으로 BEGIN·COMMIT 을 박아주는 어노테이션이에요.
SAVEPOINT — 부분 ROLLBACK
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (1, 10000);
SAVEPOINT after_order;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100;
-- 재고 부족 → 롤백
ROLLBACK TO SAVEPOINT after_order;
UPDATE orders SET status = 'PENDING_STOCK' WHERE id = <new_order_id>;
COMMIT;
큰 트랜잭션 안에 부분 취소점을 박는 기능이에요. 복잡한 비즈니스 로직에서 가끔 써요.
에러 후 트랜잭션
PG 트랜잭션 안에서 SQL 에러가 나면 이후 모든 SQL 이 거부돼요.
BEGIN;
SELECT * FROM nonexistent_table; -- 에러
SELECT 1; -- ERROR: current transaction is aborted
COMMIT; -- ROLLBACK 동작
해결책은 에러 후 ROLLBACK 을 명시하거나 SAVEPOINT 로 묶어 부분 취소를 거는 거예요.
격리 수준 — Isolation Levels
여러 트랜잭션이 동시에 돌 때 서로 어떻게 보이는지를 정하는 게 격리 수준이에요. 4단계가 있고, 더 깊은 내용은 38편 MVCC (Multi-Version Concurrency Control, 다중 버전 동시성 제어) 에서 다뤄요.
| 수준 | 의미 |
|---|---|
| READ UNCOMMITTED | 다른 트랜잭션 미커밋 변경 보임 (PG는 지원 X — READ COMMITTED 처리) |
| READ COMMITTED (PG 기본) | 커밋된 변경만 보임 |
| REPEATABLE READ | 트랜잭션 시작 시점 스냅샷 일관 |
| SERIALIZABLE | 완벽한 직렬화 (성능 비용) |
PG 기본은 READ COMMITTED 이고, 한국 회사 대부분이 이 수준에서 충분히 운영해요.
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 또는 BEGIN ISOLATION LEVEL REPEATABLE READ;
...
COMMIT;
락 — FOR UPDATE·FOR SHARE
행 단위 락으로 동시 변경을 차단해요.
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 이 행 락 획득
UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
COMMIT;
FOR UPDATE 는 다른 트랜잭션이 이 행을 UPDATE·DELETE 못 하게 막아요. 커밋이나 롤백이 날 때까지 대기시키는 거죠.
SELECT * FROM products WHERE id = 1 FOR SHARE; -- 공유 락 (UPDATE만 차단)
SELECT * FROM products WHERE id = 1 FOR UPDATE SKIP LOCKED; -- 락된 행은 건너뜀
SELECT * FROM products WHERE id = 1 FOR UPDATE NOWAIT; -- 락 대기 X (즉시 에러)
SKIP LOCKED 는 큐 처리에 굉장히 유용해서 Kafka 대안으로도 쓰여요. NOWAIT 는 락 충돌을 즉시 감지하고 싶을 때 골라요.
운영 시나리오 — 5가지
(1) 계좌 이체
BEGIN;
UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
UPDATE accounts SET balance = balance + 10000 WHERE id = 2;
INSERT INTO transfers (from_id, to_id, amount) VALUES (1, 2, 10000);
COMMIT;
(2) 주문 + 재고 + 결제
BEGIN;
INSERT INTO orders (...) RETURNING id;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100;
INSERT INTO payments (order_id, amount, status) VALUES (...);
COMMIT;
(3) 데이터 이관 (멱등)
BEGIN;
INSERT INTO archive SELECT * FROM logs WHERE created_at < '2025-01-01';
DELETE FROM logs WHERE created_at < '2025-01-01';
COMMIT;
(4) 통계 갱신 (재시도 가능)
BEGIN;
TRUNCATE daily_stats;
INSERT INTO daily_stats SELECT ... FROM orders GROUP BY ...;
COMMIT;
(5) 큐 처리 (FOR UPDATE SKIP LOCKED)
BEGIN;
SELECT * FROM job_queue
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- 잡은 작업들 처리...
UPDATE job_queue SET status = 'PROCESSED' WHERE id IN (...);
COMMIT;
여러 워커가 동시에 큐를 처리해도 중복 처리가 0 이에요.
Spring JPA 의 트랜잭션
자바 백엔드 입문 43편 @Transactional 핵심만 짚어요.
@Service
public class TransferService {
@Transactional
public void transfer(Long fromId, Long toId, int amount) {
accountRepository.decrementBalance(fromId, amount);
accountRepository.incrementBalance(toId, amount);
transferRepository.save(new Transfer(fromId, toId, amount));
}
}
@Transactional 은 메서드 시작 시 BEGIN, 정상 종료 시 COMMIT, 예외 발생 시 ROLLBACK 을 자동으로 처리해줘요. 자바 백엔드에서 사실상 표준 패턴이에요.
격리 수준 명시
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void process() { ... }
트랜잭션 + 외부 API 함정
@Transactional
public void process() {
repository.save(...);
externalApi.send(...); // ❌ 외부 호출 안 트랜잭션
repository.update(...);
}
외부 API 호출이 트랜잭션 안에 들어가면 DB 락이 외부 API 응답 시간 동안 잡혀버려서 다른 트랜잭션이 줄줄이 대기해요. OutBox 패턴 (DB 에 발송 큐를 박아 비동기로 보내는 방식) 이나 ApplicationEvent (Spring 의 이벤트 발행·구독, 자바 백엔드 입문 38편) 로 분리해요.
함정 5가지
(1) 트랜잭션 안 외부 호출
위에서 본 그 함정이에요. 외부 호출은 트랜잭션 밖으로 빼서 이벤트나 Outbox 로 처리해요.
(2) 너무 큰 트랜잭션
100만 행을 한 트랜잭션으로 UPDATE 하면 WAL 폭발에 락 폭주가 와요. chunk 로 분할해요.
(3) 자동 커밋 모드에서 BEGIN 잊음
UPDATE users SET ...; -- 자동 커밋, ROLLBACK 불가
운영에선 명시적 BEGIN 이 안전해요.
(4) 에러 후 다음 SQL 실행 시도
에러가 나면 이후 SQL 이 전부 거부되니 ROLLBACK 이나 SAVEPOINT 로 정리해요.
(5) @Transactional 자가 호출
public void caller() {
save(...); // ❌ @Transactional 안 먹힘 (프록시 회피)
}
@Transactional
public void save(...) { ... }
자바 백엔드 입문 25편 AOP (Aspect-Oriented Programming, 횡단 관심사 분리) 에서 자세히 다룬 자가 호출 함정이에요.
(1) 명시적 BEGIN·COMMIT. (2) 외부 API는 트랜잭션 밖. (3) 큰 작업은 chunk. (4) 에러 후 ROLLBACK. (5) JPA는 @Transactional 자동. FOR UPDATE SKIP LOCKED는 큐 처리 핵심.
한 줄 정리 — 트랜잭션 = ACID 묶음. BEGIN·COMMIT·ROLLBACK + SAVEPOINT. 격리 수준 4단계 (PG 기본 = READ COMMITTED). FOR UPDATE 행 락, SKIP LOCKED는 큐. JPA는 @Transactional 자동. 외부 API는 트랜잭션 밖.
시험 직전 한 번 더 — 트랜잭션 입문자가 매번 헷갈리는 것
- 트랜잭션 = 여러 SQL 한 묶음 (모두 성공 또는 모두 실패)
- BEGIN = 시작 (또는 START TRANSACTION)
- COMMIT = 확정
- ROLLBACK = 취소
- ACID = Atomicity·Consistency·Isolation·Durability
- 자동 커밋 = PG 기본 (각 SQL이 개별 트랜잭션)
- 명시 트랜잭션 = BEGIN ... COMMIT
- SAVEPOINT = 부분 취소점
- ROLLBACK TO SAVEPOINT name
- 에러 후 = 이후 SQL 거부 (ROLLBACK 또는 SAVEPOINT)
- 격리 수준 4 = READ UNCOMMITTED·READ COMMITTED·REPEATABLE READ·SERIALIZABLE
- PG 기본 = READ COMMITTED
- FOR UPDATE = 행 락 (UPDATE·DELETE 차단)
- FOR SHARE = 공유 락
- SKIP LOCKED = 락된 행 건너뜀 (큐 처리 표준)
- NOWAIT = 락 대기 X
- @Transactional = JPA 자동 트랜잭션
- @Transactional 자가 호출 = 안 먹힘 (프록시)
- 외부 API는 트랜잭션 밖 = Outbox·이벤트 패턴
- 큰 작업 = chunk 분할
- WAL = 트랜잭션 영속성 보장 메커니즘
- READ COMMITTED = 커밋된 변경만 보임
- REPEATABLE READ = 시작 시점 스냅샷 일관
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 12편 — JOIN 여러 테이블 합치기
- 13편 — UPDATE 데이터 수정 표준 패턴
- 14편 — DELETE 데이터 삭제와 소프트 삭제 패턴
- 15편 — 외래 키 참조 무결성
- 16편 — 뷰 VIEW와 MATERIALIZED VIEW
다음 글: