[친구하자] 매칭 대기열 설계 기록 : 왜 DB + Redis 하이브리드로 갔나

친구하자 프로젝트 개발 중 매칭 대기열을 구현하다가 생긴 고민을 정리해보았습니다.

“대기열=실시간성, 기록/분석/복구=영속성.” 이 두 욕심을 동시에 만족시키려다 보니 하이브리드(DB+Redis)가 자연스럽게 답이 되었다.


문제 배경

  • 친구하자 구현 중 핵심인 통화 매칭 부분을 구현하는 중이였다.
  • 초반 설계에서는 Redis만 사용해서 구현하기로 하여 하고있었는데, 이렇게 하면 대기열 기록이 되지않아 나중에 시스템 분석 및 복구에 어려움이 있어보였다.
  • 따라서 DB와 함께 구현하는 방식이 많이 사용되는 방식인지, 어떤 부분에 장단점이 있고 고려해야하는 부분은 어떤 것인지 궁금했다.
  • 또한 관련되서 더 심화된 기술은 어떤 것이 있는지 알아보고싶었다.

TL;DR

  • 문제의식: Redis만 쓰면 빠르지만 휘발성·운영/분석/복구가 약하다. DB만 쓰면 영속적이지만 지연·경합에 취약하다.
  • 결론: DB(사실의 원천, 이력/분석/복구) + Redis(실시간 대기열/매칭) 를 분리한 하이브리드 구조.
  • 실무 팁: 아웃박스 패턴으로 이중 쓰기 일관성, Redis ZSET+Lua원자적 매칭, 멱등키/TTL/청소잡/AOF+복제로 운영 내구성 확보.
  • 대안: 규모·요구사항에 따라 Redis Streams, RabbitMQ, Kafka, SQS, Postgres SKIP LOCKED도 선택지.

1) 내가 왜 이런 고민을 하게 됐나 (맥락)

친구하자는 1분 내 매칭 같은 저지연 실시간성이 핵심이다. 그래서 처음엔 Redis 단독이 끌렸다. 하지만 곧바로 다음 현실에 부딪혔다.

  • 휘발성 vs 영속성: Redis는 빠르지만(메모리) 장애/재시작 시 데이터 유실 리스크.
  • 운영/디버깅: 매칭 실패/타임아웃/취소 이슈를 재현하고 원인 추적하려면 이력 테이블이 꼭 필요.
  • 통계/제품개선: 카테고리별 대기시간·매칭률·시간대 부하 같은 지표는 DB가 편하다.
  • 복구 시나리오: 서버/Redis 재기동 시 “누가 줄에 서 있었나?” 를 복원하려면 DB에 근거가 있어야 한다.

결국 “실시간 처리=Redis”, “이력/분석/복구=DB”로 역할을 나누는 하이브리드가 합리적이라는 결론에 도달했다.


2) 현재 구현의 문제 정리 (Redis 단독일 때)

  1. 데이터 영속성: 서버/Redis 재시작 시 대기열 유실 가능
  2. 통계/분석 취약: 매칭 히스토리/패턴 분석이 어려움
  3. 디버깅 곤란: 실패 재현·CS 대응 근거 부족
  4. 복구 어려움: 장애 시 대기열/상태 재구성이 힘듦

3) 하이브리드 설계 원칙

(1) 단일 출처(Single Source of Truth, SOT) 명확화

  • 대기열의 SOT = Redis (실시간 기준)
  • 이력/상태의 SOT = DB (사실 기준)

(2) 일관성 경계 정의

  • 두 저장소에 언제/어떤 순서로 쓸지 고정.
  • 권장: DB 트랜잭션으로 matching_queue(WAITING) + 아웃박스 이벤트를 함께 기록 → 워커가 Redis에 enqueue (재시도 가능)

(3) 멱등성/중복 방지

  • 사용자 중복 등록 방지: SET user:{id}:queued 1 NX EX 600
  • 매칭 결과 멱등 업데이트: match_id UNIQUE 제약 등

