architecture2025-03-02·10 min·160/348

Vercel에서 WebSocket 서버 구현

Vercel 환경에서 WebSocket 서버를 구현하는 방법과 대안 솔루션을 알아봅니다.

Vercel에서 WebSocket 서버 구현

Introduction

Vercel은 서버리스 아키텍처를 기반으로 하기 때문에 전통적인 WebSocket 서버를 직접 호스팅하기 어렵습니다. 하지만 다양한 대안을 통해 실시간 통신을 구현할 수 있습니다. 이 글에서는 Vercel 환경에서 WebSocket을 구현하는 방법을 알아보겠습니다.

Environment

  • Vercel 프로젝트 (Next.js 14+)
  • Pusher/Ably (외부 서비스)
  • Redis Pub/Sub
  • Socket.io

Problem

Vercel에서 직접 WebSocket 서버를 실행할 때 발생하는 문제:

// Vercel에서는 이 코드가 작동하지 않음
const http = require('http');
const WebSocket = require('ws');

const server = http.createServer();
const wss = new WebSocket.Server({ server });

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    ws.send(`Echo: ${message}`);
  });
});

server.listen(3001);

// 에러: Serverless 환경에서는 장기 연결 지원 불가

Analysis

Vercel 환경에서 WebSocket이 작동하지 않는 이유:

  1. 무상태(stateless) 함수: 각 요청이 독립적으로 처리됨
  2. 함수 수명 제한: 함수가 실행된 후 즉시 종료됨
  3. 연결 유지 불가: 장기 연결이 유지되지 않음

Solution

1. Pusher를 사용한 실시간 통신

// lib/pusher.js
import Pusher from 'pusher';

const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID,
  key: process.env.PUSHER_APP_KEY,
  secret: process.env.PUSHER_APP_SECRET,
  cluster: 'eu',
  useTLS: true,
});

export default pusher;

// 클라이언트 코드
import Pusher from 'pusher-js';

const pusherClient = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY, {
  cluster: 'eu',
});

const channel = pusherClient.subscribe('chat');

channel.bind('message', (data) => {
  console.log('New message:', data);
});

2. Socket.io와 Redis Adapter

// server.js (별도의 WebSocket 서버)
const { createServer } = require('http');
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

const httpServer = createServer();
const io = new Server(httpServer, {
  cors: {
    origin: process.env.ALLOWED_ORIGINS?.split(',') || [],
    methods: ['GET', 'POST'],
  },
});

async function setupRedis() {
  const pubClient = createClient({ url: process.env.REDIS_URL });
  const subClient = pubClient.duplicate();

  await Promise.all([pubClient.connect(), subClient.connect()]);

  io.adapter(createAdapter(pubClient, subClient));
}

io.on('connection', (socket) => {
  console.log('User connected:', socket.id);

  socket.on('join-room', (room) => {
    socket.join(room);
    socket.to(room).emit('user-joined', socket.id);
  });

  socket.on('message', (data) => {
    io.to(data.room).emit('message', data);
  });

  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
  });
});

setupRedis().then(() => {
  httpServer.listen(3001, () => {
    console.log('WebSocket server running on port 3001');
  });
});

3. Server-Sent Events (SSE)

// app/api/sse/route.js
import { NextResponse } from 'next/server';

const clients = new Map();

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const clientId = searchParams.get('clientId') || Date.now().toString();

  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    start(controller) {
      clients.set(clientId, controller);

      controller.enqueue(
        encoder.encode(`data: ${JSON.stringify({ type: 'connected', clientId })}\n\n`)
      );

      request.signal.addEventListener('abort', () => {
        clients.delete(clientId);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    },
  });
}

export function broadcast(event, data) {
  const encoder = new TextEncoder();
  const message = `data: ${JSON.stringify({ event, data })}\n\n`;

  clients.forEach((controller) => {
    try {
      controller.enqueue(encoder.encode(message));
    } catch (error) {
      console.error('Failed to send event:', error);
    }
  });
}

// POST로 이벤트 전송
export async function POST(request) {
  const { event, data } = await request.json();
  broadcast(event, data);
  return NextResponse.json({ success: true });
}

4. Supabase Realtime

// lib/supabase.js
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseKey);

// 실시간 구독
export function subscribeToMessages(callback) {
  return supabase
    .channel('messages')
    .on(
      'postgres_changes',
      { event: 'INSERT', schema: 'public', table: 'messages' },
      (payload) => callback(payload.new)
    )
    .subscribe();
}

5. Polling 구현

// hooks/usePolling.js
import { useState, useEffect, useCallback } from 'react';

export function usePolling(url, interval = 5000) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  const fetchData = useCallback(async () => {
    try {
      const response = await fetch(url);
      const result = await response.json();
      setData(result);
      setError(null);
    } catch (err) {
      setError(err.message);
    }
  }, [url]);

  useEffect(() => {
    fetchData();
    const id = setInterval(fetchData, interval);
    return () => clearInterval(id);
  }, [fetchData, interval]);

  return { data, error };
}

Lessons Learned

  1. 외부 서비스 활용: Vercel 환경에서는 Pusher, Ably 같은 외부 서비스가 효과적입니다.
  2. SSE 고려: 단방향 통신이 필요한 경우 Server-Sent Events가 더 간단합니다.
  3. Redis Pub/Sub: 별도의 WebSocket 서버를 운영하는 경우 Redis Pub/Sub이 유용합니다.
  4. 연결 관리: 서버리스 환경에서는 연결 상태를 외부 서비스에 위임해야 합니다.
  5. 비용 고려: 외부 서비스의 가격 정책을 충분히 검토해야 합니다.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.