트랜잭션 실무 패턴
이론적인 ACID를 이해했다면, 실무에서 트랜잭션을 어떻게 관리하는지 알아야 합니다. 서비스 레이어의 트랜잭션 관리부터 분산 환경까지, 올바른 트랜잭션 설계가 서비스 안정성의 핵심입니다.
서비스 레이어의 트랜잭션 관리
애플리케이션에서 트랜잭션은 보통 서비스 레이어에서 관리합니다. 비즈니스 로직의 단위가 곧 트랜잭션의 단위입니다.
Controller → Service → Repository
│
└→ 트랜잭션 경계
하나의 서비스 메서드 = 하나의 트랜잭션
주문 생성
1. 재고 확인
2. 재고 차감
3. 주문 생성
4. 결제 처리
→ 하나라도 실패하면 전부 ROLLBACK@Service
public class OrderService {
@Transactional // 메서드 전체가 하나의 트랜잭션
public Order createOrder(Long userId, List<OrderItemDto> items) {
// 1. 재고 확인 및 차감
for (OrderItemDto item : items) {
productRepository.decreaseStock(
item.getProductId(), item.getQuantity()
);
}
// 2. 주문 생성
Order order = orderRepository.save(new Order(userId));
// 3. 주문 상세 생성
for (OrderItemDto item : items) {
orderItemRepository.save(new OrderItem(order.getId(), item));
}
return order;
// 정상 종료 → 자동 COMMIT
// 예외 발생 → 자동 ROLLBACK (RuntimeException 기본)
}
}Controller
→ HTTP 요청/응답 처리 담당
→ 하나의 요청이 여러 서비스를 호출할 수 있음
→ 트랜잭션 범위를 정확히 제어하기 어려움
Service
→ 비즈니스 로직 단위 = 트랜잭션 단위
→ 관심사 분리: Controller는 웹, Service는 비즈니스
→ 테스트 용이: 서비스 메서드 단위 테스트 가능
Repository
→ 너무 작은 단위 (단일 SQL)
→ 여러 Repository 연산을 하나의 트랜잭션으로 묶을 수 없음트랜잭션 범위의 원칙
| 원칙 | 설명 | 이유 |
|---|---|---|
| 짧게 유지 | 트랜잭션이 길면 락 점유 시간 증가 | 동시성 저하, 데드락 위험 |
| 외부 호출 제외 | HTTP 호출, 파일 I/O는 트랜잭션 밖에서 | 외부 응답 지연 시 락 장기 보유 |
| 읽기 전용 분리 | 조회만 하는 트랜잭션은 readOnly=true | 데이터 변경 방지, 최적화 힌트 |
| 필요한 곳에만 | 모든 메서드에 트랜잭션을 걸지 말 것 | 불필요한 오버헤드 방지 |
| 예외 처리 주의 | checked 예외 시 롤백 정책 확인 | 프레임워크별 기본 동작 상이 |
[나쁜 예]
@Transactional
public void processOrder(Order order) {
orderRepository.save(order); // DB 업데이트
paymentApi.charge(order.getTotal()); // 외부 HTTP 호출 (3초+)
inventoryRepository.decrease(...); // DB 업데이트
}
→ 외부 API 응답 대기 동안 트랜잭션 유지
→ 락 장기 점유 → 다른 트랜잭션 대기 → 성능 저하
[좋은 예]
@Transactional
public Order createOrder(Order order) {
orderRepository.save(order);
inventoryRepository.decrease(...);
return order; // 트랜잭션 종료
}
// 트랜잭션 밖에서 외부 호출
public void processOrder(Order order) {
Order saved = createOrder(order);
paymentApi.charge(saved.getTotal()); // 실패 시 보상 트랜잭션
}트랜잭션 전파 (Propagation)
중첩 메서드 호출 시 트랜잭션을 어떻게 전파할지 결정합니다. Spring에서 가장 중요한 설정 중 하나입니다.
REQUIRED (기본값)
현재 트랜잭션이 있으면 참여, 없으면 새로 생성
→ 대부분의 경우 이것으로 충분
REQUIRES_NEW
항상 새 트랜잭션 생성 (기존 트랜잭션 일시 중단)
→ 로그 기록, 알림 전송 등 독립적 작업
NESTED
현재 트랜잭션 내에 중첩 트랜잭션 (SAVEPOINT 활용)
→ 중첩 트랜잭션 롤백해도 부모는 유지
SUPPORTS
현재 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행
NOT_SUPPORTED
트랜잭션 없이 실행 (기존 트랜잭션 일시 중단)
MANDATORY
현재 트랜잭션이 반드시 있어야 함, 없으면 예외
NEVER
트랜잭션이 있으면 예외 발생@Service
public class OrderService {
@Transactional
public void createOrder(OrderDto dto) {
Order order = orderRepository.save(dto.toEntity());
// 주문 로그는 주문 롤백과 무관하게 항상 저장
auditService.log(order); // REQUIRES_NEW
// 포인트 적립은 실패해도 주문은 유지
pointService.addPoints(order); // NESTED or try-catch
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(Order order) {
auditRepository.save(new AuditLog(order));
// 독립 트랜잭션: 주문이 롤백되어도 로그는 커밋됨
}
} 트랜잭션 필요?
/ \
Yes No
/ \
기존 트랜잭션에 SUPPORTS 또는
참여해도 되나? NOT_SUPPORTED
/ \
Yes No
/ \
REQUIRED REQUIRES_NEW읽기 전용 트랜잭션
조회만 수행하는 메서드에 readOnly=true를 지정하면 DBMS가 내부 최적화를 수행할 수 있습니다.
@Transactional(readOnly = true)
public List<Order> getOrdersByUser(Long userId) {
return orderRepository.findByUserId(userId);
}JPA/Hibernate
→ 영속성 컨텍스트의 변경 감지(Dirty Checking) 생략
→ 스냅샷 저장 생략 → 메모리 절약
→ flush 생략 → 성능 향상
MySQL
→ InnoDB에서 읽기 전용 트랜잭션 최적화
→ Undo Log 생성 최소화
Oracle
→ READ ONLY 트랜잭션 → Undo 데이터 접근만 (일관된 읽기)
→ DML 시도 시 에러 발생
Spring Data JPA
→ @QueryHints로 Hibernate 최적화 힌트 전달
→ Master-Slave 구조에서 Slave로 라우팅 가능분산 트랜잭션과 2PC
마이크로서비스 환경에서는 여러 서비스가 각각의 데이터베이스를 가집니다. 하나의 비즈니스 로직이 여러 DB에 걸치면 분산 트랜잭션이 필요합니다.
코디네이터(Coordinator)가 참여자(Participant)들을 조율
Phase 1 — 준비(Prepare)
코디네이터 → 참여자A: "커밋 준비됐나?"
코디네이터 → 참여자B: "커밋 준비됐나?"
참여자A → 코디네이터: "준비 완료 (YES)"
참여자B → 코디네이터: "준비 완료 (YES)"
Phase 2 — 커밋(Commit)
코디네이터 → 참여자A: "커밋해라"
코디네이터 → 참여자B: "커밋해라"
한 참여자라도 "준비 실패 (NO)"면
코디네이터 → 전체: "롤백해라"1. 블로킹 문제
→ 코디네이터 장애 시 참여자들이 무기한 대기
→ Prepare 완료 후 Commit/Rollback 명령을 못 받으면 락 유지
2. 단일 장애점 (Single Point of Failure)
→ 코디네이터가 죽으면 전체 시스템 영향
3. 성능 저하
→ 모든 참여자가 준비 완료할 때까지 대기
→ 네트워크 왕복 2회 필요
4. 확장 한계
→ 참여자 수 증가 시 지연 시간 선형 증가
개선: 3PC (Three-Phase Commit)
→ Pre-Commit 단계 추가로 블로킹 문제 완화
→ 그러나 여전히 복잡하고 실무에서 잘 사용하지 않음Saga 패턴
Saga 패턴은 분산 트랜잭션의 대안입니다. 각 서비스가 로컬 트랜잭션을 실행하고, 실패 시 보상 트랜잭션(Compensating Transaction)을 실행합니다.
정상 흐름
주문 서비스: 주문 생성 →
결제 서비스: 결제 처리 →
재고 서비스: 재고 차감 →
배송 서비스: 배송 접수
실패 시 보상 (역순)
배송 서비스: 배송 접수 실패!
재고 서비스: 재고 복구 (보상) ←
결제 서비스: 결제 취소 (보상) ←
주문 서비스: 주문 취소 (보상) ←Choreography vs Orchestration
각 서비스가 이벤트를 발행하고 구독하여 자율적으로 동작
주문서비스 ──주문생성 이벤트──→ 결제서비스
결제서비스 ──결제완료 이벤트──→ 재고서비스
재고서비스 ──차감완료 이벤트──→ 배송서비스
장점: 느슨한 결합, 서비스 독립성
단점: 흐름 추적 어려움, 디버깅 복잡
적합: 단순한 워크플로우 (3~4단계)중앙 오케스트레이터가 전체 흐름을 제어
┌─────────────────┐
│ Orchestrator │
│ (Order Saga) │
└──┬──┬──┬──┬─────┘
│ │ │ │
┌──┘ │ │ └──┐
▼ ▼ ▼ ▼
주문 결제 재고 배송
장점: 흐름 명확, 디버깅 용이
단점: 오케스트레이터 = 단일 장애점
적합: 복잡한 워크플로우 (5단계 이상)| 비교 | 2PC | Saga |
|---|---|---|
| 일관성 | 강한 일관성 (ACID) | 최종적 일관성 (Eventual) |
| 성능 | 락 대기로 느림 | 비동기로 빠름 |
| 복잡도 | 코디네이터 필요 | 보상 로직 필요 |
| 격리성 | 완전한 격리 | 중간 상태 노출 가능 |
| 적합 환경 | 단일 DB 또는 소수 DB | 마이크로서비스 |
Outbox 패턴
Saga 패턴에서 메시지 발행과 DB 업데이트의 원자성을 보장하는 패턴입니다.
문제
@Transactional
void createOrder(order) {
orderRepo.save(order); // 1. DB 저장 성공
messageQueue.send(orderEvent); // 2. 메시지 발행 실패!
}
→ DB에는 저장, 이벤트는 미발행 → 불일치
해결: Outbox 테이블
@Transactional
void createOrder(order) {
orderRepo.save(order); // 1. 주문 저장
outboxRepo.save(new OutboxEvent(order)); // 2. 아웃박스 저장
}
// 별도 프로세스가 outbox 테이블을 폴링하여 메시지 발행
// 발행 완료 후 outbox 레코드 삭제
┌────────────┐ ┌──────────────┐ ┌────────────┐
│ Orders │ │ Outbox │ │ Message │
│ 테이블 │←───│ 테이블 │───→│ Broker │
│ │같은│ │폴링│ │
│ │트랜│ │ │ │
└────────────┘잭션└──────────────┘ └────────────┘멱등성 (Idempotency)
분산 환경에서 메시지가 중복 전달될 수 있으므로, 같은 요청을 여러 번 처리해도 결과가 같아야 합니다.
1. 고유 키 활용
INSERT INTO payments (payment_id, amount)
VALUES ('pay-uuid-001', 50000)
ON DUPLICATE KEY UPDATE amount = amount;
→ payment_id가 같으면 무시
2. 상태 확인 후 처리
SELECT status FROM orders WHERE id = 'order-001';
→ 이미 '결제완료'면 스킵
3. 멱등성 키 (Idempotency Key)
HTTP 헤더: Idempotency-Key: uuid-12345
→ 서버가 이 키로 중복 요청 감지멱등한 연산 (안전)
* SET balance = 50000 → 몇 번 실행해도 50000
* DELETE WHERE id = 1 → 이미 삭제되어도 에러 없음
* PUT /users/1 {name: "A"} → 항상 같은 결과
비멱등한 연산 (위험)
* UPDATE balance = balance + 1000 → 실행할 때마다 증가
* INSERT INTO orders (...) → 매번 새 행 생성
* POST /orders {item: "A"} → 매번 새 주문 생성
→ 비멱등 연산을 멱등하게 만드는 것이 설계의 핵심낙관적 vs 비관적 동시성 제어
트랜잭션이 동시에 같은 데이터를 수정할 때의 충돌 처리 전략입니다.
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 다른 트랜잭션은 이 행을 수정/읽기 불가 (잠금 해제까지)
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
적합: 충돌이 빈번한 경우
→ 재고 차감 (동시 주문이 많은 상품)
→ 계좌 이체 (잔액 변경)
단점: 동시성 저하, 데드락 위험1. 읽기: SELECT stock, version FROM products WHERE id = 1;
→ stock=10, version=5
2. 수정: UPDATE products
SET stock = 9, version = 6
WHERE id = 1 AND version = 5;
→ 영향 받은 행 = 1이면 성공
→ 영향 받은 행 = 0이면 충돌 (다른 트랜잭션이 먼저 수정)
→ 충돌 시 재시도 또는 에러 반환
적합: 충돌이 드문 경우
→ 게시글 수정 (동시 편집이 드문 경우)
→ 설정 변경
장점: 락 없이 동시성 유지@Entity
public class Product {
@Id
private Long id;
private int stock;
@Version // 자동으로 version 관리
private int version;
}
// 충돌 시 OptimisticLockException 발생
// → 재조회 후 재시도| 전략 | 방식 | 충돌 처리 | 적합 상황 |
|---|---|---|---|
| 비관적 | SELECT FOR UPDATE | 사전 방지 | 충돌이 빈번한 경우 |
| 낙관적 | 버전 컬럼 비교 | 사후 감지 | 충돌이 드문 경우 |
배치 처리의 트랜잭션
대량 데이터를 처리하는 배치 작업은 적절한 단위로 커밋해야 합니다.
[나쁜 예] 전체를 하나의 트랜잭션으로
BEGIN;
1,000,000건 INSERT/UPDATE
COMMIT;
→ Undo/Redo 로그 폭증, 메모리 부족
→ 중간 실패 시 전체 ROLLBACK (한참 걸림)
[좋은 예] N건 단위로 분할 커밋
FOR each batch (1000건)
BEGIN;
1,000건 처리
COMMIT;
END FOR
→ Undo/Redo 적정 수준 유지
→ 실패 시 해당 배치만 재처리
배치 크기 가이드
INSERT: 1,000 ~ 10,000건 단위
UPDATE: 500 ~ 5,000건 단위
DELETE: 1,000 ~ 5,000건 단위
→ DBMS, 데이터 크기, 인덱스 수에 따라 조정ItemReader → ItemProcessor → ItemWriter
│
chunk 단위 커밋
chunk-size=1000
1,000건 읽기 → 처리 → 쓰기 → COMMIT
1,000건 읽기 → 처리 → 쓰기 → COMMIT
...
→ 실패 시 해당 chunk만 ROLLBACK 후 재시도트랜잭션 실무 체크리스트
□ 트랜잭션 경계가 서비스 레이어에 있는가?
□ 트랜잭션 내에 외부 API 호출이 없는가?
□ 읽기 전용 메서드에 readOnly=true를 설정했는가?
□ 트랜잭션 전파 속성이 적절한가?
□ 장기 트랜잭션 (30초 이상) 가능성은 없는가?
□ 배치 처리 시 적절한 단위로 커밋하고 있는가?
□ 예외 처리 시 롤백 정책을 확인했는가?
□ 분산 환경에서 보상 트랜잭션이 정의되어 있는가?
□ 멱등성이 보장되는가?
□ 데드락 방지를 위해 테이블 접근 순서를 통일했는가?정리
트랜잭션 실무에서 가장 중요한 것은 적절한 범위 설정입니다. 트랜잭션을 너무 넓게 잡으면 동시성이 떨어지고, 너무 좁게 잡으면 데이터 정합성이 깨집니다.
단일 DB 환경에서는 서비스 레이어에서 @Transactional로 관리하고, 전파 속성으로 중첩 호출을 제어합니다. 분산 환경에서는 2PC 대신 Saga 패턴이 표준이며, Outbox 패턴으로 메시지 원자성을, 멱등성으로 중복 처리를 보장합니다.
낙관적/비관적 잠금은 충돌 빈도에 따라 선택합니다. 대부분의 웹 애플리케이션은 읽기가 쓰기보다 압도적으로 많으므로 낙관적 잠금이 기본 선택이고, 금융이나 재고 관리처럼 충돌이 빈번한 경우에 비관적 잠금을 사용합니다.
시험에서는 2PC의 두 단계(Prepare와 Commit)와 블로킹 문제, Saga 패턴의 보상 트랜잭션 개념, Choreography와 Orchestration의 차이, 낙관적 잠금과 비관적 잠금의 구분이 빈출됩니다.
다음 장에서는 동시성 문제의 구체적인 유형과 해결책인 동시성 제어를 다루겠습니다.