트랜잭션 실무 패턴
이론적인 ACID를 이해했다면, 실무에서 트랜잭션 경계를 어디에 두고 실패를 어떻게 복구할지 판단할 수 있어야 합니다. 서비스 레이어의 트랜잭션 관리부터 분산 환경의 보상 흐름까지, 올바른 트랜잭션 설계는 정합성과 장애 대응의 핵심입니다.
서비스 레이어의 트랜잭션 관리
애플리케이션에서 트랜잭션은 보통 서비스 레이어에서 관리합니다. 한 유스케이스에서 함께 성공하거나 함께 취소되어야 하는 작업 묶음이 트랜잭션의 단위가 됩니다.
Spring의 @Transactional은 보통 프록시를 통해 적용되므로 같은 클래스 내부에서 자기 메서드를 직접 호출하면 트랜잭션이 기대대로 적용되지 않을 수 있습니다. 또한 기본 롤백 규칙은 런타임 예외 중심이므로 checked 예외, catch 위치, rollbackFor 설정을 함께 확인해야 합니다.
@Service
public class OrderService {
@Transactional // 메서드 전체가 하나의 트랜잭션
public Order createOrder(Long userId, List<OrderItemDto> items) {
// 1. 조건부 재고 차감 (부족하면 예외 또는 0행 확인)
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
// RuntimeException/Error → 기본 ROLLBACK
// checked 예외는 rollbackFor 설정 확인
}
}트랜잭션 범위의 원칙
| 원칙 | 설명 | 이유 |
|---|---|---|
| 짧게 유지 | 필요한 DB 작업만 트랜잭션 안에 둔다 | 락 점유 시간과 데드락 위험 감소 |
| 외부 호출 제외 | HTTP 호출, 파일 I/O는 가능하면 밖에서 한다 | 외부 지연이 DB 락으로 번지지 않음 |
| 읽기 전용 분리 | 조회 유스케이스는 readOnly=true를 검토한다 | Flush 완화 가능, 최적화 힌트 전달 |
| 필요한 곳에만 | 모든 메서드에 트랜잭션을 걸지 않는다 | 경계가 흐려지고 전파가 복잡해짐 |
| 예외 처리 주의 | 롤백 대상 예외와 catch 위치를 확인한다 | 예외를 삼키면 커밋될 수 있음 |
트랜잭션 안에서 외부 API를 호출해야만 하는 경우도 있지만, 이때는 타임아웃, 재시도, 중복 요청, 보상 흐름을 별도로 설계해야 합니다. 가능하면 DB 변경을 먼저 확정하고 Outbox나 이벤트로 외부 처리를 이어 가는 방식이 운영하기 쉽습니다.
트랜잭션 전파 (Propagation)
중첩 메서드 호출 시 기존 트랜잭션에 참여할지, 새 트랜잭션을 만들지, 트랜잭션 없이 실행할지를 결정합니다. Spring에서는 프록시 호출, 예외 전파, 데이터소스 지원 여부까지 함께 고려해야 합니다.
@Service
public class OrderService {
@Transactional
public void createOrder(OrderDto dto) {
Order order = orderRepository.save(dto.toEntity());
// 주문 롤백과 독립적으로 남겨야 하는 감사 로그
auditService.logOrderAttempt(userId, dto.getRequestId()); // REQUIRES_NEW
// 포인트 적립은 주문 커밋 이후 이벤트/Outbox로 분리하는 편이 안전
pointService.schedulePoints(order);
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logOrderAttempt(Long userId, String requestId) {
auditRepository.save(new AuditLog(userId, requestId));
// 독립 트랜잭션: 바깥 주문 트랜잭션과 별도로 커밋/롤백됨
}
} 트랜잭션 필요?
/ \
Yes No
/ \
기존 트랜잭션에 SUPPORTS 또는
참여해도 되나? NOT_SUPPORTED
/ \
Yes No
/ \
REQUIRED REQUIRES_NEWMANDATORY와 NEVER는 호출 위치가 맞는지 강제하는 안전장치로 사용할 수 있고, NESTED는 savepoint를 지원하는 환경에서 같은 물리 트랜잭션의 일부만 되돌리고 싶을 때 검토합니다.
REQUIRES_NEW는 바깥 트랜잭션을 잠시 보류하고 새 트랜잭션을 커밋하므로 감사 로그처럼 독립성이 필요한 기록에는 유용합니다. 다만 안쪽 트랜잭션은 바깥 트랜잭션의 미커밋 데이터를 볼 수 없습니다. 따라서 감사 로그가 아직 커밋되지 않은 주문 행을 FK로 참조하면 실패하거나, 바깥 주문은 롤백됐는데 감사 로그만 남는 흐름이 될 수 있습니다. 주문, 결제, 재고처럼 함께 원자적으로 맞아야 하는 업무 상태를 REQUIRES_NEW로 분리하면 불일치가 생깁니다. NESTED도 항상 가능한 것이 아니라 savepoint와 트랜잭션 매니저 지원이 필요하며, JPA 환경에서는 JpaTransactionManager, JDBC savepoint, 드라이버/DB 조합을 확인해야 합니다.
읽기 전용 트랜잭션
조회만 수행하는 메서드에 readOnly=true를 지정하면 Spring/Hibernate가 Flush를 줄이거나 커넥션에 읽기 전용 힌트를 전달할 수 있습니다. 다만 DBMS와 드라이버에 따라 효과가 다르며, 쓰기를 물리적으로 항상 막아 주는 보안 장치는 아닙니다.
readOnly=true는 성능 힌트이자 의도 표현에 가깝습니다. Hibernate에서는 변경 감지와 flush 비용을 줄이는 데 도움이 될 수 있지만, 네이티브 쿼리, 임시 테이블, 드라이버/DBMS 설정에 따라 쓰기 차단 범위가 달라집니다. 권한 분리나 읽기 전용 복제본 라우팅은 별도 정책으로 설계해야 합니다.
@Transactional(readOnly = true)
public List<Order> getOrdersByUser(Long userId) {
return orderRepository.findByUserId(userId);
}분산 트랜잭션과 2PC
마이크로서비스 환경에서는 여러 서비스가 각각의 데이터베이스를 가지는 경우가 많습니다. 여러 저장소가 하나의 업무 결과로 묶이고 강한 원자성이 필요하다면 분산 트랜잭션을 검토하지만, 락 보유 시간과 장애 복구 복잡도 때문에 신중하게 선택합니다.
2PC는 Prepare 단계에서 참여자가 커밋 가능 상태를 보장해야 하므로 락과 로그를 오래 붙잡을 수 있고, 코디네이터 장애 시 참여자가 결정을 기다리는 블로킹 문제가 생길 수 있습니다. XA 지원 여부, 타임아웃, 휴리스틱 결정 가능성까지 운영 관점에서 확인해야 합니다.
Saga 패턴
Saga 패턴은 장시간 비즈니스 절차를 여러 로컬 트랜잭션으로 나누는 방식입니다. 각 서비스가 자기 DB에 커밋하고 이벤트나 명령으로 다음 단계를 진행하며, 실패 시 보상 트랜잭션(Compensating Transaction)으로 이미 완료된 단계를 되돌리는 흐름을 설계합니다.
Saga의 보상은 Undo 로그처럼 과거를 자동 복원하는 것이 아니라, 반대 의미의 업무 작업을 새로 실행하는 것입니다. 그래서 각 단계의 상태 저장, 보상 순서, 재시도, 멱등성 키, 중간 상태 노출을 함께 설계해야 합니다.
Choreography vs Orchestration
| 비교 | 2PC | Saga |
|---|---|---|
| 일관성 | 참여자 전체의 원자적 커밋을 목표 | 단계별 커밋과 최종적 일관성 |
| 성능 | 락과 준비 상태 때문에 지연 가능 | 비동기 진행으로 지연을 분산 |
| 복잡도 | 코디네이터와 복구 로그 필요 | 보상 로직과 중복 처리 필요 |
| 격리성 | 격리는 각 DB 트랜잭션 설정에 의존 | 중간 상태가 노출될 수 있음 |
| 적합 환경 | 적은 참여자, 짧은 강한 원자성 | 긴 업무 흐름, 서비스 간 낮은 결합도 |
Outbox 패턴
Outbox 패턴은 DB 변경과 “발행할 메시지 기록”을 같은 로컬 트랜잭션에 저장하는 방식입니다. 메시지 브로커 전송 자체까지 한 번에 원자적으로 묶는 것은 아니므로, relay 재시도와 소비자 멱등성이 함께 필요합니다.
Outbox relay는 같은 메시지를 두 번 보낼 수 있습니다. 발행 완료 표시 전에 장애가 나거나 브로커 응답을 받지 못하면 재시도 과정에서 중복 발행이 자연스럽게 발생합니다. 따라서 이벤트 ID를 전역적으로 고정하고, 소비자는 처리 이력 테이블이나 유니크 키로 중복 소비를 막아야 합니다.
멱등성 (Idempotency)
분산 환경에서는 네트워크 재시도, 브로커 재전달, 사용자의 중복 클릭으로 같은 요청이 여러 번 도착할 수 있습니다. 따라서 요청 키나 이벤트 ID를 기준으로 이미 처리한 작업을 식별하고, 같은 요청을 반복 처리해도 최종 결과가 같도록 설계해야 합니다.
멱등성은 “중복 요청을 무시한다”만으로 끝나지 않습니다. 요청 키를 유니크하게 저장하고, 처리 중/성공/실패 상태와 이전 응답을 함께 남겨야 재시도 시 같은 결과를 안정적으로 돌려줄 수 있습니다. 키 저장과 업무 변경은 가능하면 같은 트랜잭션 안에서 원자적으로 처리합니다.
낙관적 vs 비관적 동시성 제어
트랜잭션이 동시에 같은 데이터를 수정할 때 충돌을 언제 감지하고 어떻게 기다리거나 재시도할지 정하는 전략입니다.
@Entity
public class Product {
@Id
private Long id;
private int stock;
@Version // 자동으로 version 관리
private int version;
}
// 충돌 시 OptimisticLockException 발생
// → 재조회 후 제한된 횟수만 재시도| 전략 | 방식 | 충돌 처리 | 적합 상황 |
|---|---|---|---|
| 비관적 | SELECT FOR UPDATE 등 | 먼저 잠그고 대기/타임아웃 | 충돌이 빈번하거나 손실 비용이 큼 |
| 낙관적 | 버전 컬럼 또는 조건부 갱신 | 커밋 시점에 감지 후 재시도 | 충돌이 드물고 재시도가 쉬움 |
JPA의 @Version은 엔티티 변경에는 잘 동작하지만, JPQL 벌크 업데이트나 네이티브 SQL은 버전 체크를 우회할 수 있습니다. 낙관적 재시도는 무한 반복하지 말고 backoff와 최대 횟수를 둡니다. 비관적 잠금은 DBMS별 차이가 큽니다. MySQL InnoDB는 격리 수준과 인덱스 조건에 따라 record lock, gap lock, next-key lock 범위가 달라질 수 있고, PostgreSQL/Oracle은 NOWAIT, SKIP LOCKED, lock wait timeout 같은 선택지가 다릅니다. 인덱스가 부실하면 잠금 범위가 커질 수 있으며, 여러 테이블을 잠글 때는 접근 순서를 통일해야 데드락을 줄일 수 있습니다.
배치 처리의 트랜잭션
대량 데이터를 처리하는 배치 작업은 전체를 한 트랜잭션으로 묶기보다 적절한 chunk 단위로 커밋합니다. chunk 크기는 메모리, 로그 사용량, 락 보유 시간, 실패 후 재시작 비용을 함께 보고 정합니다.
chunk 단위 커밋은 업무 원자성을 쪼개는 결정이기도 합니다. 이미 커밋된 chunk는 전체 작업 실패 시 자동으로 되돌릴 수 없으므로, 재시작 지점, 중복 실행 안전성, skip/retry 정책, 보정 작업을 함께 설계해야 합니다.
ItemReader → ItemProcessor → ItemWriter
│
chunk 단위 커밋
chunk-size=1000
1,000건 읽기 → 처리 → 쓰기 → COMMIT
1,000건 읽기 → 처리 → 쓰기 → COMMIT
...
→ 실패 시 정책에 따라 해당 chunk ROLLBACK, 재시도, skip트랜잭션 실무 체크리스트
□ 트랜잭션 경계가 서비스 레이어에 있는가?
□ 트랜잭션 내에 외부 API 호출이 없는가?
□ 읽기 전용 메서드에 readOnly=true를 설정했는가?
□ 트랜잭션 전파 속성이 적절한가?
□ 장시간 락을 잡는 트랜잭션 가능성은 없는가?
□ 배치 처리 시 적절한 단위로 커밋하고 있는가?
□ 예외 처리 시 롤백 정책을 확인했는가?
□ 분산 환경에서 보상 트랜잭션이 정의되어 있는가?
□ 멱등성이 보장되는가?
□ 데드락 방지를 위해 테이블 접근 순서를 통일했는가?정리
트랜잭션 실무에서 가장 중요한 것은 적절한 범위 설정입니다. 트랜잭션을 너무 넓게 잡으면 동시성이 떨어지고, 너무 좁게 잡으면 비즈니스 원자성이나 불변식이 흔들릴 수 있습니다.
단일 DB 환경에서는 서비스 레이어에서 @Transactional로 유스케이스 경계를 잡고, 전파 속성으로 중첩 호출의 참여 방식을 제어합니다. 분산 환경에서는 2PC, Saga, Outbox를 요구 일관성, 지연 허용도, 운영 복잡도에 맞춰 선택합니다.
낙관적/비관적 잠금은 충돌 빈도와 실패 비용에 따라 선택합니다. 읽기가 많고 충돌이 드문 화면은 낙관적 잠금과 재시도가 잘 맞고, 금융 정산이나 재고 차감처럼 충돌 비용이 큰 흐름은 비관적 잠금이나 원자적 갱신을 검토합니다.
시험에서는 2PC의 두 단계(Prepare와 Commit)와 블로킹 문제, Saga 패턴의 보상 트랜잭션 개념, Choreography와 Orchestration의 차이, 낙관적 잠금과 비관적 잠금의 구분이 빈출됩니다.
다음 장에서는 동시성 문제의 구체적인 유형과 해결책인 동시성 제어를 다루겠습니다.