← 활용 사례

Vercel과 서버리스의 Next.js

Server Action에서 발행하고 Client Component에서 구독, 소켓은 함수 밖에.

Vercel, Netlify, Cloudflare, AWS Lambda에서 돌아가는 Next.js 앱의 서버 코드는 요청마다 실행됩니다. 수백 밀리초만 살아 있는 함수가 수천 개의 WebSocket 연결을 열어 둘 수는 없으니, "실시간 업데이트 추가"는 보통 폴링이나 따로 운영하는 서버로 끝납니다. Hibira를 쓰면 Route Handler와 Server Action은 짧은 HTTPS 호출 한 번으로 발행하고, 브라우저는 함수가 아니라 Hibira와 연결을 유지합니다.

사용자가 얻는 것

  • 주문, 작업, 결제 상태가 데이터베이스에서 바뀌는 순간 화면에서도 바뀝니다.
  • 알림과 댓글이 공유 링크로 연 탭을 포함해 열려 있는 모든 탭에 나타납니다.
  • 페이지는 여전히 빠릅니다. 첫 렌더링은 계속 Server Component에서 나옵니다.

사용하는 기능

  • Route Handler와 Server Action에서 pusher(Node.js 서버 라이브러리).
  • Client Component에서 pusher-js.
  • 세션을 확인하는 Route Handler가 인가하는 프라이빗 채널(private-).
  • 변경한 사용자가 두 번 받지 않도록 하는 보낸 사람 제외(socket_id).

1단계: 환경 변수

PUSHER_APP_ID=YOUR_APP_ID
PUSHER_APP_SECRET=YOUR_APP_SECRET
NEXT_PUBLIC_PUSHER_KEY=YOUR_APP_KEY
NEXT_PUBLIC_PUSHER_HOST=ws.hibira.io

NEXT_PUBLIC_ 접두사는 브라우저로 보내지는 키와 호스트에만 붙입니다. 시크릿에는 절대 붙이지 않습니다.

2단계: 서버 전용 클라이언트

// lib/pusher-server.ts
import 'server-only';
import Pusher from 'pusher';

export const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_APP_SECRET!,
  host: process.env.NEXT_PUBLIC_PUSHER_HOST!,
  useTLS: true,
});

라이브러리는 npm install pusher pusher-js server-only로 설치합니다. server-only를 import해 두면 Client Component가 이 파일을 가져오는 순간 빌드가 실패하므로, 시크릿이 브라우저 번들에 들어갈 수 없습니다.

3단계: Server Action에서 발행

// app/orders/actions.ts
'use server';
import { pusher } from '@/lib/pusher-server';

export async function shipOrder(id: string, socketId?: string) {
  const order = await db.orders.ship(id);
  await pusher.trigger(`private-order-${id}`, 'status', { status: order.status }, { socket_id: socketId });
  return order;
}

Route Handler, 결제 서비스의 웹훅, cron 작업도 같은 방식으로 발행합니다. 호출은 항상 await하세요. 서버리스 플랫폼은 응답을 보내자마자 함수를 멈출 수 있습니다.

4단계: 프라이빗 채널 인가

// app/api/pusher/auth/route.ts
import { pusher } from '@/lib/pusher-server';
import { auth } from '@/auth';

export async function POST(req: Request) {
  const session = await auth();
  if (!session?.user) return new Response('Unauthorized', { status: 401 });
  const form = await req.formData();
  const socketId = String(form.get('socket_id'));
  const channel = String(form.get('channel_name'));
  const id = channel.replace(/^private-order-/, '');
  if (!(await db.orders.belongsTo(id, session.user.id))) return new Response('Forbidden', { status: 403 });
  return Response.json(pusher.authorizeChannel(socketId, channel));
}

여기서 auth()는 Auth.js입니다. 앱에서 이미 쓰고 있는 세션 읽기 방식을 사용하세요.

5단계: Client Component에서 구독

// lib/pusher-client.ts
import Pusher from 'pusher-js';

let client: Pusher | undefined;

export function getPusher(): Pusher {
  client ??= new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
    cluster: 'ap',
    wsHost: process.env.NEXT_PUBLIC_PUSHER_HOST!,
    forceTLS: true,
    enabledTransports: ['ws', 'wss'],
    channelAuthorization: { endpoint: '/api/pusher/auth', transport: 'ajax' },
  });
  return client;
}
// app/orders/[id]/order-status.tsx
'use client';
import { useEffect, useState } from 'react';
import { getPusher } from '@/lib/pusher-client';

export function OrderStatus({ id, initial }: { id: string; initial: string }) {
  const [status, setStatus] = useState(initial);
  useEffect(() => {
    const name = `private-order-${id}`;
    const channel = getPusher().subscribe(name);
    const onStatus = (data: { status: string }) => setStatus(data.status);
    channel.bind('status', onStatus);
    return () => {
      channel.unbind('status', onStatus);
      getPusher().unsubscribe(name);
    };
  }, [id]);
  return <p>{status}</p>;
}

Server Component가 현재 상태를 렌더링해 initial로 넘기고, Client Component가 그것을 최신으로 유지합니다. 클라이언트는 useEffect 안에서 만들어지므로 서버 렌더링 중에는 아무것도 연결하지 않습니다.

팁

  • 인가 라우트와 발행 코드는 Node.js 런타임(기본값)에 두세요. pusher 라이브러리는 Node의 crypto를 사용합니다.
  • 변경한 사용자를 건너뛰려면 위의 shipOrder처럼 getPusher().connection.socket_id를 Server Action에 넘기세요.
  • 여러 컴포넌트가 한 채널을 함께 쓴다면 공통 부모에서 한 번만 구독하거나, 구독 해제 전에 사용 수를 세세요. 그래야 한 컴포넌트가 언마운트돼도 나머지가 끊기지 않습니다.
  • 데이터를 보내는 대신 Server Component를 재사용하려면, 이벤트가 오면 router.refresh()를 호출해 Next.js가 새 렌더링을 가져오게 하세요.
  • 로그인하면 Node.js 가이드와 JavaScript 가이드에 앱 값이 채워진 설정이 있습니다.