미터링 배치 시스템 설계: 쓰기 경합·청사진·패턴 명명·저장 전략 통일까지

미터링 배치 시스템 설계: 쓰기 경합·청사진·패턴 명명·저장 전략 통일까지

미터링 배치 파이프라인을 만들며 내린 쓰기 경합·청사진·패턴 명명·저장 전략 네 의사결정을 정리합니다.

미터링 배치 시스템 설계: 쓰기 경합·청사진·패턴 명명·저장 전략 통일까지

TL;DR
5분 수집 / 10분 집계 / 일간 집계 미터링 파이프라인을 만들며, 하나의 시스템을 여러 각도에서 바라봤습니다. 순서가 아니라, 각 각도에서 내린 의사결정을 정리합니다.

  • 쓰기 경합: 스키마 분리 + @Transactional(readOnly) 라우팅으로 해결.
  • 배치 진화: 내장 스케줄러 → CronJob → 이벤트 → 분산, Stage 0→3 청사진 + 전환 시그널.
  • 패턴 명명: 적용 패턴에 이름 붙이기(HWM, Tumbling Window, Catch-up, Idempotency).
  • 저장 전략: 합리적 UPSERT를 DELETE+INSERT로 통일(YAGNI).

더 깊게 파고든 두 주제(MySQL 파티셔닝, 용량 세 제약 의사결정)는 별도 글로 분리.

0. 배경: 미터링 파이프라인과 기술 스택

운영 중인 클라우드 GPU 서비스의 과금 근거가 되는 사용량 데이터를 빠짐없이 수집·집계해야 했습니다. 설계부터 구현까지 직접 담당했습니다.

Prometheus (GPU/CPU/Memory)
    ↓ 5분 수집
[원천] Pod 단위 저장
    ↓ 10분 집계
[구간] 서비스/Pod 단위
    ↓ 일간 누적
[일간] 서비스/Pod 단위
    ↓
사용자 조회 API
분류기술
Language / FrameworkJava 17, Spring Boot
ORMSpring Data JPA
DatabaseMySQL(마스터/슬레이브 복제, 월별 파티셔닝)
Architecture헥사고날 아키텍처
BatchJobRunr
SourcePrometheus
InfraKubernetes

1. 쓰기 경합: 스키마 분리 + 트랜잭션 라우팅

단일 DataSource의 세 가지 문제

문제내용
쓰기 경합5분 수집과 10분 집계가 같은 DB에서 동시 실행, 락 경합
조회 성능 불안정배치가 대량 INSERT/UPDATE 중일 때 사용자 조회 API 응답 시간 불안정
장애 전파외부 소스 하나의 응답 지연이 수집→집계→조회까지 연쇄 영향

서비스 수가 늘면 배치 실행 시간이 길어지고 겹침 확률이 높아집니다. 2년 예측(분기당 50 서비스 증가)으로 약 2억 건이 누적됩니다. 단일 데이터소스로는 한계에 이릅니다.

세 방안 검토

방안평가
시간대 분리5분/10분으로 촘촘해서 시간대 확보 어려움. 한 배치 지연 시 연쇄
스키마 분리 + 트랜잭션 라우팅(선택)영역별 장애 격리, 읽기 부하 분산, DataSource 복잡도 증가
메시지 큐 기반 비동기완전 디커플링이지만 현재 규모 대비 인프라 과함, 순서 제어 등 고려 부담

영역 분리 설계

영역역할스키마
원천(Source)Pod 데이터 수집만, 비즈니스 로직 없음별도 스키마(마스터/슬레이브)
가공(Processing)집계 + 조회, 비즈니스 로직 포함기본 스키마(마스터)

가공은 집계 주기(10분)와 조회 패턴이 겹칠 가능성이 낮아 마스터 하나로 충분하다고 판단했습니다. 원천만 슬레이브로 라우팅합니다.

@Transactional(readOnly) 기반 라우팅

AbstractRoutingDataSource를 확장하여 트랜잭션의 readOnly 속성으로 라우팅합니다.

public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
  @Override
  protected Object determineCurrentLookupKey() {
    return TransactionSynchronizationManager
        .isCurrentTransactionReadOnly() ? "slave" : "master";
  }
}

서비스 레이어는 어노테이션만 붙이면 라우팅이 결정됩니다.

