TL;DR
랭킹에서 중요한 것은 정답 같은 점수식을 찾는 일이 아니다.
점수식은 언제든 바뀌는 정책이고, 개발자가 설계해야 하는 것은 그 정책이 바뀌어도 버티는 구조다.
랭킹 시스템을 만들 때 가장 먼저 떠올리는 것은 보통 점수식이다.
조회는 몇 점으로 볼지, 좋아요는 얼마나 반영할지, 주문은 몇 배로 가중할지부터 고민하게 된다. 나도 처음엔 그랬다.
그런데 구현이 깊어질수록 결론은 조금 달라졌다.
점수식이 중요하지 않다는 뜻은 아니다. 다만 점수식은 대부분 정책이고, 정책은 생각보다 쉽게 바뀐다.
오늘은 종합 인기도를 보고 싶다가도, 내일은 전환율이 높은 상품을 더 끌어올리고 싶을 수 있다.
어제는 주문을 가장 강하게 봤지만, 특정 캠페인 기간에는 좋아요나 조회 반응을 더 보고 싶을 수도 있다.
그렇다면 개발자가 집중해야 할 지점은 조금 달라진다.
특정 가중치가 정답인지 맞히는 것보다, 그 가중치가 바뀌어도 시스템 전체를 다시 뜯지 않아도 되는 구조를 만드는 일이 더 중요해진다.
SOLID의 Open-Closed Principle처럼, 랭킹 정책은 확장할 수 있어야 하고 기존 집계 파이프라인은 쉽게 흔들리지 않아야 한다.
이 글은 점수식에 매달리던 사고에서 벗어나, 정책 변경을 버티는 랭킹 구조로 옮겨간 과정에 대한 기록이다.
문제 상황
처음 구현한 랭킹은 꽤 단순했다.
조회, 좋아요, 주문 이벤트를 받아 상품별 카운터를 증가시키고, 계산된 점수를 Redis ZSET에 반영했다. 랭킹 키는 ranking:all:{yyyyMMdd} 형식으로 날짜별로 나눴다.
즉 하루 단위로 집계한 결과를 날짜별 Redis 키에 담고, 조회 API는 기본적으로 오늘 날짜 키를 읽는 방식이었다.
처음 보기에는 충분해 보였다. 날짜별 랭킹도 만들 수 있고, Redis ZSET을 쓰니 상위 N개 상품도 빠르게 조회할 수 있었다.
하지만 정책 변경을 생각하는 순간 문제가 드러났다.
Redis에 저장된 점수는 특정 Scorer가 계산한 결과일 뿐이다. 점수식을 바꾸면 그 결과를 그대로 재활용할 수 없다.
예를 들어 기존에는 종합 인기도 중심으로 랭킹을 만들다가, 나중에 전환율 중심 랭킹을 실험하고 싶어졌다고 해보자.
이때 Redis에 남아 있는 최종 점수만으로는 과거 랭킹을 새 기준으로 다시 설명할 수 없다. 필요한 것은 최종 점수가 아니라, 그 점수를 다시 만들 수 있는 재료다.
기존 구조의 한계는 아래처럼 정리할 수 있었다.

