[친구하자] 배치 스케줄러에서 @Transactional(REQUIRES_NEW)를 선택하기까지 — 실전 회고
친구하자 프로젝트 개발 중 랜덤 매칭 배치 스케줄러를 구현하다가 생긴 고민을 정리해보았습니다.
“카테고리별 2인 랜덤 매칭”을 스케줄러로 돌리는데, 한 카테고리에서 예외가 나면 전체 루프가 롤백될 수 있다는 얘기를 듣고 시작된 고민. 그 결과
@Transactional(propagation = REQUIRES_NEW)
를 채택했고, 다시 self-invocation 문제를 만나 리팩터링까지 갔던 과정을 정리했습니다.
배경: 매칭 스케줄러의 요구사항
- 여러 카테고리를 순회하며 매칭을 수행한다.
- 어떤 카테고리에서 실패해도 나머지는 정상 커밋되어야 한다(부분 성공).
- DB에 매칭 세션을 만들고, Redis 대기열을 정리하고, 알림을 보낸다.
처음엔 스케줄러 전체를 @Transactional
로 감싸는 걸 고민했지만, 그러면 카테고리 하나의 실패가 전체 롤백으로 번질 수 있다. 그래서 트랜잭션 경계를 카테고리 단위로 쪼개야 했다.
옵션 검토: 왜 REQUIRES_NEW
인가
REQUIRED
: 바깥 트랜잭션이 있으면 같이 묶인다 → 부분 실패가 전체 롤백으로 확장될 위험.NESTED
: 세이브포인트 기반. 바깥이 롤백되면 내부도 함께 롤백 → 부분 성공 보장 X.- 비트랜잭션(
NOT_SUPPORTED
등): DB 원자성 보장 X. REQUIRES_NEW
: 항상 새 트랜잭션 시작, 외부 트랜잭션은 일시 중단 → 카테고리별 독립 커밋을 정확히 충족.
결론: 내 요구(카테고리별 완결·부분 성공)를 가장 잘 만족시키는 건 REQUIRES_NEW
.
첫 번째 벽: “self-invocation” 경고
스케줄러 코드 초안:
@Scheduled(fixedDelay = ...)
public void processMatching() {
for (Category category : activeCategories) {
processMatchingForCategory(category); // 내부 메서드 호출
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
protected void processMatchingForCategory(Category category) { ... }
경고 메시지:
@Transactional self-invocation ... does not lead to an actual transaction at runtime
왜 이런가?
Spring의 @Transactional
은 프록시 기반 AOP다. 같은 객체 내부에서 자기 메서드를 직접 호출하면 프록시를 우회하므로 트랜잭션이 적용되지 않는다. 접근 제어자(private→protected) 변경으로는 해결되지 않는다. 본질은 프록시를 타느냐이다.
해결: 트랜잭션 경계를 “다른 빈의 public 메서드”로 분리
리팩터링 전/후
Before: 스케줄러 클래스 내부에서 processMatchingForCategory
호출 → 프록시 미적용
After: 별도의 서비스 빈으로 분리 + public
메서드에 @Transactional(REQUIRES_NEW)
스케줄러는 “그 빈을 주입 받아 호출” → 프록시를 경유하므로 트랜잭션 정상 적용
// A. 카테고리 단위 워커 (새 트랜잭션 경계)
@Service
@RequiredArgsConstructor
public class CategoryMatchWorker {
private final RedisMatchingQueueService redisMatchingQueueService;
private final UserRepository userRepository;
private final CallRepository callRepository;
// ... 필요한 의존성
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processCategory(Category category) {
// 카테고리 하나에 대한 모든 DB 작업 (예외 → 이 트랜잭션만 롤백)
// 1) 대기 인원 확인 → 2) 하이브리드 매칭 → 3) 사용자 조회 검증
// 4) Call 생성/저장 → 5) 큐 상태 업데이트 → 6) 알림 예약(afterCommit)
}
}
// B. 스케줄러 (비트랜잭션; 실패해도 루프 계속)
@Service
@RequiredArgsConstructor
public class MatchingSchedulerService {
private final CategoryMatchWorker categoryMatchWorker;
private final CategoryRepository categoryRepository;
@Scheduled(fixedDelay = ...)
public void processMatching() {
var categories = categoryRepository.findByIsActiveTrueOrderByName();
for (Category category : categories) {
try {
categoryMatchWorker.processCategory(category); // 프록시 경유 OK
} catch (Exception e) {
log.warn("카테고리 {} 처리 실패 - 다음으로 진행", category.getId(), e);
}
}
}
}
대안으로
TransactionTemplate
(프로그래매틱 트랜잭션)도 있다. 카테고리마다PROPAGATION_REQUIRES_NEW
로 실행 블록을 감싸는 방식. 프록시 우회 이슈가 없다.
Redis와의 경계: “한 트랜잭션이 아니다”
- DB와 Redis는 동일 트랜잭션 경계가 아님(2PC 안 쓰는 한).
안전한 순서:
- DB 커밋 성공 후(트랜잭션 경계 밖) Redis/PubSub/WebSocket 등 사이드 이펙트 수행
- Spring에선
TransactionSynchronizationManager.registerSynchronization(... afterCommit)
훅으로 구현 가능
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() {
// DB 커밋이 확정된 뒤에만 알림/이벤트 발행
webSocketEventService.notifyMatchingSuccess(...);
}
});
내가 얻은 체크리스트
- 부분 성공이 목표면 트랜잭션을 **처리 단위(여기선 카테고리)**로 쪼갠다.
REQUIRES_NEW
는 외부와 분리된 커밋/롤백을 보장한다.@Transactional
은 프록시 기반 → 자기 자신 호출은 적용되지 않는다.- 트랜잭션 메서드는 public + 다른 빈으로 분리해 호출.
- 또는 TransactionTemplate로 명시 제어.
- 롤백 규칙을 의도대로: 체크 예외까지 롤백이면
rollbackFor = Exception.class
. - Redis/메시징은 DB 트랜잭션과 별개 → afterCommit에 배치.
- 멱등성·재시도·처리상태 플래그를 준비(일부 성공/일부 실패가 정상인 아키텍처).
최종 결론
- 스케줄러 루프 전체를 하나의 트랜잭션으로 묶는 대신, **카테고리별로
REQUIRES_NEW
**를 적용해 부분 성공과 격리를 확보한다. - 이때
@Transactional
이 실제로 동작하려면 프록시를 타야 하므로, 트랜잭션 메서드는 다른 빈의 public 메서드로 분리한다. - DB 커밋 이후에만 외부 부작용(알림/Redis)을 발생시키는 흐름으로 정합성을 지킨다.
이 과정을 거치면서 “왜 REQUIRES_NEW
인가?”에 더해 “어디에, 어떻게 붙여야 실제로 동작하는가”를 체득했다. 스프링의 트랜잭션은 어노테이션 한 줄이 아니라, 경계 설계 + 호출 구조까지 포함한 문제였다.
Redis.. 어렵다..