스프링 AOP 기반으로 다음과 같은 구조의 배치 로그 어노테이션을 만들어서 테스를 하던 도중 이해가 되지 않는 상황이 발생해서, 찾아봤던 기록을 남겨봅니다.
간단하게 Batch 작업 실행시 로그를 남겨 주는 부분을 AOP 로 만들고, 기존의 트랜잭션과는 분리하여 독립적으로 저장될 수 있게 하였습니다.
@Around("@annotation(batchJob)")
public Object around(ProceedingJoinPoint joinPoint, BatchJob batchJob) throws Throwable {
BatchJobHistory history = batchJobLogger.start(batchJob.name(), batchJob.operatorId());
try {
Object result = joinPoint.proceed();
batchJobLogger.complete(history, (Integer) result);
return result;
} catch (Exception e) {
batchJobLogger.fail(history, "FAIL"); // 메서드는 실행되나 저장이 되지 않음
throw e;
}
}
@Service
@RequiredArgsConstructor
public class BatchJobLogger {
private final BatchJobRepository batchJobRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public BatchJobHistory start(String jobName, String operatorId) {
BatchJobHistory history = BatchJobHistory.start(jobName, operatorId, getHostName());
return batchJobRepository.save(history);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void complete(BatchJobHistory history, Integer rowsAffected) {
history.complete(rowsAffected);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void fail(BatchJobHistory history, String message) {
history.fail(message);
}
}
@Component
@RequiredArgsConstructor
public class ScheduleDeleteTask {
private final SampleService sampleService;
@BatchJob(name = "someJobName", operatorId = "system")
public void someMethod() {
sampleService.someService();
}
}
의도했던 동작 방식은 다음과 같습니다.
start()에서 실행 로그를 기록하고,complete()에서 처리 결과를 남기며,fail()에서 오류 메시지를 기록하는 구조입니다.각 메서드는 모두 @Transactional(propagation = REQUIRES_NEW)으로 정의되어 있어,
독립적인 트랜잭션으로 로그를 남길 것이라고 기대했습니다.
throw가 발생하면 fail()은 정상적으로 실행됩니다.batch_job_history 테이블에는 실패 로그가 저장되지 않았습니다.history.fail(message); 더티 체킹으로 update 쿼리가 실행될 줄 알았으나, 실행되지 않았습니다.EntityManager.contains(history) 결과는 false였습니다.complete() 는 영속성 범위 내에서 관리되어 더티 체킹이 잘 적용되고 있었습니다.
트랜잭션은 분리했는데, 왜 저장이 안 되지?
왜 영속성 컨텍스트에 포함되어 있지 않은 걸까?
이 의문을 해소하기 위해 트랜잭션과 EntityManager의 동작을 직접 추적해봤습니다.
우선 JPA 영속성
예외가 발생하기 전의 흐름을 보면 다음과 같습니다.
예외 발생 전 흐름
BatchJobHistory history = batchJobLogger.start(...) // 트랜잭션 A
Object result = joinPoint.proceed() // 비즈니스 로직 실행 중 예외 발생
batchJobLogger.fail(history, "FAIL") // 트랜잭션 C
BatchJobHistory 객체 가 제대로 영속성 컨텍스트로 관리되는지 확인 하기 위해서 로그를 찍어서 직접 확인해 보았습니다.
start()는 트랜잭션 A에서 history 객체를 save() 하여 영속화.proceed() 중 예외가 발생하여 외부 트랜잭션은 rollback 예약 상태가 됨.fail()은 트랜잭션 C에서 실행되지만, EntityManager.contains(history)는 false.@PersistenceContext
private EntityManager em;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void fail(BatchJobHistory history, String message) {
log.info(">>> before merge: isPersistent = {}", em.contains(history));
history.fail(message);
}

반면에 예외가 발생하지 않고 정상적으로 진행되는 경우에는 EntityManager.contains(history) 는 true 로 여전히 영속상태로 유지된다.
@PersistenceContext
private EntityManager em;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void complete(BatchJobHistory history, Integer rowsAffected) {
log.info(">>> before merge: isPersistent = {}", em.contains(history));
history.complete(rowsAffected);
}

트랜잭션도 독립적으로 분리되고 여전히 영속성태로 객체가 전달되어 더티체킹도 잘 작동하게 된다.
🔍
complete()에서는em.contains(history)가true일까?
REQUIRES_NEW인데, 왜 결과가 다를까?REQUIRES_NEW는 매 호출마다 새로운 트랜잭션과 새로운 영속성 컨텍스트(EntityManager)를 생성합니다.
EntityManager Proxy는 하나지만 내부에서 트랜잭션에 따라 실제 delegate 교체됨 (ThreadLocal 기반)
그렇다면 complete()와 fail() 모두 준영속 객체가 되어야 할 것처럼 보이지만…
✅
complete()에서는em.contains(history)가true❌
fail()에서는em.contains(history)가false
📌 이 차이는 예외 발생 시점과 영속성 컨텍스트 생명주기의 차이에서 비롯됩니다.
complete() 호출)BatchHistory history = batchJobLogger.start(...) // 트랜잭션 A - history 영속화
Object result = joinPoint.proceed() // 정상 실행
batchJobLogger.complete(history, result) // 트랜잭션 B에서 history 사용
history 객체는 여전히 JVM 메모리 상에서 살아 있고,em.contains(history)는 truefail() 호출)BatchHistory history = batchJobLogger.start(...) // 트랜잭션 A - history 영속화
Object result = joinPoint.proceed() // ❗ 예외 발생
batchJobLogger.fail(history, "FAIL") // 트랜잭션 C에서 history 사용
proceed() 도중 예외 발생 → 외부 트랜잭션은 rollback 예약fail()에서는 history는 준영속(detached) 상태로 간주em.contains(history)는 false예외 발생 → 외부 트랜잭션 rollback 마킹 → persistence context 종료
joinPoint.proceed() 중 RuntimeException 발생
→ Spring은 외부 트랜잭션을 setRollbackOnly()로 마킹함
→ 이에 따라 외부 트랜잭션이 rollback 예약 상태가 되고
→ 트랜잭션이 commit/rollback 되면서 EntityManager는 persistence context를 종료
→ 그동안 관리되던 영속 객체는 모두 준영속(detached) 상태가 됨
JPA 문서에도 관련된 내용이 존재합니다.
"Upon transaction rollback, all managed entities become detached, and the persistence context is closed."
→ rollback이 예정된 시점 이후에는 이전까지 관리되던 엔티티도 더 이상 영속 상태가 아님
https://jakarta.ee/specifications/persistence/3.0/jakarta-persistence-spec-3.0.html#a2049
REQUIRES_NEW 트랜잭션이라도,
외부 트랜잭션이 정상 상태일 때 호출되면 기존 컨텍스트와의 객체 관계가 유지되지만,
예외 발생 후 호출되면 컨텍스트가 소멸되었기 때문에 준영속 상태가 된다.
@Transactional(REQUIRES_NEW)는 트랜잭션 분리뿐 아니라, 영속성 컨텍스트도 트랜잭션 단위로 분리한다.
하나의 Thread에서 실행되더라도, rollback 예약된 외부 트랜잭션은 컨텍스트가 종료되며, 객체는 준영속 상태가 된다.
이로 인해 후속 트랜잭션에서는 save/merge가 반드시 필요하다.
따라서, 명시적으로 save() 를 호출해주게 되면 문제없이 저장이 됩니다.