본문으로 건너뛰기
이력으로 돌아가기

원장 시스템 개발

중복을 차단하고, 멈춘 지점부터 복구하는 사용량 흐름

클라우드 사용량 과금 파이프라인의 중복·누락을 원장과 체크포인트로 탐지하고 복구하도록 설계했습니다.

  • Java 21
  • Spring Boot
  • Spring Batch
  • MySQL
  • Kubernetes

사용량 과금 배치는 중복 실행과 전송 응답 유실을 모두 견뎌야 합니다. 한 번의 실패가 누락이나 이중 과금으로 번지지 않게 수집부터 전송, 원장 대조까지 각 단계의 처리 상태를 남기도록 다시 설계했습니다.

핵심은 처리 속도보다 재실행 가능한 경계였습니다. 읽는 키와 보내는 키를 분리했습니다. 전송 전 원장과 대상별 체크포인트를 남겨 어떤 실패 뒤에도 성공 여부를 확인할 수 있는 지점부터 다시 시작하게 했습니다.

문제의 시작

과금 기능을 시작하려면 고객이 실제로 사용한 자원량을 외부 과금 시스템에 주기적으로 전달해야 했습니다. 원본 사용량은 우리 데이터베이스에서 만들어지는 값이 아니라 외부 시스템에 먼저 쌓였습니다. 배치는 이를 읽어 고객과 상품 식별자를 맞춘 뒤 다시 과금 원장으로 보내야 했습니다. 단가 계산보다 먼저 해결해야 한 일은 ‘어느 사용량을 읽었고, 무엇을 고객 앞으로 보냈는가’를 끝까지 추적할 수 있는 파이프라인을 만드는 것이었습니다.

일반 데이터 동기화와 달리 이 흐름에서는 같은 값을 한 번 더 보내면 이중 과금이 되고 한 번 덜 보내면 청구 누락이 됩니다. 대상마다 마지막 성공 시점도 달랐습니다. 여러 실행 인스턴스가 같은 시각에 스케줄을 받는 구성에서는 동일 구간을 동시에 처리할 가능성도 있었습니다.

가장 위험한 구간은 외부 전송과 내부 기록 사이였습니다. 외부 시스템은 전송을 받았지만 응답이 돌아오기 전에 프로세스가 종료될 수 있고, 내부에는 성공 기록이 없으므로 다음 실행이 같은 값을 다시 보낼 수 있습니다. 반대로 내부 기록을 먼저 완료로 남기면 실제 전송 전에 실패했을 때 누락을 찾기 어렵습니다. 외부 HTTP 호출과 데이터베이스 쓰기를 하나의 트랜잭션으로 묶을 수 없기 때문에 호출 순서만 바꿔서는 두 위험을 동시에 없앨 수 없었습니다.

최근 24시간처럼 고정된 구간을 매번 다시 읽는 방식도 검토할 수 있지만, 한 회차가 통째로 빠진 뒤 시간이 지나면 그 구간을 다시 발견하지 못합니다. 재시도 횟수를 늘리거나 강한 잠금 하나에 의존해도 이미 외부에 반영됐는지는 설명할 수 없습니다. 그래서 동시 실행 제어, 전송 전후 상태 원장, 수신 결과 대조, 대상별 체크포인트가 서로 다른 실패를 맡도록 분리했습니다.

검증 기준도 단순한 정상 전송 성공이 아니었습니다. 중복 입력과 동시 실행에서 한 건만 반영되는지, 외부 응답이 유실된 뒤 내부 상태만으로 다음 판단을 할 수 있는지, 배치가 여러 회차 멈춘 뒤 마지막 성공 지점부터 밀린 구간을 따라잡는지를 확인해야 했습니다. 이 기준을 먼저 세운 뒤 아래와 같이 수집부터 원장과 체크포인트까지 상태를 남기는 흐름을 설계했습니다.

전송 전후에 남기는 처리 상태

전송 전에 원장을 쓰고, 확인된 건만 완료 상태와 체크포인트를 갱신합니다.

사용량을 정규화하고 집계한 뒤 전송 대기 원장(PENDING)에 기록합니다. 전송 확인 뒤 전송 완료(SENT) 상태와 대상별 체크포인트를 갱신합니다.

동시 실행을 한 번으로 제한하기

