자동화 slecs

정산 배치 재시도 큐 추가와 로그 레벨 정비

목차

정산 배치 시스템에서 재시도 로직이 빠져 있던 건 꽤 오래된 숙제였다. NO_MATCH, MANUAL_REQUIRED 상태로 빠진 레코드들이 그냥 큐에 쌓이기만 하고, 누군가 수동으로 처리하거나 별도 스크립트를 돌리지 않으면 그대로 방치됐다. 운영 입장에서는 매번 들여다봐야 하는 포인트가 생기는 거고, 그게 쌓이면 결국 정산 오류로 이어진다.

이번 작업은 그 두 상태에 대한 재시도 배치를 추가하고, 함께 로그 레벨을 정비한 게 전부다. 규모 자체는 크지 않았지만, 없어서 계속 불편했던 부분이라 마무리하고 나서 체감이 꽤 달랐다.

재시도 배치를 따로 뺀 이유

처음엔 기존 정산 집계 배치 안에서 재시도까지 함께 돌리는 방향을 생각했다. 그런데 그렇게 하면 집계 배치의 실행 주기와 재시도 주기가 강결합된다. 집계는 하루 한 번 돌려도 충분한 경우가 있고, 재시도는 더 촘촘하게 돌려야 할 때가 있다. 분리해두면 각자 독립적으로 스케줄 조정이 가능하다.

Spring @Scheduled로 주기적으로 실행되는 배치들은 이미 아래 표처럼 역할별로 나눠져 있었다.

배치 역할 주기
쿠폰 만료 처리 기한 지난 쿠폰 상태 변경 매일 새벽
정산 집계 수수료 확정 매일
잔액 동기화 외부 결제대행사 잔액 확인 수 분 주기
미입금 알림 미결제 주문 알림 발송 매일 오전
NO_MATCH 재시도 매칭 실패 레코드 재처리 수 분 주기 (신규)
MANUAL_REQUIRED 재시도 수동 플래그 해제 후 재처리 10분 주기 (신규)

NO_MATCH는 외부 결제 데이터와 내부 주문 데이터가 자동 매칭에 실패한 케이스다. 금액, 시각, 식별자 중 하나라도 어긋나면 매칭 로직을 통과하지 못하고 이 상태로 빠진다. 대부분은 약간의 시간 오차나 외부 시스템 지연 때문에 발생하는 일시적 불일치라서, 일정 간격으로 재시도하면 자연스럽게 해소된다.

MANUAL_REQUIRED는 말 그대로 사람이 확인해야 한다고 표시된 건인데, 담당자가 확인하고 플래그를 제거하면 이후 처리는 자동화할 수 있다. 그 "이후 처리"를 위한 배치가 없었으니 담당자가 플래그 해제 후에도 직접 처리까지 해야 했던 것.

재시도 로직 자체는 단순하다. 상태값으로 필터링해서 대상 레코드를 가져오고, 처리 가능한 상태로 변경한 뒤 기존 처리 흐름에 다시 태운다. 재시도 횟수를 컬럼으로 관리해서 일정 횟수 초과 시 더 이상 자동 재시도하지 않도록 했다. 무한 루프 방지다.

// 재시도 대상 조회 - 최대 재시도 횟수 이하이고 일정 시간 경과한 건만
List<Settlement> retryTargets = settlementRepository
    .findByStatusInAndRetryCountLessThanAndUpdatedAtBefore(
        List.of(NO_MATCH, MANUAL_REQUIRED),
        MAX_RETRY_COUNT,
        LocalDateTime.now().minusMinutes(RETRY_INTERVAL_MINUTES)
    );

for (Settlement s : retryTargets) {
    try {
        settlementService.reprocess(s);
        s.incrementRetryCount();
    } catch (Exception e) {
        LOGGER.warn("재시도 실패: id={}, retryCount={}", s.getId(), s.getRetryCount(), e);
    }
}