4) 개선된 데이터 흐름

  1. 참가(Enqueue):

    • DB 트랜잭션: matching_queue(WAITING) + outbox(enqueue_event)
    • Outbox Consumer가 Redis ZSET에 등록
  2. 매칭(Match):

    • Redis에서 원자적으로 두 명을 Pop (Lua 스크립트)
    • DB에 MATCHING → MATCHED 상태 전이(멱등)
  3. 통계/리포트:

    • DB 이력 기반 분석/대시보드
  4. 실시간 조회:

    • Redis 대기열 길이, 평균 대기시간(샘플링) 즉시 응답

5) Redis 자료구조 선택과 이유

  • ZSET(정렬집합) 권장

    • score = 대기 시작 시각 or 우선순위
    • 공정성(FCFS)/우선순위/타임아웃 처리 쉽다.
    • 꺼낼 때 원자성을 위해 Lua 스크립트 사용.
  • 덧붙임

    • SET NX로 중복 등록 방지
    • HASH(queue:{queueId})로 사용자·카테고리·TTL 메타 저장
    • TTL + 정리잡으로 고아 항목 청소

단순 리스트(LLEN/LPOP)는 쉽지만 공정성·타임아웃·중복 제어를 구조적으로 풀기 어렵다. Streams는 컨슈머 그룹/ACK로 내구성이 좋지만 매칭 “쌍짓기”엔 별도 설계가 필요.


6) 원자적 매칭: Lua 스크립트 예시(개념)

-- KEYS[1]=zset key, ARGV[1]=score(복원용)
local k = KEYS[1]
local a = redis.call('ZRANGE', k, 0, 0)
if #a == 0 then return {} end
redis.call('ZREM', k, a[1])

local b = redis.call('ZRANGE', k, 0, 0)
if #b == 0 then
  -- 짝이 없으면 되돌리기
  redis.call('ZADD', k, ARGV[1], a[1])
  return {}
end
redis.call('ZREM', k, b[1])
-- 필요 시 락/마킹/TTL 등 추가
return {a[1], b[1]}

실전에서는 되돌리기/락/타임아웃/카테고리 필터까지 넣어야 한다.


7) 복구 메커니즘(서버 재기동/장애 대비)

  • Redis는 AOF(append-only) + 영속 볼륨 + 복제/센티넬로 내구성 강화
  • 그래도 안전망으로 “최근 WAITING만 DB→Redis 재적재” 수행
// 서버 기동 시 안전 복구(개념)
@PostConstruct
public void recoverQueuesFromDatabase() {
    var active = matchingQueueRepository.findByQueueStatusAndCreatedAtAfter(
        QueueStatus.WAITING,
        LocalDateTime.now().minusMinutes(10)
    );
    active.forEach(queue -> {
        // Redis 재적재 로직(ZSET + 메타 HSET 등)
    });
}

운영에선 ApplicationRunner + 분산락으로 중복 복구 방지를 권장.


8) 스키마/인덱스 설계

matching_queue

  • id, user_id, category, status(WAITING/MATCHING/MATCHED/EXPIRED/CANCELLED), created_at, updated_at
  • 인덱스: (status, created_at), (user_id, status)

match_events

  • match_id(UNIQUE), user_a, user_b, started_at, ended_at, result, reason
  • 멱등키: match_id UNIQUE

outbox

  • event_id, type, payload, created_at, processed_at NULLABLE
  • 워커가 processed_at IS NULL만 읽고 성공 시 채움(재시도 가능)

9) 시스템 구성(개념 아키텍처)

[API] ──(Tx)──> [DB] ──(Outbox)──> [Outbox Consumer] ──> [Redis ZSET]
  │                                              │
  └──────────────(조회/이력)──────────────────────┘

[Matching Worker] <──> [Redis ZSET + Lua + TTL]
      │                            │
      └────(멱등 업데이트)──────> [DB: 상태/이력]