여러 인스턴스가 같은 시각에 스케줄을 받기 때문에 애플리케이션 메모리 잠금으로는 부족했습니다. 데이터베이스 시간을 기준으로 ShedLock 획득에 성공한 인스턴스만 작업을 시작하게 했습니다.

잠금은 작업 종료 즉시 풀지 않고 다음 주기 직전까지 유지했습니다. 서버 시계가 조금 다르거나 실행이 빨리 끝나도 같은 실행 주기가 다시 시작되지 않게 하기 위한 선택입니다.

데이터베이스 시간으로 고른 단 하나의 실행

실행 주체는 여러 개여도 같은 실행 주기의 작업은 하나만 시작합니다.

두 실행 인스턴스가 ShedLock을 요청하고, 잠금 획득에 성공한 하나만 배치 작업을 실행합니다.

전송을 트랜잭션 밖에서도 복구하기

외부 전송은 데이터베이스 트랜잭션에 함께 묶을 수 없습니다. 그래서 보낼 내용을 먼저 PENDING으로 기록하고 수신 확인 뒤에만 SENT로 바꾸는 아웃박스 패턴을 적용했습니다.

응답이 유실되면 PENDING은 그대로 남습니다. 이후 수신 결과와 원장을 대조하는 작업(대사)이 수신 측 총량과 원장 합계를 비교해 일치하면 완료로 바꾸고, 다르면 제한적으로 다시 보냅니다. 동일 입력 키와 원장의 중복 방지 제약을 함께 써서 중복 방어도 두 겹으로 만들었습니다.

응답 유실을 견디는 원장 상태

PENDING은 실패가 아니라 다음 판단에 필요한 복구 상태입니다.

전송 전 PENDING 상태를 기록하고, 확인되면 SENT로 바꿉니다. 오래 남은 PENDING은 원장 대조 작업으로 비교해 완료하거나 재전송합니다.

수신 결과와 내부 원장을 대조하는 과정

수신 측 사실과 내부 원장을 비교한 뒤에만 대사 위치를 옮깁니다.

원장 대조 작업이 마지막 대조 완료 위치 이후 구간의 수신 총량과 전송 원장 합계를 비교하고 확인한 지점까지 마지막 대조 완료 위치를 갱신합니다.

고정 구간 대신 체크포인트로 따라잡기

최근 24시간처럼 고정된 조회 구간은 한 회차가 통째로 빠지면 시간이 지난 뒤 영구적인 사각지대를 만듭니다. 대상별 마지막 성공 지점을 저장하고 그 이후부터 현재까지를 계산하도록 바꿨습니다.

한 대상의 지연이 다른 대상의 진행 위치를 되돌리지 않도록 체크포인트 키도 대상과 상품 단위로 나눴습니다.

누락되는 고정 구간과 밀린 작업을 찾는 처리 위치

시간 창은 과거를 잊지만 체크포인트는 마지막 성공 이후를 계속 가리킵니다.

고정 구간은 회차 누락을 다시 찾지 못하게 만들 수 있습니다. 대상별 체크포인트는 밀린 구간을 계산해 누락 구간까지 이어서 처리합니다.

검증

가짜 수신 서버를 둔 E2E에서 중복 입력, 중간 실패, 응답 유실과 재개를 반복했습니다. 비운영 환경에서는 원천 행, 원장, 수신 결과를 같은 시간 구간으로 맞춰 대조했습니다.

부하 검증은 작은 데이터에서 시작해 약 20만 건까지 단계적으로 늘렸고 처리율이 초당 500~600건 범위로 올라가는 것을 확인했습니다. 초기 목표였던 고객사 100곳, 시간당 1만 건 기준으로는 처리에 20초 안팎이면 충분해 다음 주기 직전까지 확보해 둔 ShedLock의 59분에 비해 여유가 컸습니다.

부하가 잠금 시간을 넘을 때의 방어선

작업이 잠금 유지 시간을 넘겨도 원장의 중복 방지 장치가 과금 중복을 막습니다.

대규모 누락 구간 보충 작업이 잠금 시간을 넘기면 다른 실행이 진입할 수 있지만 원장의 중복 방지 키가 중복을 막고 운영 경고를 남깁니다.

© 2026 주병주

관찰하고, 기록하고, 더 단단하게 만듭니다.