Skip to content

[Design Doc] 같은 이벤트를 두 번 세지 않고 실시간으로 — 일간 랭킹 시스템 설계(9주차 · 6팀 · 김동원) #420

Description

@dnjs2514

TL;DR

"순위는 근사를 허용하되, 중복·유실은 불허한다" 는 원칙으로 설계했다.

본문

Introduction & Goals

  • Context / Background:
    7주차에 Transactional Outbox → Kafka 파이프라인으로 상품 조회/좋아요/주문 이벤트가 catalog-events / order-events 토픽에 흐르고 있고, streamer 가 이를 소비해 product_metrics 를 집계한다. 9주차 과제는 이 이벤트 스트림 위에 "오늘 인기 상품" 일간 랭킹을 얹는 것이다.
    기존에도 정렬 조회용 읽기모델(product_rank, likes_desc 키셋)이 있지만 이는 좋아요 수 스냅샷의 배치 재집계라 "지금 무슨 일이 일어나고 있는가"를 반영하지 못한다. 랭킹은 조회·좋아요·주문이라는 이종 신호를 가중 합산해 실시간 반영해야 하고, 동시에 파이프라인이 at-least-once 라서 재전달된 이벤트를 두 번 세면 순위가 조작된다 — 이 문서의 중심 질문은 "실시간성과 멱등을 어떻게 같이 가져가는가"다.

  • Goals:

    • 조회/좋아요/주문 이벤트를 가중 점수로 실시간 합산한 일자별 랭킹 제공 (GET /api/v1/rankings)
    • at-least-once 재전달·컨슈머 재기동에도 중복 집계 0 (이벤트 단위 멱등)
    • 랭킹 파이프라인의 장애·지연이 핵심 집계(product_metrics)에 전파되지 않게 격리
    • 자정 랭킹판 초기화의 콜드 스타트 완화 (어제의 인기가 0 시 1 분에 증발하지 않게)
    • 상품 상세에 현재 순위 노출 — 단, Redis 장애가 상세 조회를 죽이지 않게 (fail-open)

Detailed Design

System Architecture

                     (기존 7주차 파이프라인)
commerce-api ──Outbox relay──▶ Kafka: catalog-events / order-events
                                   │
                                   ├─ group=product-metrics ─▶ product_metrics 집계 (기존)
                                   │
                                   └─ group=ranking (신규, 독립 오프셋 재소비)
                                        RankingConsumer → RankingProcessor
                                          이벤트 → delta 매핑 (RankingScorePolicy)
                                          └─▶ Redis master: Lua [SETNX dedup + ZINCRBY×N + EXPIRE]

commerce-api (조회)
  GET /api/v1/rankings ─▶ RankingFacade ─▶ RankingQueryRepository (replica 허용)
                              │                ZREVRANGE / ZCARD
                              └─ 상품정보 배치 aggregation (Product + LikeCount)
  GET /api/v1/products/{id} ─▶ ProductFacade ─ 캐시된 상세 + ZREVRANK 실시간 rank 덧씌움

commerce-streamer (스케줄러)
  23:50 KST ─▶ RankingCarryOverScheduler ─▶ ZUNIONSTORE (내일 = 오늘 × 0.1)
  • 별도 컨슈머 그룹 ranking — metrics 그룹과 같은 토픽을 독립 오프셋으로 재소비한다. 랭킹 반영이 느리거나 죽어도 핵심 집계는 영향받지 않고, 그 역도 성립한다. 실패 정책은 공용 에러핸들러(1s×5 재시도 → <topic>.DLT)를 그대로 쓴다.
  • RankingScorePolicy — 이벤트 타입 → delta 매핑. ProductViewed +0.1, LikeAdded +0.2, LikeRemoved -0.2(취소는 음수 — "오늘의 순수 관심 증감"), 주문 라인은 +0.7 × quantity. 가중치는 ranking.* 설정으로 코드 수정 없이 조절한다. 모르는 타입은 0(미반영) — 토픽에 새 이벤트가 추가돼도 랭킹은 무해하다.
  • RankingProcessor — 주문 이벤트는 라인들을 상품별로 합산한 뒤 한 번에 반영한다. 이벤트 하나 = applyOnce 하나 — 라인별로 나눠 반영하면 중간 실패 시 "절반만 반영된 주문"이 남아 멱등 단위가 깨진다.
  • 멱등의 핵심 — Lua 원자화 — dedup 마커(SET NX)와 점수 반영(ZINCRBY)을 한 스크립트로 묶었다. 마커와 부수효과가 같은 저장소이므로 "마킹됐는데 미반영 / 반영됐는데 미마킹"이 구조적으로 불가능하다. (metrics 컨슈머의 event_handled 테이블+DB 트랜잭션과 같은 원리를 Redis 로 옮긴 것.)
  • carry-over 스케줄러 — 23:50(KST)에 ZUNIONSTORE 로 내일 키를 "오늘 × 0.1" 로 사전 생성한다. 자정 직후에도 어제의 인기 상품이 낮은 점수로 남아 있고, 오늘의 이벤트가 그 위에 누적되며 자연스럽게 물갈이된다. 23:50~24:00 사이 이벤트는 carry-over 에 빠지지만(근사 허용) 오늘 키에는 정상 반영된다.
  • 조회 경로 분리 — 쓰기는 master 템플릿 고정, 표시용 조회는 REPLICA_PREFERRED 허용(쓰기 직후 강한 일관성이 필요한 대기열과 달리 랭킹 표시는 replica 지연을 허용).