재시도 실패도 예외 처리 안으로 잡아서 배치 전체가 죽지 않도록 했다. 배치에서 단일 레코드 실패가 전체 롤백으로 이어지면 안 된다. 이건 배치 작업에서 기본 중의 기본인데, 처음에 짜면 자꾸 빠뜨리게 되는 부분이기도 하다.

로그 레벨 정비 - 왜 배치에서 유독 중요한가

배치는 주기적으로 실행된다. 잔액 동기화처럼 몇 분 간격으로 도는 배치라면, 하루에 수백 번 실행된다. 매 실행마다 "처리 시작", "처리 중" 같은 INFO 로그가 찍히면 Kibana든 CloudWatch든 실제로 봐야 할 에러 로그가 그 속에 묻힌다.

정상 실행 흐름의 로그는 DEBUG로 내렸고, 집계 결과나 에러는 INFO/WARN/ERROR 레벨로 유지했다.

// Before: 정상 흐름도 INFO로 찍혔음
LOGGER.info("정산 집계 배치 시작");
LOGGER.info("대상 건수: {}건", count);
LOGGER.info("처리 완료");

// After: 시작/진행은 DEBUG, 결과/이상만 INFO 이상
LOGGER.debug("정산 집계 배치 시작");
LOGGER.debug("대상 건수: {}건", count);
LOGGER.info("정산 집계 완료: {}건 처리, 실패 {}건", successCount, failCount);

// 재시도 배치에서 예외는 WARN - 에러는 아니지만 추적이 필요한 것
LOGGER.warn("NO_MATCH 재시도 실패: id={}", settlement.getId(), e);

로그 레벨 정책을 팀 내에서 합의해두지 않으면 나중에 제각각이 된다. 이번에 배치 쪽 기준을 정리하면서 간단하게나마 기준을 문서화해뒀다.

  • DEBUG: 루프 진입, 개별 레코드 처리 시작/완료 같은 반복성 흐름
  • INFO: 배치 단위 집계 결과, 주요 상태 전이
  • WARN: 예외는 잡혔지만 추적이 필요한 케이스
  • ERROR: 배치 자체가 실패하거나 데이터 정합성 문제

이 기준이 완벽하진 않다. 서비스 특성에 따라 DEBUG를 기본적으로 끄는 환경이라면, 중간 정도의 정보를 INFO로 남겨야 할 수도 있다. 환경마다 적합한 레벨이 다르니까 팀 내 로그 집계 도구와 함께 어느 레벨까지 보이게 할지 먼저 맞춰놓고 정하는 게 맞다.

정산 화면 쪽 잡생각

작업하면서 정산 UI도 눈에 밟혔다. 결국 운영 팀이 매일 보는 화면인데, 숫자 표기 하나가 잘못 돼 있으면 혼란이 생긴다.

  • 금액은 천 단위 구분자와 '원' 단위를 일관되게: 1,234,500원
  • 마이너스 금액은 색상으로 직관적으로 구분 (빨간색 계열)
  • 총 거래액, 수수료, 실수령액 순서는 고정해서 보는 사람이 스캔 방향을 익힐 수 있게

이번 배치 작업과 직접 연결되진 않지만, 재시도 배치가 제대로 돌면 NO_MATCH 건이 정산 화면에서 훨씬 빨리 사라질 테고 그러면 담당자가 처리해야 할 수동 항목도 줄어든다. 자동화가 UI의 복잡도를 낮추는 셈이다.

작업 자체는 반나절 분량이었다. 하지만 이런 종류의 작업 - 재시도 없이 쌓이던 상태를 처리하는 배치, 묻혀있던 로그를 정리하는 것 - 이 쌓이면서 시스템이 실제로 운영하기 편해진다. 기능 추가보다 눈에 안 띄는데, 없으면 매일 조금씩 불편한 것들이다.

댓글 0

첫 댓글 달아줘.