@Transactional(value = "sourceTransactionManager", readOnly = true)
public List<SourceMetric> findByRange(Instant from, Instant to) { ... }  // → 슬레이브

@Transactional(value = "sourceTransactionManager")
public void saveAll(List<SourceMetric> instances) { ... }  // → 마스터

원천 영역은 독립된 EntityManagerFactoryTransactionManager를 둡니다. 가공은 Spring Boot 기본 설정을 그대로 씁니다.

미리 대응한 엣지 케이스

  • 슬레이브 복제 지연: 집계 주기(10분)가 수집 주기(5분)보다 길어 자연스러운 버퍼 확보
  • 배치 실패 시 빈 구간: 마지막 성공 시점 추적 + 자동 재처리
  • 멀티 소스 장애 격리: 소스별 Job 분리
  • 데이터 증가 대응: 월별 파티셔닝 + 일정 기간 후 아카이빙

CQRS라고 하면 이벤트 소싱이나 별도 읽기 저장소를 떠올리기 쉽지만, 스키마 분리 + 트랜잭션 라우팅만으로도 쓰기·읽기 독립 이점을 얻을 수 있었습니다. 시스템 규모에 맞는 수준 선택이 더 중요합니다.

2. 배치 진화 청사진: Stage 0 → 3 + 전환 시그널

핵심 원칙은 하나입니다. 전환 시그널이 나타날 때까지 현재 단계를 유지합니다. 미리 과도하게 설계하면 복잡성만 늘어납니다.

Stage 0: 내장 스케줄러 + 상시 서버(현재)

JobRunr BackgroundJobServer가 주기적으로 Job을 폴링합니다. 메서드명이 곧 Job 이름이라 이름을 바꾸면 새 Job으로 인식되어 이력이 단절됩니다. 이 제약은 의도적입니다. Job 이력 일관성이 실패 복구의 기반이기 때문입니다.

@Job  // name 생략 → 메서드명이 Job 이름
public void collectSource() {
    var from = helper.getLastSuccessAt("collectSource");
    var rawData = prometheus.queryRange(query, from, now, "1m");
    sourceRepository.upsertAll(rawData);  // 멱등성 (UPSERT)
}

왜 CronJob이 아니라 상시 서버?

CronJob은 매 실행마다 JVM 기동(~30초)이 실제 작업 시간(~10초)의 3배입니다. 5분마다 이 비용을 내느니 서버를 상시 띄우는 편이 합리적입니다.

시그널의미
적합 규모소스 1~2종, 인스턴스 수천 건, 단일 DB
한계 시그널유휴 리소스 80%+, Job 미실행 시간 대부분

상시 서버의 대가는 명확합니다. 하루 약 48분만 실행되고 나머지 23시간 12분은 JobRunr 폴링과 Health Check만 하면서 리소스를 점유합니다.

Stage 1: CronJob + Native Image

상시 점유 → 실행 시에만 리소스. GraalVM Native Image로 기동 ~0.5초, 메모리 128MB로 줄이면 CronJob의 기동 오버헤드 문제가 크게 풀립니다.

대가: JobRunr의 Job 이력 자동 관리가 없으니 별도 BatchStatusRepository로 마지막 성공 시점을 직접 관리합니다.

전환 시그널: 유휴 리소스 비용 > 구현 복잡성 비용

Stage 2: 이벤트 기반(수집과 집계 디커플링)

[CronJob: 수집] → [Message Queue] → [집계 Worker]
                  (수집 완료 이벤트)

소스 종류가 늘면 시간 기반 집계(10:10) 시점에 일부 소스 데이터가 아직 없을 수 있습니다. 이벤트 기반이면 모든 소스 수집 완료 후 집계를 시작합니다.

대가: Exactly-once 처리. Stage 0부터 유지한 멱등성(UPSERT)이 이 지점에서 필요해집니다.

전환 시그널: 소스 종류 3개 이상, 수집-집계 타이밍 이슈 발생

Stage 3: 분산 처리(파티셔닝)

Prometheus는 쿼리당 총 샘플 수를 제한합니다(기본 5천만). 인스턴스 5,000개 + 7일 복구가 필요하면 단일 쿼리로 한계에 도달합니다.