운영 필수 체크:

  • Redis AOF + 복제/센티넬
  • 아웃박스/재시도로 DB↔Redis 일관성 보장
  • SET NX/TTL/청소잡/분산락
  • 대기열 길이/평균 대기시간/타임아웃률/매칭 성공률 메트릭
  • enqueue/match/cancel/timeout 이벤트 로깅

10) 실무에서의 선택지 비교

시나리오권장 스택핵심 포인트
저지연 실시간 매칭 (MVP~중규모)Redis (ZSET/Streams) + DB속도·복잡도 밸런스 좋음
내구성 높은 큐/재처리/다중소비자Redis Streams or RabbitMQ + DBACK/리트라이/가시성 타임아웃 쉬움
대규모 분산/리플레이Kafka (+ Redis 캐시)파티셔닝·재처리 강점, 매칭 로직은 앱에서
아주 단순/저QPSPostgres (FOR UPDATE SKIP LOCKED)운영 단순, 지연·경합은 감수
관리형 간단 큐SQS쉬움+내구성, 초저지연 매칭은 보완 필요

친구하자의 “1분 내 매칭·공정성·운영 용이성” 기준에선 현재 Redis ZSET + DB가 가장 적합. 추후 트래픽 급증 시 Streams/Kafka로 확장 가능.


11) 테스트/운영 시나리오

  • 부하 테스트: 카테고리별 동시 1k~5k 등록, 평균/95p 대기시간, 매칭 성공률 측정
  • 경합 테스트: 동시 매칭 워커 2~10개, 중복 매칭/유실 여부
  • 장애 시나리오: Redis 재시작, 네트워크 분리, DB 쓰기 실패 시 재시도 동작
  • 복구 리허설: DB→Redis 재적재 로직의 멱등성/중복 방지 검증

12) 내가 배운 점 & 선택의 근거

  • 실시간성만 보면 Redis 단독이 매력적이지만, 운영·분석·복구까지 생각하면 DB 하이브리드가 필수.
  • 단일 출처를 나누고(대기열=Redis, 이력=DB), 이중 쓰기 일관성(아웃박스/재시도)을 확보하면 MVP 이후에도 확장 가능한 길이 열린다.
  • Redis에선 ZSET + Lua공정성/타임아웃/원자성을 한 번에 잡는 실전 해법이었다.

13) Next Steps (로드맵)

  1. 아웃박스 컨슈머 도입 및 재시도/백오프
  2. Lua 스크립트에 락/타임아웃/복원 로직 보강
  3. AOF+복제/센티넬 운영화
  4. 메트릭/알람: 대기열 길이·대기시간·타임아웃률·에러율
  5. 트래픽 증가 시 Streams 도입 검토(컨슈머 그룹 기반), 더 커지면 Kafka 병행

부록 A. 코드 스니펫(개념)

DB → Outbox 트랜잭션

@Transactional
public void enqueue(Long userId, String category) {
    MatchingQueue q = matchingQueueRepository.save(
        MatchingQueue.waiting(userId, category)
    );
    outboxRepository.save(OutboxEvent.enqueue(q.getId(), category));
}

Outbox Consumer → Redis

public void handle(OutboxEvent e) {
    String key = "mq:" + e.getCategory();
    // 중복 방지
    Boolean ok = redis.setIfAbsent("user:" + e.getUserId() + ":queued", "1", Duration.ofMinutes(10));
    if (Boolean.TRUE.equals(ok)) {
        redis.zAdd(key, e.getEnqueuedAt().toEpochSecond(ZoneOffset.UTC), e.getQueueId().toString());
    }
    outboxRepository.markProcessed(e.getId());
}

마무리

이번 설계는 “현재 요구(저지연)”와 “미래 요구(운영/분석/복구/확장)”를 동시에 충족시키기 위한 균형점을 찾는 과정이었다. DB+Redis 하이브리드는 그 균형점 위에서 실무적으로 검증된 길이며, MVP에서 시작해 Streams/Kafka로 확장 가능한 진화 경로를 갖는다. 친구하자의 성격(실시간 매칭 + 장기 운영/분석 필요)에 정합한 선택이라고 생각한다.