대량 회원 데이터를 외부에서 받아 저장하는 배치를 만들었습니다. 요구사항은 단순했습니다. 한 건이 잘못돼도 나머지는 저장돼야 한다. 그래서 각 건을 독립된 트랜잭션으로 처리하려고 @Transactional(REQUIRES_NEW)를 붙였습니다. 그런데 운영에 올리고 보니, 중간에 한 건이 실패하면 그 배치 전체가 통째로 롤백되거나, 반대로 실패한 건까지 저장돼 버리는 이상한 일이 벌어졌습니다.
@Service
public class MemberImportService {
public void importAll(List<MemberDto> rows) {
for (MemberDto row : rows) {
saveOne(row); // 같은 객체(this)로 직접 호출
}
}
@Transactional(propagation = REQUIRES_NEW)
public void saveOne(MemberDto row) {
memberRepository.save(toEntity(row));
validate(row); // 실패하면 이 건만 롤백되길 기대
}
}
증상부터가 헷갈렸습니다
처음엔 트랜잭션 전파 설정을 잘못 이해한 줄 알았습니다. REQUIRES_NEW가 분명히 새 트랜잭션을 연다고 문서에 써 있으니까요. 확인을 위해 각 지점에서 TransactionSynchronizationManager.isActualTransactionActive()를 로그로 찍었습니다. 결과는 충격이었습니다. saveOne 안에서 이 값이 false였습니다. 새 트랜잭션은커녕 트랜잭션이 아예 없이 실행되고 있었던 겁니다. 어노테이션은 붙어 있는데 아무 일도 하지 않고 있었습니다.
스프링이 트랜잭션을 거는 진짜 방식
@Transactional은 마법이 아니라 AOP 프록시가 대신 트랜잭션을 열고 닫아 주는 구조입니다. 스프링은 애플리케이션을 띄울 때 MemberImportService를 그대로 쓰지 않고, 이 클래스를 감싼 프록시 객체를 만들어 빈으로 등록합니다. 인터페이스가 있으면 JDK 동적 프록시로, 없으면 CGLIB으로 자식 클래스를 만들어 감쌉니다. 외부에서 saveOne을 부르면 이 프록시가 먼저 가로채 트랜잭션을 열고, 진짜 메서드를 호출한 뒤, 끝나면 커밋 또는 롤백합니다.
문제는 여기 있습니다. importAll 안에서 saveOne(row)를 부를 때 실제로 참조한 건 프록시가 아니라 진짜 this였습니다. 자기 자신을 직접 부르면 프록시를 우회합니다. 가로챌 프록시가 없으니 트랜잭션을 여는 코드가 실행되지 않고, 그냥 바깥 트랜잭션에 묻어가거나 트랜잭션 없이 실행됩니다. 같은 이유로 private 메서드나 final 메서드에 붙인 @Transactional도 동작하지 않습니다. 프록시가 오버라이드해서 감쌀 수 없는 메서드니까요.
해결 1 — 다른 빈으로 분리 (택한 방식)
가장 깔끔했던 건 트랜잭션 단위를 별도 빈으로 빼는 것이었습니다. 호출이 빈 경계를 넘으면 프록시를 거치게 됩니다.
@Service
@RequiredArgsConstructor
public class MemberImportService {
private final MemberWriter writer; // 다른 빈
public void importAll(List<MemberDto> rows) {
for (MemberDto row : rows) {
try { writer.saveOne(row); }
catch (Exception e) { log.warn("skip: {}", row.id(), e); }
}
}
}
@Service
public class MemberWriter {
@Transactional(propagation = REQUIRES_NEW)
public void saveOne(MemberDto row) { ... }
}
이제 saveOne은 프록시를 거쳐 진짜 새 트랜잭션에서 돌고, 한 건이 실패하면 그 건만 롤백된 뒤 루프는 계속됩니다. 예외는 바깥에서 잡아 로그만 남기고 넘어가게 했습니다.
해결 2 — 자기 프록시 주입
구조를 나누기 애매할 땐 자기 자신의 프록시를 주입받아 그걸로 호출할 수도 있습니다.
@Service
public class MemberImportService {
@Autowired @Lazy
private MemberImportService self; // 프록시가 주입됨
public void importAll(List<MemberDto> rows) {
rows.forEach(self::saveOne); // self 경유 → 프록시 O
}
}
세 방식 비교
| 방식 | 트랜잭션 적용 | 가독성 |
|---|---|---|
| this.saveOne() | ❌ 안 걸림 | – |
| 별도 빈 분리 | ✅ | 좋음(권장) |
| self 프록시 주입 | ✅ | 낯섦 |
저는 나중에 읽기 좋아서 해결 1을 택했습니다. 그리고 재발을 막으려고, 트랜잭션이 실제로 열렸는지 isActualTransactionActive()로 검증하는 작은 테스트를 붙였습니다. 결론은 한 문장입니다. 같은 클래스 안에서 자기 메서드를 그냥 부르면 @Transactional은 걸리지 않는다. 이 한 줄이 반나절을 아낍니다.