총 샘플 수 = 인스턴스 수 × (시간 범위(분) / step(분))
5,000 × (7일 × 1,440 / 1) = 50,400,000  ← 5천만 초과
파티셔닝 전략장단
Range(네임스페이스 A~M / N~Z)단순, 데이터 편중 가능
Hash(hash(groupId) % N)균등, 리밸런싱 복잡
논리(프로젝트 단위)비즈니스 의미 일치(과금 단위), 프로젝트 크기 불균등

미터링은 논리 분할(프로젝트 단위)이 적합합니다. 분할 경계가 과금 단위와 일치하기 때문입니다.

전환 시그널: 단일 Prometheus 쿼리 한계 도달, 처리 시간이 배치 주기 초과

단계를 관통하는 원칙

원칙단계별 구현
멱등성모든 단계에서 UPSERT 패턴 유지
마지막 성공 시점 기반 복구JobRunr 이력 → 별도 테이블 → 이벤트 오프셋(구현은 달라도 추적 원칙은 동일)
데이터 완전성 우선성능보다 누락 방지가 먼저, 구간이 완전히 끝난 후에만 집계

3. 적용 패턴에 이름 붙이기

배치를 만들면서 “이게 안전하겠다”는 직관으로 결정한 것들이 사실 공식 이름이 있는 패턴이었습니다.

우리가 한 것패턴 이름
마지막 성공 시점 이후만 처리Incremental Load(High Water Mark)
10분 고정 구간으로 집계Tumbling Window
구간 종료 + 5분 후 처리Safety Margin(팀 내 명명)
스케줄러 복구 시 누락 구간 자동 처리Catch-up
운영자가 기간 지정하여 재처리Backfill
같은 구간 재처리해도 결과 동일Idempotency(UPSERT)
JobRunr 이력으로 진행 상태 추적Checkpoint
소스별 Job 분리Fault Isolation
프로젝트 단위 병렬 처리(계획)List Partitioning

Catch-up vs Backfill

같은 UseCase 메서드의 오버로드로 표현합니다. 비즈니스 능력은 “집계”로 동일하고 트리거 방식만 다르기 때문입니다.

aggregateUsage()                          // Catch-up (자동, 크론 기반 + 누락 구간 복구)
aggregateUsage(AggregateUsageRequest)     // Backfill (수동, 운영자가 기간 지정)

max-recovery-days(7일)와 max-aggregate-range-days(7일) 상한이 무한 백필을 차단합니다.

계층별 멱등성 전략

같은 멱등성 원칙이라도 계층마다 구현이 다릅니다.

계층전략근거
원천 수집ON DUPLICATE KEY UPDATE재수집 시 최신 값 갱신
구간 집계(서비스)UPSERT(PK 유지)일간 배치 조회 중 행 소실 방지
구간 집계(Pod)DELETE + INSERT(전체 교체)외부 참조 없음. 짧은 공백 허용 가능

이건 4장 전환의 출발점이 됐습니다.

이름을 아는 것의 가치

  1. 의사소통이 정확해집니다: “HWM 기반 Incremental Load” 한 문장으로 줄어듭니다
  2. 선택지가 보입니다: “Incremental Load” 안에 HWM 외에도 Snapshot Diff, CDC가 있습니다
  3. 검색이 됩니다: “Catch-up pattern batch”로 정확한 사례·베스트 프랙티스를 찾을 수 있습니다

4. 저장 전략 통일: UPSERT → DELETE + INSERT(YAGNI)

UPSERT 선택의 합리성

미터링 배치 설계 초기 INSERT ... ON DUPLICATE KEY UPDATE 선택 근거는 명확했습니다.

  • 멱등성: 동일 키 재수집해도 중복 없음
  • 동시성 안전: 유니크 키 제약으로 충돌 감지
  • 패턴 검증: 시계열 배치 모범 사례

AI 딥리서치도, 다른 시스템 사례도, UPSERT가 맞다는 결론이었습니다.

전환점: 팀원 자문

리뷰에서 팀원 자문 결과가 리서치와 달랐습니다.

항목수치의미
개발팀 규모5인유지보수 인력 한정
서비스 유형B2B2C GPU정적 엔터프라이즈, 버스트 트래픽 구조적으로 낮음
현재 Pod 규모~500개전체 합산
설계 최대치~2,000개물리 GPU 자원 상한
배치 아키텍처단일 노드 JobRunR동시성 경합 구조적으로 불가능

