저번에 만든 일간 랭킹 기능에 주간, 월간 랭킹을 추가하게 되었다.
일간 랭킹까지만 있을 때는 Kafka Consumer + Redis ZSET으로 충분했다.
실시간 이벤트를 소비하고 점수를 바로 올리는 구조라 빠르고 직관적이었다.
하지만 문제는 주간, 월간 랭킹을 일간 랭킹과 동일하게 가져갈 순 없었다는 것이다.
Redis에만 데이터를 적재하려니 TTL을 길게 가져가야 해서 그만큼 비용과 관리 부담이 커졌고, 결국 영속성과 안정성이 보장하는 DB에 데이터를 저장하기로 했다.
이미 product_metrics 테이블에 일간 데이터가 쌓이고 있었기 때문에 이를 기반으로 주간/월간 집계를 만들기로 했다.
그렇다면 그냥 호출 시마다 SUM / GROUP BY로 계산하면 되지 않을까?
하지만 그렇게 하면 매번 대량 데이터를 스캔해야해서 조회 속도가 느려질 수 있었다.
랭킹은 사용자가 바로 보고 싶어 하는 정보이고 그만큼 트래픽도 많이 몰리는 기능 중 하나이다.
그만큼 조회가 느려지거나 타임아웃이 나면 서비스 신뢰를 잃을 수 있다고 생각했다.
결국 조회 전용 테이블(Materialized View) 을 따로 두는 게 맞다고 판단했고 MySQL은 MV 기능이 없기 때문에 주간/월간 집계 테이블을 직접 만들게 되었다.
배치 시스템 설계
집계 시스템은 Spring Batch를 이용했다.
흐름은 일간 데이터 → 배치 집계 → 주간/월간 테이블 적재 순서로 설계했다.
배치 모델
Spring Batch는 두 가지 모델을 제공한다.
- Chunk-Oriented Processing: 대량 데이터를 안정적으로 처리하기 적합
- Tasklet: 단발성 작업(정리, 삭제, 초기화 등)에 적합
랭킹 집계는 안정성이 중요했기 때문에 Chunk-Oriented Processing을 선택했다.
@Bean("weeklyAggregateStep")
public Step aggregateStep() {
return new StepBuilder("weeklyStep", jobRepository)
.<RankingAggregateResult, WeeklyRanking>chunk(1000, transactionManager)
.reader(weeklyReader)
.processor(weeklyProcessor)
.writer(weeklyWriter)
.build();
}
flowchart LR
A[Reader] --> B[Processor]
B --> C[Writer]
C --> D[(DB: Materialized View)]
subgraph Chunk
A --> B --> C
end
style D fill:#fdf5e6,stroke:#333,stroke-width:1px
집계 단위
처음엔 MV 테이블에 적재하는 과정에서 순위까지 집계해 저장할까 고민했다.
하지만 Chunk 단위 집계 과정에서 정확도가 깨질 수 있다고 생각했다.
그래서 일단 score만 저장하고 조회 시 정렬로 순위를 계산하는 방식을 선택했다.
SELECT product_id, score
FROM MV
ORDER BY score DESC
LIMIT 10;
일단 이렇게 작성하고 보니 배치 단계를 두 단계로 나눠서 순위는 적재가 완료된 후에 후처리로 업데이트해 줘도 되지 않을까 하는 생각도 들었다.
그래서 후처리 단계(Tasklet)를 추가해 순위를 업데이트하는 방법도 고려했다.
최종적으로 MV 테이블은 다음처럼 설계했다.
erDiagram
PRODUCT {
bigint id PK
}
PRODUCT_METRICS {
bigint product_id PK
date metric_date PK
bigint sales_count
bigint like_count
bigint view_count
}
MV_PRODUCT_RANK_WEEKLY {
bigint id PK
bigint product_id
varchar year_week
decimal total_score
bigint sales_count
bigint like_count
bigint view_count
datetime aggregated_at
}
MV_PRODUCT_RANK_MONTHLY {
bigint id PK
bigint product_id
varchar year_month
decimal total_score
bigint sales_count
bigint like_count
bigint view_count
datetime aggregated_at
}
PRODUCT ||--o{ PRODUCT_METRICS : aggregates
PRODUCT ||--o{ MV_PRODUCT_RANK_WEEKLY : ranks
PRODUCT ||--o{ MV_PRODUCT_RANK_MONTHLY : ranks
스케줄링 주기
처음에는 단순히 주간은 주 1회, 월간은 월 1회만 집계하려 했다.
하지만 이 경우에는 9월 15일에 월간 랭킹을 조회했는데 아무 데이터도 안 나오지 않는 콜드스타트 문제가 발생했다.
그래서 매일 1회씩 배치를 돌려 집계 데이터를 점진적으로 쌓도록 수정했다.
모니터링도 중요해서 JobExecution 로그와 StepExecution 상태를 기록해 두었다.
마무리
이번에 배치를 구현하면서 실시간과 배치는 경쟁 관계가 아니라 보완 관계라는 걸 알게 되었다.
실시간은 빠르지만 무겁고 배치는 느리지만 안정적이다.
저번주에 고민했던 어디까지 실시간으로 가져갈지와 연결되는 부분이 있었다고 생각한다.
모든 걸 실시간으로 가져갈 수는 없기 때문에 둘을 적절히 조합해야 서비스가 안정적으로 굴러갈 수 있다.
배치를 처음 적용하다 보니 아쉬움도 남았다.
다음 의문에 대해서도 저 고민을 많이 해봐야 할 것 같다.
- 순위까지 저장해 조회 성능을 더 끌어올릴 수 있었을까?
- 실패했을 때 재처리 전략은 어떻게 가져가야 할까?
- 대규모 트래픽 상황에서도 안정적으로 동작할 수 있을까?
'Study > Architecture' 카테고리의 다른 글
| 실시간 상품 랭킹? Redis ZSET만 쓰면 끝인 줄 알았는데요 (0) | 2025.09.12 |
|---|---|
| 내부 이벤트를 넘어 Kafka 기반 이벤트 파이프라인으로 (0) | 2025.09.05 |
| 주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까? (2) | 2025.08.29 |
| 장애 대응 시스템 구축하기 (0) | 2025.08.22 |
| 캐시 구조 개선 (0) | 2025.08.22 |