| 영역 | 기존 구조 | 한계 |
|---|---|---|
| Scorer 분리 | RankingScorer 인터페이스로 점수 계산 분리 | 앞으로 들어오는 이벤트에는 적용 가능하지만, 과거 랭킹 재계산은 어렵다 |
| 저장소 | Redis ZSET + HASH counter, TTL 2일 | 시간이 지나면 재계산에 필요한 재료가 사라진다 |
| 날짜 결정 | consumer 처리 시점 기준 날짜 | 지연 처리나 재처리 시 실제 발생 날짜와 다른 날짜에 반영될 수 있다 |
| carry-over | 전날 Redis ZSET을 다음 날 키로 복사 | 전날 Redis 키가 없거나 깨지면 흐름도 같이 끊긴다 |
결국 문제는 단순히 점수식이 마음에 드느냐가 아니었다.
점수식이 바뀌었을 때, 시스템이 그 변경을 받아줄 수 있는가가 더 큰 문제였다.
분석
1. 점수식 변경시 발생하는 문제
랭킹 점수식은 수학 공식처럼 보이지만, 실제로는 제품 정책에 가깝다.
조회, 좋아요, 주문 중 무엇을 더 중요하게 볼지는 서비스가 어떤 랭킹을 보여주고 싶은지에 따라 달라진다.
많이 본 상품을 올리고 싶다면 조회 수의 영향이 커진다.
구매 가능성이 높은 상품을 올리고 싶다면 주문이나 전환율이 중요해진다.
신상품을 노출하고 싶다면 오래 쌓인 누적량은 오히려 방해가 될 수도 있다.
그러니 조회 0.15, 좋아요 0.35, 주문 0.50 같은 값에 너무 강하게 묶이면 안 된다.
그 값은 지금의 가정일 뿐이고, 서비스가 보고 싶은 랭킹이 바뀌면 언제든 바뀔 수 있다.
우리는 기획자가 아니다.
특정 가중치가 영원히 맞는지 판정하는 사람이라기보다, 그 가중치가 바뀌어도 코드와 데이터 구조가 버틸 수 있게 만드는 사람에 가깝다.
2. Scorer만 교체할 수 있으면 완벽한가?
Scorer를 인터페이스로 분리한 것은 맞는 방향이었다.
점수 계산을 RankingScorer 뒤로 숨기면, SaturationScorer 대신 LinearScorer나 ConversionScorer를 넣어볼 수 있다.
하지만 이것만으로는 부족했다.
Scorer를 갈아끼울 수 있다는 말은, 기본적으로 앞으로 들어올 이벤트를 다른 방식으로 계산할 수 있다는 뜻에 가깝다.
이미 지나간 날짜의 랭킹은 여전히 예전 점수식이 만든 결과로 남아 있다.
Redis에 남은 최종 score만 보고는 그 점수가 조회 때문에 높았는지, 좋아요 때문인지, 주문 때문인지 새 정책 기준으로 다시 해석하기 어렵다.
여기서 필요한 것은 단순한 전략 패턴이 아니었다.
새 Scorer를 추가해도 기존 consumer, Redis 반영, 재계산 흐름이 흔들리지 않는 구조가 필요했다. 즉 랭킹 시스템에도 OCP가 필요했다.
3. 과거 랭킹을 다시 계산하려면 어떤 데이터가 필요한가
과거 랭킹을 새 점수식으로 다시 계산하려면 무엇을 저장해야 할까?
원본 이벤트를 모두 다시 읽는 방법도 있다. 하지만 매번 전체 이벤트를 읽어 날짜별로 분류하고, 상품별로 합산하고, 다시 점수를 계산하는 방식은 비용이 너무 크다.
반대로 최종 score만 저장하는 것도 답이 아니다.
그 score는 특정 시점의 특정 Scorer가 만든 결과라, Scorer가 바뀌는 순간 재료로 쓰기 어렵다.
그래서 중간 형태가 필요했다.
원본 이벤트보다는 작고, 최종 score보다는 해석 가능해야 한다. 내가 남기기로 한 것은 날짜별 상품별 view / like / order counter였다.

