AOP + REQUIRES_NEW 로그 저장 설계 중 겪은 트랜잭션과 JPA의 함정

dev_hwan·2025년 5월 10일

스프링 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() 하여 영속화.
  • 이 객체는 EntityManager A의 영속성 컨텍스트에 등록됨.
  • 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 기반)

참고 https://woodcock.tistory.com/35

그렇다면 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 메모리 상에서 살아 있고,
  • JPA는 이 객체를 여전히 관리 가능한 상태로 인식
  • em.contains(history)true

❌ 예외 발생 후 흐름 (fail() 호출)

BatchHistory history = batchJobLogger.start(...)   // 트랜잭션 A - history 영속화

Object result = joinPoint.proceed()          // ❗ 예외 발생

batchJobLogger.fail(history, "FAIL")      // 트랜잭션 C에서 history 사용
  • proceed() 도중 예외 발생 → 외부 트랜잭션은 rollback 예약
  • rollback이 예약되면 Spring은 영속성 컨텍스트를 정리(destroy)
  • 이후 호출된 fail()에서는 history준영속(detached) 상태로 간주
  • em.contains(history)false

예외 발생 → 외부 트랜잭션 rollback 마킹 → persistence context 종료
joinPoint.proceed()RuntimeException 발생

Spring은 외부 트랜잭션을 setRollbackOnly()로 마킹함
→ 이에 따라 외부 트랜잭션이 rollback 예약 상태가 되고
→ 트랜잭션이 commit/rollback 되면서 EntityManagerpersistence 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() 를 호출해주게 되면 문제없이 저장이 됩니다.

profile
내맘대로 주제잡고 재미로 글쓰는 개발일지 블로그 👨‍💻

0개의 댓글