이벤트 1건의 멱등 반영 흐름

sequenceDiagram
    participant K as Kafka (group=ranking)
    participant P as RankingProcessor
    participant R as Redis (master)

    K->>P: CatalogEventMessage / OrderEventMessage
    P->>P: 타입→delta 매핑, 주문은 상품별 합산
    P->>R: EVAL Lua (KEYS: dedup, daily)

    alt SET NX 성공 — 처음 보는 이벤트
        R->>R: ZINCRBY × N + EXPIRE(2일)
        R-->>P: 1 (반영됨)
    else SET NX 실패 — 재전달 이벤트
        R-->>P: 0 (스킵 — 중복 집계 0)
    end
    P->>K: ack (배치 내 실패 시 BatchListenerFailedException → 재시도/DLT)
Loading

자정 콜드 스타트 완화 — carry-over 타임라인

sequenceDiagram
    participant S as CarryOverScheduler<br/>(streamer)
    participant R as Redis

    Note over S,R: 07/17 23:50 KST
    S->>R: ZUNIONSTORE ranking:all:20260718<br/>= ranking:all:20260717 × 0.1
    S->>R: EXPIRE 내일 키 (2일)

    Note over R: 07/17 23:50~24:00 이벤트<br/>→ 오늘 키에만 반영 (근사 허용)

    Note over S,R: 07/18 00:00 — 자정 직후
    Note over R: 내일 키가 이미 "어제 인기 × 0.1" 로 시작<br/>랭킹판이 비어 보이지 않음
    R->>R: 이후 오늘 이벤트가 ZINCRBY 로 누적<br/>→ 자연스러운 물갈이
Loading

Data Models

Redis (신규 상태 저장소 — RDB 스키마 변경 없음)

타입 내용 TTL
ranking:all:{yyyyMMdd} ZSET member=productId, score=가중 점수 합 2일
ranking:dedup:{eventId} String("1") 이벤트 반영 완료 마커 (SET NX) 2일
  • 일자는 이벤트 발생 시각(occurredAt)의 KST 날짜로 양자화 — 소비가 자정을 넘겨 지연돼도 이벤트가 "발생한 날"의 랭킹판에 반영된다. 키 형식은 streamer/api 양쪽 RankingKeys크로스-앱 계약(동일 유지 필수).
  • TTL 2일 = "오늘 + 어제" 조회 보장. dedup TTL 을 일간 TTL 과 같게 둬서, 랭킹판이 살아있는 동안의 재전달은 반드시 걸러진다.

소비 메시지 계약 (metrics 컨슈머와 동일 토픽 재소비 — record 재사용)

CatalogEventMessage(eventId, type, productId, likeCount, version, occurredAt)
OrderEventMessage(eventId, type, orderId, lines[{productId, quantity}], occurredAt)

가중치 설정 (RankingProperties, yml 로 조절)

항목 기본값 비고
viewWeight 0.1 ProductViewed
likeWeight 0.2 LikeAdded / LikeRemoved(음수)
orderWeight 0.7 주문 라인 × quantity. 0.6→0.7 조정 — Constraints 참조
ttlDays 2 일간 키·dedup 공통
carryOverRate 0.1 내일 = 오늘 × rate

