발단
프로젝트 진행 중 ERP 인터페이스 스케줄러를 구현 중이었다.
가상의 ERP 스테이징 테이블을 5초마다 폴링해서 출고 계획으로 변환하는 코드였다.
@Component
public class ErpInterfaceScheduler {
@Scheduled(fixedDelay = 5000)
public void poll() {
for (IfOutPlan ifOutPlan : ifOutPlanService.findPending()) {
processOne(ifOutPlan);
}
}
@Transactional
public void processOne(IfOutPlan ifOutPlan) {
outPlanService.createFromIf(ifOutPlan);
ifOutPlan.markProcessed();
}
}
processOne은 출고계획(OutPlan)저장과 ERP에서 내려온 출고계획(IfOutPlan)의 상태 업데이트를 하나의 트랜잭션으로 묶어야한다.
중간에 실패하면 둘다 롤백되어야 하니까.
그런데 여기서 클로드가 한가지 문제를 알려줬다.
저 Transactional은 동작하지 않는다!
왜 @Transactional을 쓰는가
먼저 근본으로 돌아가 이 어노테이션에 대해서 다시 공부를 하고 지나가려고 한다.
위 예시에서 processOne()이 실행도중 서버가 죽었다고 가정해보면
outPlanRepository.save(newOutPlan); // DB에 저장됨
// 💥 서버 다운
ifOutPlan.markProcessed(); // 실행 안 됨
OutPlan은 DB에 있는데 IfOutPlan은 여전히 PENDING상태로
다음 poll()에서 같은 데이터를 또 처리하려다가 중복 오류가 발생하는 사태가 생길 것이다.
전부 성공 아니면 전부 취소 가 트랜잭션의 존재 이유다.
만약 이 어노테이션이 없다면 어떻게 구현하는가?
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false);
// 비즈니스 로직
conn.commit();
} catch (Exception e) {
conn.rollback();
} finally {
conn.close();
}
추억의 코드..! 스프링 배우기 전 대학교 3학년때 했던 프로젝트가 아른거린다...
아무튼 이 긴 코드를 매번 쓰기 싫으니 @Transactional로 간편하게 해결을 하는 것이다.
Spring은 @Transactional을 어떻게 구현하는가 : 프록시
Spring이 직접 내 소스코드를 수정할 수 없다. 그래서 선택한 방법이 런타임 프록시 생성이다.
@Transactional이 붙은 빈을 Spring이 발견하면, 원본 클래스를 상속한 가짜 클래스를 런타임에 동적으로 만들어 컨테이너에 등록한다.
// 내가 만든 원본
@Service
public class OutPlanService {
@Transactional
public void createFromIf(IfOutPlan ifOutPlan) {
// 비즈니스 로직만 있음
}
}
// Spring(CGLIB)이 런타임에 생성하는 프록시 (개념적으로)
public class OutPlanService$$SpringCGLIB$$0 extends OutPlanService {
@Override
public void createFromIf(IfOutPlan ifOutPlan) {
트랜잭션시작();
try {
super.createFromIf(ifOutPlan); // 원본 호출
커밋();
} catch (Exception e) {
롤백();
}
}
}
Spring 컨테이너에는 원본 대신 이 프록시가 등록된다.
다른 곳에서 주입받을 때 실제로 들어오는 것은 이 프록시 객체다.
실제로 확인해볼 수 있는 코드를 클로드가 써줬다.
@Autowired OutPlanService outPlanService;
System.out.println(outPlanService.getClass().getName());
// 출력: dev.gyungmean.newwms.out.service.OutPlanService$$SpringCGLIB$$0
그런데 .class 파일을 만드는 건 아니다
Java ClassLoader는 .class 파일 없이도 바이트코드 배열(byte[])만 있으면 클래스를 JVM에 올릴 수 있다.
// ClassLoader의 핵심 메서드
protected Class<?> defineClass(String name, byte[] b, int off, int len)
CGLIB는 프록시 클래스의 바이트코드를 메모리에서 직접 생성한 뒤, 이 메서드로 JVM에 올린다.
디스크엔 아무것도 쓰지 않는다. 앱이 꺼지면 사라진다.
트랜잭션 공부하려다가 여기까지 오게됐다 유익한시간...
Self-Invocation : 왜 같은 클래스 안에서 호출하면 안 되는가
다시 원래 내용으로 들어와서 위의 코드는 왜 @Transactional이 의미가 없어지는가
트랜잭션 코드가 프록시 안에 있다는 것을 기억해야한다.
컨테이너에 등록된 것:
[ErpInterfaceScheduler$$Proxy] ← 외부에서 이걸 주입받음
↕ 내부에 품고 있음
[원본 ErpInterfaceScheduler]
자 그럼 어디선가 processOne()을 호출할 때
외부 → [$$Proxy].processOne() → 트랜잭션 시작 → 원본.processOne()
이런 흐름이 될 것이다.
하지만 지금 나의 processOne은 같은 클래스 안의 poll()안에서 호출을 당하고 있다.
그렇다면 구조가 어떻게 될까?
외부 → [$$Proxy].poll() → (poll은 @Transactional 없으니 그냥 통과)
↓
[원본 ErpInterfaceScheduler].poll()
↓
this.processOne()
↑
this = 원본 객체, 프록시가 아님
원본.processOne()엔 트랜잭션 코드 없음
poll()이 실행되는 시점엔 이미 원본 객체 안에 있다.
거기서 this.processOne()을 호출하면 프록시를 거치지 않고 원본을 직접 호출한다.
트랜잭션 코드는 프록시에만 있으므로, 결국 트랜잭션 없이 실행된다.
해결: processOne을 별도 빈으로 분리
해결을 위해서는 ProcessOne()을 다른 @Service 클래스로 옮기면 된다.
그럼 그 클래스도 프록시로 등록되고, 주입받을 때 프록시가 들어오기 때문이다.
// ErpInterfaceProcessor.java
@Slf4j
@Service
@RequiredArgsConstructor
public class ErpInterfaceProcessor {
private final OutPlanService outPlanService;
@Transactional
public void processOne(IfOutPlan ifOutPlan) {
try {
outPlanService.createFromIf(ifOutPlan);
ifOutPlan.markProcessed();
} catch (Exception e) {
ifOutPlan.markError(e.getMessage());
log.error("IfOutPlan 처리 실패: {}", ifOutPlan.getDeliverOrdNo(), e);
}
}
}
// ErpInterfaceScheduler.java
@Component
@RequiredArgsConstructor
public class ErpInterfaceScheduler {
private final IfOutPlanService ifOutPlanService;
private final ErpInterfaceProcessor processor; // 프록시가 주입됨
@Scheduled(fixedDelay = 5000)
public void poll() {
ifOutPlanService.findPending().forEach(processor::processOne);
// ↑
// [$$Proxy].processOne() → 트랜잭션 정상 동작
}
}
위와 같은 방식으로 수정을 했다.
그럼 이때의 호출 흐름은 어떻게 될까?
[ErpInterfaceScheduler].poll()
↓ processor는 주입받은 외부 빈 = 프록시
[ErpInterfaceProcessor$$Proxy].processOne()
↓ 트랜잭션 시작
[원본 ErpInterfaceProcessor].processOne()
↓ 트랜잭션 커밋/롤백
정리
| self-invocation | 별도 빈 | |
| 호출 경로 | this.processOne() | proxy.processOne() |
| 프록시 통과 | X | O |
| 트랜잭션 동작 | X | O |
- @Transactional은 스프링이 프록시 객체를 만들어 트랜잭션 코드를 주입하는 방식으로 동작한다.
- 프록시는 CGLIB가 런타임에 바이트코드를 메모리에서 생성해 JVM에 올리는 방식으로 만들어진다.
- 같은 클래스 내 this.메서드() 호출은 프록시를 이용하지 않고 원본 객체 내부에서 동작하므로 트랜잭션이 동작하지 않는다.
- 해결책은 트랜잭션이 필요한 메서드는 별도 빈에서 분리해 외부 호출이 되도록 하자.
'프로젝트기록 > new wms' 카테고리의 다른 글
| [JPA] 복합 PK vs 대리키 + UniqueConstraint (0) | 2026.03.30 |
|---|---|
| [JPA Persistable<T>] 자동 생성되는 인조키가 아닌 자연키를 사용할 때 (2) | 2026.03.27 |