Batch와 Event를 활용한 근태 집계 최적화

Batch와 Event를 활용한 근태 집계 최적화

1. 개요: 실시간 집계와 시스템 부하

데브라임 근태 대시보드를 개발하며, 데이터는 일(Day) -> 월(Month) -> 년(Year)으로 데이터가 누적되며 집계되는 구조인데, 하위 데이터가 변할 때마다 상위 지표를 매번 다시 계산하는 것은 리소스 낭비가 꽤 심하다고 생각했습니다.

단순히 기능을 구현하기보다, 비즈니스 로직의 특성에 맞춰 Vizend Tempo 스케줄러와 이벤트를 어떻게 배치했는지를 정리해보려고 합니다.

2. 문제: 고빈도 이벤트

처음에는 "데이터가 바뀌면 도메인 이벤트로 바로 상위 지표를 업데이트하면 되지 않을까?"라고 단순하게 생각했습니다. 하지만 개발을 진행해 보니 큰 장벽이 있었습니다.

  • 잦은 빈도의 이벤트와 리소스 낭비: TeamAttendanceDay(일별 근태) 데이터는 팀원들이 출근 체크를 하거나 휴가를 신청할 때마다 수시로 발생합니다.
  • 불필요한 오버헤드: 앞선 1번 팀원의 정산이 채 끝나기도 전에 2번, 3번 팀원의 정산 요청이 밀려들면 시스템 전체에서 실제로 병목이 생길 수 있는 구조였습니다.

3. 해결: 도메인 특성에 따른 정산 방식 분리

이 문제를 해결하기 위해 데이터의 발생 주기와 연산의 무게에 따라 처리 방식을 두 가지로 나눴습니다.

[Day → Month] 스케줄러 기반의 일괄 처리 (Flow)

일별 데이터는 발생 빈도가 매우 높기 때문에, 실시간 이벤트 대신 특정 시간에 한 번만 실행되는 Tempo를 사용한 스케줄러 방식을 선택했습니다.

  • 적용 방식: 일마감(DailySettlement)이 성공한 직후에 월마감(MonthlySettlement)을 순차적으로 호출합니다.
  • 이점: 하루 동안 발생하는 수많은 개별 변경 사항을 무시하고, 최종 확정된 데이터를 바탕으로 하루에 딱 한 번만 정산함으로써 불필요한 연산을 꽤 줄일 수 있었습니다.

[Month → Year] 트랜잭션 이벤트 기반의 비동기 처리

반면, 연간 정산은 월간 데이터가 확정되거나 수정될 때만 실행하면 됩니다. 월간 정산은 스케줄러에 의해 하루에 한 번만 발생하므로, 이때는 이벤트 방식을 사용하였습니다.

4. 사용 기술: @TransactionalEventListener와 Propagation.REQUIRES_NEW

이벤트 방식을 적용하면서 Spring의 두 가지 기능을 조합해 사용했습니다.

예시 코드

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void on(TeamAttendanceMonthEvent event) {
     teamYearStatsSettlementTask.executeYearlySettlement(
          teamMonth.getTeamId(), teamMonth.getYear()
     );
}

1) @TransactionalEventListener(phase = AFTER_COMMIT)

보통의 이벤트 리스너는 이벤트가 발생하자마자 즉시 실행됩니다. 하지만 정산을 다루는 시스템에서 이 '즉시'는 오히려 독이 될 수 있습니다. 월간 정산 로직이 아직 DB에 최종 커밋(Commit)되기도 전에 연간 정산이 시작되어 버리면, 존재하지도 않는 데이터를 참조하게 되어 데이터 정합성에 큰 문제가 생기기 때문입니다.

이때 사용하는 @TransactionalEventListener는 이름 그대로 '트랜잭션의 상태'를 감시하는 리스너입니다. 실제로 Spring 공식 문서에서도 이 어노테이션의 핵심 도입 목적을 다음과 같이 설명하고 있습니다.

"The typical example is to handle the event when the transaction has completed successfully. Doing so lets events be used with more flexibility when the outcome of the current transaction actually matters to the listener."

가장 대표적인 사례는 트랜잭션이 성공적으로 완료되었을 때 이벤트를 처리하는 것입니다. 이를 통해 현재 트랜잭션의 결과가 리스너에게 중요할 때, 이벤트를 훨씬 더 유연하게 다룰 수 있습니다.

공식 문서의 설명처럼, 이번 정산 아키텍처 역시 앞선 트랜잭션(월간 정산)의 결과가 리스너(연간 정산)에게 절대적으로 중요했습니다. 따라서 AFTER_COMMIT 옵션을 주어, 메인 작업(월간 정산)이 DB에 성공적으로 완료된 것을 확인한 후에만 다음 작업(연간 정산)을 시작하게끔 설계하였습니다.

2) @Transactional(propagation = Propagation.REQUIRES_NEW)

연간 정산 도중 알 수 없는 에러가 발생해서 전체 시스템이 멈추게 되면 안됩니다. 일반적인 설정이라면 연간 정산의 실패가 이미 성공한 월간 정산까지 함께 롤백 시켜 버릴 수 있습니다.

여기서 사용한 REQUIRES_NEW 옵션은 기존의 흐름과는 완전히 독립된 트랜잭션을 생성함으로써, 설령 연간 정산이 실패하더라도 이미 완료된 월간 정산 결과에는 영향을 주지 않습니다.

5. 결과

이번 아키텍처 개선을 통해 다음과 같은 실질적인 개선을 거둘 수 있었습니다.

  • 리소스 최적화: 무분별한 중복 연산을 제거하였습니다.
  • 운영 안정성: 트랜잭션 분리 설계를 통해 특정 단계의 장애가 전체 시스템으로 번지는 것을 방지했습니다.

우리가 다루는 데이터가 얼마나 자주 발생하는지와 실패했을 때의 리스크를 먼저 분석하는 것이 중요하다는 교훈을 얻은 과정이었습니다.

6. 참고문헌

  • Spring Framework Reference Documentation(Transaction-bound Events)
  • Spring Framework Reference Documentation(Transaction Propagation)

imawalrus