TroubleShooting & Study/Architecture & Design Pattern

[WIL] - Round10. 집계 단위 설계와 Read Model 안전 갱신

DH_0518 2026. 4. 16. 22:47

배치 집계 시스템은 보통 두 가지 문제에서 깨진다.
하나는 원천 데이터의 단위가 처음부터 잘못 설계되어 있는 경우고,
다른 하나는 집계 결과를 조회 전용 모델로 교체하는 과정에서 장애 격리가 안 되는 경우다.
이 두 축을 분리해서 이해하면 배치 설계의 판단 기준이 훨씬 명확해진다.


1. 집계 원천의 단위 설계

1-1. 누적 스냅샷은 기간 분리 집계에 답을 낼 수 없다

원천 데이터를 단일 레코드에 누적하는 구조는 가장 단순한 방식이다.
하나의 식별자에 대한 전체 합계를 한 레코드에 계속 더해 나간다.

이 구조에서는 '전체까지의 합산'은 바로 읽힌다.
그러나 '이번 주에만 해당하는 수치'를 꺼내는 순간 답이 없어진다.

누적값에서 특정 기간의 기여분을 분리하려면, 과거 특정 시점의 스냅샷과 현재 값을 비교해야 한다.
그 스냅샷이 없으면 계산 자체가 불가능하다.

1-2. 기간 분리 집계가 필요하면 일간 단위가 기준이다

기간별 집계를 만들어야 하는 요건이 생기는 순간, 원천 데이터는 날짜를 포함한 복합 키 구조로 설계해야 한다.

-- 일간 단위 구조
PRIMARY KEY (metric_date, entity_id)

이 구조에서는 어떤 날짜 범위를 WHERE 조건으로 넣어도 SUM으로 재집계할 수 있다.

예시 코드

SELECT
    SUM(view_count)  AS view_count,
    SUM(like_count)  AS like_count,
    SUM(sales_count) AS sales_count
FROM metrics
WHERE metric_date BETWEEN :start AND :end

주간, 월간, 분기 모두 같은 구조로 대응된다.
누적 스냅샷은 조회를 단순하게 만들지만, 기간 분리 요건이 들어오는 순간 재설계 비용이 커진다.
'나중에 기간 집계가 필요할 수 있다'고 판단되는 원천이라면, 처음부터 일간 단위로 둔다.

1-3. 집계 원천이 여럿일 때는 명시된 기준을 따른다

비슷해 보이는 집계 원천이 여러 개 있을 때, 어느 쪽을 기반으로 할지 판단해야 한다.
이때 기준은 단순하다.

  • 요구사항에 집계 대상 테이블이 명시되어 있으면 그것을 쓴다
  • 다른 목적으로 만들어진 조회용 데이터나 캐시는 집계 원천으로 쓰지 않는다

비슷해 보인다고 재사용하면, 원천의 적재 단위와 목적이 뒤섞여서 나중에 수치가 어긋난다.


2. Chunk 배치에서 순위 계산의 위치

2-1. Processor에서 순위를 부여하면 안 된다

Chunk-Oriented 배치는 Reader, Processor, Writer로 나뉜다.
Processor는 한 건씩 받아 처리한다.
이때 Processor 안에서 전역 카운터로 순위를 부여하면 아래 상황에서 순위가 깨진다.

  • Chunk 재시작: 실패한 청크를 다시 읽을 때 카운터가 이미 증가해 있다
  • skip/retry: 특정 건이 건너뛰어지면 카운터와 실제 순서가 어긋난다
  • 병렬화: 청크 간 순서가 보장되지 않는다

따라서 Processor는 stateless여야 한다.
한 건을 받아 변환하는 역할만 담당하고, 전체 순서에 의존하는 계산을 Processor 내부에 두지 않는다.

2-2. 순위는 Reader SQL에서 미리 결정한다

순위처럼 전체 정렬에 의존하는 계산은 SQL에서 처리한다.
Reader가 이미 순위가 부여된 데이터를 읽어오게 하면, Processor와 Writer는 그 값을 그대로 다룬다.

예시 코드

