같은 랭킹인데 왜 다르게 풀었을까 — 실시간 Redis에서 배치 MV까지
TL;DR: 일간 랭킹은 Redis ZSET으로, 주간/월간 랭킹은 Spring Batch + Materialized View로 풀었다. “랭킹”이라는 같은 도메인인데 왜 인프라를 나눴는지, 그 과정에서 인터페이스를 한 번 잘못 설계했다가 되돌린 이야기를 정리했다.
1. 시작은 단순한 질문이었다
9주차에 Redis ZSET 기반 실시간 일간 랭킹을 만들었다. Kafka Consumer가 좋아요/주문 이벤트를 받아 ZINCRBY로 점수를 갱신하고, API에서 ZREVRANGE로 조회하는 구조다.
잘 돌아갔다. 그런데 10주차 과제가 떨어졌다.
“주간, 월간 랭킹도 만들어보세요.”
처음 든 생각은 단순했다. “일간을 7개 합치면 주간이고, 30개 합치면 월간 아닌가?”
Redis의 ZUNIONSTORE로 7일치 키를 합산하면 될 것 같았다. 그런데 이 생각이 틀렸다는 걸 금방 깨달았다.
2. 왜 Redis로 주간/월간을 풀면 안 되는가
ZUNIONSTORE의 시간복잡도는 O(N) + O(M log M)이다. N은 입력 키들의 총 원소 수, M은 결과 집합의 크기다. 상품이 10만 건이고 7일치를 합산하면 N은 최대 70만이다.
하지만 진짜 문제는 성능이 아니었다.
주간/월간 랭킹은 실시간일 필요가 없다. 사용자가 “이번 주 인기 상품”을 볼 때, 3초 전에 발생한 좋아요가 반영되지 않아도 아무도 모른다. 경영진이 보는 주간 리포트에 수 초 단위의 실시간성이 필요한 경우는 거의 없다.
이 시점에서 우아한형제들 기술블로그의 배치 경험기에서 읽었던 문장이 떠올랐다.
“실시간으로 처리할 수 있다고 해서 실시간으로 처리해야 하는 것은 아니다.”
정확히 이 상황이었다. 실시간으로 풀 수는 있지만, 풀 필요가 없다. 오히려 매 요청마다 7일치를 합산하면 Redis에 불필요한 부하를 주는 셈이다.
| 관점 | 일간 랭킹 | 주간/월간 랭킹 |
|---|---|---|
| 갱신 주기 | 이벤트 발생 즉시 | 하루 한 번이면 충분 |
| 핵심 가치 | 신속성 (UX) | 정확성 & 효율성 |
| 적합한 처리 | 실시간 (Redis ZSET) | 배치 (Spring Batch → DB) |
그래서 결론을 내렸다. 일간은 Redis, 주간/월간은 배치로 분리한다.
3. Materialized View라는 선택
주간/월간 랭킹은 “미리 계산해둔 조회 전용 테이블”에 넣기로 했다. MySQL에는 PostgreSQL 같은 네이티브 MV 기능이 없으므로, 별도 테이블 + 배치 적재 방식을 사용한다.
-- 주간 랭킹 MV
mv_product_rank_weekly (
product_id, year_week,
like_count, order_count, view_count,
score, ranking, -- 순위를 미리 계산해서 저장
updated_at
)
-- 월간 랭킹 MV
mv_product_rank_monthly (
product_id, year_month,
like_count, order_count, view_count,
score, ranking,
updated_at
)
여기서 한 가지 고민이 있었다. ranking(순위)을 테이블에 저장할 것인가, 조회할 때 계산할 것인가?
조회 시 ORDER BY score DESC로 정렬해서 순위를 매기는 것도 방법이다. 하지만 MV의 본질은 “복잡한 집계를 미리 계산해두는 것”이다. 조회할 때마다 정렬하면 MV의 의미가 반감된다. 배치에서 TOP 100만 뽑으면서 순위를 미리 매겨두면, 조회는 WHERE year_week = ? ORDER BY ranking ASC로 인덱스 스캔만 하면 끝이다.
4. Spring Batch Job 설계 — Chunk가 맞는 이유
배치 처리 모델을 정할 때 세 가지 선택지가 있었다.
선택지 A: Tasklet에서 Native Query 한 방
// Tasklet 안에서 SQL 한 줄로 끝내기
@Override
public RepeatStatus execute(...) {
jdbcTemplate.update("""
INSERT INTO mv_product_rank_weekly (...)
SELECT product_id, ..., RANK() OVER (ORDER BY score DESC)
FROM product_metrics
LIMIT 100
""");
return RepeatStatus.FINISHED;
}
단순하고 빠르다. 하지만 상품이 100만 건이 되면? SQL 한 방에 100만 건을 정렬하고, 트랜잭션 하나에 묶이고, 실패하면 전체 롤백이다.
선택지 B: Chunk-Oriented Processing
Reader(product_metrics에서 score순 읽기)
→ Processor(순위 부여 + MV 엔티티 변환)
→ Writer(MV 테이블 저장)
청크 단위로 트랜잭션이 관리되고, 실패 시 해당 청크만 재시도할 수 있다. Spring Batch의 StepExecution으로 읽은 건수, 쓴 건수를 자동 추적한다.
선택지 C: Tasklet 안에서 Chunk를 수동 구현
// Tasklet 안에서 직접 페이징하며 처리
int offset = 0;
while (offset < total) {
List<Metrics> chunk = repository.findAll(PageRequest.of(offset, 1000));
// 직접 처리...
offset += 1000;
}
자유도는 높지만, Batch 프레임워크의 재시작/모니터링 메커니즘을 전부 포기하게 된다.
우아한형제들의 Spring Batch와 Querydsl 글에서 대규모 데이터 처리 시 JpaPagingItemReader의 한계와 최적화 방법을 참고했다. 현재 규모에서는 표준 JpaPagingItemReader로 충분하지만, 데이터가 커지면 offset 없는 커서 기반 Reader로 전환이 필요하다는 점을 인지하고 있다.
최종적으로 B를 선택했다. 이유는 두 가지다.
- TOP 100만 읽으면 되므로
maxItemCount(100)과pageSize(100)을 맞추면 DB에서 딱 100건만 가져온다. A의 “SQL 한 방”과 성능 차이가 없으면서, 프레임워크의 이점(모니터링, 재시작)을 그대로 쓸 수 있다. - 이번 과제의 학습 목표 자체가 “Chunk-Oriented Processing”이다. 실무에서도 이 패턴을 알아야 한다.
그리고 Job 구조는 두 단계로 나눴다.
Step 1: Cleanup Tasklet → 해당 주차/월의 기존 데이터 삭제
Step 2: Aggregate Chunk → Reader → Processor → Writer
Cleanup은 단발성 DELETE 쿼리 하나이므로 Tasklet이 적합하고, 집계는 Chunk가 적합하다. 하나의 Job 안에서 각 Step의 성격에 맞는 처리 모델을 혼합한 것이다.
5. 인터페이스를 한 번 잘못 설계했다
API에서 주간/월간 랭킹을 제공하려면, 기존 RankingRepository에 주간/월간 메서드를 추가해야 했다.
처음에는 단순하게 기존 인터페이스를 확장했다.
// 처음 시도: 기존 인터페이스에 추가
public interface RankingRepository {
// 기존 (일간, Redis)
List<RankedProduct> getTopRankings(LocalDate date, int offset, int size);
long getTotalCount(LocalDate date);
// 추가 (주간/월간, JPA)
List<RankedProduct> getWeeklyRankings(LocalDate date, int offset, int size);
List<RankedProduct> getMonthlyRankings(LocalDate date, int offset, int size);
}
컴파일은 됐다. 하지만 구현체를 보는 순간 위화감을 느꼈다.
@Repository
public class RedisRankingRepository implements RankingRepository {
private final RedisTemplate<String, String> redisTemplate;
private final ProductRankWeeklyJpaRepository weeklyJpaRepository; // ← ?!
private final ProductRankMonthlyJpaRepository monthlyJpaRepository; // ← ?!
// Redis로 일간 조회...
// JPA로 주간/월간 조회...
}
이름은 “Redis”인데 JPA를 주입받고 있었다. 이건 명백한 SRP(단일 책임 원칙) 위반이다.
왜 이런 일이 발생했는지 생각해보면, 하나의 인터페이스에 두 가지 변경 원인을 밀어넣었기 때문이다.
RankingRepository의 일간 메서드들은 Redis ZSET이 변경되면 바뀐다.- 주간/월간 메서드들은 MV 테이블 스키마가 변경되면 바뀐다.
변경 원인이 다르면 인터페이스도 달라야 한다. SOLID의 I(Interface Segregation Principle)가 말하는 바로 그것이다.
수정: 인터페이스 분리
// 일간 랭킹 (Redis) — 기존 그대로
public interface RankingRepository {
List<RankedProduct> getTopRankings(LocalDate date, int offset, int size);
long getTotalCount(LocalDate date);
Long getRank(LocalDate date, Long productId);
Double getScore(LocalDate date, Long productId);
}
// 기간별 랭킹 (DB MV) — 신규
public interface PeriodRankingRepository {
List<RankedProduct> getWeeklyRankings(LocalDate date, int offset, int size);
long getWeeklyTotalCount(LocalDate date);
List<RankedProduct> getMonthlyRankings(LocalDate date, int offset, int size);
long getMonthlyTotalCount(LocalDate date);
}
구현체도 깔끔하게 분리됐다.
// Redis만 아는 구현체
@Repository
public class RedisRankingRepository implements RankingRepository { ... }
// JPA만 아는 구현체
@Repository
public class JpaPeriodRankingRepository implements PeriodRankingRepository { ... }
Service에서는 두 인터페이스를 주입받아 period에 따라 라우팅한다.
@Service
public class RankingQueryService {
private final RankingRepository rankingRepository; // 일간 (Redis)
private final PeriodRankingRepository periodRankingRepository; // 주간/월간 (JPA MV)
public PageResult<RankingItemInfo> getRankings(LocalDate date, int page, int size, RankingPeriod period) {
return switch (period) {
case WEEKLY -> /* periodRankingRepository.getWeeklyRankings(...) */
case MONTHLY -> /* periodRankingRepository.getMonthlyRankings(...) */
default -> /* rankingRepository.getTopRankings(...) */
};
}
}
처음에 하나로 합쳤을 때 “이게 맞나?” 싶은 위화감이 있었는데, 그 감각이 맞았다. 이름과 책임이 안 맞으면 뭔가 잘못된 것이다.
6. 전체 구조 — 실시간과 배치의 공존
최종적으로 만들어진 구조는 이렇다.
[일간 랭킹 — 실시간]
Kafka Event → Commerce Streamer → Redis ZSET (ZINCRBY)
↓
API (ZREVRANGE) → 사용자
[주간/월간 랭킹 — 배치]
product_metrics → Spring Batch Job → MV 테이블 (saveAll)
(일간 누적) (Chunk Processing) ↓
API (JPA 조회) → 사용자
같은 “랭킹”이지만, 갱신 주기와 핵심 가치가 다르면 인프라도 달라야 한다. 일간은 이벤트 하나하나가 즉시 반영되어야 UX가 살아나고, 주간/월간은 하루 한 번 정확하게 집계되면 그만이다.
API 엔드포인트는 하나로 통합했다.
GET /api/v1/rankings?period=DAILY&date=20260417&page=0&size=20
GET /api/v1/rankings?period=WEEKLY&date=20260417&page=0&size=20
GET /api/v1/rankings?period=MONTHLY&date=20260417&page=0&size=20
클라이언트 입장에서는 period 파라미터 하나만 바꾸면 된다. 뒤에서 Redis를 보는지 DB를 보는지는 알 필요가 없다.
7. 회고 — 10주간 달라진 사고방식
이번 과제를 하면서 가장 많이 바뀐 건, “이걸로 풀 수 있다”에서 “이걸로 풀어야 하는가”로 질문이 바뀐 것이다.
10주 전이었다면 “Redis로 주간도 만들 수 있으니까 Redis로 하자”고 했을 것이다. 지금은 “주간 랭킹의 갱신 주기와 정확성 요구사항이 뭔지”를 먼저 본다. 기술이 아니라 요구사항이 아키텍처를 결정한다.
10주간의 흐름을 돌아보면:
- 1~3주차: 도메인 모델링과 계층 분리. “테이블 먼저”에서 “도메인 먼저”로 사고가 바뀌었다.
- 4~6주차: 트랜잭션과 동시성, 외부 시스템 연동.
@Transactional하나면 충분하다고 생각했는데, 분산 환경에서는 전혀 아니었다. - 7주차: 이벤트와 Kafka. 동기 호출로 엮인 시스템을 이벤트로 분리하는 순간, 확장성에 눈이 떠졌다.
- 8주차: 대기열 큐. Redis의 다른 자료구조 활용.
- 9주차: Redis ZSET 기반 실시간 집계. “쓰기 최적화”를 고민하기 시작했다.
- 10주차: Spring Batch와 MV. 같은 도메인에서 실시간과 배치를 나누는 판단을 했다.
가장 큰 전환점을 꼽자면, 9주차에서 10주차로 넘어오는 지점이다. 같은 “랭킹”인데 다른 도구를 쓴다는 결정을 하려면, 기술의 장단점만으로는 부족하다. 이 데이터가 얼마나 자주 바뀌어야 하는가, 누가 소비하는가, 틀려도 되는가 — 이런 질문을 할 수 있어야 한다.
8. 남은 과제
현재 product_metrics는 날짜 구분 없는 누적 카운터다. 주간 배치와 월간 배치가 같은 시점의 스냅샷을 읽으므로, 엄밀히 말하면 “이번 주에 새로 발생한 좋아요 수”를 구분하지 못한다.
이를 해결하려면 일별 스냅샷 테이블(product_metrics_daily)을 도입해야 한다. 주간 집계 = 이번 주 7일치 daily의 SUM, 월간 집계 = 이번 달 daily의 SUM. 이렇게 되면 진정한 기간별 차분 집계가 가능하다.
지금은 “현재 누적 상태의 주기적 스냅샷”이라는 한계를 인지하고 있다. 하지만 데이터 파이프라인은 한 번에 완성하는 게 아니라, 요구사항이 구체화되면서 점진적으로 발전시키는 것이라고 생각한다.
참고
- 우아한형제들 - Spring Batch와 Querydsl — 대규모 데이터 처리 시 JpaPagingItemReader의 한계와 offset 없는 Reader 전략
- 우아한형제들 - 파일럿 프로젝트를 통한 배치경험기 — 300만 건 데이터 처리와 배치 Job 설계 패턴
- 우아한형제들 - 회원시스템 이벤트기반 아키텍처 구축하기 — 이벤트 분리와 도메인 간 의존성 관리
- Spring Docs - Spring Batch Reference — Chunk-Oriented Processing 공식 문서