
TL;DR: ORDER BY score DESC의 300ms가 불안해서 Redis ZSET의 0.3ms 세계로 넘어갔다. Kafka 이벤트 파이프라인으로 가중치 기반 점수를 실시간 누적하고, 자정 콜드 스타트는 전일 점수 10%를 이월하는 Carry-Over 배치로 완화했다. 핵심 판단은 "핵심 기능과 부수 기능의 분리", "YAGNI 기반 가중치 관리 수준 결정", "EMA 구조의 감쇠 비율 선택"이었다.
참고: 본문에 등장하는 "사제문답"은 Claude와의 기술 대화를 무협 세계관으로 각색한 것입니다. 말투에 놀라지 마시길...
주문 이벤트를 Kafka에 태우고, Redis ZSET에 가중치 기반 점수를 쌓고, 랭킹 API를 만들고, 통합 테스트를 짜고, 마지막으로 콜드 스타트를 완화하는 배치 잡까지. 글로 쓰면 한 문장이지만, 그 과정에서 내 마음은 셀 수 없이 무너졌다가 다시 세워지기를 반복했다. 그래도 손은 멈추지 않았다. 걱정만 하고 있으면 남는 건 걱정뿐이니까.
누군가는 "랭킹이 뭐 그리 어렵다고"라고 말할 수도 있겠다. ORDER BY score DESC LIMIT 20 한 줄이면 될 것을. 그런데 10만 개 상품이 있고, 초당 수백 명이 홈 화면을 열어볼 때, 그 한 줄의 SQL이 300ms씩 걸린다는 사실을 알게 되면 이야기가 달라진다. 300ms. 사용자가 커피를 한 모금 마시기엔 짧지만, 서버가 커넥션 풀을 소진하기엔 충분한 시간이다.
그래서 나는 Redis의 Sorted Set, 줄여서 ZSET이라는 세계에 발을 들였다. 0.3ms. DB 대비 1,000배 빠른 그 세계에. 발을 들이면서도 계속 생각했다. 이렇게 빠른 데 분명 뭔가 대가가 있을 거라고. INFP의 직감은 틀린 적이 없다.
대가는 있었다. 다만, 그 대가가 "복잡성"이라는 이름이었을 뿐. 복잡하면 복잡한 대로, 일단 부딪혀 보기로 했다.
에피소드 1. 주문이 일어났는데, 랭킹은 모른다
문제 직면
이커머스 플랫폼에서 사용자가 주문을 완료했다. 결제도 끝났다. 그런데 랭킹 시스템은 이 사실을 전혀 모른다. 상품 조회나 좋아요는 이미 Kafka로 이벤트를 쏘고 있었지만, 정작 가장 강력한 구매 신호인 "주문 완료"는 파이프라인에 연결되어 있지 않았다.
극심한 걱정
주문 흐름은 이미 잘 돌아가고 있는 기찻길 같은 거다. 거기에 랭킹이라는 새 분기선을 잇겠다고 선로를 건드리는 순간, 기존 열차가 탈선할 수도 있다. 잘못하면 주문 자체가 깨질 수도 있고, 이벤트가 유실되면 랭킹 데이터가 영원히 어긋나고, 그러면 사용자는 "이 사이트 랭킹 이상한데?"라고 생각하고 이탈하고... 아, 벌써 머릿속에서 장애 시나리오가 도미노처럼 쓰러지기 시작한다.
무엇보다 걱정되는 건, 주문 하나에 상품이 여러 개 들어있을 수 있다는 점이었다. 주문 이벤트 하나가 곧 상품별 이벤트 N개로 분리되어야 한다. 이걸 어디서 해야 하지? API에서? Consumer에서? 잘못된 위치에서 하면 나중에 분명 후회할 것 같은데.
그래도 머릿속으로만 굴리면 걱정의 가짓수만 늘어난다. 일단 손을 움직여 보기로 했다.
깊은 고민
핵심은 "핵심 기능과 부수 기능의 분리"였다. 주문 생성은 핵심 기능이고, 랭킹 점수 반영은 부수 기능이다. 부수 기능의 장애가 핵심 기능에 전파되면 안 된다. 그래서 commerce-api는 이벤트를 Kafka에 "던지기만" 하고, 실제 Redis 쓰기는 commerce-streamer(Collector)가 담당하는 구조를 따르기로 했다.
이벤트 분리는 Outbox Appender에서 처리하기로 했다. 주문 하나에 상품이 2개면, Outbox 레코드를 2개 만든다.
@EventListener
fun appendOrderCompleted(event: OrderCompletedEvent) {
event.orderItems.forEach { item ->
val message = CatalogEventMessage( eventId = UUID.randomUUID().toString(),
productId = item.productId, eventType = CatalogEventType.ORDER_COMPLETED, delta = item.quantity.toLong(), version = System.currentTimeMillis(), occurredAt = ZonedDateTime.now(), ) append(message) }
}
forEach로 상품별로 쪼개서 각각 Outbox에 저장한다. 이렇게 하면 Kafka Consumer 쪽은 "상품 하나에 대한 이벤트"만 처리하면 되므로 로직이 단순해진다.
사제문답 — 역직렬화의 기연
소생(김현우): "사부님... 소생이 OrderCompletedEvent에 orderItems라는 새 필드를 추가하고자 하옵니다.
허나 기존 이벤트의 역직렬화가 깨질까 두렵사옵니다."
사부(킹갓제네럴 클로드): "겁낼 것 없느니라.
Kotlin data class에 기본값(emptyList())을 설정하면 기존 메시지와 호환되느니,
이미 발행된 이벤트에 해당 필드가 없으면 빈 리스트로 처리될 것이다."
소생(김현우): "오... 그렇다면 기존에 orderItems 없이 발행된 이벤트는 Outbox 레코드가
하나도 생기지 않는 것이옵니까? 그것이 참으로 괜찮은 것이옵니까?"
사부(킹갓제네럴 클로드): "그것이 바로 forEach의 도(道)이니라. 빈 리스트가 들어가면
아무 것도 실행하지 않는다. 기존 코드에 영향을 주지 않으면서 점진적으로
확장할 수 있는 구조이니, 마음을 놓아도 되느니라."
소생(김현우): "사부님의 말씀을 듣고 보니, 빈 리스트가 이리 든든할 줄은 몰랐사옵니다.
소생, 감히 코드를 올려 보겠나이다."
해결
OrderCompletedEvent에 orderItems 필드를 추가하고, OrderFacade에서 주문 아이템을 매핑해서 넣어주었다.
data class OrderCompletedItem( val productId: Long,
val quantity: Int,
)
applicationEventPublisher.publishEvent(
OrderCompletedEvent( orderId = createdOrder.id, userId = userId, totalAmount = createdOrder.totalAmount, orderItems = items.map { OrderCompletedItem(it.productId, it.quantity) }, ),)
테스트에서는 상품 2개 주문 시 Outbox 레코드 2개가 생성되는지, 빈 주문은 Outbox 레코드가 0개인지를 검증했다. 초록불이 뜨는 순간, 숨을 좀 쉴 수 있었다.
그래도 남은 고민
forEach 안에서 Outbox를 하나씩 save하는 구조가 마음에 걸린다. 지금은 주문 하나에 상품이 2~3개니까 괜찮지만, 만약 30개짜리 대량 주문이 들어오면 save가 30번 호출된다. DB 입장에서는 INSERT 문이 30번 날아가는 셈이다. 트래픽이 커지면 saveAll로 배치 저장하거나 bulk insert를 고려해야 할 것이다.
그보다 더 신경 쓰이는 건 트랜잭션 경계다. 주문 생성과 Outbox 저장이 같은 트랜잭션 안에 있으므로, 주문이 롤백되면 Outbox 30개도 함께 사라진다. 논리적으로는 맞는 동작이다. 주문이 실패했는데 이벤트만 남아 있으면 그게 더 큰 문제니까. 하지만 반대로, Outbox 저장 중 하나가 실패해서 주문 전체가 롤백되는 시나리오도 이론적으로 가능하다.
부수 기능이 핵심 기능을 끌어내리는 상황. 지금 당장은 일어나기 어렵지만, 이런 가능성을 인지해 둔 채로 다음 단계로 넘어가기로 했다.
에피소드 2. 0.1, 0.2, 0.7 — 숫자 세 개가 세상을 바꾼다
문제 직면
Kafka에 이벤트가 잘 흘러가는 걸 확인한 순간, 다음 질문이 밀려왔다. "이 이벤트를 받아서 Redis에 점수를 쌓아야 하는데, 얼마나 쌓아야 하지?"
조회 1건과 주문 1건이 같은 1점이면 안 된다는 건 직감적으로 알 수 있다. 앱을 켜서 스크롤만 해도 조회는 발생하지만, 주문은 실제로 돈을 지불하는 행위니까. 마케팅에서 말하는 퍼널(Funnel) 구조와 같다. 위가 넓고 아래가 좁은 깔때기. 조회 10,000명 중 좋아요는 3,000명, 주문은 500명. 아래로 내려갈수록 행동의 깊이가 깊어지고, 그 행동의 가치도 높아진다.
극심한 걱정
가중치라는 건 결국 "플랫폼의 가치관을 코드로 표현한 것"이라고 한다. YouTube가 2012년에 조회수 기반에서 시청 시간 기반으로 가중치를 바꿨을 때, 클릭베이트 영상이 사라지고 양질의 콘텐츠가 올라왔다는 이야기를 읽었다. 숫자 하나를 잘못 정하면 플랫폼 전체의 콘텐츠 품질이 바뀔 수 있다니. 내가 정하는 이 0.1, 0.2, 0.7이라는 숫자가 그런 무게를 갖는 건 아닐까.
잠깐, Netflix는 완주율에 가장 높은 가중치를 두고, Spotify는 반복 재생에 가장 높은 가중치를 두면서 건너뛰기에는 아예 음수 가중치를 매긴다고 한다. 서비스마다 완전히 다르다. 그러면 우리 서비스에 맞는 가중치가 정말 0.1/0.2/0.7이 맞는 건지, 어떻게 확신할 수 있지?
확신은 없다. 하지만 시도하지 않으면 이 숫자가 맞는지 틀리는지조차 알 수 없다. 틀리면 그때 고치면 된다. 일단 넣어보자.
깊은 고민
확신할 수 없다는 걸 인정하는 데서 시작했다. 이 가중치는 보편 상수가 아니라 "프로젝트에서 정한 값"이다. 이커머스에서 구매에 가장 높은 가중치를 두는 건 쿠팡, Amazon 같은 곳에서도 따르는 패턴이니 방향은 맞다. 완벽한 값은 나중에 데이터로 튜닝하면 된다.
가중치 관리의 성숙도는 4단계로 나뉜다. Level 1(코드 상수)부터 Level 4(ML 파이프라인)까지. 지금은 Level 2, 즉 application.yml에서 가중치를 외부화하는 수준으로 구현했다. YAGNI 원칙에 따라 지금 필요한 만큼만.
@Component
class RankingScorePolicy(
@Value("\${ranking.weight.view:0.1}") private val viewWeight: Double,
@Value("\${ranking.weight.like:0.2}") private val likeWeight: Double,
@Value("\${ranking.weight.order:0.7}") private val orderWeight: Double,
) {
fun calculateIncrement(eventType: CatalogEventType, delta: Long): Double {
val weight = when (eventType) {
CatalogEventType.PRODUCT_VIEWED -> viewWeight
CatalogEventType.LIKE_CHANGED -> likeWeight
CatalogEventType.ORDER_COMPLETED -> orderWeight
}
return weight * delta
}
}
when 표현식으로 이벤트 타입별 가중치를 매핑한다. delta를 곱하는 이유는, 주문에서 quantity가 2이면 0.7 * 2 = 1.4가 되어야 하기 때문이다. 그리고 이 계산된 점수를 Redis ZSET에 ZINCRBY로 누적한다.
val scoreIncrement = rankingScorePolicy.calculateIncrement(event.eventType, event.delta)
val key = RankingKeyGenerator.dailyKey(event.occurredAt.toLocalDate())
rankingRepository.incrementScore(key, event.productId, scoreIncrement)
RankingKeyGenerator.dailyKey는 ranking:all:20260410 같은 일별 키를 생성한다. 이렇게 하면 하루 단위로 ZSET이 분리되어 롱테일 문제를 방지할 수 있다. 누적 점수 기반이면 12개월 전에 출시된 상품이 지금 대히트 중인 신상품을 이기는 데 무려 7.7년이 걸린다는 걸 스터디에서 배웠기 때문이다.
사제문답 — 열쇠의 생멸(生滅)
소생(김현우): "사부님, 한 가지 여쭙겠사옵니다.
ZINCRBY를 내려칠 때, 아직 키가 세상에 존재하지 않으면 어찌 되는 것이옵니까?
자정이 지나 새 일별 키가 태어나기도 전에 이벤트가 먼저 도착한다면..."
사부(킹갓제네럴 클로드): "허허, 그것이 Redis의 오묘한 도(道)이니라.
ZINCRBY는 키가 없으면 스스로 창조하고, 멤버가 없으면 무(無)에서 시작하느니라.
그대가 미리 길을 닦아놓을 필요가 없다."
소생(김현우): "오오... 무에서 유를 창조한다니, 과연 Redis... 그런데 사부님,
그렇게 날마다 키가 태어나기만 하면 메모리가 넘쳐흐르지 않겠사옵니까?"
사부(킹갓제네럴 클로드): "생(生)이 있으면 멸(滅)도 있는 법. TTL을 설정하면 된다.
2일이면 충분하니라. 내일의 carry-over를 위해 오늘의 키가 필요하므로
최소 이틀은 유지해야 하고, 그 이후엔 Redis가 스스로 소멸시키느니라."
소생(김현우): "생멸의 이치가 TTL에 담겨 있었사옵니까... 소생이 한 수 배웠나이다."
TTL이 설정되지 않은 키(getExpire == -1L)에만 TTL을 건다. 이미 TTL이 있으면 다시 설정하지 않는다.
override fun incrementScore(key: String, productId: Long, score: Double) {
redisTemplate.opsForZSet().incrementScore(key, productId.toString(), score) if (redisTemplate.getExpire(key) == -1L) { redisTemplate.expire(key, TTL) // Duration.ofDays(2) }}
해결
테스트에서 가장 뿌듯했던 부분은 "주문 1건(0.7점)이 좋아요 3건(0.6점)보다 높은 순위를 갖는다"를 검증한 것이다.
@DisplayName("주문 1건(0.7)이 좋아요 3건(0.6)보다 높은 순위를 갖는다")
@Test
fun singleOrderOutranksThreeLikes() {
val date = ZonedDateTime.of(2026, 4, 10, 12, 0, 0, 0, ZoneId.of("Asia/Seoul"))
simulateEvent(100L, CatalogEventType.ORDER_COMPLETED, 1, date)
repeat(3) {
simulateEvent(200L, CatalogEventType.LIKE_CHANGED, 1, date)
}
val key = RankingKeyGenerator.dailyKey(LocalDate.of(2026, 4, 10))
val topN = fakeRepo.topN(key, 10)
assertThat(topN[0].first).isEqualTo(100L) // 주문 상품이 1위
assertThat(topN[1].first).isEqualTo(200L) // 좋아요 상품이 2위
}
이 테스트가 의미하는 건, 숫자 세 개(0.1, 0.2, 0.7)가 "실제로 돈을 쓴 상품"을 "그냥 좋아요 누른 상품"보다 위에 올려놓는다는 것이다. 가중치가 코드에서 의도대로 동작하는 걸 눈으로 확인하니까, 비로소 마음이 좀 놓였다.
그래도 남은 고민
좋아요 취소(delta = -1)도 점수를 깎는다는 걸 테스트에서 확인했다. 좋아요 3건(0.6) - 취소 1건(0.2) = 0.4점. 그런데 만약 악의적인 사용자가 좋아요를 100번 눌렀다 취소하기를 반복하면? ZSET 점수가 음수가 될 수도 있나? Redis는 음수 점수를 허용한다.
그러면 특정 상품의 점수가 마이너스가 되어 랭킹 맨 밑으로 떨어질 수 있는 건데... 이건 나중에 멱등성 처리에서 다뤄야 할 문제인 것 같다.
에피소드 3. ZREVRANGE, 0.3ms의 세계
문제 직면
점수를 쌓는 파이프라인은 완성했다. 이제 이 점수를 사용자에게 보여줄 차례다. 랭킹 페이지 조회 API와, 상품 상세 페이지에서 "이 상품은 현재 N위입니다"를 표시하는 기능이 필요하다.
극심한 걱정
API를 만드는 건 매일 하는 일인데, 왜 이렇게 불안할까. 아마 Redis에서 가져온 상품 ID로 다시 DB를 조회해야 한다는 점 때문일 것이다. Redis에서 Top 20을 가져오면 ID 20개가 나오고, 이 ID로 상품 이름, 가격, 브랜드를 조회해야 한다. 상품 20개를 하나씩 조회하면... N+1 문제. 스터디 노트(10-production-pitfalls.md)에서 정확히 이 함정을 경고하고 있었다.
"메시지를 받는 것보다 언제 처리 완료로 간주할 것인가가 더 중요한 질문"이라는 말이 떠올랐다. 데이터를 가져오는 것보다 어떻게 조합하느냐가 더 중요한 문제였다. 걱정은 알겠고, 일단 N+1이 안 나오게 짜보자.
깊은 고민
RankingFacade에서 상품 ID를 한 번에 모아서 IN 쿼리로 가져오는 방식을 택했다.
@Component
class RankingFacade(
private val rankingService: RankingService,
private val productService: ProductService,
private val brandService: BrandService,
) {
@Transactional(readOnly = true)
fun getRankings(date: LocalDate, page: Int, size: Int): RankingPageInfo {
val rankingPage = rankingService.getTopRankings(date, page, size)
if (rankingPage.entries.isEmpty()) {
return RankingPageInfo.empty(page, size)
}
val productIds = rankingPage.entries.map { it.productId }
val productMap = productService.findAllByIds(productIds).associateBy { it.id }
val brandIds = productMap.values.map { it.brandId }.distinct()
val brandMap = brandService.findAllByIds(brandIds).associateBy { it.id }
val content = rankingPage.entries.mapNotNull { ranked ->
val product = productMap[ranked.productId] ?: return@mapNotNull null
val brand = brandMap[product.brandId] ?: return@mapNotNull null
RankingItemInfo(
rank = ranked.rank,
score = ranked.score,
product = RankingProductInfo.of(product, brand),
)
}
return RankingPageInfo(
content = content,
totalElements = rankingPage.totalElements,
page = page,
size = size,
)
}
}
포인트는 findAllByIds다. 상품 ID 20개를 한 번에 넘겨서 WHERE id IN (...) 쿼리 한 방으로 가져온다. 그다음 associateBy { it.id }로 Map으로 변환하면, 순위 데이터와 상품 데이터를 O(1)으로 조합할 수 있다. 브랜드도 같은 패턴이다. 쿼리는 Redis 1회 + DB 2회, 총 3회.
RankingService의 핵심 로직은 ZSET의 reverseRangeWithScores를 활용한다.
fun getTopRankings(date: LocalDate, page: Int, size: Int): RankingPage {
val key = RankingKeyGenerator.dailyKey(date)
val offset = ((page - 1) * size).toLong()
val entries = rankingRepository.getTopN(key, offset, size.toLong())
val totalCount = rankingRepository.getTotalCount(key)
return RankingPage(
entries = entries.mapIndexed { index, entry ->
RankedProduct(
rank = offset + index + 1,
productId = entry.productId,
score = entry.score,
)
},
totalElements = totalCount,
page = page,
size = size,
)
}
offset + index + 1로 실제 순위를 계산한다.
2페이지의 첫 번째 항목이면 offset은 20이고, index는 0이니까 21위. Redis의 ZREVRANGE는 이미 정렬된 Skip List에서 노드를 따라가기만 하면 되므로, 10만 개 상품이 있어도 Top 20 조회에 0.3ms밖에 안 걸린다. DB의 300ms와는 차원이 다른 세계다.
사제문답 — 순위의 근원을 묻다
소생(김현우): "사부님, 상품 상세 페이지에서 '이 상품은 현재 23위'라 표시하고자 하옵니다.
그런데 ZREVRANK가 내뱉는 값이 0부터 시작하옵니다.
설마 사용자에게 '0위'를 보여줄 수는 없지 않사옵니까?"
사부(킹갓제네럴 클로드): "허허, 그것은 Redis의 세계와 인간의 세계가 다르기 때문이니라.
Redis는 0에서 세고, 인간은 1에서 세느니라. +1이면 족하다."
소생(김현우): "그렇다면 아직 ZSET에 발을 들이지 못한 상품은 어찌하옵니까?
순위가 없는 자에게 억지로 순위를 매기는 것은 도리에 어긋나지 않사옵니까?"
사부(킹갓제네럴 클로드): "도리를 아는구나. null이면 null 그대로 내보내거라.
존재하지 않는 순위를 지어내는 것이야말로 사도(邪道)이니라."
소생(김현우): "null은 null로 두라... 무(無)를 억지로 유(有)로 만들지 말라는 뜻이옵니까.
명심하겠나이다, 사부님."
fun getProductRank(date: LocalDate, productId: Long): Long? {
val key = RankingKeyGenerator.dailyKey(date)
return rankingRepository.getRank(key, productId)?.let { it + 1 }
}
?.let { it + 1 }. Kotlin의 null-safe 연산이 여기서 빛을 발한다. Redis에 해당 상품이 없으면 null, 있으면 1-based 순위를 반환한다. 이 결과를 상품 상세 응답의 ranking 필드에 넣어준다.
해결
상품 상세 API에서 순위 정보가 자연스럽게 포함되도록 ProductFacade를 수정했다.
val ranking = rankingService.getProductRank(LocalDate.now(), productId)
return productInfo.copy(ranking = ranking)
copy로 기존 ProductInfo에 ranking 필드만 추가한다. Kotlin data class의 불변성을 해치지 않으면서 필드를 확장하는 깔끔한 방법이다.
그래도 남은 고민
Redis에서 가져온 상품 ID가 DB에 없으면 어떡하지? 상품이 삭제되었는데 ZSET에는 아직 남아 있는 경우.
mapNotNull로 조용히 건너뛰고 있긴 한데, 이러면 사용자가 "Top 20 보여줘"라고 했을 때 18개만 나올 수도 있다. 빈 슬롯을 다음 순위 상품으로 채워야 할까? 아니면 삭제된 상품을 ZSET에서 정리하는 별도 배치가 필요할까? 읽기 성능 문제는 쿼리 하나만 고친다고 끝나지 않는다는 걸 또 한번 느낀다.
에피소드 4. 자정의 공포 — 콜드 스타트와 Carry-Over
문제 직면
모든 게 잘 동작하는 것 같았다. 그런데 문득 깨달았다. 자정이 되면 어떡하지?
일별 키 방식은 ranking:all:20260410처럼 하루 단위로 ZSET을 만든다. 23시 59분까지 상품A가 3,500점을 모아서 당당히 1위를 지키고 있었는데, 자정이 되는 순간 ranking:all:20260411 키가 생기면서 모든 상품의 점수가 0이 된다.
새벽 1시, 누군가가 우연히 주문 1건을 넣는다. 0.7점. 이 상품이 1위다. 어제의 베스트셀러 3,500점짜리는 흔적도 없이 사라지고, 0.7점짜리 상품이 "오늘의 인기 상품 1위"로 올라간다. 통계적으로 무의미한 랭킹이다.
극심한 걱정
이건 그냥 UX 문제가 아니다. 새벽에 홈 화면을 여는 사용자에게 "인기 상품"이라면서 아무도 사지 않은 상품을 보여주는 건 거짓말이다. 서비스의 신뢰도에 직결되는 문제다. "읽기 성능 문제는 쿼리 하나만 고친다고 끝나지 않는다"는 말이 다시 떠올랐다. 빠르게 읽는 것만이 전부가 아니었다. 읽어서 보여주는 데이터가 의미 있어야 했다.
콜드 스타트. 이름부터 차갑다. 해결할 수 있을까? 아니, 스터디에서 배운 가장 중요한 전제가 있다. "콜드 스타트는 완전히 제거할 수 없다. 모든 방법은 완화(mitigate)할 뿐이다." 완벽을 추구하면 안 된다. 완벽이란 건 이 문제에서 존재하지 않으니까. 다만, "N시간 이내로 완화했고, 이런 한계가 남아있다"고 설명할 수 있으면 된다. 완벽하지 않아도 된다. 완화할 수 있으면 충분하다. 그러니 일단 해보자.
깊은 고민
4가지 대안을 검토했다. Sliding Window, Exponential Decay, Multi-Window Blending, Fallback. 그리고 우리가 선택한 Score Carry-Over.
Carry-Over의 아이디어는 단순하다. 전날 점수의 10%를 오늘 초기 점수로 이월한다. 어제 상품A가 3,500점이었으면 오늘 새벽에 350점을 갖고 시작한다. 새벽 1시의 0.7점짜리 상품이 350점을 이기려면 한참 멀었으므로, 어제의 인기가 자연스럽게 오늘 새벽까지 이어진다.
10%라는 숫자는 감이 아니다. 수학적으로 보면 이건 지수이동평균(EMA)과 동일한 구조다.
1일 후 10%, 2일 후 1%, 3일 후 0.1%로 급격히 감쇠한다. 즉, carry-over 점수는 이틀이면 사실상 사라진다. "오늘의 랭킹은 오늘 데이터가 결정한다"는 원칙을 지키면서, 새벽 시간대만 어제 데이터로 보완하는 것이다. Amazon BSR의 특허(US Patent 7,848,948)에서도 정확히 같은 구조를 사용한다.
이걸 Spring Batch Job으로 구현했다.
@Component
class RankingCarryOverService(
private val rankingCarryOverRepository: RankingCarryOverRepository,
@Value("\${ranking.carry-over.weight:0.1}") private val carryOverWeight: Double,
) {
fun execute(baseDate: LocalDate): Long {
val sourceKey = RankingKeyGenerator.dailyKey(baseDate)
val destKey = RankingKeyGenerator.dailyKey(baseDate.plusDays(1))
log.info(
"ranking_carry_over_start sourceKey={} destKey={} weight={}",
sourceKey, destKey, carryOverWeight,
)
val count = rankingCarryOverRepository.carryOver(sourceKey, destKey, carryOverWeight)
log.info(
"ranking_carry_over_complete sourceKey={} destKey={} copiedMembers={}",
sourceKey, destKey, count,
)
return count
}
}
sourceKey는 오늘, destKey는 내일. 오늘의 모든 멤버 점수에 0.1을 곱해서 내일 키에 합산한다. 이미 내일 키에 점수가 있으면(새벽에 이벤트가 먼저 도착한 경우) incrementScore로 합산된다.
Repository 구현체에서는 sourceKey의 모든 멤버를 읽어서 하나씩 incrementScore한다.
override fun carryOver(sourceKey: String, destKey: String, carryOverWeight: Double): Long {
val members = redisTemplate.opsForZSet()
.reverseRangeWithScores(sourceKey, 0, -1) ?: return 0
if (members.isEmpty()) return 0
members.forEach { tuple ->
val member = tuple.value ?: return@forEach
val score = tuple.score ?: return@forEach
redisTemplate.opsForZSet().incrementScore(destKey, member, score * carryOverWeight)
}
if (redisTemplate.getExpire(destKey) == -1L) {
redisTemplate.expire(destKey, TTL)
}
return members.size.toLong()
}
사제문답 — 원본불변(原本不變)의 도
소생(김현우): "사부님, 통합 테스트에서 '내일 키에 이미 점수가 있을 때
carry-over 점수가 합산되는지'를 검증하고자 하옵니다.
carry-over를 시전하기 전에 내일 키에 미리 점수를 심어두면 되겠사옵니까?"
사부(킹갓제네럴 클로드): "그러하다.
Fake Repository에 seedScore로 씨앗을 심어놓고,
carry-over 시전 후 기존 값과 이월 값이 합산되었는지 살피면 되느니라."
소생(김현우): "오... 그런데 사부님, 한 가지 더 여쭙겠나이다.
carry-over가 원본 키의 점수를 건드리지는 않사옵니까?
원본이 변하면 그것은... 무공의 근본이 흔들리는 것과 같지 않겠사옵니까?"
사부(킹갓제네럴 클로드): "호오, 핵심을 짚었구나.
원본을 해하지 않는 것, 이것이 carry-over의 제일 계율이니라.
반드시 검증하거라. carry-over 후에도 sourceKey의 점수가
한 치의 오차 없이 그대로인지."
소생(김현우): "원본불변... 소생, 반드시 시험하여 증명하겠나이다!"
사부(킹갓제네럴 클로드): "좋다. 의심이 많은 것은 나쁜 것이 아니니라.
의심하되 검증으로 풀어라. 그것이 테스트의 도(道)이다."
@DisplayName("carry-over 후에도 원본 키의 점수는 변하지 않는다")
@Test
fun sourceKeyIsNotModified() {
val todayKey = RankingKeyGenerator.dailyKey(LocalDate.of(2026, 4, 10))
fakeRepo.seedScore(todayKey, 101L, 100.0)
service.execute(LocalDate.of(2026, 4, 10))
assertThat(fakeRepo.getScore(todayKey, 101L)).isEqualTo(100.0)
}
이 테스트가 주는 안도감. carry-over가 원본 데이터를 건드리지 않는다는 확신.
INFP에게 이런 확신은 잠을 잘 수 있느냐 없느냐의 차이다.
해결
배치 잡이 자정 직후(00:05)에 실행되어, 전날 점수의 10%를 다음 날 키에 이월한다. 00시가 아니라 00시 05분인 이유는, 자정 직전 이벤트가 Kafka lag으로 아직 처리 중일 수 있기 때문이다.
테스트에서 전일 점수 100점의 10%인 10점이 다음 날 키에 복사되는 것, 기존 점수에 합산되는 것, 원본이 건드려지지 않는 것, 원본이 비어있으면 아무것도 하지 않는 것까지 모두 검증했다.
그래도 남은 고민
Carry-Over로 새벽 콜드 스타트는 완화했지만, 이것만으로 충분할까? 10만 개 상품의 점수를 전부 읽어서 하나씩 incrementScore하는 현재 구현은 상품 수가 많아지면 느려질 수 있다. Redis의 ZUNIONSTORE를 쓰면 원자적으로 한 방에 처리할 수 있는데, 현재 구현은 멤버별로 루프를 도는 방식이라 중간에 장애가 나면 일부만 carry-over될 수 있다.
그리고 Melon 차트 사건이 계속 떠오른다. 2019년 개편 전, 팬덤이 자정에 집중 스트리밍하면 무조건 1위가 되었던 역기획 문제. 우리 서비스에서도 비슷한 일이 벌어질 수 있지 않을까? 누군가가 carry-over 비율을 역이용해서, 전날에 점수를 의도적으로 높여놓고 carry-over의 혜택을 받는 식으로. 지금은 걱정이 앞서는 것일 수 있지만, "콜드 스타트를 해결했다"가 아니라 "이 방법으로 6시간 이내로 완화했고, 이런 한계가 남아있다"고 말할 수 있어야 한다.
에필로그. 일단 박죠?
게임 레이드에서 자주 듣는 말이 있다. "일단 박죠?"
공략 영상을 아무리 돌려봐도 실전은 다르다. 보스의 패턴을 머릿속으로 시뮬레이션하며 "이 타이밍에 이 스킬을 쓰면..."이라고 계획을 세우는 건 좋지만, 직접 맞아보기 전까지는 그 계획의 해상도가 낮다. 부딪혀봐야 "아, 여기서 진짜 아프구나"를 알고, 그래야 다음 트라이에서 더 나은 그림을 그릴 수 있다.
이번 구현도 그랬다. 주문이 꼬이면 ZSET 점수가 오염될 수 있다는 걸 안다. 오염된 데이터를 수기로 하나하나 보정하는 장면을 상상하면 벌써 어지럽다. 그런데 그 걱정을 이유로 시도하지 않았다면? 지금 내 손에 남은 건 아무것도 없었을 것이다. 경험도, 지표도, 다음 단계를 그릴 해상도도.
결국 만든 것은 하나의 파이프라인이다.
사용자 주문 → Kafka → 가중치 계산 → Redis ZSET → API 응답 → 사용자 화면
↑ Carry-Over 배치
중요한 건 이 파이프라인의 어느 한 곳이 무너져도 전체가 멈추지 않는다는 점이다.
API가 죽어도 이벤트는 Kafka에 남아 있고, Redis가 죽어도 주문은 정상 처리되고, 배치가 실패해도 새벽 랭킹만 좀 이상해질 뿐이다. "일단 박는" 것과 "무모하게 돌진하는" 것의 차이는 여기에 있다고 생각한다. 달려나가되, 넘어져도 치명상을 입지 않도록 각 단계에 fallback을 깔아두는 것. 핵심 기능과 부수 기능을 분리해서, 부수 기능이 쓰러져도 핵심은 살아남게 하는 것.
그리고 솔직히 말하면, 나 혼자였으면 이 마인드를 유지하기 어려웠을 것 같다. 달리기만 하면 차선을 벗어나기 쉬운데, Claude가 FSD처럼 옆에서 차선을 잡아준다. "이건 이래서 안전합니다", "여기는 이렇게 하면 호환됩니다"라는 응답 하나하나가, 핸들을 꺾어야 할 타이밍을 알려주는 레인 어시스트 같았다. 나는 악셀만 밟으면 된다. 방향은 둘이 같이 잡으면 되니까.
불안은 사라지지 않았다. 다만 "만들 수 있을까?"라는 불안이 "만들었는데 지킬 수 있을까?"로 바뀌었고, 그건 분명 앞으로 나아간 거다.
다음 레이드는 아마 데이터 정합성이 될 것이다. 주문이 얽히면서 점수가 꼬였을 때, 그걸 어떻게 자동으로 바로잡을 것인가. 역시나 걱정이 앞서지만, 이미 답은 정해져 있다.
일단 박죠?ㅋ
출처 및 참고자료
- Redis ZSET 공식 문서 — Sorted Sets
- Redis ZINCRBY 명령어 레퍼런스
- Redis ZREVRANGE 명령어 레퍼런스
- Amazon Best Sellers Rank 특허 (US Patent 7,848,948)
- YouTube 랭킹 알고리즘 변경 (2012) — Watch Time 기반 전환
- Hacker News Ranking Algorithm 분석
- Reddit Ranking Algorithms — Hot, Best, Rising
- Spring Batch Reference Documentation
- Skip List — Wikipedia
- 지수이동평균(EMA) — Investopedia