주문상품 랭킹 시스템의 장애 대응 설계기

2026. 7. 17. 12:51·루퍼스 4기

랭킹 시스템을 구현하기 전에는 단순히 주문 수나 좋아요 수를 합산해서 점수를 계산하면 되는 기능이라고 생각했는데 실제로 설계를 시작해 보니 고려해야 할 요소가 많았다.

 

어떤 데이터를 랭킹의 기준으로 사용할지, 이벤트는 어떻게 전달할지, Redis 장애가 발생하면 어떻게 복구할지, 신규 상품은 어떻게 랭킹에 진입시킬지까지 고민해야 했다.

 

이번 글에서는 랭킹 시스템을 구현하면서 고민했던 설계 과정과, 그 과정에서 선택한 방법 그리고 그 이유를 정리해보려고 한다.

랭킹 데이터 지표 선정

랭킹 시스템에서 가장 먼저 고민한 것은 어떤 데이터를 랭킹의 신호(Signal)로 사용할 것인가​였다.

후보는 크게 세 가지였다.

  • 조회수(View)
  • 좋아요(Like)
  • 주문(Order)

최종적으로는 주문 횟수와 좋아요 수만 랭킹 점수에 반영하기로 결정했다.

조회수를 제외한 이유

조회수는 가장 쉽게 수집할 수 있는 데이터이지만, 랭킹 지표로 사용하기에는 Popularity Bias(인기 편향) 문제가 있었다.

한 번 랭킹 상위에 오른 상품은 더 많은 사용자에게 노출되고, 노출이 많아질수록 조회수도 자연스럽게 증가한다.

이 구조에서는 조회수가 실제 상품의 인기를 반영하는 것이 아니라, 랭킹이 랭킹을 강화하는 구조(Self-reinforcing Loop)가 만들어진다.

 

반면 주문과 좋아요는 로그인한 사용자가 직접 수행하는 행동이다.

  • 주문은 실제 구매 의사가 반영된 데이터
  • 좋아요는 상품에 대한 명시적인 관심 표현

따라서 조회수보다 훨씬 신뢰도가 높은 지표라고 판단했다.

최종 점수 계산 방식

최종적으로 아래와 같은 가중치를 적용했다.

지표 가중치
주문 80%
좋아요 20%

구체적인 계산 방식은 다음과 같다. 

  • 주문 : 주문 건수 × 0.8
  • 좋아요 : 좋아요 건수 × 0.2

여기서 주문 수량은 반영하지 않았다.

 

한 명이 상품을 여러 개 구매하는 것보다, 여러 명이 각각 구매한 상품이 더 많은 사용자에게 선택받은 상품이라고 판단했기 때문이다.

이벤트 기반 랭킹 집계

랭킹 점수에 필요한 주문과 좋아요 기능이 호출 할 때마다 Kafka 이벤트를 통해 집계하도록 설계했다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleProductLiked(ProductLikedEvent event) {
    send(productLikedTopic, event.productId(), event);
}

private void send(String topic, Long productId, Object event) {
    kafkaTemplate.send(topic, String.valueOf(productId), event)
            .exceptionally(e -> {
                log.warn("좋아요 이벤트 발행 실패 [topic={}, productId={}]: {}", topic, productId, e.getMessage());
                return null;
            });
}

@TransactionalEventListener(AFTER_COMMIT)을 사용하여 주문 또는 좋아요 트랜잭션이 정상적으로 커밋된 이후에만 Kafka 이벤트를 발행하도록 했다.

 

이를 통해 DB 트랜잭션이 정상 커밋 된 경우에만 이벤트만 발행되는 상황을 방지할 수 있었다.

왜 Outbox 패턴은 적용하지 않았는지?

좋아요는 서비스의 핵심 비즈니스가 아니라고 판단하여 Outbox 패턴을 적용해 발행 정합성까지 보장하지는 않았다.

즉, DB는 정상 커밋, Kafka 발행 실패가 발생하면 해당 이벤트는 유실될 수 있다.

 

하지만 랭킹은 주문과 달리 약간의 오차를 허용할 수 있는 기능이라고 판단했고, 구현 복잡도보다 단순한 구조를 선택했다.

반대로 Kafka에서 동일한 이벤트가 여러 번 전달되는 문제는 반드시 막아야 했기 때문에 컨슈머에서 멱등키를 이용한 중복 처리를 적용했다.

처음 설계했던 구조

처음에는 Kafka 이벤트를 소비하면 Redis에만 반영하고, DB는 30초마다 배치로 동기화하는 구조였다.

문제가 없어 보였는데 Redis 장애를 가정해 보니 문제가 있었다.

Redis가 다운되면 Kafka는 계속 이벤트를 소비하지만 저장할 곳이 없어진다.

그리고 DB에는 아직 최신 데이터가 반영되지 않았기 때문에 장애 동안 발생한 데이터는 그대로 유실될 수 있었다.

결국 Redis가 원장(Source of Truth) 역할을 하고 있었던 것이다.

Kafka Consumer에서 DB도 함께 갱신하도록 변경

이 문제를 해결하기 위해 구조를 변경했다.

 

Kafka Consumer가 이벤트를 소비하는 순간 Redis뿐 아니라 DB의 product_metrics 테이블도 증분(Update Increment) 방식으로 함께 갱신하도록 수정했다.

이렇게 변경하면서 Redis 장애가 발생해도 원장 데이터는 DB에 안전하게 남게 되었다.

Redis는 랭킹 조회 성능을 위한 순위, 캐시 역할만 담당하도록 책임을 분리했다.

Redis를 원장으로 사용하지 않은 이유

처음에는 단순히 Redis는 메모리니까 안 된다. 정도로 생각했다.

하지만 Redis도 AOF(Append Only File)와 RDB Snapshot을 사용하면 디스크에 데이터를 저장할 수 있다.

그럼에도 원장으로 선택하지 않은 이유는 다음과 같다.

  • AOF everysec 설정에서도 최대 1초 정도의 데이터 유실 가능성
  • RDB는 마지막 스냅샷 이후 데이터 유실 가능성
  • Redis는 내구성보다 성능을 우선으로 설계된 저장소

즉, Redis를 안전하게 사용하는 것은 가능하지만 서비스의 최종 데이터(Source of Truth)를 관리하기 위한 저장소로는 적합하지 않다​고 판단했다.

Kafka Replay는 대안이 될 수 있을까?

Kafka는 소비한 메시지를 바로 삭제하지 않고 Retention 기간 동안 보관한다.

따라서 Redis 장애가 발생했을 경우 Kafka에 남아 있는 이벤트를 다시 읽어(Replay) Redis를 복구하는 것도 가능하다.

다만 Replay는 많은 이벤트를 다시 처리해야 하기 때문에 시간이 오래 걸리고 비용도 크다.

그래서 Replay는 장애 복구를 위한 비상 대응 수단으로만 사용하고, 평상시에는 DB를 실시간으로 증분 갱신하는 방식이 더 적합하다고 판단했다.

랭킹 Failback 전략

마지막으로 고민했던 부분은 Redis가 비어 있거나 장애가 발생했을 때 어떤 랭킹을 사용자에게 보여줄 것인가였다.

 

내가 선택한 방법은 전날 랭킹을 대신 제공하는 것이었다.

하지만 계속 과거 랭킹만 사용하면 신규 상품은 랭킹에 진입하기 어려워진다.

이 문제는 추천 시스템에서 자주 이야기하는 콜드 스타트(Cold Start) 문제이며, 더 넓게는 Exploration vs Exploitation 트레이드오프와도 연결된다.

 

이를 해결하기 위해 랭킹 점수를 매일 초기화하도록 설계했다.

매일 모든 상품이 동일한 출발선에서 경쟁하도록 만들고, 점수가 아직 쌓이지 않은 시간에는 전날 랭킹을 Failback 데이터로 제공했다.

 

이렇게 하면 사용자는 빈 랭킹 화면을 보지 않아도 되고, 새로운 데이터가 쌓이면 자연스럽게 당일 랭킹으로 전환된다.

랭킹 시스템 설계 후 느낀 점

이번 랭킹 시스템을 구현하면서 가장 크게 느낀 점은 랭킹은 점수 계산보다 데이터 관리가 더 중요한 시스템이라는 것이었다.

단순히 주문 수와 좋아요 수를 합산하는 것에서 끝나는 것이 아니라, 어떤 데이터를 신뢰할 것인지, 이벤트를 어떻게 전달할 것인지, 장애가 발생하면 어떻게 복구할 것인지, 신규 상품도 공정하게 경쟁할 수 있도록 어떻게 설계할 것인지까지 함께 고민해야 했다.

 

특히 Redis를 원장처럼 사용했던 초기 설계를 다시 돌아보면서 캐시(Cache)와 원장(Source of Truth)은 명확히 분리되어야 한다​는 점을 배울 수 있었다.

 

또한 모든 기능에 완벽한 정합성을 적용하기보다, 좋아요 이벤트처럼 일부 유실을 허용할 수 있는 기능은 Outbox 패턴을 적용하지 않는 선택을 하는 등 비즈니스 중요도에 따라 적절한 트레이드오프를 선택하는 것 역시 시스템 설계의 중요한 요소라는 점도 경험할 수 있었다.

 

현재는 주문과 좋아요만 반영하고 있지만, 앞으로는 시간 감쇠(Time Decay), ​카테고리별 랭킹, ​실시간 인기 급상승 상품 등을 추가하면 더욱 현실적인 랭킹 시스템으로 발전시킬 수 있을 것이라고 생각한다.

저작자표시 (새창열림)

'루퍼스 4기' 카테고리의 다른 글

루퍼스 LOOPPAK 백엔드 코스 4기 수료 후기  (0) 2026.08.02
주문 폭주를 막는 대기열, 배치 사이즈는 어떻게 정해지나  (0) 2026.07.10
주문 도메인에서 아웃박스 패턴과 polling 스케줄러 도입기  (1) 2026.07.02
주문 상태는 Pending 인데, 사용자는 결제 안내를 받았다.  (0) 2026.06.26
인덱스 적용부터 반정규화까지, 조회 성능 개선 실험  (0) 2026.06.19
'루퍼스 4기' 카테고리의 다른 글
  • 루퍼스 LOOPPAK 백엔드 코스 4기 수료 후기
  • 주문 폭주를 막는 대기열, 배치 사이즈는 어떻게 정해지나
  • 주문 도메인에서 아웃박스 패턴과 polling 스케줄러 도입기
  • 주문 상태는 Pending 인데, 사용자는 결제 안내를 받았다.
LIMTAEYANG
LIMTAEYANG
오늘 할 코딩을 내일로 미루지 말자
  • LIMTAEYANG
    lty's Blog
    LIMTAEYANG
  • 전체
    오늘
    어제
    • 분류 전체보기 (80)
      • 웹개발 (23)
        • JAVA (1)
        • Spring (6)
        • JavaScript (7)
        • css (2)
        • Vue (1)
      • 클라우드 (4)
        • AWS (2)
        • MSA (0)
        • Docker (2)
      • 운영 및 배포 (4)
      • Backend (6)
      • Database (2)
      • 명령어 (2)
        • Linux (2)
      • 형상관리 (8)
        • github & git (8)
      • 알고리즘 (1)
      • 개인 프로젝트 (2)
      • CS (11)
        • 소프트웨어 공학 (10)
        • 운영체제 (1)
      • 정보처리기사 (2)
      • 취미활동 (2)
        • 등산 (1)
        • 여행 (0)
        • 글쓰기 (1)
      • 회고 (0)
      • 루퍼스 4기 (13)
  • 블로그 메뉴

    • 홈
    • 개인프로젝트
    • 프로그래밍 언어
    • 운영체제
    • 형상관리
    • 자기개발
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    대기열 배치사이즈
    루퍼스 4기 수료 후기
    설계
    되돌리기
    낙관적 락
    Callback 장애 대응
    DDD 주문설계
    TDD
    CI/CD
    CDC 방식
    2주차 회고
    원자적 연산
    테스팅
    prefilight
    reset
    소프트웨어
    4주차 WIL
    아웃박스 테이블 polling 방식
    LOOPPAK
    배포 자동화
    대기열 TPS 테스트
    루프팩백엔드4기
    Jenkins
    비관적 락
    재고 동시성 차감
    환경변수
    설계ERD
    PG 정합성
    루퍼스 백엔드 코스
    랭킹 레디스 장애 대응
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
LIMTAEYANG
주문상품 랭킹 시스템의 장애 대응 설계기
상단으로

티스토리툴바