콘텐츠 대표 이미지 - 라라벨과 웹소켓으로 실시간 블록체인 알림 시스템 구현하기 : 체인 이벤트를 초 단위로 낚아채는 아키텍처 설계
프로그램개발 · 블록체인/Web3

라라벨과 웹소켓으로 실시간 블록체인 알림 시스템 구현하기 : 체인 이벤트를 초 단위로 낚아채는 아키텍처 설계

블록은 계속 쌓인다. 사용자는 기다리지 않는다.
그 간극을 메우는 건 결국 실시간 파이프라인이다.

🧩 왜 "새로고침"은 Web3에서 최악의 UX인가

지갑에서 토큰을 전송한 사용자는 3초 안에 확인을 원한다.
하지만 대부분의 서비스는 1분짜리 크론(cron)을 돌린다.

그 사이 사용자는 F5를 누르고, 트랜잭션 해시를 익스플로러에 복사해 붙여넣고, 결국 고객센터에 문의를 남긴다.
이 흐름은 기술의 문제가 아니라 정보 전달 지연 구조의 문제다.

블록체인은 본질적으로 이벤트 기반 시스템이다.
EVM 계열 체인의 스마트 컨트랙트는

event Transfer(address indexed from, address indexed to, uint256 value)
같은 로그를 블록에 기록한다.
이 로그는 노드가 실시간으로 구독(subscribe) 가능한 데이터다.

즉, 데이터는 이미 실시간인데 우리 백엔드만 폴링(polling)이라는 구시대 방식에 갇혀 있는 것이다.

폴링 방식 vs 실시간 구독 방식 Polling (크론 60초) Chain 요청·응답 반복 API 평균 지연 : 약 30초 불필요 RPC 호출 : 다수 누락 위험 : 재구성 시 높음 사용자 체감 : 답답함 WebSocket 구독 Node 이벤트 푸시 User 평균 지연 : 1~3초 커넥션 유지 : 단일 소켓 재조직 대응 : 확정 카운트 사용자 체감 : 즉각적

🏗 전체 아키텍처 : 5개의 레이어

라라벨(Laravel) 기반 실시간 블록체인 알림 시스템은 다음과 같이 나뉜다.

1. Chain Listener — 노드의 WebSocket RPC에 연결해 로그를 수신하는 상주 프로세스

2. Normalizer — 원시 로그(raw log)를 ABI로 디코딩해 도메인 이벤트로 변환

3. Queue — Redis 큐에 적재, 재시도·중복 제거 담당

4. Broadcaster — Laravel Broadcasting으로 Reverb/Pusher 채널에 푸시

5. Client — Laravel Echo가 구독하고 화면에 토스트 알림 렌더링

핵심 원칙은 하나다.
체인에서 들어오는 속도와, 사용자에게 내보내는 속도를 분리하라.

체인은 초당 수십~수백 개 로그를 뱉을 수 있고, 브라우저는 그걸 다 받을 이유가 없다.
중간의 큐가 충격 흡수 장치 역할을 한다.

실시간 알림 파이프라인 구성도 EVM Node wss:// RPC Listener artisan command Redis Queue Job + 중복제거 Reverb WebSocket 서버 Browser Laravel Echo 공통 관심사 : 재연결 · 블록 커서 저장 · 확정(confirmation) 판정 장애가 나도 마지막 처리 블록부터 다시 따라잡을 수 있어야 한다

⚡ Laravel Reverb : 이제 브로드캐스팅은 집안일이다

과거 라라벨에서 실시간을 하려면 Pusher(유료 SaaS)나 Node 기반 laravel-echo-server가 필요했다.
Laravel 11부터 공식 제공되는 Reverb는 PHP로 작성된 자체 WebSocket 서버다.

Pusher 프로토콜과 호환되므로, 클라이언트 코드(Laravel Echo)는 거의 그대로 재사용된다.

composer require laravel/reverb
php artisan install:broadcasting
php artisan reverb:start --host=0.0.0.0 --port=8080

.env 설정은 다음과 같다.

BROADCAST_CONNECTION=reverb
REVERB_APP_ID=chainnotify
REVERB_APP_KEY=local-key
REVERB_APP_SECRET=local-secret
REVERB_HOST=127.0.0.1
REVERB_PORT=8080
REVERB_SCHEME=http

QUEUE_CONNECTION=redis

실전 팁 — Reverb는 단일 프로세스에서 이벤트 루프(ReactPHP)로 동작한다.
동시 접속이 수천 단위를 넘어가면 ulimit -n(파일 디스크립터 한계)을 먼저 올려야 한다.
기본값 1024로는 커넥션이 조용히 끊긴다.

🔌 체인 리스너 : 상주 프로세스로 로그 구독하기

1) Artisan 커맨드로 만드는 이유

블록체인 리스너는 절대 죽지 않아야 하는 데몬이다.
라라벨에서는 php artisan chain:listen 형태의 커맨드를 만들고, Supervisor로 감시하는 패턴이 표준이다.

php artisan make:command ListenChainEvents

2) PHP에서 WebSocket RPC 연결

PHP 생태계에서는 ratchet/pawl(비동기 WebSocket 클라이언트) + react/event-loop 조합이 가장 안정적이다.

use Ratchet\Client\Connector;
use React\EventLoop\Loop;

public function handle(): int
{
    $loop = Loop::get();
    $connector = new Connector($loop);
    $url = config('chain.ws_rpc'); // wss://...

    $connector($url)->then(function ($conn) {
        // ERC-20 Transfer 토픽 해시
        $topic = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';

        $conn->send(json_encode([
            'jsonrpc' => '2.0',
            'id'      => 1,
            'method'  => 'eth_subscribe',
            'params'  => ['logs', [
                'address' => config('chain.token_address'),
                'topics'  => [$topic],
            ]],
        ]));

        $conn->on('message', function ($msg) {
            $payload = json_decode($msg, true);
            $log = $payload['params']['result'] ?? null;
            if (! $log) return;

            \App\Jobs\ProcessChainLog::dispatch($log)
                ->onQueue('chain');
        });

        $conn->on('close', fn () => Loop::get()->stop());
    }, function ($e) {
        report($e);
        Loop::get()->stop();
    });

    $loop->run();
    return self::SUCCESS;
}

여기서 중요한 포인트.
리스너는 디코딩하지 않는다. 받아서 큐에 던지고 끝이다.
소켓 루프 안에서 DB를 건드리는 순간, 지연이 누적되며 노드 쪽 버퍼가 터진다.

3) 토픽 해시라는 열쇠

EVM 로그의 topics[0]은 이벤트 시그니처의 keccak256 해시다.
Transfer(address,address,uint256)를 해싱하면 위의 0xddf252ad...가 나온다.
이 값은 모든 ERC-20 토큰에서 동일하므로, 주소 필터를 빼면 체인 전체 전송을 잡을 수도 있다(단, 트래픽 폭탄 주의).

indexed 파라미터는 topics[1..3]에, 나머지는 data에 들어간다.
주소는 32바이트로 패딩되어 있으므로 뒤 20바이트만 잘라 써야 한다.

$from  = '0x' . substr($log['topics'][1], 26);
$to    = '0x' . substr($log['topics'][2], 26);
$value = gmp_strval(gmp_init($log['data'], 16)); // 문자열로 유지

반드시 기억할 것 — uint256은 PHP의 int(64bit)로 표현 불가능하다.
18 decimals 토큰 1개 = 1018 wei. 조금만 커져도 오버플로가 난다.
BCMath / GMP를 쓰고, DB에는 DECIMAL(65,0) 또는 문자열로 저장하라.
float 캐스팅은 금융 사고의 지름길이다.

📦 Job : 정규화 · 멱등성 · 확정 대기

멱등성(Idempotency)이 전부다

WebSocket 재연결, 노드 이중화, 체인 재조직(reorg).
이 세 가지 때문에 같은 로그가 두 번 이상 도착하는 일은 반드시 일어난다.

해결책은 단순하다. transaction_hash + log_index를 유니크 키로 잡는다.

Schema::create('chain_events', function (Blueprint $t) {
    $t->id();
    $t->string('tx_hash', 66);
    $t->unsignedInteger('log_index');
    $t->unsignedBigInteger('block_number');
    $t->string('block_hash', 66);
    $t->string('from_address', 42)->index();
    $t->string('to_address', 42)->index();
    $t->decimal('amount', 65, 0);
    $t->unsignedTinyInteger('confirmations')->default(0);
    $t->string('status', 20)->default('pending'); // pending|confirmed|reverted
    $t->timestamps();
    $t->unique(['tx_hash', 'log_index']);
});
class ProcessChainLog implements ShouldQueue
{
    public function __construct(private array $log) {}

    public function handle(): void
    {
        $event = ChainEvent::firstOrCreate(
            ['tx_hash' => $this->log['transactionHash'],
             'log_index' => hexdec($this->log['logIndex'])],
            [
                'block_number' => hexdec($this->log['blockNumber']),
                'block_hash'   => $this->log['blockHash'],
                'from_address' => '0x'.substr($this->log['topics'][1], 26),
                'to_address'   => '0x'.substr($this->log['topics'][2], 26),
                'amount'       => gmp_strval(gmp_init($this->log['data'], 16)),
            ]
        );

        if (! $event->wasRecentlyCreated) {
            return; // 중복 → 조용히 종료
        }

        broadcast(new TokenTransferDetected($event));
    }
}

pending과 confirmed를 구분하라

블록에 포함됐다고 끝이 아니다.
체인 재조직이 일어나면 그 블록이 통째로 사라질 수 있다.

단계알림 문구UI 처리
mempool 감지전송 요청 접수회색 스피너
1 confirmation블록에 포함됨노란 배지
N confirmations전송 완료초록 체크
reorg 발생취소됨빨간 경고 + 롤백

N값은 체인마다 다르다. 이더리움 메인넷은 Finality(약 2 에폭, 12~13분) 기준이 안전하고,
Polygon PoS는 통상 128블록 이상, BSC는 15블록 내외를 관행적으로 쓴다.
서비스의 금액 규모에 따라 조정하는 게 정답이다.

📡 브로드캐스트 이벤트와 프라이빗 채널

알림은 지갑 주소 소유자에게만 가야 한다.
누구나 구독 가능한 퍼블릭 채널로 잔액 변동을 쏘면, 그건 온체인 프라이버시 유출이다.

class TokenTransferDetected implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public ChainEvent $event) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('wallet.'.strtolower($this->event->to_address))];
    }

    public function broadcastAs(): string
    {
        return 'token.transfer';
    }

    public function broadcastWith(): array
    {
        return [
            'tx'      => $this->event->tx_hash,
            'amount'  => $this->event->amount,   // 문자열 그대로
            'from'    => $this->event->from_address,
            'block'   => $this->event->block_number,
            'status'  => $this->event->status,
        ];
    }
}

채널 인가는 routes/channels.php에서 처리한다.

Broadcast::channel('wallet.{address}', function ($user, $address) {
    return $user->wallets()
        ->whereRaw('LOWER(address) = ?', [strtolower($address)])
        ->exists();
});

지갑 소유 증명은 세션 로그인만으로 부족하다.
EIP-4361(Sign-In with Ethereum) 방식으로 nonce가 포함된 메시지에 서명받고,
서버에서 ecrecover로 주소를 복구해 소유권을 검증한 뒤 wallets 테이블에 매핑하는 게 표준이다.

프론트엔드 : Laravel Echo

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;
window.Echo = new Echo({
  broadcaster: 'reverb',
  key: import.meta.env.VITE_REVERB_APP_KEY,
  wsHost: import.meta.env.VITE_REVERB_HOST,
  wsPort: 8080,
  forceTLS: false,
  enabledTransports: ['ws', 'wss'],
});

Echo.private(`wallet.${myAddress.toLowerCase()}`)
    .listen('.token.transfer', (e) => {
        showToast(`${formatUnits(e.amount, 18)} 토큰 수신`, e.tx);
    });

broadcastAs()로 이름을 커스텀했다면, 리스너 앞에 점(.)을 붙여야 한다.
이 점 하나 빼먹고 반나절 날리는 개발자가 매년 생긴다.

🛡 운영 현실 : 소켓은 끊기고 노드는 죽는다

1) 블록 커서(cursor) 저장은 필수

리스너가 30초 멈춘 사이 블록 3개가 지나갔다면, 그 이벤트는 영원히 사라진다.
따라서 마지막으로 처리한 블록 번호를 Redis나 DB에 저장하고,
재기동 시 eth_getLogs로 공백 구간을 백필(backfill)해야 한다.

$last = (int) Cache::get('chain:last_block', $startBlock);
$head = hexdec($rpc->call('eth_blockNumber'));

for ($from = $last + 1; $from <= $head; $from += 2000) {
    $to = min($from + 1999, $head);
    $logs = $rpc->getLogs($from, $to, $address, [$topic]);
    foreach ($logs as $log) ProcessChainLog::dispatch($log);
    Cache::put('chain:last_block', $to);
}

대부분의 퍼블릭 RPC 제공자는 eth_getLogs 블록 범위를 제한한다(보통 2,000~10,000).
청크 단위로 나눠 요청하는 코드는 선택이 아니라 필수다.

2) Heartbeat와 좀비 커넥션

