Spring @Transactional 동작 원리와 롤백되지 않는 이유
개인 학습용 기술 노트 1회차
주제: Spring 트랜잭션, AOP 프록시, 내부 호출, 예외와 롤백
들어가며
Spring 기반 업무 시스템을 개발하다 보면 다음과 같은 문제를 한 번쯤 만나게 된다.
- 메서드에
@Transactional을 붙였는데 트랜잭션이 적용되지 않는다. - 데이터 등록 중 오류가 발생했는데 앞에서 처리한 데이터가 그대로 남는다.
- 분명히 예외를 발생시켰는데 롤백되지 않는다.
try-catch로 예외 로그를 남겼더니 데이터가 커밋된다.- 같은 서비스 클래스 안에서 메서드를 분리했는데 기대한 트랜잭션이 시작되지 않는다.
이 문제를 제대로 이해하려면 @Transactional을 단순히 "오류가 나면 DB 작업을 취소해 주는 애너테이션"으로 기억해서는 안 된다. Spring이 트랜잭션을 어떤 방식으로 시작하고, 어떤 호출을 가로채며, 어떤 예외를 보고 롤백을 결정하는지 이해해야 한다.
이번 글에서는 다음 순서로 내용을 정리한다.
- 데이터베이스 트랜잭션의 기본 개념
- Spring
@Transactional의 실제 동작 방식 - AOP 프록시와 동일 클래스 내부 호출 문제
- 예외 종류에 따른 기본 롤백 규칙
try-catch로 예외를 삼킬 때 발생하는 문제- 학습 문제와 직접 작성한 답안
- 답안 해설과 올바른 수정 코드
- 실무에서 롤백되지 않을 때의 점검 순서
- 경력직 면접에서 설명하는 방법
1. 트랜잭션이란 무엇인가
트랜잭션은 여러 데이터베이스 작업을 하나의 논리적인 작업 단위로 묶는 것이다.
예를 들어 주문을 생성하는 업무에 다음 세 작업이 포함된다고 가정한다.
- 주문 정보를 저장한다.
- 결제 정보를 저장한다.
- 재고 수량을 차감한다.
세 작업이 모두 성공해야 주문이 정상적으로 만들어진다. 주문과 결제는 저장됐지만 재고 차감이 실패했다면 데이터가 불완전한 상태가 된다. 이런 경우 전체 작업을 이전 상태로 되돌려야 한다.
public void createOrder(OrderRequest request) {
orderRepository.insert(request.toOrder());
paymentRepository.insert(request.toPayment());
stockRepository.decrease(request.getProductId(), request.getQuantity());
}
트랜잭션이 적용된 경우에는 다음과 같이 처리된다.
- 세 작업이 모두 성공하면
COMMIT - 작업 도중 롤백 대상 예외가 발생하면
ROLLBACK
1.1 COMMIT
트랜잭션 안에서 수행한 변경 사항을 데이터베이스에 최종 확정하는 것이다. 커밋 이후에는 다른 트랜잭션에서도 해당 변경 사항을 정상적으로 확인할 수 있다.
1.2 ROLLBACK
트랜잭션 안에서 수행한 변경 사항을 취소하고 트랜잭션 시작 전 상태로 되돌리는 것이다.
1.3 ACID 속성
트랜잭션은 일반적으로 ACID라는 네 가지 속성으로 설명한다.
| 속성 | 의미 | 주문 처리 예시 |
|---|---|---|
| Atomicity, 원자성 | 전부 성공하거나 전부 실패해야 한다 | 주문·결제·재고가 함께 반영되거나 모두 취소된다 |
| Consistency, 일관성 | 처리 전후에 데이터 규칙이 유지돼야 한다 | 재고가 음수가 되는 등 업무 규칙 위반을 막는다 |
| Isolation, 격리성 | 동시에 실행되는 트랜잭션이 서로 부적절하게 간섭하지 않아야 한다 | 동시에 같은 상품을 주문할 때 재고 계산 충돌을 통제한다 |
| Durability, 지속성 | 커밋된 데이터는 장애가 발생해도 보존돼야 한다 | 주문 완료 후 서버가 재시작돼도 주문이 남는다 |
@Transactional은 이 중 특히 여러 변경 작업을 하나로 묶어 원자성을 확보하는 데 많이 사용된다. 다만 애너테이션 하나만 붙였다고 항상 원하는 범위에서 트랜잭션이 성립하는 것은 아니다.
2. Spring @Transactional은 어떻게 동작하는가
다음 코드를 보자.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentRepository paymentRepository;
@Transactional
public void createOrder(OrderRequest request) {
orderRepository.insert(request.toOrder());
paymentRepository.insert(request.toPayment());
}
}
겉으로 보기에는 createOrder()가 직접 트랜잭션을 시작하는 것처럼 느껴진다. 그러나 실제 메서드 코드가 트랜잭션을 직접 시작하는 것은 아니다.
Spring은 일반적인 프록시 모드에서 OrderService 앞에 프록시 객체를 두고, 외부 호출이 프록시를 통과할 때 트랜잭션 부가 기능을 실행한다.
Controller
↓
OrderService 프록시
↓ 트랜잭션 시작
실제 OrderService.createOrder()
↓
정상 반환 또는 예외 발생
↓
OrderService 프록시
↓
커밋 또는 롤백 결정
좀 더 구체적인 처리 순서는 다음과 같다.
- Spring 컨테이너가
OrderService를 빈으로 등록한다. - 트랜잭션 적용 대상이라면 원본 객체를 감싸는 프록시를 준비한다.
- 외부 객체가
createOrder()를 호출하면 프록시가 먼저 호출을 받는다. - 프록시 내부의 트랜잭션 인터셉터가
PlatformTransactionManager를 통해 트랜잭션을 시작한다. - 프록시가 실제
OrderService.createOrder()를 호출한다. - 메서드가 정상적으로 반환되면 커밋한다.
- 예외가 프록시까지 전달되면 롤백 규칙을 확인한다.
- 롤백 대상이면 롤백하고, 대상이 아니면 기본 정책에 따라 커밋할 수 있다.
- 예외는 호출자에게 다시 전달된다.
중요한 점은 다음 두 가지다.
첫째, 호출이 트랜잭션 프록시를 통과해야 한다.
둘째, 프록시가 롤백 여부를 판단할 수 있도록 예외가 트랜잭션 경계 밖으로 전달돼야 한다.
3. 프록시란 무엇인가
프록시는 원본 객체를 대신하여 호출을 먼저 받는 대리 객체다. 프록시는 원본 메서드를 호출하기 전과 후에 공통 기능을 추가할 수 있다.
Spring에서는 트랜잭션 외에도 다음과 같은 기능에 프록시 기반 AOP가 많이 활용된다.
- 보안 검사
- 실행 시간 측정
- 로깅
- 캐시
- 비동기 처리
- 재시도
트랜잭션의 경우 프록시가 다음과 같은 역할을 한다.
// 실제 Spring 내부 코드가 아니라 개념을 단순화한 의사 코드다.
public Object invoke(MethodInvocation invocation) {
TransactionStatus status = transactionManager.begin();
try {
Object result = invocation.proceed();
transactionManager.commit(status);
return result;
} catch (Throwable e) {
if (isRollbackTarget(e)) {
transactionManager.rollback(status);
} else {
transactionManager.commit(status);
}
throw e;
}
}
실제 구현은 훨씬 복잡하지만 학습 단계에서는 위 흐름을 기억하면 충분하다.
3.1 JDK 동적 프록시와 CGLIB
Spring AOP 프록시는 대표적으로 두 방식이 있다.
| 방식 | 개념 |
|---|---|
| JDK Dynamic Proxy | 인터페이스를 기반으로 프록시를 생성한다 |
| CGLIB Proxy | 대상 클래스를 상속한 하위 클래스를 만들어 프록시를 생성한다 |
CGLIB 방식에서는 상속과 오버라이딩을 이용하므로 일반적으로 final 클래스, final 메서드, private 메서드는 프록시 적용에 제약이 있다. 실무에서는 트랜잭션 경계 메서드를 별도 Spring Bean의 public 메서드로 두는 방식이 가장 이해하기 쉽고 안전하다.
4. 가장 중요한 함정: 동일 클래스 내부 호출
다음 코드를 살펴보자.
@Service
public class MigrationService {
public void migrate() {
migrateData();
}
@Transactional
public void migrateData() {
// 여러 건의 데이터 이관 작업
}
}
외부 객체가 migrationService.migrate()를 호출한다. migrate() 안에서는 migrateData()를 호출한다. 개발자는 migrateData()에 @Transactional이 있으므로 트랜잭션이 시작될 것이라고 생각할 수 있다.
그러나 일반적인 프록시 방식에서는 기대한 트랜잭션이 시작되지 않는다.
4.1 내부 호출이 프록시를 우회하는 이유
같은 객체 안에서 다음과 같이 메서드를 호출하면 실제 의미는 this.migrateData()와 같다.
public void migrate() {
this.migrateData();
}
최초의 migrate() 호출은 프록시를 거칠 수 있지만, 원본 객체 안에 진입한 뒤 발생하는 this.migrateData() 호출은 프록시로 되돌아가지 않는다. 따라서 migrateData()에 연결된 트랜잭션 인터셉터가 실행될 기회를 얻지 못한다.
이를 self-invocation, 즉 자기 호출 문제라고 한다.
[기대한 흐름]
migrate() → 프록시 → migrateData() → 트랜잭션 시작
[실제 흐름]
프록시 → 실제 객체의 migrate()
↓
this.migrateData()
↓
프록시를 거치지 않음
4.2 단순히 메서드만 분리해서는 해결되지 않는다
하나의 클래스 안에서 메서드를 두 개로 나누는 것은 코드 정리에는 도움이 되지만 Spring Bean 경계를 분리한 것이 아니다.
@Service
public class MigrationService {
public void execute() {
executeInTransaction();
}
@Transactional
public void executeInTransaction() {
// DB 처리
}
}
이 구조도 내부 호출이므로 일반적인 프록시 모드에서는 문제가 그대로 남는다.
4.3 권장 해결책: 클래스를 분리한다
호출을 담당하는 서비스와 트랜잭션 안에서 DB 작업을 수행하는 서비스를 별도의 Spring Bean으로 분리한다.
@Service
@RequiredArgsConstructor
public class MigrationService {
private final MigrationTransactionService transactionService;
public void migrate() {
transactionService.migrateData();
}
}
@Service
@RequiredArgsConstructor
public class MigrationTransactionService {
private final SourceRepository sourceRepository;
private final TargetRepository targetRepository;
@Transactional
public void migrateData() {
SourceData source = sourceRepository.findById("123");
targetRepository.insert(source);
}
}
이제 MigrationService가 주입받은 MigrationTransactionService를 호출한다. 두 객체가 다른 Spring Bean이므로 호출이 MigrationTransactionService 프록시를 통과하며 트랜잭션이 정상적으로 시작된다.
5. 예외 종류와 기본 롤백 규칙
트랜잭션이 정상적으로 시작되었다고 해서 모든 예외가 자동으로 롤백을 일으키는 것은 아니다.
Spring의 기본적인 선언적 트랜잭션 롤백 규칙은 다음과 같다.
| 예외 종류 | 기본 처리 |
|---|---|
RuntimeException 및 하위 예외 |
롤백 |
Error 및 하위 오류 |
롤백 |
| Checked Exception | 기본적으로 롤백하지 않음 |
5.1 Checked Exception
Exception을 직접 상속하지만 RuntimeException은 상속하지 않는 예외를 일반적으로 checked exception이라고 한다.
public class MigrationCheckedException extends Exception {
public MigrationCheckedException(String message) {
super(message);
}
}
다음 메서드에서 checked exception이 발생하더라도 Spring 기본 설정에서는 롤백되지 않을 수 있다.
@Transactional
public void migrateData() throws Exception {
targetRepository.insert(...);
throw new Exception("이관 실패");
}
이 메서드에서 트랜잭션 자체는 적용됐더라도 new Exception()은 기본 롤백 대상이 아니다. 따라서 예외가 발생했는데도 앞서 실행한 insert가 커밋되는 상황이 생길 수 있다.
5.2 RuntimeException
RuntimeException과 그 하위 예외는 기본 롤백 대상이다.
public class MigrationException extends RuntimeException {
public MigrationException(String message) {
super(message);
}
}
@Transactional
public void migrateData() {
targetRepository.insert(...);
throw new MigrationException("이관 실패");
}
MigrationException이 트랜잭션 프록시까지 전달되면 Spring은 기본 규칙에 따라 롤백한다.
5.3 Checked Exception도 롤백하도록 설정하는 방법
업무 설계상 checked exception을 반드시 사용해야 한다면 rollbackFor를 명시할 수 있다.
@Transactional(rollbackFor = Exception.class)
public void migrateData() throws Exception {
targetRepository.insert(...);
throw new Exception("이관 실패");
}
더 구체적인 예외 유형을 지정하는 편이 의도를 명확하게 보여준다.
@Transactional(rollbackFor = MigrationCheckedException.class)
public void migrateData() throws MigrationCheckedException {
targetRepository.insert(...);
throw new MigrationCheckedException("이관 실패");
}
모든 서비스 메서드에 무조건 rollbackFor = Exception.class를 붙이는 것이 정답은 아니다. 어떤 실패가 업무 전체를 취소해야 하는지 먼저 정하고, 해당 업무 규칙에 맞는 예외 정책을 설계해야 한다.
6. try-catch로 예외를 잡으면 왜 롤백되지 않는가
다음 코드를 보자.
@Transactional
public void migrateData() {
try {
targetRepository.insert(...);
throw new MigrationException("이관 실패");
} catch (Exception e) {
log.error("이관 중 오류 발생", e);
}
}
메서드 내부에서 예외를 잡은 뒤 다시 던지지 않았다. 그러면 프록시 입장에서는 메서드가 정상적으로 반환된 것처럼 보인다.
트랜잭션 시작
↓
INSERT 수행
↓
예외 발생
↓
메서드 내부 catch에서 처리
↓
프록시에는 정상 반환
↓
COMMIT
로그에 오류가 찍혔다고 해서 Spring이 그 로그를 보고 롤백하는 것은 아니다. 프록시는 트랜잭션 메서드 밖으로 전달된 예외 또는 명시적으로 설정된 rollback-only 상태를 기준으로 판단한다.
6.1 방법 1: 예외를 다시 던진다
@Transactional
public void migrateData() {
try {
targetRepository.insert(...);
processData();
} catch (Exception e) {
log.error("이관 중 오류 발생", e);
throw new MigrationException("이관 작업 실패", e);
}
}
로그를 남긴 뒤 런타임 예외로 변환하여 다시 던지면 프록시가 예외를 인식하여 롤백할 수 있다.
6.2 방법 2: 트랜잭션 밖에서 예외를 잡는다
업무 흐름 제어와 트랜잭션 실행을 별도 클래스로 분리한다.
@Service
@RequiredArgsConstructor
public class MigrationService {
private final MigrationTransactionService transactionService;
public void migrate() {
try {
transactionService.migrateData();
} catch (MigrationException e) {
log.error("이관 실패", e);
// 실패 결과 저장, 알림 발송 등 후속 처리
}
}
}
@Service
@RequiredArgsConstructor
public class MigrationTransactionService {
private final TargetRepository targetRepository;
@Transactional
public void migrateData() {
targetRepository.insert(...);
throw new MigrationException("이관 실패");
}
}
처리 순서는 다음과 같다.
- 외부 서비스가 트랜잭션 서비스의 프록시를 호출한다.
- 프록시가 트랜잭션을 시작한다.
- 실제
migrateData()가 실행된다. MigrationException이 발생한다.- 예외가 프록시까지 전달된다.
- 프록시가 트랜잭션을 롤백한다.
- 롤백 처리가 끝난 뒤 예외가
MigrationService로 전달된다. - 외부 서비스가 예외를 잡아 로그와 후속 처리를 수행한다.
따라서 트랜잭션 메서드를 호출한 바깥쪽에서 예외를 잡는 것은 가능하다. 중요한 것은 트랜잭션 프록시가 먼저 예외를 확인하고 롤백 결정을 내릴 수 있어야 한다는 점이다.
7. 학습 문제
다음 코드는 데이터 이관을 단순화한 예제다.
@Service
@RequiredArgsConstructor
public class MigrationService {
private final SourceRepository sourceRepository;
private final TargetRepository targetRepository;
public void migrate() {
try {
migrateData();
} catch (Exception e) {
log.error("데이터 이관 실패", e);
}
}
@Transactional
public void migrateData() throws Exception {
SourceData source = sourceRepository.findById("123");
targetRepository.insert(source);
throw new Exception("강제 오류 발생");
}
}
문제 1
외부에서 migrationService.migrate()를 호출했을 때 migrateData()의 @Transactional은 정상적으로 적용되는가?
문제 2
targetRepository.insert(source)가 실행된 뒤 new Exception()이 발생하면 데이터는 자동으로 롤백되는가?
문제 3
이 코드의 문제를 해결하려면 어떻게 수정해야 하는가?
8. 직접 작성한 답안
처음 작성한 답안은 다음과 같다.
- 트랜잭션이 적용되지 않습니다.
- 롤백되지 않습니다.
- 클래스는 나누고
throw new Exception소스를 삭제합니다.
답안 평가
1번과 2번은 정확하다. 3번의 클래스 분리도 핵심 해결 방향을 찾았다. 다만 throw new Exception을 삭제한다는 부분은 보완이 필요하다.
예외를 삭제하면 오류가 사라지는 것이 아니라 실패를 알리는 신호가 없어지는 것이다. 실패가 발생했는데도 예외를 발생시키지 않으면 Spring은 메서드가 정상 종료되었다고 판단하여 커밋할 수 있다.
따라서 올바른 해결책은 다음과 같다.
- 호출 흐름을 담당하는 서비스와 트랜잭션을 담당하는 서비스를 별도 클래스로 분리한다.
- 별도 Spring Bean을 호출하여 트랜잭션 프록시를 통과하게 한다.
RuntimeException을 상속한 업무 예외를 사용하거나 checked exception에rollbackFor를 설정한다.- 예외를 트랜잭션 메서드 안에서 삼키지 않고 프록시 밖으로 전달한다.
- 필요하면 트랜잭션 처리가 끝난 외부 메서드에서 예외를 잡아 로그와 후속 처리를 한다.
9. 문제 1 정답과 해설
정답
트랜잭션이 기대한 방식으로 적용되지 않는다.
이유
외부에서는 migrate()를 호출한다. migrateData()는 동일한 MigrationService 객체 내부에서 호출된다.
public void migrate() {
migrateData(); // 실질적으로 this.migrateData()
}
이 호출은 Spring 프록시를 다시 통과하지 않는다. 따라서 migrateData()의 @Transactional을 처리할 트랜잭션 인터셉터가 실행되지 않는다.
핵심 문장
@Transactional이 붙어 있다는 사실만으로는 부족하며, 해당 메서드 호출이 트랜잭션 프록시를 통과해야 한다.
10. 문제 2 정답과 해설
정답
현재 코드에서는 롤백되지 않는다.
첫 번째 이유: 트랜잭션 자체가 시작되지 않을 가능성
동일 클래스 내부 호출이므로 migrateData()에 선언된 트랜잭션이 적용되지 않는다. 트랜잭션이 시작되지 않았다면 Spring이 롤백할 트랜잭션도 없다.
두 번째 이유: Exception은 checked exception
설령 외부 호출로 바꾸어 트랜잭션이 정상적으로 시작되더라도 다음 예외는 기본 롤백 대상이 아니다.
throw new Exception("강제 오류 발생");
Spring은 기본적으로 RuntimeException과 Error를 롤백 대상으로 본다. checked exception까지 롤백하려면 별도의 롤백 규칙이 필요하다.
세 번째 점검 항목: 예외를 잡는 위치
현재 migrate()가 예외를 잡고 있지만, 원래 코드에서는 내부 호출 문제 때문에 프록시가 예외를 확인할 기회가 없다.
클래스를 분리한 후에는 트랜잭션 서비스에서 발생한 예외가 먼저 프록시까지 전달되고 롤백된 다음, 외부 서비스의 catch에 도달한다. 따라서 외부에서 예외를 잡는 것 자체가 항상 잘못된 것은 아니다. 트랜잭션 경계 안에서 예외를 삼키는지가 핵심이다.
11. 문제 3 정답: 권장 수정 코드
11.1 업무 예외 정의
public class MigrationException extends RuntimeException {
public MigrationException(String message) {
super(message);
}
public MigrationException(String message, Throwable cause) {
super(message, cause);
}
}
11.2 전체 실행 흐름을 담당하는 서비스
@Slf4j
@Service
@RequiredArgsConstructor
public class MigrationService {
private final MigrationTransactionService transactionService;
public void migrate() {
try {
transactionService.migrateData("123");
} catch (MigrationException e) {
log.error("데이터 이관 실패. rcptNo={}", "123", e);
// 필요에 따라 실패 이력 저장이나 관리자 알림 수행
}
}
}
11.3 트랜잭션을 담당하는 별도 서비스
@Service
@RequiredArgsConstructor
public class MigrationTransactionService {
private final SourceRepository sourceRepository;
private final TargetRepository targetRepository;
@Transactional
public void migrateData(String rcptNo) {
SourceData source = sourceRepository.findById(rcptNo)
.orElseThrow(() -> new MigrationException(
"원본 데이터를 찾을 수 없습니다. rcptNo=" + rcptNo
));
targetRepository.insertParent(source);
targetRepository.insertChildren(source.getChildren());
validateMigration(source);
}
private void validateMigration(SourceData source) {
if (source.isInvalid()) {
throw new MigrationException("이관 데이터 검증 실패");
}
}
}
여기서 validateMigration()은 private 메서드지만 자체적으로 새 트랜잭션을 시작하려는 메서드가 아니다. 이미 migrateData()에서 시작한 트랜잭션 안에서 단순 업무 로직으로 실행되므로 문제가 없다.
11.4 처리 결과
insertParent()와 insertChildren()이 성공한 후 validateMigration()에서 MigrationException이 발생하면 다음과 같이 진행된다.
MigrationService
↓
MigrationTransactionService 프록시
↓ 트랜잭션 시작
migrateData()
↓
부모 데이터 INSERT
↓
자식 데이터 INSERT
↓
MigrationException 발생
↓
프록시가 RuntimeException 확인
↓
부모·자식 INSERT 모두 ROLLBACK
↓
MigrationService의 catch에서 로그 처리
12. Checked Exception을 유지하는 수정 방법
외부 API 또는 파일 처리 코드 때문에 checked exception을 그대로 사용해야 하는 상황도 있다.
@Service
@RequiredArgsConstructor
public class MigrationTransactionService {
private final TargetRepository targetRepository;
@Transactional(rollbackFor = Exception.class)
public void migrateData() throws Exception {
targetRepository.insert(...);
callExternalSystem();
}
}
이 경우 checked exception이 프록시까지 전달되면 rollbackFor 규칙에 따라 롤백한다.
다만 가능하면 너무 넓은 Exception.class보다 실제 업무 예외를 지정하는 것이 좋다.
@Transactional(rollbackFor = MigrationCheckedException.class)
public void migrateData() throws MigrationCheckedException {
// DB 처리
}
이렇게 하면 코드만 읽어도 어떤 실패가 롤백을 유발하는지 파악하기 쉽다.
13. 잘못된 해결 방법과 그 이유
13.1 throw new Exception만 삭제한다
@Transactional
public void migrateData() {
targetRepository.insert(...);
// 예외 삭제
}
오류 상황 자체가 존재하지 않는 테스트 코드라면 예외를 삭제할 수 있다. 그러나 실제 업무 실패를 표현하던 예외까지 제거하면 실패한 데이터를 정상 데이터처럼 커밋할 수 있다.
핵심은 예외 삭제가 아니라 적절한 예외 유형과 전달 경로를 설계하는 것이다.
13.2 트랜잭션 메서드 안에서 모든 예외를 잡는다
@Transactional
public void migrateData() {
try {
targetRepository.insert(...);
} catch (Exception e) {
log.error("실패", e);
}
}
예외가 프록시로 전달되지 않아 정상 반환으로 판단될 수 있다. 로그를 남겨야 한다면 예외를 다시 던지거나 외부 계층에서 잡는다.
13.3 같은 클래스에 메서드만 추가로 만든다
public void migrate() {
migrateInTransaction();
}
@Transactional
public void migrateInTransaction() {
// DB 작업
}
클래스가 같다면 여전히 내부 호출이다. 트랜잭션 경계를 별도 Spring Bean으로 분리하는 것이 명확하다.
13.4 객체를 직접 생성한다
MigrationTransactionService service = new MigrationTransactionService(...);
service.migrateData();
new로 직접 만든 객체는 Spring 컨테이너가 관리하는 프록시 빈이 아니다. @Transactional을 붙여도 Spring 프록시 기반의 트랜잭션 기능을 기대할 수 없다.
13.5 같은 메서드 안에서 이미 커밋된 작업까지 되돌리려고 한다
트랜잭션이 시작되기 전에 이미 다른 커넥션에서 커밋된 데이터나 별도의 트랜잭션으로 확정된 데이터는 현재 트랜잭션의 롤백으로 되돌릴 수 없다. 롤백 범위는 반드시 실제 트랜잭션 경계와 연결 자원 범위를 기준으로 판단해야 한다.
14. 서로 다른 데이터베이스를 사용하는 경우
데이터 이관에서는 원본 DB와 대상 DB가 서로 다른 경우가 많다.
Oracle A DB에서 조회
↓
Oracle B DB에 등록
여기서 반드시 구분해야 할 것이 있다.
- 원본 DB에서는 단순 조회만 수행하는가?
- 원본 DB와 대상 DB 양쪽에서 데이터를 변경하는가?
- 각각 다른
DataSource와 트랜잭션 매니저를 사용하는가?
@Transactional 하나가 항상 모든 데이터소스의 변경을 묶어 주는 것은 아니다. 일반적인 로컬 트랜잭션 매니저는 자신이 관리하는 데이터소스의 트랜잭션만 제어한다.
예를 들어 B DB용 트랜잭션 매니저를 지정할 수 있다.
@Transactional(transactionManager = "targetTransactionManager")
public void migrateData(String rcptNo) {
SourceData source = sourceRepository.findById(rcptNo);
targetRepository.insert(source);
}
원본 DB에서는 조회만 하고 대상 DB에만 변경 작업을 한다면 대상 DB의 변경을 하나의 로컬 트랜잭션으로 묶는 방식이 현실적이다.
반면 두 DB에서 모두 변경 작업을 하고 두 변경을 반드시 원자적으로 함께 커밋하거나 롤백해야 한다면 단일 로컬 트랜잭션만으로는 부족하다. 이때는 다음과 같은 설계를 별도로 검토해야 한다.
- XA/JTA 기반 분산 트랜잭션
- 재시도 가능한 상태 기반 처리
- 보상 트랜잭션
- 아웃박스 또는 메시지 기반 일관성
- 이관 성공·실패 이력과 재처리 설계
분산 트랜잭션은 운영 복잡성과 장애 대응 비용이 크므로, 단순히 애너테이션을 하나 더 붙이는 문제로 생각해서는 안 된다.
15. JPA와 MyBatis/JDBC에서 추가로 확인할 점
15.1 JPA는 SQL 실행 시점이 늦어질 수 있다
JPA의 save()를 호출했다고 즉시 SQL이 실행된다고 단정할 수 없다. 영속성 컨텍스트에 변경 사항이 쌓였다가 flush 또는 커밋 시점에 SQL이 실행될 수 있다.
따라서 테스트에서 실제 제약조건 오류를 트랜잭션 안에서 확인해야 한다면 필요한 시점에 flush()를 고려할 수 있다.
repository.save(entity);
entityManager.flush();
단, flush()는 커밋이 아니다. SQL을 DB로 보내 동기화하는 것이며 이후 롤백 대상 예외가 발생하면 트랜잭션 변경은 여전히 롤백될 수 있다.
15.2 MyBatis/JDBC는 같은 트랜잭션 자원을 사용해야 한다
Spring이 관리하는 DataSource와 트랜잭션 매니저를 통해 얻은 커넥션을 사용해야 하나의 트랜잭션에 참여할 수 있다. 코드가 별도로 커넥션을 열고 직접 커밋하거나 자동 커밋을 사용하면 Spring 트랜잭션과 분리될 수 있다.
확인해야 할 항목은 다음과 같다.
- 같은
DataSource를 사용하는가? - 올바른
DataSourceTransactionManager또는JpaTransactionManager를 사용하는가? - 직접
commit()을 호출하지 않는가? - 별도 커넥션을 직접 생성하지 않는가?
- SQL 매퍼가 예상한 세션 팩토리에 연결돼 있는가?
16. 전파 속성과의 관계
이번 문제의 직접적인 원인은 내부 호출과 예외 규칙이지만, 트랜잭션을 더 깊게 이해하려면 전파 속성도 알아야 한다.
@Transactional의 기본 전파 속성은 REQUIRED다.
@Transactional(propagation = Propagation.REQUIRED)
public void save() {
// 기존 트랜잭션이 있으면 참여하고, 없으면 새로 시작한다.
}
자주 접하는 속성을 단순화하면 다음과 같다.
| 전파 속성 | 개념 |
|---|---|
REQUIRED |
기존 트랜잭션이 있으면 참여하고, 없으면 새로 시작한다 |
REQUIRES_NEW |
기존 트랜잭션을 잠시 중단하고 새로운 트랜잭션을 시작한다 |
SUPPORTS |
트랜잭션이 있으면 참여하고, 없으면 트랜잭션 없이 실행한다 |
MANDATORY |
반드시 기존 트랜잭션이 있어야 한다 |
NOT_SUPPORTED |
트랜잭션 없이 실행한다 |
NEVER |
트랜잭션이 존재하면 예외를 발생시킨다 |
NESTED |
지원되는 환경에서 중첩 트랜잭션처럼 저장점을 사용한다 |
여기서 중요한 점은 REQUIRES_NEW를 내부 호출 메서드에 붙여도 프록시를 우회하면 새 트랜잭션이 시작되지 않는다는 것이다.
public void outer() {
inner();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() {
// 같은 클래스의 내부 호출이면 프록시가 개입하지 못한다.
}
전파 속성 역시 애너테이션을 읽고 처리하는 프록시가 실행돼야 의미가 있다.
17. 트랜잭션이 롤백되지 않을 때 점검 순서
실무 장애에서는 무작정 rollbackFor = Exception.class부터 추가하지 말고 다음 순서로 확인하는 것이 좋다.
17.1 호출 구조 확인
@Transactional메서드가 다른 Spring Bean에서 호출됐는가?- 같은 클래스 내부의
this호출은 아닌가? - 해당 객체를
new로 직접 만들지는 않았는가?
17.2 Spring Bean과 프록시 확인
- 클래스가
@Service,@Component,@Repository등으로 등록됐는가? - 컴포넌트 스캔 범위에 포함됐는가?
- 프록시 적용이 가능한 메서드 구조인가?
private,final등의 프록시 제약은 없는가?
17.3 예외 확인
- 실제 발생한 예외가
RuntimeException인가 checked exception인가? - checked exception이라면
rollbackFor가 필요한가? - 예외가 다른 예외로 변환되는 과정에서 원인이 사라지지 않았는가?
17.4 예외 처리 위치 확인
- 트랜잭션 메서드 내부에서 예외를 잡고 끝내지는 않았는가?
catch한 뒤 런타임 예외로 다시 던졌는가?- 바깥 계층이 예외를 잡기 전에 프록시까지 전달됐는가?
17.5 데이터소스와 트랜잭션 매니저 확인
- 여러 데이터소스를 사용하고 있는가?
@Transactional이 정확한 트랜잭션 매니저를 선택했는가?- 변경 SQL이 트랜잭션 매니저가 관리하는 커넥션으로 실행됐는가?
- 코드에서 직접 커밋하거나 자동 커밋을 사용하지는 않았는가?
17.6 이미 독립적으로 커밋된 작업 확인
- 내부 서비스가
REQUIRES_NEW를 사용했는가? - DB 프로시저 내부에서 커밋하는 구조인가?
- 외부 시스템 호출은 DB 롤백으로 취소할 수 없는 작업인가?
17.7 테스트 방식 확인
- 롤백 여부를 실제 별도 조회로 확인했는가?
- 같은 영속성 컨텍스트의 캐시만 보고 판단하지 않았는가?
- JPA라면 필요한 시점에 flush가 발생했는가?
18. 트랜잭션 테스트 예제
다음과 같이 통합 테스트를 구성하면 롤백 여부를 직접 확인할 수 있다.
@SpringBootTest
class MigrationTransactionServiceTest {
@Autowired
private MigrationTransactionService transactionService;
@Autowired
private TargetRepository targetRepository;
@Test
void 이관_중_런타임예외가_발생하면_전체_롤백된다() {
String rcptNo = "123";
assertThatThrownBy(() -> transactionService.migrateData(rcptNo))
.isInstanceOf(MigrationException.class);
assertThat(targetRepository.existsByRcptNo(rcptNo)).isFalse();
}
}
단, 테스트 메서드 자체에 @Transactional을 붙이면 테스트 트랜잭션과 서비스 트랜잭션이 합쳐져 실제 운영 호출 구조와 다르게 보일 수 있다. 트랜잭션 경계를 검증하려는 테스트에서는 테스트 자체의 트랜잭션 적용 여부를 의식적으로 결정해야 한다.
내부 호출 문제를 확인하는 테스트도 만들 수 있다.
@Test
void 동일_클래스_내부호출은_별도_트랜잭션_프록시를_통과하지_않는다() {
// 내부 호출 코드 실행
// 트랜잭션 활성 여부 로그 또는 TransactionSynchronizationManager로 확인
}
학습 목적으로는 다음 코드로 현재 스레드에 실제 트랜잭션이 활성화돼 있는지 확인할 수 있다.
boolean active = TransactionSynchronizationManager
.isActualTransactionActive();
log.info("transaction active={}", active);
이 코드는 원인 진단을 위한 도구이지 업무 로직이 트랜잭션 유무에 강하게 의존하도록 만들기 위한 것은 아니다.
19. 실무 설계 원칙
19.1 트랜잭션 경계는 서비스 계층에 둔다
여러 저장소 호출을 하나의 업무 단위로 묶는 서비스 메서드에 트랜잭션을 선언하면 업무의 원자성 범위가 명확해진다.
@Transactional
public void createOrder(...) {
orderRepository.insert(...);
paymentRepository.insert(...);
stockRepository.decrease(...);
}
19.2 트랜잭션을 지나치게 길게 유지하지 않는다
트랜잭션 안에서 대용량 파일 처리, 긴 네트워크 호출, 사용자 입력 대기 등을 수행하면 DB 커넥션과 락을 오래 보유할 수 있다.
다음 요소를 분리할 수 있는지 검토한다.
- 데이터 사전 검증
- 파일 파싱
- 외부 API 호출
- 실제 DB 변경
- 결과 알림
19.3 예외는 업무 의미를 표현한다
throw new MigrationException("자식 데이터 참조 무결성 검증 실패");
throw new RuntimeException("error")처럼 의미가 모호한 예외보다 업무 맥락을 가진 예외를 사용하면 로그와 장애 분석이 쉬워진다.
19.4 로그와 롤백을 구분한다
로그를 남기는 행위와 트랜잭션을 롤백시키는 행위는 서로 다르다.
log.error()는 기록만 한다.- 예외가 프록시까지 전달되거나 rollback-only로 표시돼야 롤백 판단이 이루어진다.
19.5 트랜잭션 범위를 주석이 아니라 코드 구조로 드러낸다
MigrationTransactionService처럼 클래스 책임과 이름에서 트랜잭션 경계를 파악할 수 있게 만들면 유지보수자가 실수할 가능성이 줄어든다.
20. 경력직 면접 1분 답변
Spring의
@Transactional은 일반적으로 AOP 프록시를 통해 동작합니다. 외부 호출이 프록시를 통과하면 트랜잭션 매니저가 트랜잭션을 시작하고 실제 대상 메서드를 호출합니다. 메서드가 정상 반환되면 커밋하고, 예외가 전달되면 롤백 규칙에 따라 롤백 여부를 결정합니다. 주의할 점은 같은 클래스 내부에서 호출하는 self-invocation은 프록시를 우회하므로 해당 내부 메서드의 트랜잭션 설정이 적용되지 않을 수 있다는 것입니다. 또한 기본적으로 RuntimeException과 Error는 롤백되지만 checked exception은 롤백 대상이 아니므로 업무 정책에 따라 rollbackFor를 설정해야 합니다. 예외를 메서드 내부에서 잡아 삼키면 프록시는 정상 반환으로 판단할 수 있으므로 예외를 다시 던지거나 트랜잭션 경계 밖에서 처리해야 합니다.
21. 면접 추가 질문과 답변
Q1. @Transactional 메서드는 반드시 public이어야 하나요?
프록시 방식과 Spring 버전에 따라 세부 지원 범위가 달라질 수 있지만, 다른 Bean에서 호출되는 public 서비스 메서드에 트랜잭션 경계를 두는 것이 가장 명확하고 이식성 있는 설계다. private 메서드는 프록시가 가로챌 수 없으며, 내부 호출 문제도 함께 발생하기 쉽다.
Q2. 예외를 catch하면 무조건 커밋되나요?
무조건은 아니다. 예외를 잡은 뒤 롤백 대상 예외를 다시 던지면 롤백할 수 있다. 또한 이미 트랜잭션이 rollback-only로 표시된 상태일 수도 있다. 하지만 예외를 잡고 정상 반환하면 프록시가 정상 처리로 판단할 수 있으므로 의도하지 않은 커밋 위험이 있다.
Q3. 바깥 서비스에서 예외를 잡아도 되나요?
된다. 트랜잭션 메서드의 예외가 먼저 프록시까지 전달되어 롤백 판단이 완료된 후, 그 바깥 호출자가 예외를 잡는 구조는 가능하다. 중요한 것은 트랜잭션 프록시에 도달하기 전에 예외를 삼키지 않는 것이다.
Q4. rollbackFor = Exception.class를 항상 붙이면 안전하지 않나요?
기술적으로 더 넓은 예외를 롤백 대상으로 만들 수 있지만, 모든 실패를 동일하게 취급하면 업무 의도가 불분명해질 수 있다. 재고 부족처럼 정상적인 업무 거절과 시스템 장애가 같은 방식으로 처리돼야 하는지 검토해야 한다. 구체적인 업무 예외와 롤백 정책을 설계하는 편이 좋다.
Q5. REQUIRES_NEW를 사용하면 내부 호출 문제도 해결되나요?
아니다. 내부 호출이 프록시를 통과하지 않으면 REQUIRES_NEW라는 설정을 해석할 인터셉터도 실행되지 않는다. 별도 Bean 호출 등 프록시를 통과하는 구조가 먼저 필요하다.
Q6. DB 작업은 롤백됐는데 외부 API 호출은 왜 취소되지 않나요?
일반적인 DB 트랜잭션은 해당 DB 자원만 제어한다. 이미 전송한 이메일, 외부 결제 요청, HTTP API 요청은 DB 롤백만으로 취소되지 않는다. 이런 작업은 보상 처리, 이벤트 발행, 아웃박스 패턴, 멱등성 등의 별도 설계가 필요하다.
22. 최종 요약
이번 학습에서 반드시 기억해야 할 내용은 다음과 같다.
@Transactional은 일반적으로 Spring AOP 프록시를 통해 적용된다.- 외부 호출이 프록시를 통과해야 트랜잭션 인터셉터가 실행된다.
- 동일 클래스 내부 호출은
this호출이므로 프록시를 우회한다. - 해결 방법으로 트랜잭션 메서드를 별도 Spring Bean으로 분리할 수 있다.
RuntimeException과Error는 기본 롤백 대상이다.- checked exception은 기본적으로 롤백되지 않으며 필요하면
rollbackFor를 지정한다. - 트랜잭션 메서드 안에서 예외를 잡고 삼키면 정상 반환으로 판단되어 커밋될 수 있다.
- 로그를 남겼다는 사실만으로는 롤백되지 않는다.
- 예외를 삭제하는 것은 해결책이 아니다. 실제 실패를 적절한 업무 예외로 표현해야 한다.
- 여러 데이터소스를 사용하면 어떤 트랜잭션 매니저가 어떤 DB 자원을 관리하는지 확인해야 한다.
한 문장으로 정리하면 다음과 같다.
Spring 트랜잭션이 정상적으로 롤백되려면 호출이 트랜잭션 프록시를 통과해야 하고, 롤백 규칙에 해당하는 예외가 프록시까지 전달되어야 한다.
23. 다음 학습을 위한 복습 문제
문제 1
아래 코드에서 inner()의 REQUIRES_NEW는 정상적으로 새 트랜잭션을 시작하는가? 이유도 설명해 보자.
@Service
public class PaymentService {
@Transactional
public void outer() {
inner();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() {
// DB 작업
}
}
문제 2
다음 코드에서 insert() 이후 예외가 발생하면 기본 설정으로 롤백되는가?
@Transactional
public void save() throws IOException {
repository.insert(...);
throw new IOException("파일 오류");
}
문제 3
다음 코드에서 롤백이 보장되도록 수정해 보자.
@Transactional
public void save() {
try {
repository.insert(...);
validate();
} catch (Exception e) {
log.error("저장 실패", e);
}
}
문제 4
A DB에서 조회하고 B DB에 등록하는 시스템에서 @Transactional을 선언했다. 그런데 B DB의 등록은 롤백되지만 A DB에서 별도로 수행한 상태 변경은 롤백되지 않았다. 어떤 항목을 점검해야 하는가?
다음 학습에서는 위 문제를 바탕으로 REQUIRED, REQUIRES_NEW, rollback-only, UnexpectedRollbackException까지 연결하여 정리할 수 있다.
참고 자료
'개발 > java,spring' 카테고리의 다른 글
| 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 |
| [Java 컴파일 경고] 세부사항을 보려면 -Xlint:unchecked (1) | 2025.07.03 |