try-catch로 잡았는데 왜 전체 롤백될까? — Spring rollback-only 전파와 배치 트랜잭션 분리
[Spring] 배치 루프에서 try-catch가 소용없던 이유 — rollback-only 전파와 TransactionTemplate(REQUIRES_NEW)
배치 루프에서 개별 항목의 예외를 try-catch로 잡았는데도 커밋 시점에 성공분까지 전부 롤백되는 문제를 겪었다.
원인은 Spring 트랜잭션의 rollback-only 마킹이었고,noRollbackFor검토 후 기각, 최종적으로
항목 단위TransactionTemplate(REQUIRES_NEW)로 해결했다. 그 과정을 정리한다.
1. 문제 상황
퇴사자 정리 배치에서 한 사용자의 리소스(채팅방) 여러 건을 루프로 처리하고 있었다.
한 건이 실패해도 나머지는 계속 처리되도록 방 단위 try-catch를 걸어뒀는데,
한 건이라도 RuntimeException이 나면 이미 성공 처리된 건까지 전부 롤백되는 현상이 발생했다.
기대: 방 10개 중 3번째 실패 → 9개 성공, 1개 실패 (로그 집계)
실제: 방 10개 중 3번째 실패 → 10개 전부 롤백 + UnexpectedRollbackException분명 catch로 잡았는데 왜 전체가 롤백될까? 이걸 이해하려면 @Transactional의 동작 방식부터 짚어야 한다.
2. @Transactional 기본 동작
Spring의 @Transactional은 프록시 기반 AOP로 동작한다. 어노테이션이 붙은 빈을 주입받으면
실제로는 프록시 객체가 주입되고, 메서드 호출 시 프록시가 앞뒤로 트랜잭션 처리를 끼워 넣는다.
호출자 → [프록시: TX 시작] → 실제 메서드 → [프록시: 커밋 or 롤백]- 메서드가 정상 종료하면 → 커밋
- 메서드에서 RuntimeException / Error가 프록시 경계를 빠져나가면 → 롤백
- Checked Exception은 기본적으로 롤백하지 않는다 (
rollbackFor로 지정해야 함)
핵심은 롤백 판정이 "예외가 프록시 경계를 통과하는 순간" 이뤄진다는 것이다.
이 타이밍이 이번 문제의 핵심이 된다.
3. 전파(Propagation): REQUIRED vs REQUIRES_NEW
트랜잭션 메서드가 다른 트랜잭션 메서드를 호출하면 트랜잭션을 어떻게 이어갈지 결정해야 하는데,
이 정책이 전파 속성(Propagation) 이다. 실무에서 가장 자주 만나는 둘만 비교하면:
| REQUIRED (기본값) | REQUIRES_NEW | |
|---|---|---|
| 기존 TX가 있으면 | 참여한다 (같은 TX 공유) | 기존 TX를 잠시 멈추고 새 TX 시작 |
| 기존 TX가 없으면 | 새 TX 시작 | 새 TX 시작 |
| 커밋/롤백 단위 | 바깥 TX와 운명 공동체 | 독립적으로 커밋/롤백 |
| DB 커넥션 | 바깥과 공유 | 별도 커넥션 추가 점유 |
REQUIRED로 참여한 안쪽 메서드는 물리적으로 별도 트랜잭션이 아니다.
논리적 경계만 있을 뿐, 커밋/롤백은 가장 바깥 트랜잭션 하나로 묶인다.
"안쪽만 롤백하고 바깥은 커밋"이 불가능하다는 뜻이고, 이게 문제의 씨앗이다.
4. 문제 재현: rollback-only 마킹
문제가 됐던 구조를 일반화하면 이렇다.
@Service
@Transactional // 클래스 전체 = 하나의 트랜잭션 경계
public class UserCleanupBatchService {
@Autowired
private RoomService roomService;
public void cleanupUser(String userId) {
List<Room> rooms = findTargetRooms(userId);
int success = 0, failed = 0;
for (Room room : rooms) {
try {
roomService.deleteRoom(room.getId()); // 별도 빈, @Transactional(REQUIRED)
success++;
} catch (RuntimeException e) {
failed++; // 잡았다고 생각하지만...
log.warn("방 삭제 실패 room={}", room.getId(), e);
}
}
log.info("완료 success={} failed={}", success, failed);
// ← 여기서 커밋 시도 → UnexpectedRollbackException → 전체 롤백
}
}
@Service
public class RoomService {
@Transactional // REQUIRED: 배치의 트랜잭션에 "참여"
public void deleteRoom(String roomId) {
// ... 삭제 로직, 중간에 RuntimeException 발생 가능
}
}
무슨 일이 일어나는가
cleanupUser()진입 → 트랜잭션 T1 시작- 루프에서
roomService.deleteRoom()호출 → REQUIRED이므로 T1에 참여 deleteRoom()내부에서 RuntimeException 발생- 예외가 RoomService의 프록시 경계를 빠져나가는 순간, 프록시가 판단한다:
"내 트랜잭션에서 RuntimeException이 났다 → 롤백 대상"
그런데 T1은 자기 소유가 아니라 참여 중인 트랜잭션이므로 직접 롤백할 수 없다.
대신 T1에 rollback-only 플래그를 마킹한다. - 예외는 계속 전파되어 배치의 catch에 잡힌다. 루프는 계속 돈다. 하지만 이미 늦었다.
- 루프가 끝나고
cleanupUser()가 정상 종료 → 프록시가 T1 커밋 시도 - T1에 rollback-only가 찍혀 있으므로 커밋 대신 전체 롤백 +
UnexpectedRollbackException
즉 catch는 예외 전파만 막을 뿐, 이미 찍힌 rollback-only 마킹은 지울 수 없다.
try-catch가 소용없던 이유가 이것이다.
5. 대안 검토 ①: noRollbackFor — 왜 기각했나
@Transactional(noRollbackFor = X.class)는 "예외 X는 롤백 사유로 치지 않는다"는 설정이다.
언뜻 해결책 같지만, 세 가지 이유로 기각했다.
(1) 붙여야 하는 위치가 바깥이 아니라 "안쪽"이다
rollback-only 마킹은 4번 단계, 즉 안쪽 경계(RoomService)의 프록시가 찍는다.
바깥 배치 서비스에 noRollbackFor를 붙여봤자 마킹 시점에는 아무 영향이 없다.
효과를 보려면 deleteRoom() 쪽에 붙여야 한다.
(2) 공용 메서드의 롤백 정책을 오염시킨다
deleteRoom()은 배치 전용이 아니라 화면에서 사용자가 방을 삭제할 때도 쓰는 공용 메서드다.
여기에 noRollbackFor = RuntimeException.class를 붙이면 모든 호출처에서
런타임 예외가 나도 롤백하지 않게 된다. 배치 하나의 사정 때문에 정상 케이스의
데이터 정합성 방어선을 무너뜨리는 꼴이다.
(3) 롤백 단위가 틀어진다 — 부분 쓰기 잔류
noRollbackFor가 하는 일은 "롤백하지 마라"이지 "이 항목만 롤백해라"가 아니다.
롤백의 단위는 여전히 트랜잭션 전체 하나다.
방 하나를 처리할 때 여러 건의 쓰기(공유 레코드, 소유권 변경 등)가 발생하는데,
중간에 예외가 나면 noRollbackFor 하에서는 실패한 방의 반쪽짜리 쓰기가 그대로 커밋된다.
필요한 것은 "성공한 방은 커밋, 실패한 방은 통째로 롤백"인데,noRollbackFor로는 후자를 표현할 방법이 없다.
정리하면 —
noRollbackFor는 예외 분류 정책을 바꾸는 도구이고,
이번 문제는 트랜잭션 경계를 바꿔야 하는 문제였다. 도구의 축이 다르다.
6. 대안 검토 ②: 메서드에 @Transactional(REQUIRES_NEW) — self-invocation 함정
"그럼 방 처리 메서드에 REQUIRES_NEW를 붙이면 되지 않나?"
@Service
public class UserCleanupBatchService {
public void cleanupUser(String userId) {
for (Room room : rooms) {
try {
processOneRoom(room); // ← 같은 클래스 내부 호출!
} catch (RuntimeException e) { ... }
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOneRoom(Room room) { ... }
}
이 코드는 동작하지 않는다. @Transactional은 프록시를 거쳐야 적용되는데,
같은 클래스 안에서 this.processOneRoom()을 부르면 프록시를 우회해서
어노테이션이 통째로 무시된다. 이것이 유명한 self-invocation 문제다.
우회 방법으로 자기 자신을 주입받는 self-injection이나 별도 빈 분리가 있지만,
전자는 구조가 어색하고 후자는 방 하나 처리를 위해 클래스를 쪼개야 한다.
7. 선택: TransactionTemplate
최종적으로 선택한 것은 프로그래밍 방식 트랜잭션인 TransactionTemplate이다.
@Service
public class UserCleanupBatchService {
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private RoomService roomService;
public void cleanupUser(String userId, String successorId) {
TransactionTemplate tx = new TransactionTemplate(transactionManager);
tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
List<Room> rooms = findTargetRooms(userId);
int success = 0, failed = 0;
for (Room room : rooms) {
try {
// 방 1건 = 트랜잭션 1건
Result r = tx.execute(status -> {
Result result = roomService.deleteRoom(room.getId());
// 예외 없이 FAIL을 반환하는 경우에도 롤백 — 부분 쓰기 잔류 방지
if (result == null || !result.isSuccess()) {
status.setRollbackOnly();
}
return result;
});
if (r != null && r.isSuccess()) success++;
else failed++;
} catch (RuntimeException e) {
// execute() 안에서 예외 발생 시 해당 TX만 롤백된 상태로 전파됨
failed++;
log.warn("방 삭제 실패 room={}", room.getId(), e);
}
}
log.info("완료 success={} failed={}", success, failed);
}
}
무엇이 달라졌나
- 롤백 단위 = 방 1건. 각
execute()가 독립 트랜잭션(REQUIRES_NEW)이므로,
한 방의 실패는 그 방만 롤백하고 이미 커밋된 다른 방에 영향을 주지 않는다. - 성공분 즉시 커밋. 처리 도중 프로세스가 죽어도 그때까지의 진행분은 살아 있다.
유저 전체를 한 TX로 묶었을 때처럼 커넥션과 락을 오래 잡는 문제도 사라진다. - 예외 없는 실패까지 제어. 서비스가 예외 대신 FAIL 결과를 반환하는 경우
status.setRollbackOnly()로 명시적으로 롤백할 수 있다.
선언적 방식(@Transactional)으로는 표현하기 까다로운 지점이다. - self-invocation 문제 원천 회피. 프록시가 아니라 코드로 경계를 만들기 때문에
같은 클래스 내부 호출이어도 확실하게 트랜잭션이 걸린다.
TransactionTemplate이 좋은 경우
- 루프의 반복 단위마다 트랜잭션을 끊고 싶을 때 (배치의 전형적 요구)
- 트랜잭션 경계를 메서드 단위보다 잘게 잡고 싶을 때
- 예외뿐 아니라 반환값 조건으로도 롤백을 결정하고 싶을 때
- self-invocation 때문에 어노테이션이 안 먹는 구조를 리팩토링 없이 해결하고 싶을 때
반대로 "메서드 전체 = 트랜잭션 하나"인 평범한 케이스라면 선언적 @Transactional이
코드도 깔끔하고 관례에도 맞다. 템플릿은 경계를 세밀하게 제어해야 할 때 꺼내는 도구다.
8. 정리
@Transactional의 롤백 판정은 예외가 프록시 경계를 통과하는 순간 일어난다.
REQUIRED로 참여 중인 안쪽 경계에서 RuntimeException이 새어 나가면
공유 트랜잭션에 rollback-only가 마킹되고, 바깥에서 catch해도 되돌릴 수 없다.noRollbackFor는 예외 분류 정책을 바꾸는 도구다. "이 항목만 롤백"이라는
경계 문제에는 답이 될 수 없고, 공용 메서드에 붙이면 다른 호출처까지 오염시킨다.- 배치처럼 "항목별 독립 커밋/롤백"이 필요하면 항목 단위 REQUIRES_NEW로 경계를 쪼갠다.
같은 클래스 내부 호출이라면 self-invocation 함정을 피해 TransactionTemplate을 쓰는 것이
가장 확실하다. - 예외 없이 실패를 반환하는 API가 섞여 있다면
status.setRollbackOnly()로
부분 쓰기 잔류까지 막아야 "방 1건 = 원자 단위"가 완성된다.