들어가며
랭킹 시스템을 구현할 때 고려해야할 부분이 있다.
집계 기간 설계를 어떻게 할 것인가??
실시간으로 랭킹 정보를 사용자에게 제공해 줄 것인지, 일간, 주간 등 여러 기간에 대한 랭킹을 제공해 줄 것인지...
기간을 정하고 데이터를 이용해 집계를 할 때 단순히 "오늘 하루치만 집계하면 되지 않나?" 라고 생각할 수 있지만,
다음날이 되면 모든 점수가 리셋되어 랭킹 정보를 제대로 제공하지 못하는 상황이 벌어진다.
그렇다고 이전 점수에 계속해서 누적하여 랭킹을 반영하면 상위 랭크의 변화는 쉽지 않을 것이다.
이러한 문제가 발생하는 이유와 해결하는 방법들을 알아보도록 하자.
Fixed Window 방식
먼저 Fixed Window 방식은 특정 기간을 딱 정해서 집계를 하는 방식이다.
예를 들어, 1일 단위로 랭킹을 집계하면 00시부터 23시 59분까지의 점수를 집계하여 랭크를 매긴다.
이렇게 집계를 하게되면 여러가지 문제가 발생하는데,
첫번째로 자정이 지나면 점수가 초기화되는 Reset Spike 문제가 발생한다.
밤 시간대에 1위였던 상품이 다음날이 되면 갑자기 순위가 추락할 수 있는 것이다.
두번째로 요일 또는 시간대 편향이 발생한다.
주말에 잘팔리는 상품의 경우 주간 랭킹에서 주초에는 랭킹이 하위권에 있을 가능성이 높다.
그래서 평일에 잘팔리는 상품이 랭킹에서는 유리하게 될 것이다.
콜드 스타트
콜드 스타트란 기간이 리셋된 직후, 데이터가 거의 없어서 랭킹이 의미 없어지는 현상을 뜻한다.
위에서 말한 문제들의 공통된 원인이다.
콜드 스타트를 해결하기 위한 방법에는 여러가지가 있다.
Score Carry-Over
직전 기간의 최종 점수의 일정 비율을 가져오는 방법이다.
자정이 되어 랭킹이 리셋될 때, 완전히 0에서 시작하는 것이 아니라 이전 기간의 성과를 일부 반영하여 시작하는 것이다.
예를 들어, 이월 비율을 30%로 설정했다고 가정해보자.
[1월 1일 최종 점수]
A 상품: 1000점 (1위)
B 상품: 800점 (2위)
C 상품: 600점 (3위)
[1월 2일 시작 점수 - 30% 이월]
A 상품: 300점
B 상품: 240점
C 상품: 180점
완전히 0에서 시작하는 것과 비교하면, 기존에 잘 팔리던 상품들이 어느 정도 상위권을 유지한 채로 다음날을 시작할 수 있다.
이월 비율에 따른 차이
이월 비율은 서비스의 성격에 따라 신중하게 결정해야 한다. 비율에 따라 랭킹의 성격이 크게 달라지기 때문이다.
- 이월 비율이 높을 때 (70%)
[1월 1일 최종 점수]
A 상품: 1000점 (오래된 인기 상품)
B 상품: 800점
[1월 2일 시작]
A 상품: 700점 ← 높은 시작점
B 상품: 560점
[1월 2일 신규 입점 상품 C]
C 상품: 0점 ← 아무리 잘 팔려도 따라잡기 어려운 구조
이 경우 기존에 인기 있던 상품은 랭킹을 유지하지만, 신규 상품이 아무리 잘 팔려도 누적된 점수를 따라잡기가 어려워진다.
랭킹이 고착화되는 문제가 생기는 것이다.
- 이월 비율이 낮을 때 (10%)
[1월 1일 최종 점수]
A 상품: 1000점
B 상품: 600점
[1월 2일 시작]
A 상품: 100점
B 상품: 60점
[오전 1시간 동안 판매]
B 상품: 60건 판매 → 60점 + 이월 60점 = 120점
A 상품: 10건 판매 → 10점 + 이월 100점 = 110점
이월 비율이 너무 낮으면 리셋 직후 초반 판매량이 랭킹을 좌우하게 된다. 결국 콜드 스타트 문제가 다시 발생하는 것이다.
Decay Factor
Decay Factor는 점수에 시간이 지날수록 가중치를 낮춰, 오래된 데이터의 영향력을 줄여나가는 방식이다.
Score Carry-over가 "기간이 끝날 때 점수를 일부 가져온다"는 방식이었다면, 시간 감쇠는 매 순간 점수가 조금씩 감소한다는 개념이다.
Decay Factor의 기본 수식은 아래와 같다.
유효 점수 = 원점수 × decay^(경과 시간)
decay 값은 0과 1 사이의 값으로, 시간이 지날수록 점수가 얼마나 빠르게 소멸할지를 결정한다.
하루 단위로 10% 감쇠를 적용한다고 가정하면 (decay = 0.9),
오늘 판매 100건 → 유효 점수: 100 × 0.9^0 = 100.0점
1일 전 판매 100건 → 유효 점수: 100 × 0.9^1 = 90.0점
3일 전 판매 100건 → 유효 점수: 100 × 0.9^3 = 72.9점
7일 전 판매 100건 → 유효 점수: 100 × 0.9^7 = 47.8점
같은 100건을 판매했더라도, 오래 전 판매일수록 현재 랭킹에 미치는 영향이 줄어드는 것이다.
decay 값에 따른 차이
decay 값을 어떻게 설정하느냐에 따라 랭킹의 성격이 완전히 달라진다.
- decay = 0.99 (느린 감쇠)
1일 전 판매 100건 → 99.0점
7일 전 판매 100건 → 93.2점
30일 전 판매 100건 → 74.0점
과거 데이터가 오랫동안 영향을 미친다. 꾸준히 잘 팔리는 스테디셀러 상품에 유리하지만, 신규 상품이 치고 올라오기 어려운 구조가 된다.
- decay = 0.7 (빠른 감쇠)
1일 전 판매 100건 → 70.0점
7일 전 판매 100건 → 8.2점
30일 전 판매 100건 → 0.002점 (사실상 소멸)
최근 데이터에 민감하게 반응한다. 트렌드 변화를 빠르게 반영하지만, 랭킹이 하루하루 크게 요동칠 수 있다.
Fixed Window의 콜드 스타트 문제를 해결하기 위한 두 가지 방법을 살펴봤다.
Score Carry-over는 이전 기간의 점수 일부를 다음 기간으로 이월시켜 리셋 충격을 완화했고,
시간 감쇠는 점수에 시간 가중치를 적용해 오래된 데이터의 영향력을 자연스럽게 줄여나갔다.
두 방법 모두 콜드 스타트 문제를 완화하는 데는 분명히 효과적이다.
하지만 공통적인 한계를 가지고 있다.
경계는 그대로 남아있고, 비율 또는 decay 값을 통한 파라미터를 조정하여 운영 복잡도가 높아졌다.
그리고 서비스의 성격, 상황에 따라 파라미터 값을 튜닝해줘야한다.
만약 이 값을 잘못 설정하면 오히려 랭킹 품질이 나빠질 수 있다.
파라미터로 경계를 보정하지 않고, 처음부터 경계를 두지 않으면 어떻게 될까?
"오늘 하루치 데이터" 가 아니라 "최근 24시간 데이터"를 집계하면 경계가 사라지게 된다.
시간이 흘러도 그에 맞게 데이터도 같이 흘러 들어오게 된다.
이것이 Sliding Window 방식의 핵심 아이디어이다.
Sliding Window 방식
Sliding Window는 현재 시점을 기준으로 최근 N 시간 또는 N일의 데이터를 실시간으로 집계하는 방식이다.
Fixed Window와의 차이
Fixed Window
|-- 1월 1일 --|-- 1월 2일 --|-- 1월 3일 --|
집계 A 집계 B 집계 C
(리셋) (리셋) (리셋)
Sliding Window (현재 시각: 1월 3일 06:00 기준)
|------ 최근 24시간 ------|
1월 2일 06:00 1월 3일 06:00
Fixed Window는 자정마다 집계 구간이 초기화되지만, Sliding Window는 현재 시각이 1분 지나면 집계 구간도 1분 앞으로 이동한다.
경계가 없는 것이다.
그렇다면 이 방식을 이용해서 실시간, 일간, 주간 단위 랭킹을 어떻게 구현할 수 있을까??
Sliding Window 방식은 집계 구간이 매 순간 바뀐다.
예를 들어 현재 시각이 14시 40분이라면 "최근 24시간"의 시작점은 어제 14시 40분이다. 1분 후에는 어제 14시 41분이 된다.
매 순간 집계 범위가 달라지는 것이다.
이것을 단순하게 구현하면 매 요청마다 수십만 건의 데이터를 전부 다시 집계해야 한다.
상품 수가 많고 트래픽이 높은 서비스에서는 현실적으로 불가능한 방식이다.
이 문제를 해결하는 방법이 바로 버킷(Bucket) 기반 구현이다.
버킷 기반 구현
핵심 아이디어는 간단하다.
시간을 일정한 단위로 쪼개서 미리 집계해두고, 필요할 때 해당 구간의 버킷들을 합산하는 것이다.
1분 단위 버킷을 사용하면 매 1분 마다 버킷을 생성하여 해당 버킷에 점수를 집계한다.
14시 40분 버킷 -> { A상품: 20점, B상품: 16점, C상품: 5점 }
14시 41분 버킷 -> { A상품: 15점, B상품: 25점, C상품: 20점 }
14시 42분 버킷 -> { A상품: 19점, B상품: 5점, C상품: 15점 }
그러면 "최근 3분간 랭킹"은 세 버킷을 합산하기만 하면 된다.
A상품: 20 + 15 + 19 = 54점
B상품: 15 + 25 + 20 = 60점
C상품: 19 + 5 + 15 = 39점
전체 데이터를 다시 집계할 필요 없이, 버킷 수만큼만 집계하면 되는 것이다.
버킷 기반 Sliding Window를 위한 Redis 키 설계
버킷 기반 Sliding Window는 Redis의 Sorted Set을 활용하면 효율적으로 구현할 수 있다.
1분 단위 버킷 키는 아래와 같이 설계 할 수 있다.
ranking:v1:product:all:bucket:1m:{yyyyMMddHHmm}
- v1 : 버전을 명시하여 점수 산정 방식이 바뀌는 경우 등 새로운 버전으로 버킷을 바꿔야할 경우를 대비했다.
- product : 상품이 아닌 브랜드로 랭킹을 산정할 경우 등 확장성을 고려했다.
- all : 모든 상품에 대해서 랭킹을 산정할 수도 있고, 상품의 카테고리(상의, 하의 등)로 랭킹을 산정할 수도 있다.
이렇게 1분 단위로 생성된 버킷을 이용해서 1시간, 주간, 월간 단위의 버킷을 생성할 수 있을 것이다.
1시간 버킷 = 1분 버킷 60개를 합산한 버킷
ranking:v1:product:all:bucket:1h:{yyyyMMddHH}
———
1일 버킷 = 1시간 버킷 24개를 합산한 확정 일간 버킷
ranking:v1:product:all:bucket:1d:{yyyyMMdd}
———
주간 버킷 = 확정된 1일 버킷 7개를 합산한 주간 랭킹
ranking:v1:product:all:bucket:7d:{yyyyMMdd}
- 최근 7일 확정 랭킹
———
월간 버킷 = 확정된 1일 버킷 30개를 합산한 월간 랭킹
ranking:v1:product:all:bucket:30d:{yyyyMMdd}
- 최근 30일 확정 랭킹
버킷 기반 구현의 핵심은 데이터를 미리 작은 단위로 쪼개서 저장해두는 것이다.
덕분에 랭킹 조회 시점에 전체 데이터를 다시 집계하는 부담 없이, 필요한 버킷만 빠르게 합산할 수 있다.
결론
사용자 관점에서 상품의 랭킹을 보면 그냥 인기도 순으로 정렬해 놓은 것으로 보인다.
하지만 랭킹 시스템을 구축하기 위해서 단순히 인기도만 가지고 판단하는 것이 아니라
시간 단위를 얼마 만큼 정할지,
과거 데이터에 얼마 만큼의 영향력을 부여할지,
기간의 경계에서 점수가 리셋되는 것을 방지하기 위해 어떤 방법을 사용할지 등
여러가지를 고려해야한다.
이러한 부분을 고려하지 않으면 시장에 새로 진입하는 상품이 불이익을 받을 수 있고, 사용자가 이해할 수 없는 랭킹이 만들어 질 수도 있다.
랭킹은 단순한 정렬이 아니라 사용자의 구매 의사결정에 직접적인 영향을 주는 요인이다.
그래서 랭킹의 신뢰도는 곧 서비스의 신뢰도라고도 할 수 있다.
설계가 중요하다는 것을 다시 한 번 깨닫는다.
시스템을 얼마나 잘 설계하느냐에 따라 서비스의 품질이 달라지고 신뢰도를 달라진다.
사용자가 믿고 계속해서 찾게되는 서비스를 만들기 위해서는 결국 어떤 식으로 설계를 하느냐에 달려있다.
댓글