이 counter가 있으면 Scorer가 바뀌어도 다시 계산할 수 있다.
종합 인기도가 필요하면 SaturationScorer를 적용하고, 누적량 비례가 필요하면 LinearScorer를 적용하고, 전환율 중심 실험이 필요하면 ConversionScorer를 적용하면 된다.
4. 날짜가 바뀌는 순간 랭킹을 어떻게 이어갈까
하루 단위로 랭킹을 집계하면 자정이 되는 순간 오늘 키는 새로 시작한다.
문제는 이때 랭킹이 비어 보이거나, 어제까지 이어지던 흐름이 갑자기 끊길 수 있다는 점이었다.
이때 선택지는 크게 두 가지였다.
오늘 랭킹을 완전히 0에서 다시 시작할 수도 있고, 어제까지의 흐름을 일부 가져와 오늘 랭킹의 초기값으로 사용할 수도 있다.
후자의 방식이 carry-over다.
즉 carry-over는 전날 랭킹 점수의 일부를 다음 날 랭킹으로 넘겨, 날짜가 바뀌어도 랭킹의 흐름이 완전히 끊기지 않게 만드는 방법이다.

처음에는 콜드 스타트를 막기 위한 장치라고만 생각했다.
하지만 구현하면서 보니 carry-over도 결국 정책이었다. 어제의 흐름을 오늘에 얼마나 남길지, 즉 랭킹의 관성을 결정하기 때문이다.
carry-over가 전혀 없다면 오늘 랭킹은 자정 이후 이벤트만으로 구성된다.
이 방식은 가장 엄격하게 오늘만 반영하지만, 랭킹이 지나치게 불안정해지고 트렌드 흐름이 뚝 끊긴다.
반대로 전날 점수를 너무 많이 넘기면 최신성이 약해진다.
어제 잘 나가던 상품이 오늘도 오랫동안 상단에 남아 있고, 오늘 실제로 반응이 터진 상품은 늦게 올라온다.
그래서 carry-over까지 포함하면 단순히 daily_counter만으로는 부족했다.
오늘 점수에는 오늘 organic score뿐 아니라 어제에서 넘어온 carry score도 포함된다. 결국 carry가 반영된 일간 점수 스냅샷인 daily_score도 별도로 남겨야 했다.
접근
1. 이벤트 발생 시각
먼저 날짜 기준부터 바꿨다.
기존처럼 consumer가 메시지를 처리한 시점의 오늘 날짜를 쓰면, 지연 처리나 재처리 때 이벤트가 잘못된 날짜에 들어갈 수 있다.
그래서 랭킹 날짜는 KafkaEventEnvelope.occurredAt을 KST 기준 LocalDate로 변환해 결정했다.
같은 배치 안에 여러 날짜의 이벤트가 섞여 들어와도, 이제는 (statDate, productId) 단위로 분리해서 누적한다.
이 변경은 작아 보이지만 중요하다.
랭킹을 다시 계산하려면, 먼저 이벤트가 실제로 어느 날짜의 재료인지부터 안정적으로 정해져야 하기 때문이다.
2. 랭킹의 원본
가장 큰 변화는 저장소의 역할을 나눈 것이다.
Redis는 여전히 빠른 조회에 필요하다. 하지만 Redis가 랭킹의 기준 저장소가 되면, Redis 상태가 깨지거나 TTL이 지나갔을 때 과거 랭킹을 다시 설명하기 어렵다.
그래서 ranking BC 안에 재계산에 필요한 데이터를 영속화하고, Redis는 서빙용 projection으로 낮췄다.
| 저장소 | 무엇을 담는가 | 역할 |
|---|---|---|
| ranking_daily_counter | 날짜별 상품별 view / like / order 누적값 | Scorer가 바뀌어도 다시 계산할 수 있는 재료 |
| ranking_daily_score | organic_score + carry_score | carry-over까지 반영된 일간 점수 스냅샷 |
| ranking_projection_dirty | Redis 반영 실패 날짜 | 나중에 다시 계산해 Redis를 복구하기 위한 표시 |
| Redis ZSET / HASH | 현재 조회용 랭킹과 counter | 빠른 서빙 projection |
이렇게 나누면 역할이 선명해진다.
DB는 랭킹을 다시 계산할 수 있게 만드는 기준 데이터가 되고, Redis는 빠르게 보여주기 위한 결과물이 된다.
3. 쓰기 순서
쓰기 흐름도 바꿨다.
consumer는 먼저 DB 단일 트랜잭션 안에서 counter와 score, event_handled를 확정한다. 그 다음 Redis에 반영한다.
@Transactional
public void persistDeltas(Map<RankingDailyKey, long[]> deltas, Set<String> newEventIds) {
// 1. 날짜별 상품별 delta를 DB counter에 먼저 누적한다.
counterPort.upsertDeltas(deltas);
// 2. DB에 확정된 최신 counter를 다시 읽는다.
// Redis 값이 아니라 DB 값을 기준으로 score를 다시 계산하기 위함이다.
Map<RankingDailyKey, long[]> counters = counterPort.getCounters(deltas.keySet());
// 3. 현재 활성화된 Scorer로 organic_score를 계산한다.
Map<RankingDailyKey, Double> organicScores = new HashMap<>();
for (Map.Entry<RankingDailyKey, long[]> entry : counters.entrySet()) {
long[] c = entry.getValue();
double organic = rankingScorer.calculateScore(
clamp(c[0]), clamp(c[1]), clamp(c[2])
);
organicScores.put(entry.getKey(), organic);
}
// 4. daily_score에 organic_score를 저장한다.
// carry_score는 별도 carry-over 단계에서 관리한다.
scorePort.upsertOrganicScores(organicScores, rankingScorer.scorerType());
// 5. 여기까지 성공해야 eventId를 처리 완료로 기록한다.
eventIdempotencyService.markHandledBatch(newEventIds, CONSUMER_GROUP);
}
이 순서가 중요한 이유는 Redis 실패를 다루는 방식 때문이다.
Redis에 먼저 쓰고 DB가 실패하면, 이벤트는 처리된 것처럼 보이지만 기준 데이터가 남지 않을 수 있다. 반대로 DB를 먼저 확정하면 Redis 반영이 실패해도 복구할 수 있다.
그래서 Redis 반영은 best-effort로 두었다.
실패하면 해당 날짜를 projection_dirty에 남기고, 나중에 reconcile job이 그 날짜만 다시 계산해 Redis를 복구한다.
4. Carry-over 저장 위치
carry-over도 Redis 복사에서 벗어났다.
이제 전날 Redis ZSET을 복사하지 않고, 전날 ranking_daily_score의 total score를 읽어 다음 날 carry_score로 기록한다.

