You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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)
별도 컨슈머 그룹 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 과 같게 둬서, 랭킹판이 살아있는 동안의 재전달은 반드시 걸러진다.
ZREVRANGE WITHSCORES 로 페이지(productId+score)를 뜯고, 상품명·가격·좋아요 수는 배치 조회로 aggregation (N+1 제거). 삭제/누락 상품은 표시에서 제외하되 rank 번호는 ZSET 기준 유지(근사 허용 — 아래 Constraints).
totalCount 는 ZCARD. 데이터 없는 날짜는 빈 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 오류로 분리 대응.
독립 오프셋으로 장애 격리·독립 재소비(백필 가능), ZSET 이 정렬·순위·페이지를 O(log N) 기본 제공, dedup+반영이 단일 저장소라 Lua 로 완전 원자화, TTL 로 청소 자동
Redis 메모리에 상태 보유(유실 시 오프셋 리셋 재소비로 복구), 근사 허용 지점 다수, 키 계약을 두 앱이 공유(크로스-앱 결합)
선택 근거:
랭킹의 요구는 "실시간 반영 + 정렬 조회 + 중복 불허"인데, 이 셋을 동시에 만족하는 건 C 뿐이다. A 는 실시간성이 없고, B 는 구현이 가장 싸지만 부가 기능(랭킹)의 장애가 핵심 집계를 물귀신처럼 끌고 내려가는 결합을 만든다 — Kafka 의 컨슈머 그룹이 정확히 이 격리를 공짜로 제공하므로 쓰지 않을 이유가 없다. C 의 최대 약점인 "Redis 유실"은 이벤트 원본이 Kafka 에 남아 있어 재소비로 복구 가능하고(랭킹은 read-model 일 뿐 source of truth 가 아니다), 멱등도 마커와 부수효과를 같은 저장소에 두는 Lua 원자화로 B 의 DB 방식과 동등한 강도를 확보했다. 나머지 약점(근사 지점들)은 "순위 표시"라는 도메인 특성상 수용 가능한 비용으로 판단했다.
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)Detailed Design
System Architecture
ranking— metrics 그룹과 같은 토픽을 독립 오프셋으로 재소비한다. 랭킹 반영이 느리거나 죽어도 핵심 집계는 영향받지 않고, 그 역도 성립한다. 실패 정책은 공용 에러핸들러(1s×5 재시도 →<topic>.DLT)를 그대로 쓴다.ProductViewed +0.1,LikeAdded +0.2,LikeRemoved -0.2(취소는 음수 — "오늘의 순수 관심 증감"), 주문 라인은+0.7 × quantity. 가중치는ranking.*설정으로 코드 수정 없이 조절한다. 모르는 타입은 0(미반영) — 토픽에 새 이벤트가 추가돼도 랭킹은 무해하다.SET NX)와 점수 반영(ZINCRBY)을 한 스크립트로 묶었다. 마커와 부수효과가 같은 저장소이므로 "마킹됐는데 미반영 / 반영됐는데 미마킹"이 구조적으로 불가능하다. (metrics 컨슈머의event_handled테이블+DB 트랜잭션과 같은 원리를 Redis 로 옮긴 것.)ZUNIONSTORE로 내일 키를 "오늘 × 0.1" 로 사전 생성한다. 자정 직후에도 어제의 인기 상품이 낮은 점수로 남아 있고, 오늘의 이벤트가 그 위에 누적되며 자연스럽게 물갈이된다. 23:50~24:00 사이 이벤트는 carry-over 에 빠지지만(근사 허용) 오늘 키에는 정상 반영된다.이벤트 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)자정 콜드 스타트 완화 — 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/>→ 자연스러운 물갈이Data Models
Redis (신규 상태 저장소 — RDB 스키마 변경 없음)
ranking:all:{yyyyMMdd}productId, score=가중 점수 합ranking:dedup:{eventId}"1")RankingKeys의 크로스-앱 계약(동일 유지 필수).소비 메시지 계약 (metrics 컨슈머와 동일 토픽 재소비 — record 재사용)
가중치 설정 (
RankingProperties, yml 로 조절)API Design
GET /api/v1/rankings— 일자별 랭킹 페이지 조회dateyyyyMMdd, 형식 오류 400page< 1이면 400size응답 (
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).totalCount는ZCARD. 데이터 없는 날짜는 빈 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[] }GET /api/v1/products/{id}— 상세 응답에rank필드 추가 (오늘 랭킹 1-based 순위)ZREVRANK로 실시간 덧씌운다 — 캐시 TTL 동안 순위가 동결되는 것을 방지.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 } — 순위 동결 없음, 장애에도 상세는 정상Constraints
0.2×3 = 0.6000000000000001(IEEE 754)로 오히려 역전된다. 경계값을 부동소수 합산에 걸쳐 두지 않도록 0.7 로 여유를 뒀다 — 가중치를 조절할 때도 "동점 경계가 부동소수 합으로 만들어지는가"를 확인해야 한다.ZCARD는 비원자 — 실시간 유입 중 totalCount 와 페이지 내용이 순간적으로 어긋날 수 있다.order-events페이로드에 price 가 없다. 금액 가중이 필요해지면 페이로드 확장이 선행돼야 한다(설계 결정으로 기록).@Profile("!test")로 미등록 — 통합테스트 ZSET 오염 방지.)Alternatives Considered
랭킹 집계·저장을 어디서 어떻게 할 것인가:
product_rank방식 — 배치 재집계)product_metrics집계와 함께 랭킹 반영선택 근거:
랭킹의 요구는 "실시간 반영 + 정렬 조회 + 중복 불허"인데, 이 셋을 동시에 만족하는 건 C 뿐이다. A 는 실시간성이 없고, B 는 구현이 가장 싸지만 부가 기능(랭킹)의 장애가 핵심 집계를 물귀신처럼 끌고 내려가는 결합을 만든다 — Kafka 의 컨슈머 그룹이 정확히 이 격리를 공짜로 제공하므로 쓰지 않을 이유가 없다. C 의 최대 약점인 "Redis 유실"은 이벤트 원본이 Kafka 에 남아 있어 재소비로 복구 가능하고(랭킹은 read-model 일 뿐 source of truth 가 아니다), 멱등도 마커와 부수효과를 같은 저장소에 두는 Lua 원자화로 B 의 DB 방식과 동등한 강도를 확보했다. 나머지 약점(근사 지점들)은 "순위 표시"라는 도메인 특성상 수용 가능한 비용으로 판단했다.
Cross-cutting Concerns
No response
Reference
No response