가장 악질적인 장애는 "연결은 살아있는데 데이터가 안 오는" 상태다.
TCP는 멀쩡해 보이지만 노드 쪽 구독이 끊긴 케이스.

대응책 : 60초마다 마지막 메시지 수신 시각을 확인하고,
임계치를 넘으면 스스로 프로세스를 종료한다. Supervisor가 재시작해준다.

$loop->addPeriodicTimer(30, function () use ($conn) {
    if (now()->diffInSeconds($this->lastMessageAt) > 90) {
        Log::warning('chain listener stale, restarting');
        $conn->close();
    }
});

이른바 "죽을 줄 아는 프로세스가 좋은 프로세스" 원칙이다.

3) Supervisor 설정

[program:chain-listener]
command=php /var/www/artisan chain:listen
autostart=true
autorestart=true
stopwaitsecs=10
numprocs=1
user=www-data

[program:chain-queue]
command=php /var/www/artisan queue:work redis --queue=chain --tries=3
numprocs=4
autorestart=true

리스너는 반드시 1개(중복 구독 방지), 큐 워커는 여러 개로 확장한다.
이 비대칭이 이 아키텍처의 핵심 설계다.

🚀 성능 : 알림 폭주를 막는 3가지 장치

① 디바운싱 — NFT 민팅처럼 한 트랜잭션에서 로그 50개가 터질 때,
개별 알림 대신 500ms 윈도우로 묶어 "50건 처리 완료" 한 개로 전송.

② 브로드캐스트 큐 분리 — ShouldBroadcast는 기본적으로 큐를 탄다.
public $queue = 'broadcast';로 분리해 체인 파싱 작업과 경합하지 않게 한다.

③ 페이로드 다이어트 — 모델 전체를 직렬화하면 소켓 프레임이 수 KB로 부푼다.
broadcastWith()에서 필요한 5~6개 필드만 내보내라. 상세는 REST로 다시 조회.

실제로 초당 200~300 이벤트 수준까지는 Reverb 단일 인스턴스 + Redis 큐 조합으로 충분히 소화된다.
그 이상이라면 Reverb를 다중화하고 REVERB_SCALING_ENABLED=true로 Redis Pub/Sub 스케일링을 켜면 된다.

전달 지연 비교 (측정 기준 : 블록 생성 → 브라우저 렌더) 0s 20s 40s 60s 최대 58s 60초 크론 약 21s 5초 폴링 약 2.4s WS 구독 1.1s WS+로컬노드

🧠 설계 체크리스트

☑ 금액은 문자열/DECIMAL로. float 금지.

☑ tx_hash + log_index 유니크 인덱스로 멱등성 확보.

☑ 블록 커서 저장 + 백필 로직 없이는 운영 불가.

☑ pending / confirmed / reverted 3단계 상태 머신.

☑ PrivateChannel + 서명 기반 지갑 인증으로 프라이버시 보호.

☑ 리스너 1개, 워커 N개의 비대칭 스케일링.

☑ Stale 감지 후 self-kill + Supervisor 재시작.

☑ RPC 제공자 이중화(주 노드 장애 시 폴백 엔드포인트 전환).

이 여덟 가지만 지켜도, 당신의 알림 시스템은 "가끔 안 오는 알림"이라는 최악의 평가에서 벗어난다.

🌱 마무리 : 실시간은 기술이 아니라 신뢰다

블록체인 서비스에서 사용자가 가장 불안해하는 순간은 "보냈는데 아무 반응이 없을 때"다.
돈이 걸린 시스템에서 침묵은 곧 공포다.

라라벨의 Reverb와 큐, 그리고 노드의 WebSocket 구독을 조합하면
그 침묵을 2초짜리 토스트 메시지로 바꿀 수 있다.
코드량은 생각보다 적고, 효과는 생각보다 크다.

이런 실전형 아키텍처 노하우는 문서만 읽어서는 잘 체화되지 않는다.
재능넷의 '지식인의 숲'처럼 현업 개발자들이 직접 겪은 장애 사례와 해결 패턴을 공유하는 공간을 꾸준히 들여다보면,
남의 새벽 3시 장애를 내 경험치로 바꿀 수 있다.

마지막으로 한 문장만 기억하자.

"체인은 이미 실시간이다. 늦는 건 언제나 우리 백엔드다."

이제 php artisan chain:listen을 띄우고,
첫 번째 트랜잭션이 화면에 팝업되는 순간의 쾌감을 직접 경험해보길 바란다. 🎉

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0