# 개요
크론잡에서 iTunes API를 배치 단위로 호출해 트랙을 DB에 저장하는 로직을 구현하면서, @Transactional을 어디에 어떻게 붙이느냐에 따라 전혀 다른 동작이 나온다는 걸 실무적으로 정리했다. 이론으로는 알던 내용이지만 실제 설계에서 마주치면 생각보다 빠져나오기 까다롭다.
# 문제 1 — 자기호출(self-invocation)은 트랜잭션이 적용되지 않는다
처음 구조는 같은 클래스 안에 두 메서드를 두고 @Transactional을 각각 붙이는 방식이었다.
@Service
public class CronService {
@Transactional
public CronJobResult callItunesAPI(String jobName) {
for (int i = 0; i < MAX_BATCHES; i++) {
processBatch(startId); // 같은 클래스 내부 호출
}
}
@Transactional // 이건 사실상 무시된다
public int processBatch(long startId) {
// saveAll + cronState 갱신
}
}
의도는 processBatch마다 독립 트랜잭션으로 커밋하는 것이었지만 실제로는 전혀 다르게 동작한다.
Spring의 @Transactional은 프록시 기반 AOP로 동작한다. 외부에서 빈을 호출할 때 Spring이 생성한 프록시 객체를 거치면서 트랜잭션 로직이 끼어든다. 그런데 같은 클래스 내부에서 this.processBatch()를 호출하면 프록시를 우회해서 실제 객체의 메서드를 직접 호출한다. 결과적으로 processBatch의 @Transactional은 완전히 무시되고, callItunesAPI의 트랜잭션 하나에 전부 묶인다.
1000배치 = 1 트랜잭션
배치가 1000개라면 50분짜리 트랜잭션이 열린 채 유지된다. JPA 1차 캐시(EntityManager)에 저장된 엔티티가 모두 쌓이고, 999번째 배치에서 실패하면 앞의 998배치가 전부 롤백된다.
해결: 배치 로직을 별도 빈으로 분리
@Service
@RequiredArgsConstructor
public class CronBatchService {
@Transactional // 이제 외부에서 프록시를 거쳐 호출되므로 정상 적용된다
public ProcessResult processBatch(String jobName) {
// saveAll + cronState 갱신
}
}
@Service
@RequiredArgsConstructor
public class CronService {
private final CronBatchService cronBatchService; // 별도 빈 주입
public CronJobResult callItunesAPI(String jobName) {
for (int i = 0; i < MAX_BATCHES; i++) {
cronBatchService.processBatch(jobName); // 프록시 경유 → 트랜잭션 정상 적용
}
}
}
CronBatchService가 별도 Spring 빈이므로 cronBatchService.processBatch() 호출 시 프록시를 거친다. 배치마다 @Transactional이 정상적으로 적용되어 독립 트랜잭션으로 커밋된다.
# 문제 2 — 트랜잭션 밖에서 로드한 엔티티는 detached 상태다
배치를 별도 빈으로 분리한 뒤, CronState 엔티티를 CronService에서 한 번 로드해서 배치 메서드에 파라미터로 넘기는 구조를 시도했다.
// CronService (트랜잭션 없음)
public CronJobResult callItunesAPI(String jobName) {
CronState cronState = cronStateRepository.findByJobName(jobName)...; // 여기서 로드
for (int i = 0; i < MAX_BATCHES; i++) {
cronBatchService.processBatch(startId, cronState); // 파라미터로 넘김
}
}
// CronBatchService
@Transactional
public int processBatch(long startId, CronState cronState) {
// ... saveAll ...
cronState.updateLastProcessedId(startId + 199); // dirty checking으로 갱신하려 했지만...
}
cronState.updateLastProcessedId()를 호출해도 DB가 갱신되지 않는다.
JPA의 dirty checking은 영속성 컨텍스트(EntityManager)에 managed 상태로 등록된 엔티티에만 동작한다. CronService에는 트랜잭션이 없으므로, cronStateRepository.findByJobName()으로 로드된 cronState는 로드 직후 트랜잭션이 없어 detached 상태가 된다. 이 객체를 processBatch에 넘겨도 CronBatchService의 영속성 컨텍스트에는 등록되지 않은 채로 넘어온 것이라 dirty checking 감지 대상이 아니다.
managed vs detached
- managed: 트랜잭션 내 영속성 컨텍스트에 등록된 상태. 필드를 수정하면 트랜잭션 커밋 시 UPDATE SQL이 자동 발행된다.
- detached: 영속성 컨텍스트와 분리된 상태. 필드를 수정해도 DB에 반영되지 않는다.
해결: 트랜잭션 안에서 직접 조회
processBatch 내부에서 CronState를 직접 조회하면, 해당 트랜잭션의 영속성 컨텍스트에 managed 상태로 등록된다.
@Transactional
public ProcessResult processBatch(String jobName) {
// 트랜잭션 안에서 직접 조회 → managed 상태
CronState cronState = cronStateRepository.findByJobName(jobName)...;
long startId = cronState.getLastProcessedId() != null
? cronState.getLastProcessedId() + 1 : 1L;
// ... saveAll ...
cronState.updateLastProcessedId(startId + 199); // dirty checking 정상 동작
return new ProcessResult(tracks.size(), startId + 199);
}
# 최종 구조
CronService.callItunesAPI() — 트랜잭션 없음
└─ loop (1000회)
└─ CronBatchService.processBatch() @Transactional
├─ cronStateRepository.findByJobName() → managed CronState
├─ iTunesAPIClient.lookup() → 200개 배치 조회
├─ trackRepository.saveAll()
├─ cronState.updateLastProcessedId() → dirty checking → UPDATE
└─ 커밋 ← 배치마다 독립 커밋
| 시도 | 문제 | 결과 |
|---|---|---|
| 같은 클래스에서 자기호출 | AOP 프록시 우회 | @Transactional 무시, 전체가 하나의 트랜잭션 |
| 별도 빈 분리 + 외부에서 cronState 로드 후 파라미터로 전달 | detached 엔티티 | dirty checking 동작 안 함, DB 미갱신 |
| 별도 빈 분리 + 트랜잭션 안에서 cronState 직접 조회 | 없음 | 배치마다 독립 커밋, dirty checking 정상 동작 |
# 정리
배치 작업에서 @Transactional을 설계할 때 확인할 두 가지:
- 호출 경로: 같은 클래스 내부 호출이면 프록시를 우회한다 → 배치 단위 트랜잭션이 필요하면 별도 빈으로 분리
- 엔티티 상태: dirty checking이 동작하려면 해당 트랜잭션의 영속성 컨텍스트 안에서 엔티티를 로드해야 한다 → 트랜잭션 밖에서 로드한 엔티티는 detached