왜 Producer-Consumer 분리가 필수인가
이벤트 수신과 트랜잭션 처리를 하나의 루프에서 실행하면 네 가지 프로덕션 문제가 발생한다:
- 처리 속도 커플링: NFT 민팅 tx 제출에 30초 걸리면, 그 동안 이벤트 수신도 멈춘다. 30초 동안 발생한 블록의 이벤트 처리가 밀린다.
- 스케일링 불가: 이벤트가 초당 100개 들어오는데 처리는 초당 10개라면, Consumer 인스턴스를 늘려야 하지만 하나의 루프에서는 불가하다.
- 장애 격리 불가: RPC 노드 장애 → tx 제출 실패 → 이벤트 수신까지 영향을 받는다.
- 재처리 어려움: 특정 이벤트만 재처리하고 싶을 때, 전체 루프를 멈춰야 한다.
Producer-Consumer를 분리하면 이벤트 수신(Producer)은 빠르게 큐에 적재만 하고, Consumer Pool은 독립적으로 처리한다. Redis Streams가 버퍼이자 이력 보존소 역할을 한다.
Redis Streams 내부 자료구조 이해
Redis Streams는 Radix Tree + Listpack으로 구성된다.
Radix Tree: 메시지 ID의 timestamp 부분을 키로 사용한다. 시간순 정렬이 자동으로 보장되고, 범위 조회(XRANGE)가 효율적이다.
Listpack(이전의 ziplist): 같은 timestamp의 메시지들을 압축 저장한다. 메모리 효율적이다(~24 bytes/entry 오버헤드).
메시지 ID는 <millisecondsTime>-<sequenceNumber> 형식이다. 예: 1700000000000-0, 1700000000000-1. 같은 밀리초에 여러 메시지가 들어오면 sequence가 증가한다.
Consumer Group 프로토콜 — 내부 동작
Consumer Group의 핵심 개념:
- last_delivered_id: 그룹에 마지막으로 전달된 메시지 ID
- PEL (Pending Entries List): 전달됐지만 XACK 안 된 메시지 목록
- Consumer별 PEL: 각 Consumer가 읽었지만 아직 처리 완료하지 않은 메시지
XREADGROUP 동작
Consumer가 >(새 메시지)를 요청하면 last_delivered_id 이후의 메시지를 가져오고, 해당 Consumer의 PEL에 메시지를 추가한다. 같은 메시지가 다른 Consumer에게 전달되지 않는다.
XACK 동작
Consumer가 처리 완료 후 XACK를 호출하면 PEL에서 해당 메시지가 제거된다. Redis가 “이 메시지는 정상 처리됨”으로 인식한다.
XACK를 “택배 수령 확인”에 비유하면 이해하기 쉽다. “택배가 배달됐지만 수령 확인(XACK)이 안 되면 택배사(Redis)는 배달 완료로 처리하지 않는다. 일정 시간이 지나면 다른 배달원(Consumer)이 재배달한다.”
XAUTOCLAIM — Consumer 장애 복구 메커니즘
Consumer A가 메시지를 읽고 PEL에 등록했는데 처리 중 크래시가 나면, 메시지는 PEL에 남고 idle_time이 증가한다. Consumer B가 XAUTOCLAIM을 호출하면 60초 이상 XACK가 안 된 메시지를 B에게 넘긴다. Consumer B가 처리 후 XACK한다.
idle_time 설정 가이드:
- 평균 처리 시간이 5초인 경우 → idle_time = 60초 (10배)
- 너무 짧으면: 정상 처리 중인 메시지가 다른 Consumer에게 오탈취됨
- 너무 길면: 장애 복구가 느림
At-Least-Once vs Exactly-Once
Exactly-Once Delivery는 분산 시스템에서 이론적으로 불가능에 가깝다. “정확히 한 번 전달”을 보장하려면 2PC(Two-Phase Commit)가 필요하고 성능 저하가 심각하다.
현실적인 선택은 At-Least-Once Delivery + Idempotency다. 메시지가 중복 전달될 수 있지만 멱등성이 이를 무해하게 만든다. txHash:logIndex UNIQUE KEY 패턴이 그대로 적용된다. Coinbase가 이 방식으로 하루 수백만 건의 이벤트를 처리한다.
Dead Letter Queue (DLQ)
최대 재시도 횟수를 초과한 메시지는 DLQ로 이동한다. DLQ에는 원본 메시지 전체, 에러 메시지, 실패 시각, 재시도 횟수가 저장된다.
DLQ 깊이가 0이 아니면 알림을 발송한다. DLQ 메시지는 수동 검토 후 재처리 또는 폐기한다. 반복되는 패턴이면 코드 수정이 필요하다.
Consumer 재시작 안전성 — 메인 루프 설계
Consumer 시작 시 메인 루프에서 가장 중요한 것은 처리 우선순위다:
- Step 1: processPendingMessages() — 이 Consumer의 “pending” 목록 먼저 처리. 이전에 읽었지만 ACK 못한 메시지를 재처리. 재시작 복구의 핵심이다.
- Step 2: xReadGroup(key, '>', COUNT: 10, BLOCK: 5000) — 아직 이 그룹이 본 적 없는 새 메시지를 가져온다. 새 메시지 없으면 5초 대기한다.
- Step 3: 각 메시지를 processMessage()로 처리 — 성공 시 XACK, 실패 시 retry_count 증가 후 DLQ.
NFT Relayer 파이프라인 통합
사용자 서명을 받아 즉시 tx를 제출하면 100명이 동시에 요청할 때 nonce 충돌이 발생하고 Relayer 다운 시 서명이 유실된다.
파이프라인 통합 후:
- 사용자 서명 → Relayer API → Redis Streams → Consumer → tx 제출
- 100명 동시 → 큐에 적재 → Consumer가 순서대로 → nonce 순차 관리
- Consumer 다운 → 재시작 시 XAUTOCLAIM → 자동 복구
함정: NFT Relayer Consumer 여러 개 실행 시 nonce 충돌
같은 Relayer 지갑에서 여러 Consumer가 동시에 tx를 제출하면 nonce가 충돌한다. Consumer A와 B가 각각 eth.getTransactionCount를 조회하면 같은 nonce=5를 받게 되고, 두 tx 중 하나만 채굴된다.
해결: NFT Relayer Consumer는 단일 인스턴스로 고정하거나, Redis INCR로 nonce를 원자적으로 관리한다.
핵심 요약
- Producer-Consumer 분리가 주는 가장 큰 가치는 “장애 격리”: tx 제출 실패가 이벤트 수신에 영향을 주지 않는다
- XACK는 반드시 처리 완료 후에 호출해야 한다
- XAUTOCLAIM의 idle_time은 평균 처리 시간의 5-10배로 설정한다
- DLQ 모니터링 = 서비스 건강 지표: DLQ가 비어있으면 정상, 쌓이면 문제
- 동일한 파이프라인 패턴이 NFT 민팅, 입출금, DeFi 모니터링에 모두 적용된다