API Design

GET /api/v1/rankings — 일자별 랭킹 페이지 조회

파라미터 기본 검증
date 오늘(KST) yyyyMMdd, 형식 오류 400
page 1 1-based, < 1 이면 400
size 20 1~100, 초과 400

응답 (ApiResponse 래핑):

{
  "date": "20260717", "page": 1, "size": 20, "totalCount": 135,
  "items": [
    { "rank": 1, "productId": 3, "name": "상품명", "price": 15000,
      "likeCount": 42, "score": 12.4 }
  ]
}
  • ZREVRANGE WITHSCORES 로 페이지(productId+score)를 뜯고, 상품명·가격·좋아요 수는 배치 조회로 aggregation (N+1 제거). 삭제/누락 상품은 표시에서 제외하되 rank 번호는 ZSET 기준 유지(근사 허용 — 아래 Constraints).
  • totalCountZCARD. 데이터 없는 날짜는 빈 items 로 200 (404 아님 — "그날 랭킹이 비어 있음"은 정상 상태).
sequenceDiagram
    participant C as 클라이언트
    participant F as RankingFacade
    participant R as Redis (replica 허용)
    participant DB as MySQL

    C->>F: GET /api/v1/rankings?date&page&size
    F->>F: 검증 (page>=1, size 1~100, yyyyMMdd) — 실패 400
    F->>R: ZCARD (totalCount)
    F->>R: ZREVRANGE WITHSCORES [offset, offset+size-1]
    R-->>F: [(productId, score) × size] — 점수 내림차순

    F->>DB: Product IN (ids) + LikeCount IN (ids) — 배치 2쿼리
    DB-->>F: 상품정보·좋아요 수 Map

    F->>F: rank = offset+i+1 부여, 삭제 상품 제외
    F-->>C: 200 { date, page, totalCount, items[] }
Loading

GET /api/v1/products/{id} — 상세 응답에 rank 필드 추가 (오늘 랭킹 1-based 순위)

  • 상세는 Redis 캐시(read-through)를 쓰므로, 캐시에는 rank=null 로 저장하고 응답 직전에 ZREVRANK 로 실시간 덧씌운다 — 캐시 TTL 동안 순위가 동결되는 것을 방지.
  • 순위 밖이거나 Redis 조회 실패 시 rank=null (fail-open) — 부가 정보인 순위가 상세 조회 본체를 죽이면 안 된다.
sequenceDiagram
    participant C as 클라이언트
    participant F as ProductFacade
    participant R as Redis

    C->>F: GET /api/v1/products/{id}

    alt 상세 캐시 히트
        F->>R: GET detail 캐시 (rank=null 상태로 저장돼 있음)
    else 캐시 미스
        F->>F: DB 조회 → read-through 캐시 저장 (rank=null)
    end

    F->>R: ZREVRANK ranking:all:{오늘} — 응답 직전 실시간 조회

    alt 순위 안
        R-->>F: 0-based rank → +1 해서 덧씌움
    else 순위 밖
        R-->>F: nil → rank=null
    else Redis 장애
        R--xF: 예외 → warn 로그, rank=null (fail-open)
    end

    F-->>C: 200 { ..., rank } — 순위 동결 없음, 장애에도 상세는 정상
Loading