합리적인 설계였지만 우리 규모에서는 오버 엔지니어링이었습니다.

AI에게 아무리 딥리서치를 시키고 다른 사례를 봐도 이런 결론은 나오지 않았습니다. “~2,000 Pod 상한의 B2B2C GPU 서비스에서 5인이 유지보수합니다”라는 맥락은 어떤 리서치에도 다뤄지지 않습니다. 이 판단은 서비스 맥락을 이해하는 팀원에게서 나왔습니다.

쿼리 비용 비교

ON DUPLICATE KEY UPDATE는 행마다 유니크 키 존재 여부를 확인합니다. 내부 SELECT가 반드시 1건 발생합니다. JDBC batch로 1회 왕복에 전송해도 마찬가지입니다.

UPSERT (N건):      내부 SELECT × N + INSERT/UPDATE × N = 2N 연산
DELETE+INSERT:     range DELETE × 1 + batch INSERT × 1 = 2 연산

Pod별 원천 수집(5분 주기, 하루 288회)이 누적됩니다.

규모ODKUDELETE+INSERT
현재(~500 Pod)1,440,000576
최대(~2,000 Pod)5,760,000576

실측 성능 차이는 현재 규모에서 밀리초 단위입니다. 소규모에서는 체감되지 않습니다.

성능이 비슷하다면 더 단순한 쪽이 이깁니다. 5인 팀에서 JDBC 하드코딩 SQL 관리 비용은 성능 이점을 상회합니다.

파이프라인 전체 통일

단계삭제 단위삽입 방식쿼리 수
Pod별 원천 수집수집 범위(from~to)JPA saveAll()2
구간 집계(서비스)구간(started_at, ended_at)JPA saveAll()2
구간 집계(Pod)서비스에 종속JPA saveAll()2
일간 집계(서비스)날짜JPA saveAll()2
일간 집계(Pod)서비스에 종속JPA saveAll()2

모든 단계가 같은 패턴입니다.

repository.deleteAllBy...(from, to);    // 1. 범위 삭제
repository.createAll(entities);          // 2. saveAll (DELETE + INSERT)

핵심 변경은 다음과 같습니다.

항목BeforeAfter
저장 방식JDBC batch UPSERT / Native SQLJPA saveAll(DELETE + INSERT)
유니크 키복합 유니크 키(충돌 감지)없음(일반 인덱스만)
PK 전략IDENTITYSEQUENCE(Hibernate 배치 INSERT 활성화)
삭제 방식없음범위 기준 일괄 삭제

교훈: 합리적 설계 ≠ 올바른 설계

일반론으로 옳은 결정이 특정 맥락에서는 오버 엔지니어링이 됩니다. 돌이켜보면 YAGNI 위배였습니다. 현재 필요하지 않은 동시성 보호를 미리 설계한 것이 비용이 되었습니다. 그 경계를 판단하는 것은 코드가 아니라 사람입니다.

회고: 하나의 시스템을 여러 각도에서

각도의사결정핵심 원칙
쓰기 경합스키마 분리 + 트랜잭션 라우팅(CQRS 라이트)시스템 규모에 맞는 수준
진화 청사진Stage 0~3 + 전환 시그널 정의시그널이 올 때까지 현재 단계 유지
패턴 명명HWM, Tumbling Window, Catch-up, Idempotency이름이 의사소통과 검색을 정확하게 만든다
저장 통일UPSERT → DELETE + INSERT합리적 설계와 올바른 설계는 다르다(YAGNI)

배치 시스템 설계의 핵심 역량은 적정 설계 선택이라고 생각합니다. 다음 단계로의 전환 시점을 판단하는 능력이 정확한 청사진보다 중요합니다.

돌아보면 이 프로젝트에서 남은 건 개별 해법보다, 하나의 시스템을 쓰기 경합·진화·패턴·저장이라는 여러 각도에서 바라본 경험입니다. 한 각도에 매몰되지 않으려 한 시도였습니다.

이 시스템에서 더 깊게 다룬 두 주제는 별도 글로 분리했습니다.


이 글은 Claude와 함께 작업했습니다.

This post is licensed under CC BY 4.0 by the author.