[친구하자] 배치 스케줄러에서 @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 안 쓰는 한).
  • 안전한 순서:

    1. DB 커밋 성공 후(트랜잭션 경계 밖) Redis/PubSub/WebSocket 등 사이드 이펙트 수행
    2. Spring에선 TransactionSynchronizationManager.registerSynchronization(... afterCommit) 훅으로 구현 가능
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override public void afterCommit() {
        // DB 커밋이 확정된 뒤에만 알림/이벤트 발행
        webSocketEventService.notifyMatchingSuccess(...);
    }
});

내가 얻은 체크리스트

  1. 부분 성공이 목표면 트랜잭션을 **처리 단위(여기선 카테고리)**로 쪼갠다.
  2. REQUIRES_NEW는 외부와 분리된 커밋/롤백을 보장한다.
  3. @Transactional프록시 기반자기 자신 호출은 적용되지 않는다.

    • 트랜잭션 메서드는 public + 다른 빈으로 분리해 호출.
    • 또는 TransactionTemplate로 명시 제어.
  4. 롤백 규칙을 의도대로: 체크 예외까지 롤백이면 rollbackFor = Exception.class.
  5. Redis/메시징은 DB 트랜잭션과 별개 → afterCommit에 배치.
  6. 멱등성·재시도·처리상태 플래그를 준비(일부 성공/일부 실패가 정상인 아키텍처).

최종 결론

  • 스케줄러 루프 전체를 하나의 트랜잭션으로 묶는 대신, **카테고리별로 REQUIRES_NEW**를 적용해 부분 성공격리를 확보한다.
  • 이때 @Transactional이 실제로 동작하려면 프록시를 타야 하므로, 트랜잭션 메서드는 다른 빈의 public 메서드로 분리한다.
  • DB 커밋 이후에만 외부 부작용(알림/Redis)을 발생시키는 흐름으로 정합성을 지킨다.

이 과정을 거치면서 “왜 REQUIRES_NEW인가?”에 더해 “어디에, 어떻게 붙여야 실제로 동작하는가”를 체득했다. 스프링의 트랜잭션은 어노테이션 한 줄이 아니라, 경계 설계 + 호출 구조까지 포함한 문제였다.

Redis.. 어렵다..

참고