그날, 우리의 배치는 왜 멈추었을까?
올리브영 배치가 Spring Batch Tasklet에서 트랜잭션 분리 부재와 CallerRunsPolicy로 인한 메인 스레드-워커 스레드 간 교착 상태로 중단
AI 요약
Context
Spring Batch 기반 배치 작업이 예정된 시간에 완료되지 않고 무한 대기 상태에 빠졌다. 데이터독 모니터링으로 배치 미종료 알림을 받았으나 특정 데이터 업데이트 이후 로그가 전혀 올라오지 않았다. 결과적으로 DB 커넥션풀 가용 커넥션 감소와 대기 스레드 증가로 전체 시스템 부하가 발생했다.
Technical Solution
- 서비스 메소드에 @Transactional 어노테이션 부재 확인: 메인 Tasklet 레벨 트랜잭션만 적용되어 있었으며 서비스 메소드 수준의 트랜잭션 경계 없음
- ThreadPoolExecutor를 통한 멀티스레드 작업 실행: 각 워커 스레드는 TransactionSynchronizationManager의 ThreadLocal 메커니즘으로 인해 메인 스레드와 독립적인 트랜잭션을 자동 생성
- CallerRunsPolicy 거부 정책 적용: ThreadPoolExecutor 큐 및 최대 스레드 수 초과 시 메인 스레드에서 작업 실행하도록 설정 (코어 2, 최대 4, 큐 길이 10)
- 메인 스레드와 워커 스레드 간 동일 데이터 접근: 메인 스레드의 미커밋 트랜잭션이 워커 스레드의 데이터 수정 시도를 블로킹하고, 메인 스레드는 모든 워커 스레드 종료 대기로 순환 대기 발생
Key Takeaway
Spring Batch에서 멀티스레드 병렬 처리 시 서비스 메소드에 @Transactional(propagation = Propagation.REQUIRES_NEW)을 명시적으로 설정하여 각 스레드의 트랜잭션 경계를 분리해야 메인 스레드와 워커 스레드 간 교착 상태를 예방할 수 있다. ThreadPoolExecutor 설정 시 CallerRunsPolicy는 메인 스레드에서의 작업 수행을 유발하므로 최대 스레드 수 상향 또는 무한 큐 길이 설정으로 메인 스레드 작업 실행을 회피해야 한다.
실천 포인트
Spring Batch 기반 Tasklet에서 ThreadPoolExecutor를 사용하여 멀티스레드 병렬 처리를 구현할 때, 서비스 메소드에 @Transactional(propagation = Propagation.REQUIRES_NEW)을 적용하면 메인 스레드와 워커 스레드 간 트랜잭션 독립성을 보장하여 동일 행에 대한 순환 대기(Circular Wait)를 방지할 수 있다.