- Published on
Spring Boot 3 @Transactional 전파·롤백 함정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드에서 장애를 가장 늦게 발견하는 영역 중 하나가 트랜잭션입니다. 개발 환경에서는 잘 되다가, 운영에서만 데이터가 부분 저장되거나 반대로 전부 롤백되는 일이 흔합니다. 특히 Spring Boot 3(Spring Framework 6)에서는 기본 동작 자체가 크게 바뀐 것은 아니지만, @Transactional 을 둘러싼 전파(propagation)와 롤백(rollback) 규칙을 정확히 이해하지 못하면 “분명 트랜잭션을 걸었는데 왜 커밋됐지?” 같은 상황을 맞기 쉽습니다.
이 글은 다음을 목표로 합니다.
- 전파 옵션이 실제로 만드는 트랜잭션 경계를 코드로 확인
- 롤백이 안 되거나 과하게 되는 대표 패턴 정리
- 프록시 기반 AOP 특성(자기 호출, final, private 등)로 인한 미적용 케이스
- JPA flush, 예외 변환,
noRollbackFor같은 옵션의 함정
관련해서 JPA 성능 이슈를 같이 다루고 싶다면 Spring Boot 3+ JPA N+1 즉시 잡는 7가지도 함께 보면, 트랜잭션 경계와 쿼리 패턴을 한 번에 점검할 수 있습니다.
1) 전파(Propagation)를 “트랜잭션 경계”로 다시 보기
전파 옵션은 단순히 “안에서 또 @Transactional 을 만나면 어떻게 할지”가 아니라, 트랜잭션 경계가 어디서 시작되고 어디서 끝나는지를 결정합니다. 이 경계가 바뀌면 롤백 범위도 바뀝니다.
핵심 전파 옵션 요약
REQUIRED(기본): 있으면 참여, 없으면 새로 시작REQUIRES_NEW: 무조건 새 트랜잭션 시작, 기존 트랜잭션은 일시 중단NESTED: 저장점(savepoint) 기반 중첩(데이터베이스와 트랜잭션 매니저 지원 필요)SUPPORTS: 있으면 참여, 없으면 비트랜잭션으로 실행NOT_SUPPORTED: 비트랜잭션으로 실행, 기존 트랜잭션은 일시 중단MANDATORY: 트랜잭션 없으면 예외NEVER: 트랜잭션 있으면 예외
실무에서 사고가 많이 나는 구간은 REQUIRED 와 REQUIRES_NEW 의 조합입니다.
2) 함정 1: REQUIRES_NEW 로 “일부만 커밋”되는 구조
REQUIRES_NEW 는 겉보기엔 안전장치처럼 보이지만, 전체 작업을 원자적으로 처리해야 하는 곳에 섞이면 의도치 않은 부분 커밋을 만듭니다.
예시: 주문 생성 중 감사 로그는 별도 커밋
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final AuditService auditService;
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
// 별도 트랜잭션으로 "항상" 남기고 싶어서 REQUIRES_NEW 사용
auditService.writeAudit("ORDER_PLACED", order.getId());
// 이후 로직에서 예외 발생
if (order.getTotalPrice().signum() < 0) {
throw new IllegalStateException("invalid total");
}
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAudit(String type, Long refId) {
// auditRepository.save(...)
}
}
이 코드의 결과는 다음과 같습니다.
placeOrder트랜잭션은 예외로 롤백됨writeAudit은 별도 트랜잭션이라 이미 커밋됨
즉 “주문은 없는데 감사 로그는 있는” 상태가 됩니다. 이게 의도라면 괜찮지만, 의도가 아니라면 큰 데이터 정합성 문제입니다.
점검 체크리스트
REQUIRES_NEW는 “격리된 커밋”을 만든다는 사실을 항상 문서화- 이벤트/로그/알림처럼 정합성보다 추적성이 더 중요한 영역에만 제한적으로 사용
- 원자성이 필요한 도메인 작업 내부에 섞이지 않게 레이어를 분리
MSA 환경에서 이런 부분 커밋이 사가 보상 트랜잭션과 얽히면 디버깅 난이도가 급상승합니다. 비슷한 성격의 꼬임 사례는 MSA Saga 보상 트랜잭션 꼬임 디버깅 실전 관점으로도 연결해 볼 수 있습니다.
3) 함정 2: 롤백은 “RuntimeException” 기본, Checked 예외는 기본 커밋
Spring의 기본 롤백 규칙은 단순합니다.
RuntimeException및Error발생 시 롤백- Checked 예외(
Exception계열) 발생 시 기본적으로 커밋
이 규칙을 모르면 “예외가 발생했는데 왜 저장됐지?”가 바로 나옵니다.
예시: Checked 예외로 실패를 표현
@Transactional
public void registerUser(User user) throws Exception {
userRepository.save(user);
// 비즈니스 실패를 checked 예외로 표현
if (user.getEmail().endsWith("@blocked.com")) {
throw new Exception("blocked domain");
}
}
위 코드는 기본 설정에서 커밋될 수 있습니다.
해결 방법 1: rollbackFor
@Transactional(rollbackFor = Exception.class)
public void registerUser(User user) throws Exception {
userRepository.save(user);
if (user.getEmail().endsWith("@blocked.com")) {
throw new Exception("blocked domain");
}
}
해결 방법 2: 도메인 예외는 RuntimeException 으로 통일
실무에서는 “롤백 기준을 예외 타입으로 일관되게 유지”하는 게 더 안전합니다.
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
@Transactional
public void registerUser(User user) {
userRepository.save(user);
if (user.getEmail().endsWith("@blocked.com")) {
throw new BusinessException("blocked domain");
}
}
4) 함정 3: 예외를 잡아먹으면 롤백 트리거가 사라진다
롤백은 “트랜잭션 경계 밖으로 예외가 전파”되거나, 혹은 명시적으로 rollback-only를 표시할 때 발생합니다. 따라서 아래처럼 예외를 잡고 정상 흐름으로 끝내면 커밋될 수 있습니다.
@Transactional
public void updateProfile(Long userId) {
userRepository.updateLastSeen(userId);
try {
externalClient.call();
} catch (RuntimeException e) {
// 로깅만 하고 삼켜버림
log.warn("external failed", e);
}
// 메서드가 정상 종료되므로 트랜잭션은 커밋될 수 있음
}
해결 방향
- 정말로 커밋하면 안 되는 실패라면 예외를 다시 던지기
- 또는 강제로 rollback-only 표시
@Transactional
public void updateProfile(Long userId) {
userRepository.updateLastSeen(userId);
try {
externalClient.call();
} catch (RuntimeException e) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
throw e;
}
}
주의할 점은, setRollbackOnly 만 해두고 예외를 삼키면 호출자는 성공으로 오해합니다. 즉 “데이터는 롤백됐는데 API는 200” 같은 2차 사고가 날 수 있으니, 보통은 예외 전파가 더 낫습니다.
5) 함정 4: 자기 호출(self-invocation)로 @Transactional 이 적용되지 않는다
Spring의 선언적 트랜잭션은 프록시 기반 AOP입니다. 즉, 프록시를 통해 호출될 때만 트랜잭션이 적용됩니다. 같은 클래스 내부에서 this.someMethod() 로 호출하면 프록시를 거치지 않아 @Transactional 이 무시됩니다.
@Service
public class PaymentService {
public void pay() {
// 내부 호출: 프록시를 안 거침
confirm();
}
@Transactional
public void confirm() {
// 기대: 트랜잭션 시작
// 실제: 트랜잭션이 없을 수 있음
}
}
해결 패턴
- 트랜잭션이 필요한 메서드를 별도 서비스로 분리
- 또는 자기 자신 프록시를 주입받아 프록시로 호출(권장도는 낮음)
서비스 분리 예시:
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentTxService paymentTxService;
public void pay() {
paymentTxService.confirm();
}
}
@Service
public class PaymentTxService {
@Transactional
public void confirm() {
// 트랜잭션 적용
}
}
6) 함정 5: readOnly = true 는 “쓰기 금지”가 아니라 “힌트”다
@Transactional(readOnly = true) 는 많은 개발자가 “쓰기 하면 예외”라고 오해합니다. 실제로는 다음 성격이 강합니다.
- JPA/Hibernate에 flush 최적화 힌트를 제공
- 일부 DB에서 읽기 전용 트랜잭션 힌트를 적용
하지만 코드에서 save 를 호출한다고 반드시 막아주지 않습니다. 오히려 더 위험한 점은, flush 타이밍이 달라져서 “테스트에서는 안 나던 쓰기 쿼리”가 운영에서 늦게 터지는 식의 문제가 생길 수 있습니다.
권장:
- 조회 전용 유스케이스는
readOnly = true - 쓰기가 섞이면 트랜잭션을 분리하거나
readOnly를 제거
7) 함정 6: flush 시점 때문에 “예외가 늦게” 터져 롤백 범위가 꼬인다
JPA는 기본적으로 쓰기 SQL을 즉시 실행하지 않고, flush 시점(보통 커밋 직전)에 몰아서 실행합니다. 그래서 아래처럼 보일 수 있습니다.
save는 성공한 것처럼 보임- 뒤에서 다른 로직 실행
- 커밋 시점에 제약조건 위반 예외 발생
@Transactional
public void createTwoUsers(User a, User b) {
userRepository.save(a);
userRepository.save(b);
// 여기까지는 예외가 안 보일 수 있음
// 커밋 직전에 unique 제약 위반이 터질 수 있음
}
디버깅 팁: 의도적으로 flush를 당겨라
@Transactional
public void createTwoUsers(User a, User b) {
userRepository.save(a);
userRepository.flush(); // 예외를 여기서 조기에 확인
userRepository.save(b);
}
조기 flush는 성능에 영향을 줄 수 있으니 상시 적용이 아니라 “문제 재현과 원인 확인”에 유용합니다.
8) 함정 7: noRollbackFor 는 운영 사고를 만들기 쉽다
noRollbackFor 는 특정 예외가 발생해도 커밋하게 만듭니다. “일부 실패는 무시하고 진행” 같은 요구를 만족시키지만, 장기적으로는 데이터 품질을 갉아먹습니다.
@Transactional(noRollbackFor = BusinessException.class)
public void process() {
repository.save(...);
throw new BusinessException("ignore and commit");
}
이 패턴을 쓴다면 최소한 다음이 필요합니다.
- 커밋되는 이유를 코드 주석과 ADR로 남김
- 후속 정합성 보정 작업(배치, 재처리 큐 등)을 반드시 설계
9) 전파·롤백 문제를 빠르게 찾는 로깅/설정
트랜잭션 로그 레벨 올리기
아래 로거는 “트랜잭션이 시작됐는지, 참여했는지, rollback-only가 됐는지”를 확인하는 데 도움이 됩니다.
logging:
level:
org.springframework.transaction: DEBUG
org.springframework.orm.jpa: DEBUG
트랜잭션 동작을 코드로 확인하기
public void debugTx() {
boolean active = TransactionSynchronizationManager.isActualTransactionActive();
String name = TransactionSynchronizationManager.getCurrentTransactionName();
log.info("tx active={}, name={}", active, name);
}
이 체크를 REQUIRES_NEW 경계 전후에 심어두면 “정말로 분리된 트랜잭션이 생성되는지”를 빠르게 볼 수 있습니다.
10) 실전 권장 규칙(팀 컨벤션으로 가져가기)
- 도메인 실패는 가능하면 RuntimeException 기반으로 통일
REQUIRES_NEW는 감사로그/아웃박스/알림 등 의도가 명확한 곳에만- 예외를 잡아먹는 코드는 “커밋 의도”가 있는지 반드시 리뷰 포인트로
- 트랜잭션 메서드의 자기 호출 금지, 필요하면 서비스 분리
- 조회 트랜잭션(
readOnly = true)에 쓰기 로직 섞지 않기 - 제약조건/락/무결성 문제는
flush()로 조기 재현 후 원인 제거
트랜잭션은 한 번 사고가 나면 데이터 복구 비용이 기능 개발 비용을 압도합니다. 전파와 롤백 규칙을 “암기”가 아니라 “경계 설계”로 접근하면, Spring Boot 3에서도 예측 가능한 데이터 일관성을 유지할 수 있습니다.