일간 상품 통계를 기반으로 주간·월간 랭킹을 생성했다.
단순히 집계 쿼리를 추가하는 것에서 끝나지 않고, 다음 세 가지 관점에서 설계했다.
- Spring Batch: 실패 지점부터 재시작 가능한 Job 구성
- Batch Processing: 대량 데이터를 안정적으로 처리하고 성능을 측정하는 방법
- Materialized View: 집계 결과를 미리 계산하고 API에 원자적으로 공개하는 방법
이번 글에서는 전체 설계와 함께 재시작, 멱등성, Chunk 크기, 스냅샷 게시, 성능 테스트 과정까지 정리한다.
상품 조회, 좋아요, 주문 이벤트는 Kafka를 통해 처리되고 product_metrics 테이블에 일간 단위로 누적된다.
product_metrics
metric_date
product_id
view_count
like_count
order_count
기존 Ranking API는 Redis에 저장된 일간 랭킹을 제공하고 있었다.
여기에 주간·월간 랭킹을 추가할 때 API 요청마다 product_metrics를 집계하면 다음 문제가 생긴다.
주간·월간 랭킹은 일간 랭킹만큼 초 단위 실시간성이 중요하지 않다.
따라서 다음과 같이 저장소와 처리 방식을 분리했다.
| 기간 | 처리 방식 | 저장소 |
|---|---|---|
| 일간 | 이벤트 기반 실시간 반영 | Redis |
| 주간 | Spring Batch 사전 집계 | MySQL 스냅샷 |
| 월간 | Spring Batch 사전 집계 | MySQL 스냅샷 |
API는 이러한 저장소 차이를 외부에 노출하지 않고 동일한 응답 구조를 제공한다.
전체 데이터 흐름은 다음과 같다.
상품 조회·좋아요·주문 이벤트
│
▼
Kafka
│
▼
product_metrics
│
▼
commerce-batch
Spring Batch Job
│
▼
staging
│
▼
주간·월간 TOP 100
│
┌──────┴──────┐
▼ ▼
주간 스냅샷 월간 스냅샷
│ │
└──────┬──────┘
▼
Ranking API
일간 Ranking API ──────────▶ Redis
commerce-batch는 상시 실행되는 서버가 아니다.
Jenkins가 정해진 시각에 Docker 컨테이너를 실행하면 하나의 Job을 처리한 후 종료되는 one-shot 애플리케이션이다.
랭킹 점수는 다음 공식으로 계산한다.
점수 = 조회수 × 0.1
+ 좋아요 수 × 0.2
+ 주문 건수 × 1.0
부동소수점 오차를 방지하기 위해 Java에서는 BigDecimal, MySQL에서는 DECIMAL(30, 1)을 사용한다.
기간별 집계 범위는 다음과 같다.
targetDate가 포함된 주의 월요일 ~ targetDate
targetDate가 포함된 월의 1일 ~ targetDate
예를 들어 targetDate가 2026-05-31이면 다음 범위를 집계한다.
주간: 2026-05-25 ~ 2026-05-31
월간: 2026-05-01 ~ 2026-05-31
동점 상품의 순서가 실행마다 달라지지 않도록 다음 정렬 기준을 사용한다.
ORDER BY score DESC, product_id ASC
순위는 공동 순위가 아니라 ROW_NUMBER()를 이용해 1부터 연속해서 부여한다.
Job 이름은 productRankingAggregationJob으로 정의했다.
하나의 Job은 다음 Step을 순서대로 실행한다.
1. Staging 초기화 Tasklet
2. 주간 집계 Chunk Step
3. 주간 스냅샷 게시 Tasklet
4. 월간 집계 Chunk Step
5. 월간 스냅샷 게시 Tasklet
집계와 스냅샷 게시를 분리한 이유는 API에 불완전한 중간 결과를 노출하지 않기 위해서다.
주간 게시가 끝난 뒤 월간 집계에서 실패하면 재시작 시 이미 완료된 주간 Step은 건너뛰고 월간 Step부터 다시 실행할 수 있다.
주간·월간 집계는 Spring Batch의 Chunk-Oriented Processing으로 구현했다.
Reader → Processor → Writer → Commit
Reader는 product_metrics를 다음 순서로 조회한다.
metric_date ASC, product_id ASC
Paging Reader가 실패 지점부터 정확하게 재시작하려면 정렬 키가 유일해야 한다.
따라서 원천 테이블의 다음 제약을 전제로 한다.
UNIQUE(metric_date, product_id)
정렬 키가 유일하지 않으면 페이지 경계가 달라져 재시작 시 데이터가 중복되거나 누락될 수 있다.
Processor는 일간 통계를 랭킹 점수로 변환한다.
score = viewCount.multiply(new BigDecimal("0.1"))
.add(likeCount.multiply(new BigDecimal("0.2")))
.add(orderCount.multiply(new BigDecimal("1.0")));
Writer는 상품별 점수를 staging 테이블에 누적한다.
stg_product_rank_aggregation
staging의 식별 키는 다음과 같다.
(job_instance_id, period_type, product_id)
job_instance_id: 서로 다른 Batch 실행을 구분한다.period_type: WEEKLY와 MONTHLY를 구분한다.product_id: 상품별 누적 결과를 구분한다.집계 결과를 API가 조회하는 스냅샷 테이블에 직접 저장하면 Batch 실행 중 다음 상태가 노출될 수 있다.
이를 방지하기 위해 집계 중간 결과와 조회용 결과를 분리했다.
product_metrics
│
▼
Chunk 집계
│
▼
staging
│
▼
TOP 100 게시
│
▼
조회용 스냅샷
Chunk 처리 중 장애가 발생해도 기존 스냅샷은 변경되지 않는다.
모든 집계가 끝난 뒤 게시 Tasklet이 실행될 때만 새로운 결과가 API에 공개된다.
Job Parameter는 다음 두 개만 사용한다.
| 파라미터 | 필수 | JobInstance 식별 | 역할 |
|---|---|---|---|
targetDate | O | O | 집계 기준일 |
rebuildSequence | X | O | 완료 결과를 다시 생성할 때 사용 |
동일한 파라미터를 사용한다.
targetDate=20260531
Spring Batch는 같은 JobInstance로 판단하고 완료된 Step 다음부터 재시작한다.
원천 데이터 정정 등으로 완료된 날짜를 다시 계산해야 한다면 rebuildSequence를 증가시킨다.
targetDate=20260531
rebuildSequence=2
이 경우 새로운 JobInstance가 생성돼 전체 집계가 다시 수행된다.
정책을 정리하면 다음과 같다.
실패 복구
→ 같은 targetDate
→ 같은 rebuildSequence
완료 결과 재생성
→ 같은 targetDate
→ 증가한 rebuildSequence
매번 새로운 JobInstance를 생성하는 run.id는 사용하지 않았다. run.id가 실행할 때마다 변경되면 Spring Batch의 재시작 의미를 활용하기 어렵기 때문이다.
재시작 가능한 Batch에서 가장 주의해야 할 부분은 중복 처리다.
다음과 같은 상황을 생각할 수 있다.
1. Reader가 Chunk를 읽는다.
2. Writer가 staging에 점수를 누적한다.
3. 애플리케이션 장애가 발생한다.
4. 같은 JobInstance를 재시작한다.
Writer 결과는 커밋됐지만 Reader 위치가 저장되지 않았다면 같은 Chunk가 다시 실행돼 점수가 중복 합산될 수 있다.
이를 방지하려면 다음 항목이 같은 DataSource와 트랜잭션 매니저로 묶여야 한다.
하나의 Chunk 트랜잭션으로 처리하면 다음과 같이 동작한다.
Chunk 성공
→ staging과 Batch 상태 모두 커밋
Chunk 실패
→ staging과 Batch 상태 모두 롤백
재시작 테스트에서는 다음 항목을 검증해야 한다.
Spring Batch가 실패 지점부터 재시작할 수 있다고 해서 원천 데이터의 시점 일관성까지 보장되는 것은 아니다.
다음 상황을 생각해 볼 수 있다.
1. 일부 날짜의 집계 완료
2. Batch 실패
3. 이미 읽은 product_metrics 수정
4. 기존 JobInstance 재시작
이 경우 staging에는 변경 전 데이터가 있고, 재시작 이후에는 변경된 원천을 읽게 된다. 하나의 결과에 서로 다른 시점의 데이터가 섞일 수 있다.
현재 설계에서는 다음 정책을 사용했다.
rebuildSequence를 이용해 전체 재집계한다.장기적으로는 다음과 같은 일 마감 체계가 필요하다.
Spring Batch의 재시작은 처리 위치를 복구하지만 변경 가능한 원천의 정합성까지 자동으로 해결하지는 않는다.
MySQL은 Materialized View를 기본 기능으로 제공하지 않는다.
이번 설계에서 Materialized View는 Batch가 명시적으로 갱신하는 물리 스냅샷 테이블을 의미한다.
mv_product_rank_weekly
mv_product_rank_monthly
API는 원천 통계를 다시 집계하지 않고 해당 날짜의 최대 100행만 조회한다.
이를 통해 다음 효과를 얻을 수 있다.
주간·월간 스냅샷은 동일한 구조를 가진다.
| 컬럼 | 타입 | 역할 |
|---|---|---|
aggregation_date | DATE | 집계 기준일 |
period_start_date | DATE | 집계 시작일 |
period_end_date | DATE | 집계 종료일 |
rank_position | SMALLINT | 1~100 순위 |
product_id | BIGINT | 상품 ID |
score | DECIMAL(30, 1) | 랭킹 점수 |
generated_at | DATETIME(6) | 생성 시각 |
Primary Key는 다음과 같다.
(aggregation_date, rank_position)
같은 상품이 하나의 스냅샷에 중복으로 들어가지 않도록 Unique Key도 둔다.
(aggregation_date, product_id)
상품 테이블과의 FK는 사용하지 않았다.
상품이 나중에 삭제되거나 변경돼도 집계 당시 순위를 보존하기 위해서다. 다만 API가 현재 상품 정보만 조회한다면 삭제·비활성 상품을 어떻게 처리할지는 별도의 정책이 필요하다.
집계가 끝나면 게시 Tasklet이 staging에서 TOP 100을 계산한다.
기존 스냅샷 삭제와 새로운 스냅샷 삽입은 하나의 트랜잭션으로 실행한다.
DELETE FROM mv_product_rank_weekly
WHERE aggregation_date = :targetDate;
INSERT INTO mv_product_rank_weekly (
aggregation_date,
period_start_date,
period_end_date,
rank_position,
product_id,
score,
generated_at
)
SELECT
:targetDate,
:periodStartDate,
:targetDate,
rank_position,
product_id,
score,
NOW(6)
FROM (
SELECT
product_id,
score,
ROW_NUMBER() OVER (
ORDER BY score DESC, product_id ASC
) AS rank_position
FROM stg_product_rank_aggregation
WHERE job_instance_id = :jobInstanceId
AND period_type = 'WEEKLY'
) ranked
WHERE rank_position <= 100;
게시 트랜잭션이 성공하면 새로운 TOP 100 전체가 공개된다.
실패하면 DELETE와 INSERT가 함께 롤백되므로 기존 스냅샷이 유지된다.
게시 성공
→ 새로운 스냅샷 전체 노출
게시 실패
→ 기존 스냅샷 유지
따라서 API는 기존 결과 또는 새로운 결과 전체만 보게 된다. 부분적으로 생성된 스냅샷은 조회하지 않는다.
주간과 월간 게시 트랜잭션은 서로 독립적이다. 주간 게시 후 월간 게시가 실패하면 주간만 최신 상태일 수 있지만, API가 한 번에 하나의 기간만 조회한다는 점을 고려한 선택이다.
Ranking API는 기간과 날짜를 입력받는다.
GET /api/v1/rankings
?period=daily|weekly|monthly
&date=yyyyMMdd
&page=1
&size=20
기간별 저장소와 날짜 기본값은 다음과 같다.
| 기간 | 저장소 | 날짜 생략 시 |
|---|---|---|
daily | Redis | 오늘 |
weekly | MySQL 주간 스냅샷 | 전일 |
monthly | MySQL 월간 스냅샷 | 전일 |
주간·월간은 요청한 날짜와 정확히 일치하는 스냅샷을 조회한다.
aggregation_date = 요청한 date
스냅샷이 없으면 오류 대신 빈 목록을 반환한다.
{
"totalCount": 0,
"items": []
}
HTTP Status는 200 OK다.
이 방식은 클라이언트 처리를 단순하게 하지만 다음 두 상태를 구분하기 어렵다는 단점이 있다.
API에서는 동일하게 처리하더라도 운영 모니터링에서는 두 상태를 구분해야 한다.
기간에 따라 테이블 이름이 달라지더라도 사용자 입력을 SQL 식별자로 직접 연결하지 않는다.
검증된 Enum을 이용해 Repository를 선택한다.
return switch (period) {
case DAILY -> dailyRankingRepository.find(...);
case WEEKLY -> weeklyRankingRepository.find(...);
case MONTHLY -> monthlyRankingRepository.find(...);
};
일평균 product_metrics 행 수를 A라고 하면 월말 최대 처리량은 다음과 같다.
주간 Reader = 7A
월간 Reader = 31A
전체 처리량 = 38A
일간 상품 통계가 10만 건이라면 최대 380만 건을 처리한다.
7 × 100,000 + 31 × 100,000
= 3,800,000 items
월간 범위를 한 번 읽으면서 주간·월간을 함께 계산하면 조회량을 줄일 수 있다.
하지만 이번 설계에서는 다음 이유로 주간과 월간 Step을 분리했다.
대신 주간 범위를 중복해서 읽는 비용을 감수한다.
전체 item 수가 380만 건일 때 Chunk 크기에 따른 예상 커밋 횟수는 다음과 같다.
Chunk 100
→ 약 38,000회 커밋
Chunk 1,000
→ 약 3,800회 커밋
Chunk 5,000
→ 약 760회 커밋
Chunk가 작으면 다음 비용이 증가한다.
반대로 Chunk가 너무 크면 다음 부담이 커진다.
따라서 Chunk 크기는 추측으로 정하지 않고 실제 데이터로 비교해야 한다.
월말 최대 범위를 재현하기 위해 다음 합성 데이터를 사용했다.
기준일: 2026-05-31
기간: 31일
일간 상품 수: 100,000
원천 데이터: 3,100,000행
Batch 총 read/write: 3,800,000건
Batch 자원 제한: 2 vCPU / 2GiB
측정 항목은 다음과 같다.
측정 결과는 다음과 같았다.
| 시나리오 | Job 시간 | 처리량 | CPU p95 | Peak Memory |
|---|---|---|---|---|
| Chunk 100 | 362.728초 | 10,476 items/s | 44.05% | 288.7MiB |
| Chunk 1,000 | 108.500초 | 35,023 items/s | 164.45% | 310.2MiB |
| Chunk 1,000 + API | 140.377초 | 27,070 items/s | 202.44% | 304.3MiB |
Docker CPU는 한 개 Core 사용량을 100%로 표시한다. 2 vCPU 제한에서는 200%가 최대 사용량이다.
Chunk 1,000은 Chunk 100과 비교해 다음 결과를 보였다.
Job 시간 약 68.74% 감소
처리량 약 3.20배 증가
메모리 약 21.5MiB 증가
두 실행 모두 다음 기능 조건을 만족했다.
총 read/write: 3,800,000건
rollback: 0
skip: 0
주간 스냅샷: 100행
월간 스냅샷: 100행
최종 checksum 일치
성능은 달랐지만 최종 결과의 정합성은 같았다.
Batch가 빠르게 끝난다고 해서 성능 검증이 끝나는 것은 아니다.
API와 Batch가 같은 MySQL을 사용하면 다음 자원을 경쟁한다.
API 단독과 Batch 동시 실행 결과는 다음과 같았다.
| 시나리오 | daily p95 | weekly p95 | monthly p95 |
|---|---|---|---|
| API 단독 | 9.37ms | 13.21ms | 13.03ms |
| Batch 동시 실행 | 12.96ms | 17.99ms | 18.40ms |
| 변화율 | +38.36% | +36.17% | +41.21% |
모든 API는 절대 기준인 p95 100ms를 만족했다. 오류와 dropped iteration도 발생하지 않았다.
그러나 Batch 실행 전 대비 p95 증가율 20% 미만이라는 상대 기준은 만족하지 못했다.
절대 API SLO
→ PASS
Batch 전 대비 상대 p95 증가율
→ FAIL
Chunk를 크게 하면 Batch는 빨리 끝나지만 순간적인 DB 부하가 증가할 수 있다.
따라서 운영 Chunk 크기는 다음 조건을 함께 고려해 결정해야 한다.
Batch 완료시간
+ API 응답시간
+ DB 부하
+ 메모리
+ 실패 시 재처리량
Spring Batch는 Job 실행과 재시작을 담당하지만 스케줄러는 아니다.
현재 환경은 Kubernetes가 없는 단일 Docker 호스트이므로 Jenkins가 매일 01:30 KST에 Batch 컨테이너를 실행하도록 설계했다.
Jenkins
│
▼
commerce-batch 컨테이너
│
▼
Spring Batch Job
│
▼
종료 코드 전달
역할은 다음과 같이 분리한다.
Chunk 트랜잭션이 롤백되고 기존 스냅샷은 영향을 받지 않는다.
같은 Job Parameter로 재실행하면 실패 지점부터 다시 처리한다.
DELETE + INSERT가 함께 롤백돼 기존 스냅샷이 유지된다.
정상적인 빈 결과로 처리한다. 해당 기준일의 기존 스냅샷을 삭제하고 빈 결과를 게시한다.
Java 프로세스가 정상적으로 실패 상태를 기록하지 못하면 JobExecution이 STARTED로 남을 수 있다.
이 경우 다음 순서로 복구해야 한다.
1. 실제 Batch 컨테이너가 종료됐는지 확인한다.
2. 실행 중이라면 메타데이터를 변경하지 않는다.
3. 종료가 확인되면 승인된 절차로 실행 상태를 FAILED로 정리한다.
4. 같은 Job Parameter로 다시 실행한다.
abandon()은 해당 실행의 재시작을 막으므로 복구 수단으로 사용하지 않는다.
Jenkins가 중단된 기간의 실행은 자동으로 보충되지 않는다.
복구 후에는 다음 항목을 비교한다.
서울 기준 전일
스냅샷의 최신 aggregation_date
Spring Batch 메타데이터의 완료 날짜
누락된 날짜가 있다면 오래된 날짜부터 순서대로 backfill한다.
이번 측정은 운영 환경이 아닌 로컬 합성 부하다.
다음 항목은 아직 확인이 필요하다.
따라서 Chunk 1,000은 현재 조건에서의 다음 운영 후보일 뿐, 운영 최적값으로 확정할 수는 없다.
현재 내릴 수 있는 결론
→ Chunk 1,000이 Chunk 100보다 처리량이 높다.
아직 내릴 수 없는 결론
→ Chunk 1,000이 운영 환경의 최적값이다.
Spring Batch의 핵심은 많은 데이터를 반복해서 읽는 기능보다 다음 항목에 있다.
Spring Batch는 실패한 위치부터 다시 실행할 수 있지만, 재시작 사이에 원천 데이터가 변경되면 별도의 마감 정책이 필요하다.
결과를 미리 계산하는 것만으로는 부족하다. 사용자가 부분적으로 생성된 결과를 보지 않도록 staging과 원자적 게시 과정이 필요하다.
Batch 실행시간이 짧아도 API 응답시간을 크게 악화시키면 좋은 운영 설계라고 보기 어렵다.
성능 수치에는 데이터 규모, CPU, 메모리, DB 설정, 캐시 상태와 같은 실행 환경이 함께 기록돼야 한다.
이번 설계에서는 일간 통계를 기반으로 주간·월간 상품 랭킹을 생성하고, 이를 조회 전용 Materialized View로 제공했다.
전체 흐름을 정리하면 다음과 같다.
1. product_metrics를 Chunk 단위로 읽는다.
2. staging에 상품별 점수를 누적한다.
3. 실패하면 완료된 Chunk 다음부터 재시작한다.
4. TOP 100을 조회용 스냅샷으로 원자적으로 게시한다.
5. API는 원천 데이터 대신 스냅샷을 조회한다.
6. 완료 결과의 재생성은 rebuildSequence로 구분한다.
7. Batch 실행시간과 API 영향을 함께 측정한다.
Spring Batch를 적용하는 것 자체보다 중요했던 것은 다음 질문에 답하는 일이었다.
Batch는 정상 실행보다 실패와 재실행 과정에서 설계 품질이 드러난다.
이번 과제를 통해 단순한 정기 집계를 넘어, 재시작 가능하고 조회 일관성을 보장하는 Batch Processing 구조를 고민할 수 있었다.