이벤트를 “정확히, 한 번만, 순서대로” 처리하는 법
이벤트 파이프라인은 단순히 이벤트를 “받는” 것이 아니라, 이벤트를 “정확히, 한 번만, 순서대로” 처리하는 시스템이다. 이 세 가지 보장이 없으면 신뢰할 수 있는 서비스를 만들 수 없다.
이전 글에서 다룬 4대 위험(WebSocket 끊김, Reorg, 재시작, 중복)을 해결하는 세 가지 기둥을 설명한다.
첫 번째 기둥: 커서(Cursor) 관리
커서의 개념
커서는 “마지막으로 처리한 블록 번호”다. 데이터베이스나 파일에 저장해두어, 서버가 재시작되면 이 번호부터 다시 처리를 시작한다.
비유: 책갈피. 책을 읽다가 덮을 때 책갈피를 끼워두면, 다음에 읽을 때 어디서부터 시작할지 알 수 있다. 책갈피 없이 덮으면 처음부터 다시 읽어야 하거나 어디를 읽었는지 모른다.
커서 관리의 원칙
- 이벤트를 처리 완료한 직후에 커서를 업데이트한다
- 처리 전에 커서를 업데이트하면 처리 실패 시 그 이벤트를 건너뛰게 된다
- 처리를 완료하고 커서 업데이트에 실패하면 재시작 후 같은 이벤트를 중복 처리한다
- 이 경우 중복 처리를 허용하는 것이 이벤트 유실보다 낫다
Reorg 대응 커서
단순히 블록 번호만 저장하면 Reorg를 처리할 수 없다. 최소한 다음을 함께 저장해야 한다:
- 처리한 마지막 블록 번호
- 처리한 마지막 블록 해시
재시작 시 저장된 블록 해시가 현재 체인의 해당 블록 해시와 일치하는지 확인한다. 다르다면 Reorg가 발생한 것이므로, 그 블록 이전으로 커서를 되돌린다.
두 번째 기둥: 멱등성(Idempotency) 설계
같은 이벤트를 여러 번 처리해도 결과가 한 번 처리한 것과 같도록 설계하는 것이다.
비유: “입금 처리” 버튼이 두 번 눌렸을 때, 두 번 입금되지 않고 “이미 처리됨”을 반환하는 것. 영수증 번호가 같으면 중복으로 처리하지 않는다.
이벤트 유일 식별자
이벤트의 유일 식별자는 트랜잭션 해시 + 로그 인덱스의 조합이다. 같은 트랜잭션에서 여러 이벤트가 발생할 수 있으므로, 트랜잭션 해시만으로는 유일하지 않다.
데이터베이스에 이 식별자를 유니크 키로 설정하면, 중복 삽입 시 에러가 발생해서 자동으로 중복 처리를 막을 수 있다.
3레이어 중복 방지
Layer 1 — DB 선조회: 처리 전에 이미 처리한 이벤트인지 데이터베이스에서 확인한다. 99% 케이스를 빠르게 처리한다.
Layer 2 — DB 유니크 제약 + INSERT: tx_hash + log_index에 유니크 제약을 걸어두면, 동시성 경쟁(수평 확장 시 여러 워커가 같은 이벤트를 처리하려 할 때)을 방지한다.
Layer 3 — 처리 결과 영구 기록: 처리 완료 후 유일 식별자를 “처리됨” 상태로 저장한다.
세 번째 기둥: 상태 머신
이벤트를 “받았다 → 처리했다”의 단순한 흐름으로 보면, 처리 중 실패했을 때 어떻게 할지 알 수 없다. 상태 머신을 도입하면 각 상태의 의미가 명확해지고 재처리 로직을 설계할 수 있다.
상태 정의
- PENDING: 이벤트를 받았고 처리 대기 중이다. 처리가 시작되지 않은 상태.
- PROCESSING: 처리가 진행 중이다. 이 상태에서 서버가 죽으면 재시작 후 PENDING으로 되돌린다.
- PROCESSED: 처리가 성공적으로 완료됐다. 재처리하지 않는다.
- FAILED: 처리가 실패했다. 재시도 횟수와 마지막 오류를 기록한다.
- REORGED: Reorg로 이벤트가 무효화됐다. 필요하면 역방향 처리를 한다.
비유: 택배 시스템. 배송 요청(PENDING) → 배송 중(PROCESSING) → 배송 완료(PROCESSED). 배송 실패(FAILED)면 재시도. 주소 변경으로 취소(REORGED)되면 원래 상태로 돌아간다.
재시도 정책: 지수적 백오프
단순히 즉시 재시도하면 문제가 있는 상황에서 반복 실패가 발생한다. 지수적 백오프(Exponential Backoff)를 사용한다:
- 첫 재시도: 1초 후
- 두 번째: 2초 후
- 세 번째: 4초 후
- 네 번째: 8초 후
최대 재시도 횟수 초과 시 자동 재시도를 중단하고 운영자에게 알림을 발송한다.
Confirmation 수에 따른 처리 전략
처리하는 이벤트의 중요도에 따라 확인 블록 수를 다르게 설정한다:
- UX 피드백 (진행 표시): 1 confirmation — 속도 중시, 취소 가능성 허용
- 소액 입금 처리: 3~6 confirmation — 일반 거래 수준
- 중요 처리 완료: Safe 상태 (~6분) — 중간 규모 신뢰
- 대액 입금, 법적 효력: Finalized (~12분) — 되돌릴 수 없는 확정
1 confirmation에서 “입금 대기 중” 상태를 보여주고, 6 confirmation 이후에 “입금 완료”로 전환하는 것이 좋은 UX다. 사용자는 빠른 피드백을 받으면서도 실제 처리는 안전하게 이루어진다.
데이터베이스 스키마 설계
신뢰할 수 있는 이벤트 파이프라인에는 두 가지 테이블이 필요하다.
이벤트 커서 테이블
- last_processed_block: 마지막으로 처리한 블록 번호
- last_processed_block_hash: 해당 블록의 해시 (Reorg 감지용)
- updated_at: 마지막 업데이트 시간
이벤트 처리 테이블
- tx_hash + log_index: 복합 유니크 키
- block_number, block_hash: Reorg 감지용
- status: PENDING / PROCESSING / PROCESSED / FAILED / REORGED
- retry_count: 재시도 횟수
- last_error: 마지막 오류 내용
핵심 요약
- 커서 관리는 “어디까지 처리했는가”를 기록해서 재시작 후 안전하게 이어서 처리하는 기반이다
- 이벤트 유일 식별자(txHash + logIndex)를 데이터베이스 유니크 키로 사용하면 중복 처리를 막을 수 있다
- PENDING → PROCESSING → PROCESSED → FAILED → REORGED 상태 머신이 이벤트 처리의 신뢰성을 보장한다
- 처리 중요도에 따라 다른 Confirmation 수를 적용한다. 대액은 Finalized까지 기다린다
- Reorg 감지 후 역처리는 매우 복잡하기 때문에 외부 시스템 연동을 Finalized 이후로 미루는 것이 핵심 설계 원칙이다