Constraints

  • 부동소수 동점 함정 (가중치 0.6→0.7 조정) — 과제 기준은 "주문 1건 > 좋아요 3건". 주문 0.6 이면 좋아요 3건이 0.2×3 = 0.6000000000000001(IEEE 754)로 오히려 역전된다. 경계값을 부동소수 합산에 걸쳐 두지 않도록 0.7 로 여유를 뒀다 — 가중치를 조절할 때도 "동점 경계가 부동소수 합으로 만들어지는가"를 확인해야 한다.
  • 멱등 윈도 = TTL 2일 — dedup 마커가 만료된 뒤 도착하는 초지연 재전달은 걸러지지 않는다. 단, 그 시점엔 해당 일자 랭킹판도 함께 만료되므로(같은 TTL) 사용자 영향은 없다.
  • 근사를 허용한 지점들 (정합성 비용 대비 표시 품질 트레이드오프)
    • 페이지 조회와 ZCARD 는 비원자 — 실시간 유입 중 totalCount 와 페이지 내용이 순간적으로 어긋날 수 있다.
    • 삭제 상품은 표시에서만 제외 — 페이지 아이템이 size 보다 적을 수 있고 rank 번호에 구멍이 생긴다.
    • carry-over 는 23:50 스냅샷 — 이후 10분의 이벤트는 내일 씨앗에 미포함(오늘 키에는 정상 반영).
    • 조회는 replica 허용 — 복제 지연만큼 순위가 늦게 보일 수 있다.
  • 좋아요 취소는 음수 delta — 전일 좋아요를 당일 취소하면 그날 점수가 음수가 될 수 있으나, 랭킹 최하위로 밀릴 뿐이므로 허용("오늘의 순수 관심 증감"이라는 의미에 부합).
  • Redis 쓰기 장애 — 컨슈머가 예외를 전파해 재시도(1s×5) → DLT 격리. 오프셋이 보존되므로 Redis 복구 후 재소비로 따라잡는다. 조회 장애는 rank=null / 랭킹 API 오류로 분리 대응.
  • 주문 점수는 수량 비례, 금액 미반영order-events 페이로드에 price 가 없다. 금액 가중이 필요해지면 페이로드 확장이 선행돼야 한다(설계 결정으로 기록).
  • 스케줄러 단일 실행 전제 — carry-over 는 streamer 단일 인스턴스 전제. 다중 인스턴스 확장 시 분산 락이 필요하다. (테스트 프로필에선 @Profile("!test") 로 미등록 — 통합테스트 ZSET 오염 방지.)

Alternatives Considered

랭킹 집계·저장을 어디서 어떻게 할 것인가:

옵션 Pros Cons
A. RDB 집계 테이블 확장 (기존 product_rank 방식 — 배치 재집계) 영속성·백업 공짜, SQL 로 유연한 조회, 기존 패턴 재사용 실시간성 없음(배치 주기만큼 지연), 일간·가중 합산엔 스키마 확장 필요, 이벤트마다 UPDATE 시 쓰기 경합·정렬 쿼리 부하
B. 기존 metrics 컨슈머(group=product-metrics)에서 product_metrics 집계와 함께 랭킹 반영 컨슈머·멱등 인프라 재사용으로 구현 최소, 소비 1회로 두 집계 처리 랭킹(Redis) 장애가 핵심 집계(DB)까지 재시도·DLT 로 끌고 감, 멱등이 DB(event_handled)와 Redis 에 걸쳐 원자성 깨짐, 배포·튜닝 단위 미분리
선택: C. 별도 컨슈머 그룹(ranking) + Redis 일간 ZSET + Lua 멱등 독립 오프셋으로 장애 격리·독립 재소비(백필 가능), ZSET 이 정렬·순위·페이지를 O(log N) 기본 제공, dedup+반영이 단일 저장소라 Lua 로 완전 원자화, TTL 로 청소 자동 Redis 메모리에 상태 보유(유실 시 오프셋 리셋 재소비로 복구), 근사 허용 지점 다수, 키 계약을 두 앱이 공유(크로스-앱 결합)

선택 근거:
랭킹의 요구는 "실시간 반영 + 정렬 조회 + 중복 불허"인데, 이 셋을 동시에 만족하는 건 C 뿐이다. A 는 실시간성이 없고, B 는 구현이 가장 싸지만 부가 기능(랭킹)의 장애가 핵심 집계를 물귀신처럼 끌고 내려가는 결합을 만든다 — Kafka 의 컨슈머 그룹이 정확히 이 격리를 공짜로 제공하므로 쓰지 않을 이유가 없다. C 의 최대 약점인 "Redis 유실"은 이벤트 원본이 Kafka 에 남아 있어 재소비로 복구 가능하고(랭킹은 read-model 일 뿐 source of truth 가 아니다), 멱등도 마커와 부수효과를 같은 저장소에 두는 Lua 원자화로 B 의 DB 방식과 동등한 강도를 확보했다. 나머지 약점(근사 지점들)은 "순위 표시"라는 도메인 특성상 수용 가능한 비용으로 판단했다.

Cross-cutting Concerns

No response

Reference

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions