Skip to content

[Design Doc] Redis 실시간 랭킹에서 중복 반영과 유실 중 무엇을 선택할 것인가 (9주차 · 4팀 · 노승준) #399

Description

@seungjjun

TL;DR

DB 주문은 정확해야 하는 원본이고, Redis 랭킹은 다시 만들 수 있는 파생 값이다. 그래서 누적될 수 있는 중복 반영을 막는 대신, 좁은 장애 구간에서 발생하는 일부 점수 유실을 감수했다.

본문

Introduction & Goals

  • Context / Background:

주문이 발생할 때마다 Redis의 상품 점수를 올려 실시간 랭킹을 만든다.

문제는 주문 원본은 DB에 있고 랭킹 점수는 Redis에 있어, 두 작업을 하나의 트랜잭션으로 묶을 수 없다는 점이다.

Redis 점수를 먼저 올리면 DB 트랜잭션이 롤백돼도 점수가 남는다. 반대로 DB를 먼저 커밋하면, 이후 처리 중 장애가 발생해 같은 이벤트가 재전달될 때 점수가 중복 반영될 수 있다.

  • Goals:

다음 세 가지 불일치 중 무엇을 우선적으로 막을지 결정한다.

  • DB에 없는 주문이 랭킹에 반영되는 반영
  • 같은 주문이 두 번 반영되는 중복 가산
  • DB에는 주문이 있지만 랭킹 점수는 빠지는 유실

모든 오류를 없애기보다, 랭킹의 성격과 복구 가능성을 기준으로 감수할 오류를 선택한다.

Detailed Design

System Architecture

flowchart TD
    A[주문 이벤트 수신] --> B{이미 처리한 주문인가?}

    B -->|예| C[처리 건너뛰기]
    B -->|아니오| D[주문과 처리 이력 저장]

    subgraph TX[DB 트랜잭션 · 원본]
        D --> E[DB 커밋]
    end

    E -->|커밋 성공 후| F[Redis 랭킹 점수 증가]
    E -.->|롤백| G[Redis 반영하지 않음]
Loading

DB 커밋이 성공한 뒤에만 Redis 점수를 올린다. 이를 통해 DB에 존재하지 않는 주문이 랭킹에 남는 문제를 막는다.

메시지가 다시 전달되면 DB에 저장한 처리 이력을 확인하고, 이미 처리한 주문은 건너뛴다. 중복 가산을 막기 위한 선택이다.

다만 이 선택에도 실패 구간은 남는다.

sequenceDiagram
    participant Q as 메시지 큐
    participant C as 컨슈머
    participant D as DB
    participant R as Redis

    Q->>C: 주문 이벤트 전달
    C->>D: 주문과 처리 이력 저장
    D-->>C: 커밋 성공

    Note over C,R: Redis 반영 전 컨슈머 종료

    Q->>C: 같은 이벤트 재전달
    C->>D: 처리 이력 확인
    D-->>C: 이미 처리된 주문
    C-->>Q: 처리 건너뛰기

    Note over D,R: DB에는 주문이 있지만<br/>Redis 점수는 누락
Loading

DB 커밋 후 Redis 점수를 올리기 전에 컨슈머가 종료되면, 해당 주문은 랭킹에서 빠진다. 재전달된 이벤트도 이미 처리한 주문으로 판단하기 때문에 Redis 반영을 다시 시도하지 않는다.

Constraints

  • 메시지 큐는 같은 이벤트를 다시 전달할 수 있다.
  • DB와 Redis는 하나의 트랜잭션으로 묶을 수 없다.
  • 여러 주문 이벤트를 하나의 DB 트랜잭션으로 처리한다.
  • 주문 기록은 비즈니스 원본이다.
  • Redis 랭킹은 DB 주문으로 다시 만들 수 있는 파생 값이다.

Alternatives Considered

옵션 Pros Cons
A. DB 커밋 전에 Redis 반영 구현 흐름이 단순하다 DB가 롤백돼도 점수가 남아, 존재하지 않는 주문이 랭킹에 반영된다
B. DB 커밋 후 Redis 반영 DB에 확정된 주문만 랭킹에 반영한다 Redis 반영 후 처리 완료 전에 장애가 발생하면 재전달 시 점수가 중복 가산된다
선택: C. DB 커밋 후 반영 + 처리 이력 확인 가짜 반영과 중복 가산을 막는다 DB 커밋과 Redis 반영 사이에서 장애가 발생하면 점수가 유실된다

선택 근거: 오류를 없앤 것이 아니라, 중복 가산을 일부 유실로 바꿨다.

중복 가산은 메시지가 재전달될 때마다 반복될 수 있다. 한 번 부풀려진 점수는 자동으로 줄어들지 않으며, 재전달이 반복되면 오차도 누적된다.

반면 유실은 DB 커밋이 성공한 뒤 Redis 반영 전에 컨슈머가 종료되는 좁은 구간에서만 발생한다. 누락된 점수가 즉시 사라지는 것은 아니지만, 집계 기간이 지나면 해당 주문이 랭킹 범위에서 제외되고 필요하면 DB 원본으로 랭킹을 다시 만들 수 있다.

따라서 실시간 랭킹에서는 누적될 수 있는 중복보다 복구 가능한 일부 유실의 영향이 작다고 판단했다.

다만 이 구조만으로 유실을 자동 복구하지는 못했다. 재구축 작업이 실행되기 전까지 해당 상품의 점수는 실제보다 낮게 보일 수 있다. 유실까지 자동으로 복구하려면 Outbox나 별도의 재처리 상태를 두어야 하지만, 랭킹의 정확도 요구사항과 구현 복잡도를 비교해 이번 범위에서는 적용하지 않았다.

Comparison

같은 주문 이벤트로 실시간 랭킹이 아니라 누적 판매량을 갱신한다고 가정해보자.

판매량은 실제로 판매된 개수이므로 한 건이라도 빠지면 잘못된 값이다. 랭킹에서 감수했던 일부 유실을 판매량에서는 감수할 수 없다.

두 값의 차이는 Redis를 사용했는지, 같은 메시지를 받는지가 아니다. 그 값이 원본인지 파생 값인지에 있다.

랭킹은 DB의 주문 기록으로 다시 계산할 수 있다. 한 건이 누락되더라도 원본 주문이 사라진 것은 아니며, 필요하면 전체 랭킹을 다시 만들 수 있다.

반면 누적 판매량을 별도의 파생 저장소에만 기록하고 유실을 허용하면, 어느 값이 정확한 판매량인지 보장할 수 없다. 따라서 판매량은 주문과 같은 DB 트랜잭션 안에서 함께 갱신해야 한다.

flowchart LR
    subgraph TX[같은 DB 트랜잭션]
        A[주문 저장] --> C[커밋]
        B[누적 판매량 증가] --> C
    end

    C -->|성공| D[주문과 판매량 함께 확정]
    C -->|실패| E[주문과 판매량 함께 롤백]
Loading

결국 같은 주문 이벤트에서 만들어지는 값이라도 요구하는 정확도는 다르다.

데이터 성격 처리 기준
주문 비즈니스 원본 반드시 정확하게 저장
누적 판매량 정확한 집계 값 주문과 같은 트랜잭션에서 반영
실시간 랭킹 재생성 가능한 파생 값 복구 가능성을 전제로 일부 오차 허용

랭킹에서 유실을 감수한 이유는 유실이 중요하지 않아서가 아니다. 원본이 DB에 남아 있고 다시 계산할 수 있기 때문이다.

원본은 트랜잭션 안에서 정확하게 관리하고, 파생 값은 정확도 요구사항과 복구 가능성을 기준으로 일관성 수준을 선택한다.

Cross-cutting Concerns

  • Observability: DB에는 처리됐지만 Redis에는 반영되지 않은 건을 확인할 수 있도록 Redis 반영 실패 수와 재구축 시 DB·Redis 점수 차이를 메트릭으로 수집한다.
  • Recovery: 현재 구조는 DB 커밋과 Redis 반영 사이의 유실을 자동 복구하지 못한다. 이상 징후가 발생하면 DB 주문 원본을 기준으로 해당 기간의 랭킹을 재구축한다.

Reference

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions