여러 DB 작업이 하나의 업무를 구성한다면, 일부만 성공한 상태가 남지 않도록 하나의 트랜잭션으로 묶을 수 있다.
1. 트랜잭션이란?
하나의 논리적인 작업 단위
계좌 이체를 생각해보자.
A가 B에게 10,000원을 보낸다. DB에서는 실제로 두 작업일 수 있다.
A 계좌
100,000 → 90,000
B 계좌
50,000 → 60,000
그런데 첫 번째 작업만 성공하고 두 번째 작업이 실패하면, A는 잔고가 10,000원 감소하고 B는 그대로가 된다.
돈이 사라진 것이다. 그래서 이 두 작업을 `A 잔액 차감 + B 잔액 증가` 하나의 업무로 본다.
결과도 둘 중 하나여야 한다. (둘 다 성공 or 둘 다 실패)
2. 주문도 마찬가지
쿠폰 사용 → 재고 차감 → 포인트 차감 → 주문 저장
각각은 다른 DB 변경일 수 있다. 하지만 사용자 관점에서는 "상품 주문 1건"이다.
그래서 논리적으로는 아래와 같이 보는 것이 자연스럽다.
┌──────── 하나의 주문 ────────┐
쿠폰 사용
재고 차감
포인트 차감
주문 저장
└───────────────────────────┘
전부 성공하면 COMMIT,
중간에 하나라도 실패하면 ROLLBACK
3. Commit
트랜잭션에서 수행한 변경을 최종적으로 확정하는 것
주문이 모두 성공할 경우를 생각해보자.
쿠폰 unused → used
재고 10 → 9
포인트 10000 → 5000
주문 INSERT
↓ 모두 성공
COMMIT
이후 변경 내용이 DB에 확정된다.
4. Rollback
트랜잭션 도중 문제가 발생했을 때 지금까지 한 변경을 취소하는것
쿠폰 사용 ✅
재고 차감 ✅
포인트 차감 ✅
주문 INSERT ❌
↓ ROLLBACK
쿠폰 → 원래 상태
재고 → 원래 상태
포인트 → 원래 상태
주문 → 없음
트랜잭션 도중 문제가 생기면 전부 되돌리기 때문에, 최종 결과가 `주문 전체 성공` 또는 `주문 전체 실패` 둘 중 하나가 된다.
(중간 상태를 남기지 않는 것이 중요함)
5. 트랜잭션이 없으면..
public void order() {
coupon.use(); // DB UPDATE
product.decreaseStock(); // DB UPDATE
point.use(); // DB UPDATE
orderRepository.save(order); // 여기서 예외!
}
각 변경이 별도로 DB에 확정되어 버린다고 가정할 경우,
① 쿠폰 사용 → DB 반영 ✅
② 재고 차감 → DB 반영 ✅
③ 포인트 차감 → DB 반영 ✅
④ 주문 저장 → 오류 ❌
// 최종 DB
Coupon = 사용됨
Product = 재고 감소
Point = 포인트 감소
Order = 없음
비즈니스적으로 깨진 데이터가 되어버린다.
이를 흔히 데이터 정합성이 깨졌다고 표현할 수 있다.
6. 데이터 정합성이란?
서로 관련 있는 데이터들이 업무 규칙에 맞는 상태를 유지하는 것
# 정상 상태
주문 성공 → 쿠폰 사용 → 재고 감소 → 포인트 감소
# 정합성이 깨진 상태
주문 없음
but 쿠폰 사용됨 / 재고 감소됨 / 포인트 감소됨
트랜잭션은 정합성을 지키는 중요한 수단이다.
7. ACID
A - Atomicity
C - Consistency
I - Isolation
D - Durability
A — Atomicity, 원자성
여러 작업을 하나의 단위로 보고 전부 성공하거나 전부 실패하게 한다.
쿠폰 ✅
재고 ✅
포인트 ✅
주문 ✅
→ Commit
쿠폰 ✅
재고 ✅
포인트 ✅
주문 ❌
→ Rollback
→ 전부 취소
`Atomic`은 더 쪼갤 수 없는 하나의 단위라는 뜻이다.
사용자 입장에서 주문 하나를 하나의 원자적인 작업으로 보는 것이다.
C — Consistency, 일관성
트랜잭션 전후에 DB가 유효한 업무 규칙을 만족하는 상태여야 한다.
예를 들어, `재고는 음수가 될 수 없다.`라는 규칙이 있다면, 트랜잭션이 끝난 뒤 `stock = -1`이라는 잘못된 상태가 남으면 안 되는 것이다.
다만 DB가 모든 비즈니스 규칙을 알아서 지켜준다는 뜻은 아니라서, `Product.decreaseStock()` 같은 애플리케이션 규칙이나 DB Constraint 등을 개발자가 함께 설계해야 한다.
I — Isolation, 격리성
여러 트랜잭션이 동시에 실행될 때 서로의 작업 때문에 이상한 결과가 발생하지 않도록 어느 정도 격리한다.
주문 A
주문 B
둘 다 같은 상품 재고를 수정
트랜잭션이 있다고 해서 모든 동시성 문제가 자동으로 해결되지는 않는다.
D — Durability, 지속성
Commit에 성공했다면, 그 결과는 영구적으로 보존되어야 한다.
주문 저장
↓
COMMIT 성공
↓
"주문이 확정됐다."
그 후 애플리케이션 프로세스가 종료됐다고 해서 주문이 갑자기 사라지면 안 된다.
8. ACID를 주문으로 연결
| 성질 | 주문 예시 |
| Atomicity | 쿠폰·재고·포인트·주문이 모두 성공하거나 모두 취소 |
| Consistency | 주문 후에도 재고/쿠폰 등의 업무 규칙이 유효 |
| Isolation | 동시에 주문하는 요청들의 영향을 제어 |
| Durability | Commit된 주문은 유지 |
9. Spring에서는 `@Transactional`
@Transactional
public void order() {
...
}
`@Transactional`은 "이 메서드에서 수행하는 DB 작업들을 하나의 트랜잭션 경계 안에서 처리해줘"라고 스프링에게 알려주는 역할을 한다.
OrderService.order() 호출
↓
Spring
트랜잭션 시작
↓
쿠폰 변경
재고 변경
포인트 변경
주문 저장
↓
정상 종료?
┌──────┴──────┐
YES NO
↓ ↓
COMMIT ROLLBACK
10. Spring이 해주는 것과 우리가 결정하는 것
우리가 결정하는 것
- 어떤 작업들이 하나의 업무인가?
- 트랜잭션 범위를 어디부터 어디까지 잡을 것인가?
- 어떤 실패가 전체 업무 실패인가?
Spring이 도와주는 것
- 트랜잭션 시작
- 정상 종료 시 commit
- 예외 발생 시 rollback
- 트랜잭션 종료
즉 `@Transactional`을 붙이면 Spring이 좋은 트랜잭션 설계를 알아서 해주는 것이 아니라, 경계는 개발자가 결정해야 한다.
11. 트랜잭션 경계(Transaction Boundary)
트랜잭션을 어디서 시작해서 어디서 끝낼 것인가?
상품 조회
쿠폰 조회
포인트 조회
재고 차감
쿠폰 사용
포인트 차감
주문 저장
주문 유스케이스가 위와 같을 때, 하나의 Application Service 메서드를 `order()`로 만들고 그 전체를 하나의 트랜잭션으로 묶는 방식을 생각할 수 있다.
@Transactional
order() 시작
│
├─ 상품 조회
├─ 쿠폰 조회
├─ 포인트 조회
├─ 재고 차감
├─ 쿠폰 사용
├─ 포인트 차감
└─ 주문 저장
│
order() 종료
COMMIT
그래서 Application Service / Facade가 트랜잭션 경계가 되는 경우가 흔하다.
12. 왜 Domain 객체 하나하나에 트랜잭션을 붙이지 않을까?
Product.decreaseStock()
→ 트랜잭션 1
Coupon.use()
→ 트랜잭션 2
Point.use()
→ 트랜잭션 3
Order.save()
→ 트랜잭션 4
만약 각각 완전히 별개의 트랜잭션이고, 앞에서 순서대로 커밋된다고 생각해보자.
재고 차감 → COMMIT
쿠폰 사용 → COMMIT
포인트 차감 → COMMIT
주문 저장 → 실패
이 경우 주문은 실패했는데 앞의 세 작업은 이미 확정된 상태가 되어버린다.
따라서 사용자 관점에서 어떤 작업들을 하나의 업무로 성공/실패시켜야 하는지가 중요하다.
주문이라면 `order()`라는 유스케이스 전체가 좋은 후보가 되는 것이다.
13. Application Service에서 트랜잭션을 잡는 이유
Application Service는 하나의 유스케이스 전체 흐름을 조율한다.
OrderService
상품 조회
↓
재고 차감 요청
↓
쿠폰 사용 요청
↓
포인트 사용 요청
↓
주문 저장
이 객체는 이 작업들이 모두 하나의 주문 업무라는 사실을 알고 있다.
`Product`는 모른다. "내 재고를 차감한다."만 알고 있다.
`Coupon`도 "내 상용 상태를 변경한다."만 안다.
따라서 어떤 작업들을 하나의 트랜잭션으로 묶어야 하는가? 라는 판단은 Application Service가 하기에 자연스럽다.
14. 트랜잭션 범위가 너무 좁으면?
트랜잭션 A
쿠폰 사용
COMMIT
트랜잭션 B
재고 차감
COMMIT
트랜잭션 C
포인트 차감
COMMIT
트랜잭션 D
주문 저장
실패
하나의 업무를 여러 독립적인 트랜잭션으로 너무 잘게 나누면, 일부만 성공한 상태가 남을 수 있다.
15. 반대로 트랜잭션 범위가 너무 넓으면?
사용자 요청 시작
↓
DB 처리
↓
외부 결제 API 5초 대기
↓
메일 발송
↓
파일 업로드
↓
또 DB 처리
↓
트랜잭션 종료
무조건 크게 잡는 것도 좋지 않다. 트랜잭션이 오래 유지되면, 상황에 따라 여러 문제가 생길 수 있다.
DB Connection을 오래 점유
Lock을 오래 유지할 가능성
다른 요청의 대기 증가
처리량 감소
따라서 트랜잭션 경계는 업무 정합성을 보장하는 데 필요한 범위는 묶되, 불필요하게 길게 유지하지 않는 것이 기본적이 방향이다.
16. 외부 API는 Rollback되는가?
DB 재고 차감
↓
외부 문자 발송 API
↓
DB 주문 저장 실패
↓
Rollback
DB 변경은 Rollback 할 수 있다.
그런데 이미 발송한 문자를 롤백한다고 취소할 수 있을까?
DB Transaction → DB 작업을 되돌림
외부 시스템에서 이미 일어난 일 → DB Rollback으로 자동 취소되지 않음
이런 문제는 나중에 외부 시스템 장애 대응, 이벤트, Outbox 같은 주제로 확장될 수 있다.
트랜잭션이 모든 작업을 되돌리는 것은 아니다.
17. `@Transactional`이 붙었는데 예외가 발생하면 항상 Rollback?
Spring에서는 일반적으로 `RuntimeException`이나 `Error`가 발생하면 Rollback된다.
하지만 Checked Exception 등의 세부 규칙도 있다.
예외가 발생했다고 무조건 모든 종류가 똑같이 rollback 된다고 외우면 안 된다.
18. `@Transactional`과 동시성은 완전히 다른 문제인가?
완전히 무관한 것은 아니지만, 구분할 수 있어야 한다.
`@Transactional`을 보고 "트랜잭션이니까 동시에 주문해도 재고는 안전하겠지." 라고 생각하면 안 된다.
재고 = 10
Transaction A
10 조회
Transaction B
10 조회
A → 9 저장
B → 9 저장
각각의 트랜잭션 내부에서는 `조회 → 변경 → commit`이 정상적으로 수행됐다.
그런데 최종 결과는 `두 개 팔렸는데 재고 9`이다.
즉 트랜잭션이 존재한다와 동시에 수정해도 안전하다는 같은 말이 아니다.
19. 예제1
① 쿠폰 사용
② 재고 차감
③ 포인트 차감
④ 주문 저장
④에서 DB 오류가 발생한다. 최종 DB 상태는 아래와 같다.
트랜잭션이 없는 경우
쿠폰: 쿠폰 사용
재고: 재고 차감
포인트: 포인트 차감
주문: 주문 저장 실패
하나의 트랜잭션으로 묶은 경우
쿠폰: 사용 처리가 수행됐지만 Rollback되어 원래 미사용 상태로 돌아감
재고: 차감됐지만 Rollback되어 원래 재고로 돌아감
포인트: 차감됐지만 Rollback되어 원래 포인트로 돌아감
주문: 저장되지 않음. 트랜잭션 전체 Rollback
20. 예제2
다음 중 어떤 범위를 하나의 트랜잭션으로 잡는 것이 자연스러운지 생각해본다.
선택 A
쿠폰 사용만 Transaction
재고 차감만 Transaction
포인트 차감만 Transaction
주문 저장만 Transaction
선택 B
┌────── order() Transaction ──────┐
쿠폰 사용
재고 차감
포인트 차감
주문 저장
└───────────────────────────────┘
1. A에서 주문 저장이 실패하면 어떤 문제가 생길 수 있는가?
- 일부만 성공한 상태가 남아 데이터 정합성이 깨질 수 있다.
2. B가 하나의 주문 업무에 더 자연스러운 이유는 무엇인가?
- 사용자 관점에서 이 작업들이 모두 하나의 "주문"이라는 업무의 성공/실패를 구성하기 때문이다.
3. 그렇다면 order() 안에 외부 메일 발송이나 오래 걸리는 외부 API 호출까지 전부 넣는 것이 항상 좋은가?
- 항상 좋은 것은 아니다. DB Connection을 오래 점유하는 등의 문제가 발생할 수 있다.
4. 트랜잭션 범위를 정할 때 무엇을 기준으로 해야 할까?
- 하나의 업무에서 함께 성공하거나 함께 실패해야 데이터 정합성이 유지되는 DB 작업들을 기준으로 트랜잭션 경계를 잡되, 불필요하게 오래 유지하지 않는다.
요약
- 트랜잭션은 하나의 업무를 구성하는 여러 DB 변경을 모두 성공시키거나 모두 취소하여 중간 상태가 남는 것을 막는다.
- Application Service는 하나의 유스케이스 전체 흐름을 알고 있기 때문에 트랜잭션 경계를 설정하기 자연스러운 위치가 될 수 있다.
- `@Transactional`은 원자성을 제공하는 중요한 도구지만, 모든 외부 작업을 Rollback하거나 모든 동시성 문제를 자동으로 해결해주는 기능은 아니다.
'Spring' 카테고리의 다른 글
| 동시성 / Race Condition / Lost Update (0) | 2026.08.27 |
|---|---|
| JPA 영속성 컨텍스트 / Dirty Checking / Flush (0) | 2026.08.26 |
| [설계] Layered Architecture / Application Service / Repository Interface / DIP (0) | 2026.08.25 |
| [설계] 도메인 모델링 / 객체의 책임 / 응집도 (0) | 2026.08.24 |
| [TDD] Sequence Diagram / 주문 처리 실행 흐름 (0) | 2026.08.21 |