Spring 트랜잭션 전파 완벽 정리: REQUIRED, REQUIRES_NEW, rollback-only와 UnexpectedRollbackException
개인 학습용 Spring 트랜잭션 기술 노트 2회차
학습 방식: 개념 이해 → 실행 흐름 분석 → 실전 문제 → 직접 작성한 답안 → 해설 → 실무 설계
들어가며
지난 학습에서는 Spring의 @Transactional이 일반적으로 AOP 프록시를 통해 동작한다는 점을 공부했다.
핵심은 다음 두 문장이었다.
@Transactional메서드 호출이 Spring 프록시를 통과해야 트랜잭션 기능이 적용된다.
롤백 규칙에 해당하는 예외가 트랜잭션 프록시까지 전달되어야 Spring이 롤백을 판단할 수 있다.
이번에는 한 단계 더 들어가 본다. 실제 업무 시스템에서는 하나의 서비스가 다른 서비스를 호출하는 경우가 많다.
이관 실행 서비스
↓
마스터 등록 서비스
↓
상세 등록 서비스
↓
결과 이력 서비스
각 메서드에 @Transactional이 붙어 있다면 트랜잭션은 몇 개가 만들어질까? 내부 서비스에서 오류가 발생하면 외부 서비스까지 모두 롤백될까? 실패 로그만 별도로 저장하려면 어떻게 해야 할까?
이 질문을 해결하는 개념이 트랜잭션 전파 속성(Transaction Propagation)이다.
이번 글에서는 다음 내용을 상세히 정리한다.
- 트랜잭션 전파 속성의 의미
- 논리적 트랜잭션과 물리적 트랜잭션의 차이
- 기본 전파 속성
REQUIRED - rollback-only와
UnexpectedRollbackException - 독립 트랜잭션
REQUIRES_NEW - 두 전파 속성의 커밋·롤백 결과 비교
- 동일 클래스 내부 호출 문제
- 실전 문제와 직접 작성한 답안
- 답안 평가와 올바른 수정 코드
- 데이터 이관 시스템에 적용할 때의 실무 설계
- 커넥션 풀과 동시성 관련 주의사항
- 테스트 및 장애 점검 방법
1. 트랜잭션 전파 속성이란 무엇인가
트랜잭션 전파 속성은 트랜잭션 메서드가 다른 트랜잭션 메서드를 호출할 때 다음을 결정하는 규칙이다.
호출된 메서드가 기존 트랜잭션에 참여할 것인가, 새로운 트랜잭션을 시작할 것인가?
다음과 같이 주문 서비스가 결제 서비스를 호출한다고 가정하자.
@Service
@RequiredArgsConstructor
public class OrderService {
private final PaymentService paymentService;
private final OrderRepository orderRepository;
@Transactional
public void createOrder() {
orderRepository.insertOrder();
paymentService.pay();
}
}
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
@Transactional
public void pay() {
paymentRepository.insertPayment();
}
}
createOrder()와 pay()에 모두 @Transactional이 선언되어 있다. 애너테이션이 두 개이므로 트랜잭션도 두 개라고 생각하기 쉽다.
그러나 기본 전파 속성에서는 두 메서드가 하나의 물리적 트랜잭션을 공유한다.
@Transactional의 기본 전파 속성이 Propagation.REQUIRED이기 때문이다.
@Transactional
public void pay() {
}
위 선언은 전파 속성 관점에서 다음과 같은 의미다.
@Transactional(propagation = Propagation.REQUIRED)
public void pay() {
}
2. 논리적 트랜잭션과 물리적 트랜잭션
REQUIRED를 정확하게 이해하려면 논리적 트랜잭션과 물리적 트랜잭션을 구분해야 한다.
2.1 논리적 트랜잭션
@Transactional이 선언된 각 메서드 범위를 논리적 트랜잭션 범위라고 볼 수 있다.
@Transactional
public void createOrder() {
paymentService.pay();
}
@Transactional
public void pay() {
}
위 코드에는 애너테이션이 선언된 메서드 범위가 두 개이므로 논리적인 트랜잭션 범위도 두 개다.
논리 범위 1: createOrder()
논리 범위 2: pay()
2.2 물리적 트랜잭션
물리적 트랜잭션은 실제 데이터베이스 커넥션에서 시작되고 커밋 또는 롤백되는 트랜잭션이다.
REQUIRED에서는 내부 논리 범위가 기존 트랜잭션에 참여하므로 여러 논리 범위가 하나의 물리적 트랜잭션에 연결될 수 있다.
물리적 트랜잭션 A
├─ 논리적 트랜잭션: createOrder()
└─ 논리적 트랜잭션: pay()
이 구조에서는 createOrder()와 pay()가 서로 다른 서비스 메서드여도 실제 커밋과 롤백은 하나의 물리적 단위로 처리된다.
2.3 구분이 중요한 이유
내부 논리 범위에서 롤백이 필요하다고 판단하면 공유 중인 물리적 트랜잭션 전체에 영향을 준다.
즉, pay()가 롤백 대상 예외를 발생시키면 결제 INSERT만 취소되는 것이 아니라 같은 물리적 트랜잭션에 속한 주문 INSERT도 함께 취소된다.
3. Propagation.REQUIRED
REQUIRED는 Spring 선언적 트랜잭션의 기본 전파 속성이다.
동작 규칙은 다음과 같다.
| 호출 시점 | REQUIRED 동작 |
|---|---|
| 기존 트랜잭션이 없음 | 새 트랜잭션을 시작한다 |
| 기존 트랜잭션이 있음 | 기존 트랜잭션에 참여한다 |
3.1 기존 트랜잭션이 없는 경우
@Transactional
public void createOrder() {
orderRepository.insertOrder();
}
외부의 비트랜잭션 코드가 createOrder()를 호출하면 프록시는 새로운 트랜잭션을 시작한다.
Controller 호출
↓
OrderService 프록시
↓
새 트랜잭션 A 시작
↓
createOrder() 실행
↓
정상 종료
↓
트랜잭션 A 커밋
3.2 기존 트랜잭션이 있는 경우
@Transactional
public void createOrder() {
orderRepository.insertOrder();
paymentService.pay();
}
@Transactional
public void pay() {
paymentRepository.insertPayment();
}
createOrder()가 이미 트랜잭션 A를 시작했으므로 pay()는 새 트랜잭션을 생성하지 않고 트랜잭션 A에 참여한다.
트랜잭션 A 시작
↓
주문 INSERT
↓
pay()가 트랜잭션 A에 참여
↓
결제 INSERT
↓
트랜잭션 A 커밋
최종 결과는 다음과 같다.
| 데이터 | 결과 |
|---|---|
| 주문 데이터 | 커밋 |
| 결제 데이터 | 커밋 |
3.3 내부 서비스에서 예외가 발생하는 경우
@Transactional
public void pay() {
paymentRepository.insertPayment();
throw new PaymentException("결제 처리 실패");
}
PaymentException이 RuntimeException을 상속한다고 가정하자.
public class PaymentException extends RuntimeException {
public PaymentException(String message) {
super(message);
}
}
내부 메서드에서 롤백 대상 예외가 발생하면 공유 중인 물리적 트랜잭션 A가 롤백 대상으로 표시된다.
트랜잭션 A
├─ 주문 INSERT
└─ 결제 INSERT → RuntimeException
결과: 트랜잭션 A 전체 롤백
| 데이터 | 결과 |
|---|---|
| 주문 데이터 | 롤백 |
| 결제 데이터 | 롤백 |
이것이 REQUIRED의 가장 중요한 특징이다.
같은 물리적 트랜잭션에 참여한 변경 작업은 함께 커밋되거나 함께 롤백된다.
4. rollback-only란 무엇인가
트랜잭션을 즉시 롤백하지 않고, 해당 트랜잭션이 최종적으로 커밋될 수 없도록 표시하는 상태가 있다. 이를 일반적으로 rollback-only 상태라고 한다.
다음 코드를 보자.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
@Transactional
public void createOrder() {
orderRepository.insertOrder();
try {
paymentService.pay();
} catch (PaymentException e) {
log.error("결제 실패", e);
}
orderRepository.updateStatus("COMPLETED");
}
}
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
@Transactional
public void pay() {
paymentRepository.insertPayment();
throw new PaymentException("결제 처리 실패");
}
}
외부 메서드에서 예외를 잡았으므로 createOrder()는 계속 실행된다.
try {
paymentService.pay();
} catch (PaymentException e) {
log.error("결제 실패", e);
}
그 뒤 상태 업데이트도 실행된다.
orderRepository.updateStatus("COMPLETED");
그러나 내부 pay()를 감싼 트랜잭션 인터셉터는 롤백 대상 예외를 확인했다. pay()가 외부 트랜잭션과 같은 물리적 트랜잭션을 사용하고 있으므로 그 트랜잭션은 rollback-only로 표시된다.
처리 흐름은 다음과 같다.
트랜잭션 A 시작
↓
주문 INSERT
↓
결제 INSERT
↓
PaymentException 발생
↓
내부 트랜잭션 범위가 트랜잭션 A를 rollback-only로 표시
↓
외부 createOrder()에서 예외를 catch
↓
COMPLETED 상태 UPDATE 실행
↓
외부 메서드 정상 종료
↓
트랜잭션 A 커밋 시도
↓
rollback-only 상태 발견
↓
전체 롤백
4.1 예외를 잡으면 rollback-only가 해제되는가
아니다.
예외를 catch했다
≠
트랜잭션의 rollback-only 상태가 해제됐다
catch는 Java 예외의 전파를 멈추는 문법이다. 이미 트랜잭션 인터셉터가 표시한 rollback-only 상태를 정상 상태로 되돌리는 기능이 아니다.
4.2 SQL 실행과 커밋은 다르다
updateStatus("COMPLETED") SQL이 실제로 실행되더라도 최종 커밋 시점에 트랜잭션 전체가 롤백될 수 있다.
따라서 다음 두 문장은 전혀 다른 의미다.
- UPDATE SQL이 실행됐다.
- UPDATE 결과가 최종 커밋됐다.
트랜잭션 장애를 분석할 때 로그에서 SQL이 실행된 사실만 보고 커밋됐다고 판단하면 안 된다.
5. UnexpectedRollbackException
외부 트랜잭션 메서드는 내부 예외를 잡았기 때문에 자신이 정상적으로 완료됐다고 생각할 수 있다. 따라서 마지막에 커밋을 요청한다.
그러나 실제 물리적 트랜잭션은 내부 범위에 의해 이미 rollback-only로 표시되어 있다. Spring은 이 상황에서 전체 트랜잭션을 롤백하고 UnexpectedRollbackException을 발생시킬 수 있다.
org.springframework.transaction.UnexpectedRollbackException:
Transaction rolled back because it has been marked as rollback-only
5.1 왜 이런 예외가 필요한가
만약 Spring이 아무런 신호 없이 롤백한다면 호출자는 커밋이 성공했다고 오해할 수 있다.
orderService.createOrder();
// 예외가 없으면 주문이 저장됐다고 오해할 수 있다.
sendSuccessMessage();
실제로는 롤백됐는데 정상 반환된다면 데이터 정합성과 후속 처리에 큰 문제가 생긴다. 따라서 Spring은 외부 호출자에게 커밋이 수행되지 않았음을 명확하게 알린다.
5.2 대표적인 발생 조건
다음 조건이 결합될 때 자주 발생한다.
- 외부 메서드가 트랜잭션을 시작한다.
- 내부 메서드가
REQUIRED로 같은 트랜잭션에 참여한다. - 내부 메서드에서 롤백 대상 예외가 발생한다.
- 내부 트랜잭션 인터셉터가 공유 트랜잭션을 rollback-only로 표시한다.
- 외부 메서드가 내부 예외를 잡고 정상 종료한다.
- 외부 프록시가 커밋을 시도한다.
- 이미 rollback-only이므로 롤백되고
UnexpectedRollbackException이 발생한다.
5.3 예외를 무조건 제거하면 되는가
아니다. UnexpectedRollbackException은 원인이 아니라 결과다.
다음을 먼저 판단해야 한다.
- 내부 실패가 발생하면 외부 업무도 실패해야 하는가?
- 내부 작업만 독립적으로 실패해도 되는가?
- 외부 메서드가 예외를 잡는 것이 업무적으로 맞는가?
- 트랜잭션 경계를 잘못 나눈 것은 아닌가?
업무 전체가 원자적이어야 한다면 예외를 잡지 않고 전체 실패로 처리하는 것이 맞다. 내부 작업만 독립적으로 실패해도 된다면 REQUIRES_NEW 등의 별도 트랜잭션 설계를 검토한다.
6. Propagation.REQUIRES_NEW
REQUIRES_NEW는 기존 트랜잭션 유무와 관계없이 항상 독립적인 물리적 트랜잭션을 시작한다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveFailureLog(String message) {
failureLogRepository.insert(message);
}
동작 규칙은 다음과 같다.
| 호출 시점 | REQUIRES_NEW 동작 |
|---|---|
| 기존 트랜잭션이 없음 | 새 트랜잭션 시작 |
| 기존 트랜잭션이 있음 | 기존 트랜잭션을 일시 중단하고 새 트랜잭션 시작 |
6.1 실행 흐름
외부 트랜잭션 A에서 REQUIRES_NEW 메서드를 호출한다고 가정하자.
외부 트랜잭션 A 시작
↓
외부 작업 실행
↓
REQUIRES_NEW 메서드 호출
↓
트랜잭션 A 일시 중단
↓
독립 트랜잭션 B 시작
↓
내부 작업 실행
↓
트랜잭션 B 커밋 또는 롤백
↓
트랜잭션 A 재개
↓
외부 작업 계속
트랜잭션 A와 B는 서로 다른 물리적 트랜잭션이므로 독립적으로 커밋하거나 롤백할 수 있다.
6.2 실패 로그 저장 예제
@Service
@RequiredArgsConstructor
public class OrderService {
private final FailureLogService failureLogService;
private final OrderRepository orderRepository;
@Transactional
public void createOrder() {
try {
orderRepository.insertOrder();
processPayment();
} catch (Exception e) {
failureLogService.saveFailureLog(e.getMessage());
throw e;
}
}
}
@Service
@RequiredArgsConstructor
public class FailureLogService {
private final FailureLogRepository failureLogRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveFailureLog(String message) {
failureLogRepository.insert(message);
}
}
최종 결과는 다음과 같다.
| 작업 | 트랜잭션 | 결과 |
|---|---|---|
| 주문 INSERT | A | 롤백 |
| 실패 로그 INSERT | B | 커밋 |
실패 로그는 트랜잭션 B에서 이미 커밋됐으므로 외부 트랜잭션 A가 롤백되어도 유지된다.
7. REQUIRED와 REQUIRES_NEW 비교
| 구분 | REQUIRED |
REQUIRES_NEW |
|---|---|---|
| 기본값 여부 | @Transactional의 기본값 |
명시적으로 지정 |
| 기존 트랜잭션 존재 | 기존 트랜잭션에 참여 | 기존 트랜잭션을 중단 |
| 물리적 트랜잭션 | 외부와 공유 | 독립적으로 생성 |
| 내부 롤백의 외부 영향 | 외부 트랜잭션 커밋도 막을 수 있음 | 외부 rollback-only 상태에 직접 영향 없음 |
| 외부 롤백의 내부 영향 | 함께 롤백 | 내부가 이미 커밋됐다면 유지 |
| DB 커넥션 | 일반적으로 기존 자원 공유 | 별도 커넥션 필요 가능 |
| 대표 용도 | 하나의 원자적 업무 단위 | 독립 로그, 감사 이력, 별도 실패 처리 |
7.1 REQUIRED가 적합한 경우
- 주문과 주문 상세는 반드시 함께 저장돼야 한다.
- 부모와 자식 데이터 중 하나라도 실패하면 전체 이관을 취소해야 한다.
- 계좌 이체의 출금과 입금이 하나의 트랜잭션이어야 한다.
- 게시글 저장과 첨부파일 메타데이터 저장이 함께 성공해야 한다.
7.2 REQUIRES_NEW가 적합할 수 있는 경우
- 본 업무가 롤백돼도 실패 로그를 남겨야 한다.
- 독립적인 감사 이력을 확정해야 한다.
- 대량 처리에서 각 건을 독립적으로 성공 또는 실패 처리한다.
- 외부 작업과 분리된 상태 기록이 반드시 필요하다.
7.3 REQUIRES_NEW가 위험한 경우
다음과 같이 주문과 결제를 별도 트랜잭션으로 무작정 나누면 정합성 문제가 생길 수 있다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void pay() {
paymentRepository.insertPayment();
}
결제 트랜잭션 B 커밋
↓
외부 주문 트랜잭션 A에서 이후 오류 발생
↓
주문 트랜잭션 A 롤백
최종적으로 주문은 없는데 결제만 남을 수 있다.
따라서 REQUIRES_NEW는 기술적인 롤백 회피 수단이 아니다.
독립적으로 커밋되어도 업무적으로 문제가 없는 작업에만 사용해야 한다.
8. REQUIRES_NEW도 내부 호출이면 적용되지 않는다
다음 코드는 새로운 트랜잭션이 생길 것처럼 보인다.
@Service
public class MigrationService {
@Transactional
public void migrate() {
saveResultLog();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveResultLog() {
// 결과 로그 저장
}
}
그러나 migrate() 안의 호출은 실질적으로 다음과 같다.
this.saveResultLog();
동일 객체 내부 호출이므로 saveResultLog()를 위한 프록시 인터셉터가 실행되지 않는다. 결과적으로 REQUIRES_NEW 선언도 해석되지 않는다.
따라서 독립 트랜잭션을 사용하려면 보통 별도 Spring Bean으로 분리한다.
@Service
@RequiredArgsConstructor
public class MigrationService {
private final ResultLogService resultLogService;
@Transactional
public void migrate() {
resultLogService.saveResultLog();
}
}
@Service
@RequiredArgsConstructor
public class ResultLogService {
private final ResultLogRepository resultLogRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveResultLog() {
resultLogRepository.insert();
}
}
이제 MigrationService가 다른 빈인 ResultLogService를 호출하므로 프록시를 통과하고 새 트랜잭션을 시작할 수 있다.
9. 실전 문제
다음은 마스터 데이터와 상세 데이터를 이관하는 예제다.
@Service
@RequiredArgsConstructor
public class MigrationService {
private final MigrationDetailService detailService;
private final MigrationRepository migrationRepository;
@Transactional
public void migrate() {
migrationRepository.insertMaster("123");
try {
detailService.insertDetail("123");
} catch (Exception e) {
log.error("상세 데이터 이관 실패", e);
}
migrationRepository.updateStatus("123", "COMPLETED");
}
}
@Service
@RequiredArgsConstructor
public class MigrationDetailService {
private final MigrationRepository migrationRepository;
@Transactional
public void insertDetail(String rcptNo) {
migrationRepository.insertDetail(rcptNo);
throw new RuntimeException("상세 데이터 오류");
}
}
질문 1
migrate()와 insertDetail()은 같은 트랜잭션을 사용하는가?
질문 2
insertDetail()에서 예외가 발생하면 상세 데이터만 롤백되는가, 전체 데이터가 롤백되는가?
질문 3
외부 migrate()가 예외를 잡았으므로 COMPLETED 상태는 정상적으로 커밋되는가?
질문 4
상세 데이터 등록에 실패하더라도 마스터 데이터와 상태 값을 반드시 커밋하려면 어떻게 변경해야 하는가?
10. 직접 작성한 답안
직접 작성한 답안은 다음과 같다.
1. 같은 트랜잭션입니다.
2. 전체 데이터 롤백입니다.
3. 커밋 안 됩니다.
4번 문제에는 별도의 결과 저장 서비스를 만들고 REQUIRES_NEW를 적용하는 코드를 작성했다.
@Service
@RequiredArgsConstructor
public class ResultLogService {
private final FailureLogRepository failureLogRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveResultLog(String message) {
migrationRepository.updateStatus("123", "COMPLETED");
}
}
11. 답안 평가
11.1 문제 1: 정답
migrate()와 insertDetail()의 전파 속성은 모두 기본값인 REQUIRED다.
migrate()가 먼저 트랜잭션 A를 시작했고, insertDetail()은 기존 트랜잭션 A에 참여한다.
물리적 트랜잭션 A
├─ migrate() 논리 범위
└─ insertDetail() 논리 범위
따라서 같은 물리적 트랜잭션을 사용한다는 답은 정확하다.
11.2 문제 2: 정답
insertDetail()에서 발생한 RuntimeException은 기본 롤백 대상이다. 내부 트랜잭션 범위는 공유 중인 트랜잭션 A를 rollback-only로 표시한다.
결과적으로 트랜잭션 A에 포함된 전체 변경 사항이 롤백된다.
| 변경 작업 | 결과 |
|---|---|
| 마스터 INSERT | 롤백 |
| 상세 INSERT | 롤백 |
| 상태 UPDATE | 롤백 |
11.3 문제 3: 정답
외부 migrate()에서 예외를 잡았으므로 상태 UPDATE 코드는 실행될 수 있다.
그러나 트랜잭션 A는 이미 rollback-only이므로 최종 커밋이 불가능하다. 따라서 COMPLETED 상태는 DB에 최종 반영되지 않는다.
외부 트랜잭션이 커밋을 시도하는 시점에 UnexpectedRollbackException이 발생할 수 있다.
11.4 문제 4: 방향은 좋지만 적용 대상 보완 필요
작성한 코드에서 좋은 부분은 다음과 같다.
- 별도 서비스 클래스로 분리했다.
- 별도 Spring Bean 호출을 통해 프록시를 통과할 수 있게 했다.
REQUIRES_NEW로 독립 트랜잭션을 만들려 했다.
하지만 상태 변경만 별도 트랜잭션으로 분리하면 마스터 INSERT는 여전히 외부 트랜잭션 A에 남는다.
트랜잭션 A
├─ 마스터 INSERT
└─ 상세 INSERT → 실패 → 트랜잭션 A 롤백
트랜잭션 B
└─ 상태 COMPLETED UPDATE → 커밋
이 구조에서는 다음과 같은 모순된 결과가 생길 수 있다.
| 데이터 | 결과 |
|---|---|
| 마스터 데이터 | 롤백 |
| 상세 데이터 | 롤백 |
| 상태 값 | 독립 커밋 시도 |
마스터 데이터가 존재하지 않는데 상태만 COMPLETED가 되는 구조는 정상적인 업무 모델이 아니다.
또한 예제 코드에는 필드와 실제 사용 객체가 일치하지 않는 문제가 있다.
private final FailureLogRepository failureLogRepository;
를 주입했지만 메서드에서는 다음 객체를 사용한다.
migrationRepository.updateStatus(...);
따라서 실제 코드에서는 Repository 선언도 일치시켜야 한다.
12. 문제 조건을 충족하는 정답 코드
문제의 기술적 조건은 다음과 같다.
상세 데이터 등록이 실패하더라도 마스터 데이터와 상태 변경은 커밋한다.
이 조건을 만족하려면 실패 가능성이 있는 상세 데이터 등록을 외부 트랜잭션에서 분리하는 것이 가장 단순하다.
12.1 외부 이관 서비스
@Slf4j
@Service
@RequiredArgsConstructor
public class MigrationService {
private final MigrationDetailService detailService;
private final MigrationRepository migrationRepository;
@Transactional
public void migrate() {
String rcptNo = "123";
migrationRepository.insertMaster(rcptNo);
try {
detailService.insertDetail(rcptNo);
} catch (Exception e) {
log.error("상세 데이터 이관 실패. rcptNo={}", rcptNo, e);
}
migrationRepository.updateStatus(rcptNo, "COMPLETED");
}
}
12.2 상세 데이터 서비스
@Service
@RequiredArgsConstructor
public class MigrationDetailService {
private final MigrationRepository migrationRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void insertDetail(String rcptNo) {
migrationRepository.insertDetail(rcptNo);
throw new RuntimeException("상세 데이터 오류");
}
}
12.3 실제 실행 흐름
MigrationService 프록시
↓
외부 트랜잭션 A 시작
↓
마스터 INSERT
↓
MigrationDetailService 프록시 호출
↓
외부 트랜잭션 A 일시 중단
↓
독립 트랜잭션 B 시작
↓
상세 INSERT
↓
RuntimeException 발생
↓
트랜잭션 B 롤백
↓
외부 트랜잭션 A 재개
↓
MigrationService에서 예외 catch
↓
상태 COMPLETED UPDATE
↓
트랜잭션 A 커밋
12.4 최종 데이터 결과
| 데이터 | 트랜잭션 | 결과 |
|---|---|---|
| 마스터 데이터 | A | 커밋 |
| 상세 데이터 | B | 롤백 |
| 상태 값 | A | 커밋 |
상세 작업의 실패는 독립 트랜잭션 B의 rollback-only 상태에만 영향을 준다. 외부 트랜잭션 A는 상세 예외를 잡은 후 정상적으로 계속 실행할 수 있다.
13. 실무에서는 COMPLETED 상태가 맞는가
시험 문제의 조건을 기술적으로 충족하는 것과 실제 업무 상태를 올바르게 설계하는 것은 별개의 문제다.
상세 데이터가 실패했는데 상태를 COMPLETED로 기록하면 운영자와 후속 시스템이 전체 이관이 성공했다고 오해할 수 있다.
실무에서는 다음처럼 상태를 구분하는 편이 더 자연스럽다.
| 상황 | 권장 상태 예시 |
|---|---|
| 마스터와 상세 모두 성공 | COMPLETED |
| 마스터 성공, 상세 일부 또는 전체 실패 | PARTIAL_FAILED |
| 이관 시작 전 | READY |
| 이관 진행 중 | PROCESSING |
| 전체 실패 | FAILED |
13.1 개선 코드
@Slf4j
@Service
@RequiredArgsConstructor
public class MigrationService {
private final MigrationDetailService detailService;
private final MigrationRepository migrationRepository;
@Transactional
public void migrate(String rcptNo) {
migrationRepository.insertMaster(rcptNo);
try {
detailService.insertDetail(rcptNo);
migrationRepository.updateStatus(rcptNo, "COMPLETED");
} catch (Exception e) {
migrationRepository.updateStatus(rcptNo, "PARTIAL_FAILED");
log.error("상세 데이터 이관 실패. rcptNo={}", rcptNo, e);
}
}
}
@Service
@RequiredArgsConstructor
public class MigrationDetailService {
private final MigrationRepository migrationRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void insertDetail(String rcptNo) {
migrationRepository.insertDetail(rcptNo);
validateDetail(rcptNo);
}
private void validateDetail(String rcptNo) {
// 상세 데이터 검증
}
}
최종 결과는 다음과 같다.
| 상세 처리 | 마스터 | 상세 | 상태 |
|---|---|---|---|
| 성공 | 커밋 | 커밋 | COMPLETED |
| 실패 | 커밋 | 롤백 | PARTIAL_FAILED |
이렇게 하면 실패 건을 검색해 재처리할 수 있고 운영자가 데이터 상태를 정확하게 이해할 수 있다.
14. 실패 로그만 별도 커밋하는 구조
본 업무는 전체 롤백하되 실패 이력만 반드시 남겨야 하는 요구사항도 많다.
이 경우에는 상세 작업이 아니라 실패 로그 저장에 REQUIRES_NEW를 적용할 수 있다.
14.1 실패 로그 서비스
@Service
@RequiredArgsConstructor
public class FailureLogService {
private final FailureLogRepository failureLogRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void save(String rcptNo, String message) {
failureLogRepository.insert(rcptNo, message);
}
}
14.2 외부 이관 서비스
@Service
@RequiredArgsConstructor
public class MigrationService {
private final MigrationRepository migrationRepository;
private final FailureLogService failureLogService;
@Transactional
public void migrate(String rcptNo) {
try {
migrationRepository.insertMaster(rcptNo);
migrationRepository.insertDetails(rcptNo);
} catch (Exception e) {
failureLogService.save(rcptNo, e.getMessage());
throw e;
}
}
}
결과는 다음과 같다.
| 데이터 | 결과 |
|---|---|
| 마스터 | 롤백 |
| 상세 | 롤백 |
| 실패 로그 | 커밋 |
이 구조에서는 본 데이터가 모두 취소되지만 원인 분석과 재처리를 위한 실패 이력은 남는다.
중요한 점은 두 설계의 목적이 다르다는 것이다.
| 설계 | REQUIRES_NEW 적용 대상 |
목적 |
|---|---|---|
| 마스터는 살리고 상세만 취소 | 상세 등록 서비스 | 상세 실패를 외부 트랜잭션과 격리 |
| 본 데이터는 전체 취소하고 로그만 보존 | 실패 로그 서비스 | 실패 이력 독립 커밋 |
따라서 REQUIRES_NEW를 어디에 붙일지는 원하는 최종 데이터 상태를 먼저 정의한 뒤 결정해야 한다.
15. 실패 로그 저장도 실패할 수 있다
실패 로그를 REQUIRES_NEW로 저장하더라도 로그 테이블 INSERT 자체가 실패할 수 있다.
catch (Exception originalException) {
failureLogService.save(rcptNo, originalException.getMessage());
throw originalException;
}
위 구조에서 로그 저장이 새로운 예외를 발생시키면 최초 업무 예외가 가려질 수 있다.
필요하다면 다음과 같이 각각 처리한다.
catch (Exception originalException) {
try {
failureLogService.save(rcptNo, originalException.getMessage());
} catch (Exception logException) {
log.error("실패 이력 저장도 실패했습니다. rcptNo={}", rcptNo, logException);
}
throw originalException;
}
다만 중요한 감사 이력이라면 단순히 로그를 남기고 끝내기보다 메시지 큐, 재시도 테이블, 모니터링 알람 등 더 신뢰성 있는 구조를 검토해야 한다.
16. REQUIRES_NEW와 데이터베이스 커넥션
REQUIRES_NEW는 기존 물리적 트랜잭션과 독립적인 물리적 트랜잭션을 사용한다.
외부 트랜잭션 A가 커넥션 1을 보유한 상태에서 내부 트랜잭션 B가 새 커넥션을 요청할 수 있다.
작업 스레드
├─ 외부 트랜잭션 A → 커넥션 1 보유
└─ 내부 트랜잭션 B → 커넥션 2 추가 요청
여러 스레드가 동시에 같은 패턴으로 실행되면 커넥션 풀 고갈 위험이 생긴다.
예를 들어 다음과 같은 상황을 생각할 수 있다.
커넥션 풀 크기: 10
동시 실행 스레드: 10
10개 스레드가 외부 트랜잭션용 커넥션을 각각 하나씩 보유
↓
모든 스레드가 REQUIRES_NEW를 위해 추가 커넥션 요청
↓
남은 커넥션 0개
↓
각 스레드가 새 커넥션을 기다림
Spring 공식 문서도 REQUIRES_NEW 사용 시 외부 트랜잭션 자원은 계속 바인딩된 채 내부 트랜잭션이 별도 자원을 획득하므로, 커넥션 풀 고갈과 교착 위험을 주의해야 한다고 설명한다.
따라서 다음을 확인해야 한다.
- 최대 동시 요청 수
- 커넥션 풀 최대 크기
REQUIRES_NEW중첩 횟수- 트랜잭션 유지 시간
- 커넥션 획득 타임아웃
- 장시간 SQL 또는 락 대기 여부
REQUIRES_NEW를 반복문 안에서 건마다 호출하는 대량 이관 구조라면 처리량과 커넥션 사용량을 반드시 부하 테스트해야 한다.
17. REQUIRES_NEW의 독립성에서 생기는 잠금 문제
외부 트랜잭션 A가 어떤 행을 변경하고 아직 커밋하지 않은 상태에서 내부 트랜잭션 B가 같은 행을 수정하려고 하면 잠금 대기가 발생할 수 있다.
트랜잭션 A: MASTER 123번 행 UPDATE 후 잠금 보유
↓
트랜잭션 B: 같은 MASTER 123번 행 UPDATE 시도
↓
트랜잭션 A가 B의 종료를 기다리는 구조
↓
트랜잭션 B는 A의 잠금 해제를 기다림
내부 트랜잭션에서 외부 트랜잭션이 잠근 동일 데이터를 수정하도록 설계하면 예기치 않은 락 대기나 교착 문제가 생길 수 있다.
따라서 독립 로그 테이블처럼 외부 트랜잭션과 잠금 대상이 겹치지 않는 작업이 REQUIRES_NEW에 상대적으로 적합하다.
18. NESTED와 REQUIRES_NEW는 다르다
두 속성 모두 내부 작업만 되돌리는 것처럼 보일 수 있지만 구조가 다르다.
| 구분 | REQUIRES_NEW |
NESTED |
|---|---|---|
| 물리적 트랜잭션 | 별도 트랜잭션 | 일반적으로 하나의 물리 트랜잭션 |
| 내부 롤백 | 독립 트랜잭션 롤백 | 저장점까지 부분 롤백 |
| 내부 커밋 | 외부보다 먼저 독립 커밋 가능 | 외부 최종 커밋에 의존 |
| 외부 롤백 시 내부 결과 | 이미 커밋됐다면 유지 | 함께 롤백 |
| 주요 기술 | 별도 자원·커넥션 | JDBC Savepoint |
NESTED는 JDBC 저장점 지원 여부와 트랜잭션 매니저에 따라 사용 가능성이 달라진다. 단순히 이름만 보고 중첩 서비스에 적용해서는 안 된다.
19. 테스트로 결과 확인하기
트랜잭션 전파는 코드만 읽고 판단하기 어려운 경우가 많다. 최종 DB 상태를 검증하는 통합 테스트를 만드는 것이 좋다.
19.1 REQUIRED 전체 롤백 테스트
@SpringBootTest
class MigrationRequiredTest {
@Autowired
private MigrationService migrationService;
@Autowired
private MigrationRepository migrationRepository;
@Test
void 상세_서비스가_REQUIRED이면_전체_롤백된다() {
assertThatThrownBy(() -> migrationService.migrateRequired("123"))
.isInstanceOf(UnexpectedRollbackException.class);
assertThat(migrationRepository.existsMaster("123")).isFalse();
assertThat(migrationRepository.existsDetail("123")).isFalse();
}
}
실제 예외 타입은 서비스 코드가 예외를 잡는 위치와 트랜잭션 경계에 따라 달라질 수 있으므로 테스트 목적에 맞게 조정한다.
19.2 REQUIRES_NEW 격리 테스트
@SpringBootTest
class MigrationRequiresNewTest {
@Autowired
private MigrationService migrationService;
@Autowired
private MigrationRepository migrationRepository;
@Test
void 상세_실패를_독립시켰으면_마스터와_실패상태는_커밋된다() {
migrationService.migrate("123");
assertThat(migrationRepository.existsMaster("123")).isTrue();
assertThat(migrationRepository.existsDetail("123")).isFalse();
assertThat(migrationRepository.findStatus("123"))
.isEqualTo("PARTIAL_FAILED");
}
}
19.3 테스트 자체의 @Transactional 주의
테스트 메서드 자체에 @Transactional을 선언하면 서비스 트랜잭션이 테스트 트랜잭션에 참여할 수 있다.
@Test
@Transactional
void test() {
}
그러면 실제 운영 환경과 트랜잭션 시작점이 달라질 수 있다. 전파와 커밋 결과를 검증하려는 테스트에서는 테스트 트랜잭션 사용 여부를 의식적으로 결정해야 한다.
20. 트랜잭션 활성 여부 확인
학습 또는 장애 진단 목적으로 현재 스레드에 실제 트랜잭션이 활성화되어 있는지 확인할 수 있다.
boolean active = TransactionSynchronizationManager
.isActualTransactionActive();
String transactionName = TransactionSynchronizationManager
.getCurrentTransactionName();
log.info("transaction active={}, name={}", active, transactionName);
각 메서드 시작부에 임시 로그를 추가하면 프록시와 전파 속성이 예상대로 작동하는지 확인하는 데 도움이 된다.
다만 업무 로직이 이 API에 직접 의존하도록 설계하는 것은 권장되지 않는다. 진단 후 제거하거나 공통 관찰 도구로 제한하는 편이 좋다.
21. 트랜잭션 전파 문제 점검표
롤백 결과가 예상과 다르면 다음 순서로 확인한다.
21.1 프록시 호출 여부
- 트랜잭션 메서드가 다른 Spring Bean에서 호출됐는가?
- 동일 클래스의
this호출은 아닌가? - 객체를
new로 직접 만들지 않았는가? - 해당 클래스가 컴포넌트 스캔 대상인가?
21.2 전파 속성 확인
- 외부 메서드의 전파 속성은 무엇인가?
- 내부 메서드의 전파 속성은 무엇인가?
REQUIRED로 같은 물리적 트랜잭션을 공유하는가?REQUIRES_NEW로 실제 새 트랜잭션이 시작됐는가?
21.3 예외와 rollback-only 확인
- 내부에서 발생한 예외가 기본 롤백 대상인가?
- checked exception이라면
rollbackFor가 선언됐는가? - 내부 트랜잭션 범위가 rollback-only로 표시했는가?
- 외부가 예외를 잡고 정상 반환하고 있는가?
UnexpectedRollbackException이 최상위 호출자에게 발생했는가?
21.4 데이터베이스 자원 확인
- 외부와 내부가 같은
DataSource를 사용하는가? - 여러 트랜잭션 매니저 중 올바른 매니저를 선택했는가?
REQUIRES_NEW가 별도 커넥션을 확보할 수 있는가?- 커넥션 풀이 충분한가?
- 외부와 내부가 같은 행을 잠그고 있지는 않은가?
21.5 업무 결과 확인
- 어떤 데이터가 반드시 함께 커밋돼야 하는가?
- 어떤 데이터가 독립적으로 남아도 되는가?
- 부분 성공 상태가 필요한가?
- 실패 건의 재처리가 가능한가?
- 상태 값이 실제 데이터 결과와 일치하는가?
22. 자주 하는 실수
실수 1. REQUIRES_NEW를 붙이면 무조건 문제가 해결된다고 생각한다
독립 트랜잭션은 롤백 범위를 분리하지만 부분 커밋과 데이터 불일치를 만들 수 있다. 원하는 최종 데이터 상태를 먼저 결정해야 한다.
실수 2. 결과 상태만 별도 커밋한다
마스터 데이터는 롤백됐는데 상태 로그만 COMPLETED로 남을 수 있다. 상태가 어느 데이터의 생명주기를 표현하는지 확인해야 한다.
실수 3. 같은 클래스에서 REQUIRES_NEW 메서드를 호출한다
내부 호출은 프록시를 우회한다. 별도 Bean으로 분리하지 않으면 새 트랜잭션이 시작되지 않을 수 있다.
실수 4. 예외를 잡으면 외부 트랜잭션이 커밋된다고 생각한다
내부 REQUIRED 범위가 이미 rollback-only로 표시했다면 외부에서 예외를 잡아도 커밋할 수 없다.
실수 5. SQL이 실행됐으므로 커밋됐다고 생각한다
SQL 실행 후 최종 커밋 단계에서 전체 트랜잭션이 롤백될 수 있다. 반드시 별도 조회나 테스트로 최종 DB 상태를 확인해야 한다.
실수 6. 커넥션 풀 영향을 무시한다
REQUIRES_NEW는 외부 커넥션을 보유한 상태에서 새 커넥션을 요구할 수 있다. 동시 요청이 많으면 커넥션 풀이 고갈될 수 있다.
23. 실무 설계 절차
전파 속성을 선택할 때는 애너테이션부터 정하지 말고 다음 순서로 생각한다.
1단계: 업무 단위를 정의한다
예를 들어 데이터 이관에서 다음 작업을 구분한다.
- 마스터 데이터 등록
- 상세 데이터 등록
- 첨부파일 이관
- 이관 상태 변경
- 실패 로그 기록
2단계: 함께 성공해야 하는 작업을 정한다
마스터와 상세는 반드시 함께 성공해야 한다
이 조건이면 같은 REQUIRED 트랜잭션이 자연스럽다.
3단계: 독립적으로 남아야 하는 작업을 정한다
본 데이터가 실패해도 실패 로그는 반드시 남아야 한다
이 조건이면 실패 로그에 REQUIRES_NEW를 검토할 수 있다.
4단계: 부분 성공을 허용하는지 정한다
마스터는 성공하고 상세는 실패해도 된다
이 조건이면 상세 작업을 독립 트랜잭션으로 분리하고 PARTIAL_FAILED 같은 상태와 재처리 전략을 함께 설계한다.
5단계: 운영 위험을 검토한다
- 별도 커넥션 사용량
- 락 충돌
- 재시도 시 중복 데이터
- 상태 불일치
- 실패 로그 저장 실패
- 서버 장애 시 중간 상태
6단계: 최종 DB 상태를 테스트한다
정상·내부 실패·외부 실패·로그 저장 실패 등 각 경로에서 최종 데이터가 요구사항과 일치하는지 통합 테스트로 확인한다.
24. 경력직 면접 1분 답변
Spring의 기본 트랜잭션 전파 속성인
REQUIRED는 기존 트랜잭션이 있으면 참여하고 없으면 새로 시작합니다. 여러@Transactional메서드가 호출되더라도REQUIRED라면 각각 논리적 트랜잭션 범위를 가지면서 하나의 물리적 트랜잭션을 공유할 수 있습니다. 이때 내부 범위에서 롤백 대상 예외가 발생하면 공유 트랜잭션이 rollback-only로 표시되고, 외부에서 예외를 잡아도 최종 커밋은 불가능합니다. 외부가 커밋을 시도하면UnexpectedRollbackException이 발생할 수 있습니다. 반면REQUIRES_NEW는 기존 트랜잭션을 중단하고 독립적인 물리적 트랜잭션을 시작하므로 내부와 외부가 독립적으로 커밋하거나 롤백할 수 있습니다. 다만 부분 커밋에 따른 데이터 정합성과 추가 커넥션 사용으로 인한 커넥션 풀 고갈을 고려해야 합니다.
25. 면접 예상 질문
Q1. REQUIRED 메서드가 두 개면 트랜잭션도 두 개인가요?
논리적 트랜잭션 범위는 두 개지만 기존 트랜잭션이 있다면 하나의 물리적 트랜잭션을 공유한다.
Q2. 내부 서비스 예외를 외부에서 잡았는데 왜 롤백되나요?
내부 트랜잭션 인터셉터가 같은 물리적 트랜잭션을 이미 rollback-only로 표시했기 때문이다. Java 예외를 잡는 것과 트랜잭션 상태를 복구하는 것은 다르다.
Q3. UnexpectedRollbackException은 왜 발생하나요?
외부 범위는 정상 종료되어 커밋을 요청했지만 실제 트랜잭션은 내부 범위에 의해 rollback-only로 표시되어 있기 때문이다. Spring은 호출자가 커밋됐다고 오해하지 않도록 롤백 사실을 예외로 알린다.
Q4. 실패 로그에는 항상 REQUIRES_NEW를 써야 하나요?
항상 그런 것은 아니다. 본 트랜잭션이 롤백돼도 로그가 반드시 DB에 남아야 한다면 적합할 수 있다. 그러나 커넥션, 락, 로그 저장 실패, 감사 요구사항 등을 함께 고려해야 한다.
Q5. REQUIRES_NEW를 같은 클래스 내부 메서드에 붙이면 되나요?
일반적인 프록시 모드에서는 내부 호출이 프록시를 우회하므로 적용되지 않는다. 별도 Spring Bean 호출 등 프록시를 통과하는 구조가 필요하다.
Q6. REQUIRES_NEW의 가장 큰 위험은 무엇인가요?
업무적으로는 부분 커밋에 따른 데이터 정합성 문제이고, 기술적으로는 별도 DB 커넥션 사용에 따른 커넥션 풀 고갈과 잠금 문제가 대표적이다.
Q7. NESTED와 REQUIRES_NEW는 같은가요?
다르다. REQUIRES_NEW는 별도 물리적 트랜잭션을 사용하고 독립 커밋할 수 있다. NESTED는 일반적으로 하나의 물리적 트랜잭션 안에서 저장점을 이용하며 외부 트랜잭션이 롤백되면 함께 롤백된다.
26. 최종 요약
이번 학습에서 기억해야 할 핵심은 다음과 같다.
- 트랜잭션 전파 속성은 기존 트랜잭션에 참여할지 새 트랜잭션을 시작할지 결정한다.
@Transactional의 기본 전파 속성은REQUIRED다.REQUIRED는 기존 트랜잭션이 있으면 같은 물리적 트랜잭션에 참여한다.- 내부
REQUIRED범위의 롤백 판단은 외부 트랜잭션의 커밋 가능성에도 영향을 준다. - 외부에서 예외를 잡아도 이미 표시된 rollback-only 상태는 사라지지 않는다.
- rollback-only 트랜잭션에 외부가 커밋을 요청하면
UnexpectedRollbackException이 발생할 수 있다. REQUIRES_NEW는 기존 트랜잭션을 중단하고 독립적인 물리적 트랜잭션을 시작한다.REQUIRES_NEW내부 작업은 외부와 독립적으로 커밋하거나 롤백할 수 있다.- 동일 클래스 내부 호출에서는 전파 속성이 프록시를 통해 적용되지 않을 수 있다.
REQUIRES_NEW는 독립 커밋이 업무적으로 타당한 작업에만 사용해야 한다.- 별도 커넥션 사용에 따른 커넥션 풀 고갈과 잠금 위험을 고려해야 한다.
- 트랜잭션 설계는 애너테이션이 아니라 원하는 최종 데이터 상태에서 출발해야 한다.
한 문장으로 정리하면 다음과 같다.
REQUIRED는 여러 업무를 하나의 운명으로 묶고,REQUIRES_NEW는 특정 업무의 커밋과 롤백 운명을 분리한다.
27. 다음 학습을 위한 복습 문제
문제 1
다음 코드에서 감사 로그는 최종적으로 저장되는가?
@Transactional
public void createUser() {
userRepository.insert();
auditService.saveLog();
throw new RuntimeException("사용자 생성 실패");
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveLog() {
auditRepository.insert();
}
문제 2
다음 코드에서 UnexpectedRollbackException이 발생할 가능성이 있는 이유를 설명해 보자.
@Transactional
public void outer() {
try {
innerService.inner();
} catch (RuntimeException e) {
log.error("내부 오류", e);
}
}
@Transactional
public void inner() {
repository.insert();
throw new RuntimeException("실패");
}
문제 3
대량 데이터 1,000건을 한 건씩 독립 트랜잭션으로 이관하려고 한다. 각 건의 서비스에 REQUIRES_NEW를 적용할 때 어떤 운영 위험과 재처리 조건을 확인해야 하는가?
문제 4
외부 트랜잭션이 수정한 행을 내부 REQUIRES_NEW 트랜잭션도 수정하려고 한다. 어떤 문제가 발생할 수 있는가?
다음 학습에서는 위 문제를 바탕으로 Oracle 격리 수준, Lock, Deadlock과 Spring 트랜잭션의 연결 관계를 공부할 수 있다.
참고 자료
'개발 > java,spring' 카테고리의 다른 글
| Spring `@Transactional` 동작 원리와 롤백되지 않는 이유 (0) | 2026.09.10 |
|---|---|
| Checked Exception vs Unchecked Exception 차이: 실무에서 어떻게 선택해야 할까? (0) | 2026.03.31 |
| Java Exception 구조 이해하기: try-catch만 알면 부족한 이유 (0) | 2026.03.24 |
| REST API 설계 기본 원칙 정리 (백엔드 개발자를 위한 가이드) (0) | 2026.03.19 |
| springframework.web.filter.CharacterEncodingFiler cannot be cast to class jakarta.servlet.Filter (0) | 2025.07.20 |
| dynamic web module facet version 5.0 was not found (1) | 2025.07.17 |
| [자바 에러] java.lang.NoSuchMethodError, ByteBuffer.limit(I)Ljava (0) | 2025.07.07 |