architecture2025-04-24·9 min·110/348

Vercel에서 웹소켓 지원 방법

Vercel 플랫폼에서 웹소켓을 구현하는 방법과 대안 솔루션을 알아봅니다.

Vercel에서 웹소켓 지원 방법

Introduction

Vercel은 서버리스 아키텍처를 기반으로 하기 때문에, 전통적인 웹소켓 연결을 직접 지원하지 않습니다. 실시간 통신이 필요한 애플리케이션을 개발할 때, Vercel 환경에서 웹소켓을 구현하는 방법과 대안 솔루션을 이해하는 것이 중요합니다. 이 글에서는 Vercel에서 웹소켓을 구현하는 다양한 방법을 살펴보겠습니다.

Environment

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

Problem

Vercel의 서버리스 환경에서는 전통적인 WebSocket 연결이 작동하지 않습니다:

// 이 코드는 Vercel에서 작동하지 않습니다
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

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

// 에러: WebSocket.Server는 서버리스 환경에서 지원되지 않습니다

Analysis

Vercel의 서버리스 아키텍처는 다음 이유로 직접적인 WebSocket 지원이 어렵습니다:

  1. 무상태(stateless) 구조: 각 요청이 독립적으로 처리되며, 연결 상태를 유지하지 않음
  2. ** chức적(function) 수명 주기**: 함수가 실행된 후 즉시 종료됨
  3. 지역 리전: 특정 리전에서 실행되어 글로벌 연결이 어려움
  4. 메모리 제한: 장기 연결을 위한 메모리 할당이 제한됨

Solution

1. 외부 WebSocket 서비스 사용 (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;

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

export async function POST(request) {
  const { socket_id, channel_name } = await request.json();
  
  const auth = pusher.authorizeChannel(socket_id, channel_name);
  return NextResponse.json(auth);
}
// 클라이언트 코드
import Pusher from 'pusher-js';

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

const channel = pusher.subscribe('chat-channel');

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

// 메시지 전송
async function sendMessage(message) {
  await fetch('/api/send-message', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ message })
  });
}

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: "https://your-app.vercel.app",
    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/events/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);
    }
  });
}

4. Vercel Functions를 사용한 폴링

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

// 메모리 기반 상태 저장 (실제로는 Redis 사용 권장)
let latestUpdate = null;

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const lastCheck = searchParams.get('lastCheck');
  
  if (latestUpdate && latestUpdate.timestamp > lastCheck) {
    return NextResponse.json({
      hasUpdate: true,
      data: latestUpdate.data,
      timestamp: latestUpdate.timestamp
    });
  }
  
  return NextResponse.json({
    hasUpdate: false,
    timestamp: Date.now()
  });
}

export async function POST(request) {
  const data = await request.json();
  latestUpdate = {
    data,
    timestamp: Date.now()
  };
  
  return NextResponse.json({ success: true });
}

5. 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 subscribeToChanges(channel, callback) {
  return supabase
    .channel(channel)
    .on(
      'postgres_changes',
      { event: '*', schema: 'public', table: 'messages' },
      (payload) => callback(payload)
    )
    .subscribe();
}

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.