WITH scored AS (
    SELECT entity_id, ...,
           {score_expression} AS score
    FROM metrics
    WHERE metric_date BETWEEN :start AND :end
    GROUP BY entity_id
),
ranked AS (
    SELECT scored.*,
           ROW_NUMBER() OVER (ORDER BY score DESC, entity_id ASC) AS rank_position
    FROM scored
)
SELECT * FROM ranked WHERE rank_position <= 100

이 구조에서 Processor는 한 건을 받아 저장 형태로 변환하는 역할만 한다.
재시작해도 Reader가 동일한 순위 결과를 다시 읽어오므로 순위가 흔들리지 않는다.

2-3. 동점 처리는 고정 기준이 있어야 한다

점수가 같은 항목이 여러 개일 때 재실행할 때마다 순위가 달라지면 조회 전용 테이블이 매번 다른 내용을 가지게 된다.
동점 시 보조 정렬 기준을 고정해 두면 재실행 시에도 항상 같은 순위를 보장한다.

예시 코드

ORDER BY score DESC, entity_id ASC

식별자처럼 변하지 않는 값을 보조 정렬로 두는 것이 가장 안전하다.


3. Read Model의 안전한 교체 패턴

3-1. 직접 교체는 실패 시 API가 빈 응답을 본다

Chunk step이 완료되기 전에 기존 Read Model을 먼저 지우면 아래 문제가 생긴다.

  • Chunk step이 중간에 실패하면 API는 빈 조회 결과를 반환한다
  • 재시작해도 이미 삭제된 데이터를 복구할 수단이 없다

Read Model이 직접 집계 처리의 대상이 되면, 배치 실패가 서비스 장애로 바로 이어진다.

3-2. 임시 테이블을 경유하면 Chunk 실패가 기존 Read Model에 영향을 주지 않는다

더 안전한 방식은 임시 테이블을 거쳐 교체하는 것이다.

  • Chunk step은 임시 테이블에만 쓴다
  • 기존 Read Model은 마지막 단계까지 그대로 남아 있다
  • publish step에서 단일 트랜잭션으로 기존 Read Model을 교체한다

예시 코드

// publish 단계 — 단일 트랜잭션
DELETE FROM read_model WHERE period_key = :periodKey;

INSERT INTO read_model (...)
SELECT ... FROM staging WHERE job_execution_id = :executionId
ORDER BY rank_position;

DELETE FROM staging WHERE job_execution_id = :executionId;

실패 시나리오별 동작은 아래와 같다.

  • 임시 테이블 적재 중 Chunk step 실패 → 기존 Read Model 유지, 임시 테이블만 불완전
  • publish 단계 실패 → 트랜잭션 롤백으로 기존 Read Model 유지
  • 어느 단계에서 실패해도 기존 Read Model은 마지막 성공 상태를 유지한다

3-3. Chunk step은 재시작 시 이어 쓸 수 있어야 한다

배치 재시작 시 이미 완료된 청크를 다시 쓰면 임시 테이블이 오염될 수 있다.
Writer에 ON DUPLICATE KEY UPDATE를 두면 동일 키에 대한 재쓰기가 안전하게 처리된다.

예시 코드

INSERT INTO staging (job_execution_id, rank_position, ...)
VALUES (?, ?, ...)
ON DUPLICATE KEY UPDATE score = VALUES(score), ...

이렇게 하면 Chunk step 재시작 시 이미 쓴 데이터를 덮어써도 중복 오류가 발생하지 않는다.


정리

배치 집계 시스템의 핵심 판단은 세 곳에서 발생한다.

  • 원천 데이터: 기간 분리 집계가 필요하면 일간 단위로 설계한다
  • 순위 계산: Processor는 stateless여야 하며, 순서 의존적 계산은 Reader SQL에서 처리한다
  • Read Model 교체: 임시 테이블을 경유하면 배치 실패가 서비스 장애로 번지지 않는다

결국 이 주제의 핵심은 어떤 기술을 쓰는가보다, '실패가 어디에서 멈추는가'를 설계 단계에서 미리 결정하는 일에 있다.