| 방식 | 장점 | 단점 |
|---|---|---|
| 23:50 스케줄러 | 자정 직후 오늘 키가 비지 않는다 | 마지막 10분 이벤트는 carry-over에 반영되지 않는다 |
| 00:00:05 스케줄러 | 전날 데이터를 더 정확히 반영할 수 있다 | 자정 직후 잠깐 공백이 생길 수 있다 |
| Lazy init | 별도 스케줄러가 필요 없다 | 첫 요청 사용자가 초기화 비용을 떠안는다 |
나는 여전히 23:50 스케줄러를 택했다.
가장 정확한 방식이라서가 아니라, 자정 직후 랭킹이 비어 보이는 UX 문제를 피하는 것이 더 중요하다고 봤기 때문이다.
다만 구현 방식은 달라졌다.

이제 carry-over는 전날 Redis 상태에 의존하지 않고, DB에 남은 daily_score를 기준으로 다음 날 carry_score를 만든다. 덕분에 Redis 키가 없어도 흐름을 다시 만들 수 있고, rebuild와 carry-over가 같은 점수 스냅샷을 바라보게 됐다.
해결
Scorer 3종 구조
점수 정책은 RankingScorer 인터페이스 뒤로 분리했다.
중요한 건 특정 Scorer가 정답이라는 주장이 아니다. 정책이 바뀌었을 때 Scorer를 추가하거나 교체해도, 집계와 재계산 구조가 그대로 버틴다는 점이다.
Scorer 3종 구조
RankingScorer
├── SaturationScorer : 종합 인기도
│ sat(x, k) 기반, view / like / order를 절대량 중심으로 반영
│
├── LinearScorer : 누적량 비례
│ 단순 선형 가중합, 이벤트 수가 늘수록 비례해서 증가
│
└── ConversionScorer : 전환율 기반
"본 사람 중 몇 명이 샀나" 관점
confidence(view) × (orderRate, likeRate)
SaturationScorer는 기본 구현체다.
조회, 좋아요, 주문을 saturation 함수로 눌러 절대량 중심의 종합 인기도를 만든다.
LinearScorer는 더 직관적이다.
이벤트 수가 늘수록 점수가 선형적으로 커지는 방식이다.
ConversionScorer는 질문 자체가 다르다.
많이 본 상품보다, 본 사람 대비 잘 팔린 상품을 더 높게 본다. 다만 조회 수가 너무 작은 상품이 과대평가되지 않도록 confidence 보정을 넣었다.
이제 이 세 가지 Scorer는 단순한 실험용 구현체가 아니다.
daily_counter가 남아 있기 때문에, 같은 날짜의 같은 재료에 서로 다른 Scorer를 적용해 다시 계산할 수 있다.
재계산과 복구 경로
Scorer만 분리하고 끝내면 구조적 주장이 약하다.
정말 정책 변경을 버티려면, 새 Scorer로 과거 날짜를 다시 계산하는 경로까지 있어야 한다.
- Admin rebuild 요청
관리자는 날짜 범위와 scorerType, carryOverWeight를 넣어 재계산 요청을 보낼 수 있다. 요청은 검증되고, 실제 계산은 batch job이 수행한다. - RankingRebuildJob
날짜를 순서대로 돌면서 daily_counter를 읽고, 선택한 Scorer로 organic score를 다시 계산한다. 이후 전날 daily_score를 기준으로 carry score를 연결하고, daily_score와 Redis ZSET을 다시 만든다. - RankingReconcileJob
Redis 반영 실패로 projection_dirty에 남은 날짜를 읽고, 해당 날짜만 다시 계산해 Redis projection을 복구한다.
이 구조에서 가장 중요한 점은 모든 경로가 같은 계산 축으로 수렴한다는 것이다.
daily_counter → Scorer → daily_score → Redis

