콘텐츠로 이동

ADR-0023: Live Sync Batch Processing

Umbra 의 Live Sync 는 Discord Gateway 이벤트를 실시간 1:1 처리하지 않고 배치로 모아서 주기적으로 DB 에 반영한다.

Status

Accepted

  • Decided at — 2026-04-13
  • Decided by — Pablo

Context

Umbra 의 Recovery 기능은 "길드의 현재 상태" 가 항상 우리 DB 에 반영되어 있어야 한다. Discord Gateway 에서 멤버 추가/삭제, 역할 변경, 채널 생성 등 이벤트가 초당 수십~수백 건씩 발생할 수 있다.

순진한 구현 옵션:

  • 이벤트당 즉시 UPSERT — 각 이벤트마다 DB INSERT/UPDATE
  • 이벤트 로그 + 주기 배치 — 이벤트를 Redis 에 적재, N초마다 배치로 DB 반영
  • 이벤트 스트림 + 컨슈머 — Kafka 같은 스트림 + 컨슈머 (인프라 과함)

현실 관찰:

  • 대량 이벤트 폭주 — 길드 owner 가 역할을 재배열하면 수백 건이 수 초 내 발생
  • DB 쓰기 부담 — 즉시 UPSERT 는 connection pool 고갈 위험
  • 중복 병합 가능 — 같은 멤버가 역할 A 추가 → B 제거 → A 제거면 최종 상태만 반영하면 됨
  • 실시간 요구 낮음 — Live Sync 는 복구 시점 기준 최대 수 초 오차 허용 (분 단위 아니면 문제없음)

Decision

Live Sync 는 이벤트를 버퍼링한 뒤 배치로 DB 반영 한다.

흐름

  1. Bot 프로세스의 Discord 핸들러가 이벤트 수신
  2. 이벤트를 Redis sorted set / list 에 적재 (key: sync:guild:{guild_id}:buffer)
  3. Worker 의 asynq cron (5초 주기) 이 길드별 버퍼 조회
  4. 버퍼 내 이벤트를 merge 하여 최종 상태 계산 (같은 aggregate 에 대한 중복 이벤트 제거)
  5. 단일 트랜잭션으로 DB 반영
  6. 버퍼 비우기

설정

  • Batch interval — 5초 (기본), 길드별 조정 가능
  • Batch size cap — 1000 events (초과 시 즉시 flush)
  • High-watermark flush — 버퍼가 500 events 초과 시 즉시 flush

선택 근거:

  • DB 쓰기 부하 ↓ — 1000 이벤트가 5초 내 몰려도 1 트랜잭션으로 처리
  • 이벤트 병합 가능 — 같은 멤버의 역할 추가→제거 중복 → 최종 상태만 반영
  • rate limit 여유 — Discord 호출은 필요 시에만 (대부분은 DB 작업)
  • 단순 구조 — 이벤트 스트림 인프라 없이 Redis + asynq 로 구현

Consequences

Positive

  • 대량 이벤트 폭주 상황에서도 DB 안정
  • 중복 이벤트가 자동 병합되어 쓰기 횟수 ↓
  • Live Sync 가 느려도 Bot/API 에 영향 없음 (비동기)
  • 구현 단순 (Redis + asynq 이미 사용 중)

Negative

  • DB 반영까지 최대 5초 지연 (실시간 아님)
  • Redis 버퍼 장애 시 이벤트 손실 가능 (Redis 는 RDB/AOF 지원으로 완화)
  • 배치 실패 시 재시도 정책 필요

Neutral

  • Restore workflow 가 시작될 때는 Live Sync 를 일시 중지 (Temporal signal 로)
  • 복구 직후 Live Sync 재개 시 버퍼는 비움 (과거 이벤트가 새 상태를 덮어쓰는 것 방지)

Event buffering format

Redis 에 저장되는 이벤트 형식:

Key: sync:guild:{guild_id}:buffer
Value: List or Sorted Set of events
Event: {
  "event_type": "GUILD_MEMBER_UPDATE",
  "timestamp": "2026-04-13T00:00:00Z",
  "payload": {...}
}

Merge logic

Batch 처리 시 이벤트는 aggregate ID 기반으로 병합:

for each aggregate_id in buffer:
  events = filter by aggregate_id
  final_state = fold events (apply in order)
  upsert final_state into DB

예: 멤버 A 의 이벤트가 ADD_ROLE(X)ADD_ROLE(Y)REMOVE_ROLE(X) 이면 최종 상태는 roles=[Y]. 3번의 UPDATE 대신 1번의 UPDATE.

Pause / resume

Restore workflow 시작 시 Live Sync 는 일시 중지된다.

sequenceDiagram
    participant Restore
    participant LiveSync
    participant Redis

    Restore->>LiveSync: Signal pause
    LiveSync->>Redis: Mark buffer as paused
    Note over Restore: Restore executes
    Restore->>LiveSync: Signal resume
    LiveSync->>Redis: Clear buffer (복구 후 상태가 최신)
    LiveSync->>LiveSync: Resume batch processing

복구 후 버퍼를 비우는 이유: 복구 직후 DB 상태가 "최신" 이므로, 복구 실행 중 쌓인 이벤트(구 상태 기준)를 반영하면 DB 가 뒤틀림.

Alternatives considered

Alternative 1: 이벤트당 즉시 UPSERT

Pros

  • 실시간성 (지연 0)
  • 구현 단순

Cons

  • 대량 이벤트 시 DB 부하 폭증
  • 중복 UPSERT 발생 (역할 변경 후 철회 등)
  • Connection pool 고갈 위험

Why rejected — 결제 SaaS 규모에서 DB 안전성이 우선.

Alternative 2: Kafka 이벤트 스트림

Pros

  • 고처리량
  • 재생 가능 (재처리 시)

Cons

  • 인프라 추가 (Kafka 운영)
  • MVP 에 과함

Why rejected — Redis + asynq 로 90% 이점 확보 가능.

Alternative 3: PostgreSQL LISTEN/NOTIFY + buffer

Pros

  • PostgreSQL 네이티브

Cons

  • 메시지 크기 제한 (8KB)
  • 장애 시 손실

Why rejected — Redis 가 적합.

Alternative 4: 이벤트 중복 제거 없이 모두 UPSERT

Pros

  • 단순

Cons

  • 쓰기 횟수가 이벤트 수만큼 발생
  • 배치의 이점이 반감

Why rejected — 병합이 결정적 이점.

Compliance

  • Live Sync 구현은 engine/recovery/subdomain/sync/ 에 위치
  • Bot 프로세스는 이벤트를 Redis 에 enqueue, DB 접근 금지 (쓰기 경로 단일화)
  • Worker 의 asynq cron 이 배치 처리 주체
  • 배치 실패 시 asynq 재시도 정책 (3회 시도)
  • 메트릭: 버퍼 크기, 배치 지연, 처리 실패율 모니터링

Revisit triggers

  • 5초 지연이 Recovery 품질에 문제가 되면 interval 단축
  • 특정 길드의 이벤트 TPS 가 배치 한계 초과 시 샤딩 (guild_id 해시 기반)
  • 손실 허용 불가 수준의 요구사항이 생기면 Kafka 전환

References

  • ADR-0015 — asynq 가 배치 처리 담당
  • ADR-0007 — Redis (버퍼 저장소)
  • ADR-0024 — Restore 와의 상호작용