트랜잭션과 ACID
트랜잭션을 `@Transactional` 붙이는 것으로만 이해하고 있다면...
이 글에서는 DB 레이어와 애플리케이션 레이어의 트랜잭션을 다뤄요. 분산 시스템(Saga, 2PC)은 별도 편에서 다룰 예정이에요.
트랜잭션이란?
트랜잭션(Transaction) 은 여러 작업을 하나의 논리적 단위로 묶어, 전부 성공하거나 전부 실패하게 만드는 메커니즘이에요.
가장 고전적인 예시는 계좌 이체예요.
1. A 계좌에서 10,000원 차감
2. B 계좌에 10,000원 추가
두 작업은 반드시 함께 성공하거나 함께 실패해야 해요. 1번만 성공하고 2번이 실패하면 돈이 사라지거든요.
트랜잭션은 DB에서만 쓰는 개념이 아니에요
트랜잭션의 본질은 "원자적 처리"이며, DB에 국한되지 않아요.
| 레이어 | 예시 | 도구 |
|---|---|---|
| DB 레이어 | INSERT + UPDATE 묶기 | SQL Transaction |
| 애플리케이션 레이어 | 주문 생성 + 포인트 차감 + 재고 감소 | @Transactional |
이 글에서는 두 레이어를 차례로 다뤄요. 분산 시스템 환경(Saga 패턴, 2PC)은 별도 편에서 깊게 다룰 예정이에요.
ACID: 트랜잭션의 4가지 보장
신뢰할 수 있는 트랜잭션은 ACID 라는 4가지 속성을 만족해야 해요.
A — Atomicity (원자성)
"전부 성공하거나, 전부 실패하거나. 중간 상태는 없어요."
계좌 이체:
1. A 계좌 -10,000원 ✅
2. B 계좌 +10,000원 ❌ 실패
원자성 없으면 → A의 돈만 사라짐
원자성 보장 → 1번도 함께 롤백됨
구현 방법: Undo Log
DB는 작업 전 원래 값을 Undo Log에 기록해둬요. 실패 시 이 로그를 읽어 이전 상태로 되돌려요.
C — Consistency (일관성)
"트랜잭션 전후로 DB의 제약조건이 항상 유지되어야 해요."
-- 잔액은 0 이상이어야 한다는 제약조건
ALTER TABLE accounts ADD CONSTRAINT chk_balance CHECK (balance >= 0);
-- 잔액 100원인 계좌에서 200원 이체 시도 → 제약조건 위반 → 트랜잭션 거부
⚠️ ACID 중 유일하게 애플리케이션 책임도 있는 속성이에요.
DB 제약조건(NOT NULL, FOREIGN KEY, CHECK)은 기계적인 규칙만 강제해요. "주문 금액 = 상품 가격의 합" 같은 비즈니스 규칙은 애플리케이션 코드가 보장해야 해요.
I — Isolation (격리성)
"동시에 실행되는 트랜잭션들은 서로 영향을 주지 않아야 해요."
T1: 잔액 조회(1,000원) → ... → 잔액 다시 조회
↑
T2: 잔액 500원으로 변경 + 커밋
격리성 없으면 → T1이 같은 쿼리를 두 번 실행했는데 결과가 다름 (Non-Repeatable Read)
격리성 보장 → T1은 트랜잭션 시작 시점의 스냅샷 기준으로 일관되게 읽음
구현 방법: MVCC(Multi-Version Concurrency Control), Lock
격리성을 얼마나 엄격하게 지킬지 결정하는 게 바로 격리 수준(Isolation Level) 이에요.
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | ❌ | ❌ | ❌ |
| READ COMMITTED | ✅ | ❌ | ❌ |
| REPEATABLE READ | ✅ | ✅ | ⚠️ |
| SERIALIZABLE | ✅ | ✅ | ✅ |
MySQL InnoDB 기본값은 REPEATABLE READ, Oracle/PostgreSQL 기본값은 READ COMMITTED
D — Durability (지속성)
"한 번 커밋된 트랜잭션은 장애가 발생해도 반드시 유지돼야 해요."
커밋 직후 서버 다운
→ DB 재시작 후에도 커밋된 데이터는 그대로 있어야 해요
구현 방법: WAL (Write-Ahead Log)
DB는 실제 데이터를 바꾸기 전에 반드시 로그 파일에 먼저 기록해요.
1. 변경 내용을 WAL에 기록 ← 디스크 영구 저장
2. 메모리(Buffer Pool) 변경
3. 커밋 → WAL에 COMMIT 기록
4. 이후 실제 데이터 파일에 반영
WAL이 장애를 막는 원리
DB가 재시작되면 WAL을 스캔해서 스스로 복구해요.
DB 재시작
│
▼
WAL 스캔
│
├─── COMMIT 기록 있음 → Redo (변경사항 재적용)
│
└─── COMMIT 기록 없음 → Undo (변경사항 되돌림)
롤백 중 네트워크가 끊기면?
T1: UPDATE 실행 → WAL에 기록됨
T2: 롤백 시작...
← 여기서 서버 다운
T3: DB 재시작
T4: WAL 확인 → COMMIT 없음 → 자동 Undo → 안전하게 복구 ✅
롤백 도중 장애가 발생해도 WAL이 있는 한 데이터는 안전해요.
주의: 커밋 후 응답 유실 문제
WAL로 해결되지 않는 케이스가 하나 있어요.
1. DB 커밋 완료
2. DB → 앱서버로 "커밋 성공" 응답 전송 중
3. 네트워크 끊김
4. 앱서버는 결과를 모름 → 재시도 → 중복 처리 발생 ❌
해결 방법: 멱등성(Idempotency) 설계
public void createOrder(String idempotencyKey, OrderRequest request) {
// 이미 처리된 요청이면 무시
if (orderRepository.existsByIdempotencyKey(idempotencyKey)) {
return;
}
// 주문 처리 로직
orderRepository.save(request.toOrder(idempotencyKey));
}
같은 요청이 두 번 들어와도 결과가 동일하게 만들면 돼요.
WAL이 삭제되면?
WAL은 DB 복구의 유일한 근거예요. 삭제되면 복구가 불가능해요.
WAL 삭제 후 재시작:
→ 미커밋 트랜잭션을 Undo할 근거 없음
→ 반쯤 적용된 데이터가 디스크에 남음
→ 데이터 오염 (Inconsistent State)
→ 최악의 경우 DB 자체가 시작 불가
실무에서는 WAL 자체를 이중으로 보호해요.
| 보호 전략 | 설명 |
|---|---|
| WAL 아카이빙 | 주기적으로 외부 스토리지에 복사 |
| 복제(Replication) | Standby 서버에 WAL 스트리밍 |
| PITR | 베이스 백업 + WAL로 특정 시점 복구 |
| 디스크 모니터링 | WAL 디렉토리 용량 부족으로 인한 쓰기 실패 방지 |
실제 장애의 상당수는 실수로 지운 게 아니라 디스크 풀(Disk Full)로 WAL 쓰기가 실패하면서 발생해요.
애플리케이션 레이어의 트랜잭션
지금까지는 DB 내부 얘기였어요. 그런데 실제 서비스는 DB 작업 외에도 외부 시스템 호출이 함께 일어나요. 여기서 진짜 복잡한 문제가 시작돼요.
@Transactional의 범위를 명확히 이해해야 해요
Spring 기준으로 가장 흔한 패턴이에요.
@Transactional
public void createOrder(OrderRequest request) {
orderRepository.save(request.toOrder()); // DB 작업 1
pointService.deduct(request.getUserId()); // DB 작업 2
inventoryService.decrease(request.getItemId()); // DB 작업 3
// 셋 중 하나라도 실패 → 전부 롤백 ✅
}
세 작업이 모두 같은 DB를 바라보고 있다면 @Transactional 하나로 원자성이 보장돼요.
외부 API 호출이 섞이면 얘기가 달라져요
문제는 네트워크 대역이 다른 외부 시스템(결제사 API, 문자 발송 서비스 등)을 트랜잭션 안에 같이 두는 경우예요.
@Transactional
public void createOrder(OrderRequest request) {
orderRepository.save(order); // DB 저장
pointService.deduct(userId); // DB 포인트 차감
externalPaymentApi.pay(request); // 외부 결제 API 호출 ← 문제!
notificationService.sendSms(userId); // 외부 문자 발송 ← 문제!
}
만약 sendSms() 이후에 예외가 발생하면 어떻게 될까요?
결과:
DB 작업 (주문 저장, 포인트 차감) → 롤백 ✅
외부 결제 API 호출 → 이미 완료, 롤백 불가 ❌
외부 문자 발송 → 이미 발송됨, 취소 불가 ❌
DB 트랜잭션은 DB 커넥션 내부에서만 동작해요. 네트워크 대역이 다른 외부 시스템은 DB 롤백의 영향을 받지 않아요.
해결 방법 1: 트랜잭션 경계 밖으로 꺼내기
가장 단순한 해결책은 외부 호출을 트랜잭션 밖에서 실행하는 거예요.
public void createOrder(OrderRequest request) {
// 1. DB 작업만 트랜잭션으로 묶기
createOrderInTransaction(request);
// 2. 외부 호출은 트랜잭션 밖에서
externalPaymentApi.pay(request);
notificationService.sendSms(request.getUserId());
}
@Transactional
private void createOrderInTransaction(OrderRequest request) {
orderRepository.save(request.toOrder());
pointService.deduct(request.getUserId());
}
단, 이 경우에도 DB 커밋 후 외부 API 호출이 실패하면 불일치가 발생해요.
해결 방법 2: 보상 트랜잭션 (Compensating Transaction)
외부 시스템은 롤백이 안 되니까, 대신 "되돌리는 API"를 따로 호출하는 방식이에요.
정상 흐름:
1. DB 주문 저장
2. 외부 결제 API → pay() ✅
실패 시 보상 흐름:
1. DB 주문 저장 → rollback()
2. 외부 결제 API → cancel() ← 보상 트랜잭션
public void createOrder(OrderRequest request) {
String paymentId = null;
try {
orderRepository.save(request.toOrder());
paymentId = externalPaymentApi.pay(request); // 외부 결제
pointService.deduct(request.getUserId()); // DB 포인트 차감
} catch (Exception e) {
// 보상 트랜잭션: 결제가 완료됐다면 취소 API 호출
if (paymentId != null) {
externalPaymentApi.cancel(paymentId);
}
throw e;
}
}
이때 외부 시스템이 취소(cancel) API를 제공해줘야 한다는 전제가 필요해요. 실무에서 외부 API를 설계하거나 계약할 때 반드시 확인해야 하는 이유예요.
해결 방법 3: 멱등성 + 재시도 설계
외부 API 호출이 실패했는지 성공했는지 불분명할 때, 같은 요청을 안전하게 재시도할 수 있도록 멱등성을 보장해야 해요.
public void createOrder(String idempotencyKey, OrderRequest request) {
// 이미 처리된 요청이면 무시
if (orderRepository.existsByIdempotencyKey(idempotencyKey)) {
return;
}
orderRepository.save(request.toOrder(idempotencyKey));
externalPaymentApi.pay(idempotencyKey, request); // 결제사도 동일 키로 중복 방지
}
잘 설계된 외부 API는 동일한 idempotency-key로 온 요청을 중복 처리하지 않아요. 대표적으로 Stripe, Toss 같은 결제 API가 이 방식을 지원해요.
정리: 외부 시스템과 트랜잭션 설계 원칙
| 상황 | 권장 방법 |
|---|---|
| 외부 호출이 단순 알림(이메일, 문자) | 트랜잭션 밖에서 호출, 실패 허용 |
| 외부 호출이 금전/데이터 변경 | 보상 트랜잭션 (cancel API 필수) |
| 외부 호출 성공 여부가 불분명 | 멱등성 키로 안전하게 재시도 |
| DB 작업만 원자적으로 묶고 싶을 때 | @Transactional 범위를 DB 작업으로만 제한 |
지금까지 내용의 연결 관계
트랜잭션
├─ DB 레이어
│ └─ ACID 4가지 속성을 보장해야 함
│ ├─ A (원자성) → Undo Log로 구현
│ ├─ C (일관성) → DB 제약조건 + 애플리케이션 코드
│ ├─ I (격리성) → MVCC + Lock, 격리 수준으로 조절
│ └─ D (지속성) → WAL로 구현
│ └─ 장애 시 Redo/Undo로 자동 복구
│ └─ WAL 삭제 = 복구 불가
│
└─ 애플리케이션 레이어
└─ @Transactional = DB 커넥션 범위만 원자적 보장
└─ 외부 API는 트랜잭션 밖
├─ 보상 트랜잭션으로 일관성 유지
└─ 멱등성으로 안전한 재시도 보장
핵심 요약
- 트랜잭션 = 여러 작업을 하나의 원자적 단위로 묶는 것
- ACID = 트랜잭션이 신뢰받기 위한 4가지 보장
- WAL = 지속성(D)과 원자성(A) 롤백을 구현하는 실제 장치
- 격리 수준 = 격리성(I)을 성능과 교환하는 조절 다이얼
@Transactional= DB 커넥션 범위만 보장, 외부 API는 해당 없음- 외부 API와 트랜잭션을 함께 써야 한다면 → 보상 트랜잭션 + 멱등성 설계 필수
- 분산 시스템 환경의 트랜잭션(Saga, 2PC)은 다음 편에서 →
다음 편: 분산 시스템에서의 트랜잭션 — Saga 패턴과 2PC