실시간 집계, carry-over, rebuild, reconcile이 서로 다른 규칙으로 움직이면 나중에 설명할 수 없는 랭킹이 된다.
그래서 기준 데이터는 DB에 남기고, Redis는 언제든 다시 만들 수 있는 결과물로 두었다.
검증
1. Scorer 비교 테스트
먼저 같은 입력에 대해 Scorer가 실제로 다른 랭킹 성격을 만드는지 확인했다.
핵심 시나리오는 아래처럼 잡았다.
| 시나리오 | SaturationScorer | ConversionScorer |
|---|---|---|
| 상품 A: view=1000, order=5 | A 우세 | A 열세 |
| 상품 B: view=50, order=4 | B 열세 | B 우세 |
결과는 의도와 맞았다.
SaturationScorer는 절대량이 큰 상품을 더 높게 평가했고, ConversionScorer는 적게 노출됐어도 전환율이 높은 상품을 끌어올렸다.
추가로 아래도 확인했다.
- 동일 입력을 넣어도 3개 Scorer는 서로 다른 점수를 산출한다.
- 주문 1건이 좋아요 3건보다 더 무겁게 반영된다는 도메인 의도는 모든 Scorer에서 유지된다.
- ConversionScorer는 view=1, order=1 같은 극단값을 confidence 보정으로 과대평가하지 않는다.
2. 구조 변경 테스트
이번 변경은 점수 비교만으로는 부족했다.
정말 정책 변경과 Redis 실패를 버티는 구조가 되었는지 별도로 확인해야 했다.
| 검증 포인트 | 확인한 내용 |
|---|---|
| 발생 시각 기준 날짜 집계 | 같은 배치 안에 다른 날짜 이벤트가 섞여도 occurredAt 기준으로 서로 다른 날짜 row에 반영된다 |
| DB 우선 저장 | daily_counter, daily_score, event_handled가 하나의 DB 트랜잭션으로 확정된다 |
| Redis 실패 경로 | Redis 반영이 실패해도 DB 저장은 유지되고 projection_dirty에 복구 대상 날짜가 남는다 |
| carry-over chain | 오늘 활동이 0이어도 daily_score의 carry_score를 통해 다음 날 흐름이 이어진다 |
| rebuild / reconcile | daily_counter를 기준으로 daily_score와 Redis projection을 다시 만들 수 있다 |
특히 Redis 실패 경로가 중요했다.
예전에는 Redis 반영 실패가 곧 랭킹 시스템 실패처럼 느껴졌다. 지금은 기준 데이터가 DB에 남고 dirty row가 기록되므로, 나중에 그 날짜만 다시 계산해 복구할 수 있다.
3. E2E로 끝까지 흘려보낸 결과
마지막으로 VIEW / LIKE 이벤트를 실제로 발생시킨 뒤, outbox relay, Kafka consumer, Redis 반영, API 조회까지 전체 흐름을 E2E로 확인했다.
| 검증 포인트 | 실측 결과 | 의미 |
|---|---|---|
| 이벤트 파이프라인 | Outbox는 PUBLISHED, ranking-collector는 LAG 0 | 이벤트 발행부터 소비까지 실제로 끝까지 흘렀다 |
| Redis 랭킹 반영 | product 1 = 0.05138, product 2 = 0.04293, product 3 = 0.00437 TTL ≈ 2일 |
점수 계산, 키 전략, TTL 정책이 구현대로 동작했다 |
| API 조회 | GET /api/v1/rankings 에서 rank, name, price, brandName 반환 GET /api/v1/products/{id} 에서 rank 반환 |
Redis projection이 실제 사용자 응답까지 이어졌다 |
| 날짜 분리 | GET /api/v1/rankings?date=20260408 정상 조회 | 날짜별 랭킹 조회가 API 레벨에서도 유효했다 |
이 검증이 보여준 것은 단순히 점수가 잘 나온다는 사실이 아니다.
이벤트가 발생하고, 날짜별 재료로 쌓이고, Scorer를 거쳐 daily_score가 만들어지고, Redis projection을 통해 API 응답까지 이어지는 전체 흐름이 하나의 축으로 연결됐다는 점이다.
정리
처음에는 랭킹의 핵심이 점수식이라고 생각했다.
하지만 구현을 진행할수록 점수식은 정답이 아니라 정책이라는 쪽에 가까웠다.
정책은 바뀐다.
오늘은 종합 인기도가 필요하고, 내일은 전환율 중심 랭킹이 필요할 수 있다. carry-over 비율도 바뀔 수 있고, 날짜를 해석하는 기준도 더 정교해질 수 있다.
그래서 Scorer를 분리하는 것만으로는 부족했다.
정말 필요한 것은 Scorer가 바뀌어도 과거 랭킹을 다시 계산할 수 있고, Redis가 흔들려도 다시 만들 수 있는 구조였다.
이번 구현에서 남긴 핵심은 세 가지다.
- 점수식은 정책이므로 언제든 바뀔 수 있다.
- 정책 변경을 버티려면 최종 score가 아니라 daily_counter와 daily_score를 남겨야 한다.
- Redis는 기준 저장소가 아니라, 다시 만들 수 있는 서빙 projection이어야 한다.
결국 랭킹을 개발적으로 바라본다는 것은 정답 같은 점수식을 찾는 일이 아니었다.
바뀔 수밖에 없는 정책을 인정하고, 그 정책이 바뀌어도 시스템이 버틸 수 있게 만드는 일이었다.
"정책에 얽매이지 말자. 개발자가 할 일은 어떠한 변화에도 대응할 수 있는 설계다"