Next.js 실시간 채팅 앱: WebSocket과 SSE를 올바르게 사용하는 방법

Next.js에서 WebSocket을 지원하게 만들려는 세 번째 시도였습니다. 페이지에는 WebSocket is not supported in this environment라는 오류가 떴습니다. 방금 전까지만 해도 로컬에서 잘 작동하던 채팅 기능이 Vercel에 배포하자마자 실패했습니다.
그제야 한 가지 사실을 깨달았습니다. Next.js의 실시간 통신은 생각만큼 간단하지 않습니다.
이 글은 WebSocket을 Next.js에 억지로 끼워 넣는 방법을 설명하지 않습니다. 그 길은 직접 가 봤고 막혀 있었습니다. 대신 Vercel이 WebSocket을 지원하지 않는 이유, SSE가 정말 구원투수인지, Socket.io와 App Router를 어떻게 함께 사용할지 등 실제로 겪은 시행착오를 공유합니다. Next.js 실시간 기능 때문에 고민하고 있다면 이 글이 시행착오를 줄이는 데 도움이 되길 바랍니다.
세 가지 실시간 통신 방식 비교
채팅방, 공동 편집, 실시간 알림 같은 기능은 모두 서버가 클라이언트로 데이터를 능동적으로 보내야 합니다. HTTP의 요청-응답 방식만으로는 이 문제를 해결할 수 없어 세 가지 주요 방식을 사용합니다.
WebSocket: 전이중 통신의 이상
WebSocket은 가장 이상적인 방식입니다. 한 번 연결하면 클라이언트와 서버가 언제든 서로 메시지를 보낼 수 있습니다. 그렇다면 망설일 이유가 없다고 생각할 수 있습니다.
저도 배포하기 전까지는 그렇게 생각했습니다.
Vercel, Netlify 같은 Serverless 플랫폼은 장기 연결을 지원하지 않습니다. Next.js 앱은 클라우드 함수에서 실행되고 요청이 끝나면 함수가 회수되는데, 이런 환경에서 WebSocket 연결을 유지할 수는 없습니다. Pusher나 Ably 같은 타사 WebSocket 서비스도 써 봤지만 월 요금이 부담스러웠습니다.
물론 WebSocket을 전혀 사용할 수 없는 것은 아닙니다. 직접 서버를 임대해 배포하거나 별도의 Node.js 백엔드에서 WebSocket 서비스를 실행하면 됩니다. 다만 비용이 늘고 아키텍처도 복잡해집니다.
SSE: 단방향 채널로 해결하기
Server-Sent Events라는 이름 그대로 서버가 클라이언트에만 데이터를 보낼 수 있는 단방향 방식입니다.
메시지를 보낼 때는 HTTP POST를, 받을 때는 SSE를 써야 하니 다소 부족해 보일 수 있습니다. 하지만 채팅방에서 메시지를 보내는 동작은 능동적이고 받는 동작은 수동적입니다. 실시간 알림은 서버 푸시만 있으면 됩니다. 관점을 바꾸면 많은 상황에 정확히 맞습니다.
무엇보다 중요한 점은 SSE가 Vercel에서 작동한다는 것입니다.
예전에 간단한 알림 시스템을 만들 때 SSE로 Vercel 배포 문제를 해결했습니다. Vercel Edge Function의 25초 시간 제한 같은 제약은 있지만 메시지 푸시에는 충분했습니다.
Long Polling: 오래됐지만 유용한 방식
가장 오래된 방식입니다. 클라이언트가 요청을 보내면 서버는 새 메시지가 생길 때까지 기다렸다가 응답하고, 클라이언트는 즉시 다음 요청을 보냅니다.
성능이 좋지 않고 트래픽도 낭비됩니다. 하지만 사용자가 수백 명 정도이고 메시지가 자주 오가지 않는다면 Long Polling도 꽤 유용합니다. 코드가 간단하고 호환성 문제가 없으며 Serverless 플랫폼에서도 거부되지 않습니다.
실제로 많은 소규모 프로젝트가 Long Polling만으로도 문제없는 사용자 경험을 제공합니다. ‘기술 부채’라는 말에 겁먹을 필요는 없습니다. 문제를 해결하는 방식이 좋은 방식입니다.
비교표: 한눈에 보기
| 특성 | WebSocket | SSE | Long Polling |
|---|---|---|---|
| 양방향 통신 | ✅ | ❌(POST 병행 필요) | ✅ |
| Vercel 지원 | ❌ | ✅(시간 제한 있음) | ✅ |
| 브라우저 호환성 | 최신 브라우저 | 최신 브라우저 | 전체 |
| 연결 오버헤드 | 낮음 | 낮음 | 높음 |
| 구현 복잡도 | 보통 | 간단함 | 매우 간단함 |
| 적합한 상황 | 채팅, 게임, 공동 편집 | 알림, 실시간 업데이트 | 저빈도 메시지, 높은 호환성 요구 |
눈치챘을 수도 있습니다. Next.js+Vercel 조합에서는 SSE와 Long Polling이 주된 선택입니다. 기술이 후퇴한 것이 아니라 플랫폼 제약 안에서 찾은 현실적인 해법입니다.
Next.js 환경에서 실시간 통신 방식 선택하기
세 가지 방식을 아는 것과 실제로 하나를 고르는 것은 다른 문제입니다. 시행착오 끝에 알게 된 몇 가지 판단 기준을 정리했습니다.
배포 플랫폼이 첫 번째 기준입니다
Vercel, Netlify, Cloudflare Pages 같은 Serverless 플랫폼을 사용한다면 WebSocket은 사실상 어렵습니다. 선택지는 세 가지입니다.
- SSE 방식: 알림, 실시간 업데이트, 채팅 메시지 수신 같은 단방향 푸시에 적합
- Long Polling: 빈도가 낮은 양방향 통신에 적합
- 독립 WebSocket 서비스: 소형 서버에서 WebSocket만 따로 실행하고 Next.js 앱은 계속 Serverless 플랫폼에 배포
저는 다중 사용자 공동 편집처럼 정말 빈번한 양방향 통신이 필요한 경우가 아니라면 SSE를 선택합니다. 배포가 간단하고 비용 부담도 적습니다.
직접 서버를 임대해 VPS나 Docker로 배포한다면 WebSocket을 자유롭게 쓸 수 있습니다. 대신 로드 밸런싱과 프로세스 관리도 직접 책임져야 합니다.
사용자 수가 아키텍처 복잡도를 결정합니다
동시에 접속할 사용자가 몇 명인가요?
- 100명 미만: Long Polling이면 충분하며 한 시간 안에 만들 수 있을 정도로 코드가 간단합니다.
- 100~1000명: SSE 또는 간단한 WebSocket 방식
- 1000명 초과: 메시지 큐(Redis Pub/Sub), 로드 밸런싱, 다중 인스턴스 배포 필요
한 스타트업 팀은 첫 버전에서 Long Polling을 사용하다 사용자가 500명으로 늘어난 뒤 SSE로 전환했습니다. 초기에 아이디어를 빠르게 검증하고 나중에 성능을 최적화하는 좋은 접근입니다.
메시지 빈도도 선택에 영향을 줍니다
분당 몇 건만 발생하는 알림 시스템이라면 Long Polling으로 충분합니다. 수십 명이 동시에 메시지를 보내는 채팅방이라면 SSE나 WebSocket이 적합합니다.
쉽게 놓치는 점도 있습니다. 브라우저는 같은 도메인에 대한 HTTP 연결 수를 제한합니다. 보통 최대 6개입니다. Long Polling이나 SSE를 쓰면서 탭을 여러 개 열면 멈출 수 있습니다. 이때는 WebSocket을 사용하거나 단일 탭 감지를 구현해야 합니다.
예산도 중요한 기준입니다
Pusher, Ably, PubNub 같은 타사 WebSocket 서비스는 편리하지만 메시지 수에 따라 과금됩니다. 동시 접속자 500명인 채팅방을 계산해 보니 WebSocket 비용만 월 $49~$99였습니다.
직접 배포할 때의 비용은 다음과 같습니다.
- Vercel+SSE: 무료 할당량이 넉넉해 소규모 프로젝트는 무료
- VPS+WebSocket: 월 $5부터(Vultr, DigitalOcean)
- Railway/Render: WebSocket 지원, 월 $5~$10
제 판단 트리
Vercel에 배포하나요?
├─ 예 → 사용자 > 1000명?
│ ├─ 예 → SSE + Redis Pub/Sub
│ └─ 아니요 → 간단한 SSE 또는 Long Polling
└─ 아니요(자체 호스팅) → 빈번한 양방향 통신이 필요한가요?
├─ 예 → WebSocket + Socket.io
└─ 아니요 → SSE
결국 기술 선택에 절대적인 정답은 없습니다. 제 경험으로는 가장 간단한 방식으로 먼저 출시하고 실제로 한계에 부딪혔을 때 최적화하는 것이 좋습니다. 처음부터 WebSocket 클러스터를 구축한 프로젝트 중 상당수는 결국 사용자 100명도 모으지 못했습니다.
Socket.io 통합 실전
직접 서버를 임대하는 등 WebSocket을 사용하기로 했다면 Socket.io가 가장 성숙한 라이브러리입니다. Long Polling으로 자동 폴백하고 재연결, 방 관리 같은 기능도 내장합니다.
하지만 Next.js에 Socket.io를 통합할 때는 예상보다 함정이 많습니다. App Router의 Route Handler는 res.socket을 지원하지 않으므로 Custom Server가 필요합니다.
1단계: Custom Server 만들기
Next.js의 기본 실행 방식은 WebSocket을 지원하지 않으므로 서버를 직접 정의해야 합니다. 프로젝트 루트에 server.js를 만듭니다.
// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { Server } = require('socket.io');
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 3000;
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
// Socket.io 초기화
const io = new Server(httpServer, {
cors: {
origin: dev ? 'http://localhost:3000' : 'https://yourdomain.com',
methods: ['GET', 'POST']
}
});
// 연결 처리
io.on('connection', (socket) => {
console.log('사용자 연결:', socket.id);
// 방 입장
socket.on('join_room', (roomId) => {
socket.join(roomId);
console.log(`사용자 ${socket.id}님이 방 ${roomId}에 입장했습니다.`);
});
// 메시지 수신
socket.on('send_message', (data) => {
// 방 안의 모든 사용자에게 전송(자신 포함)
io.to(data.room).emit('receive_message', {
id: Date.now(),
user: data.user,
message: data.message,
timestamp: new Date().toISOString()
});
});
socket.on('disconnect', () => {
console.log('사용자 연결 종료:', socket.id);
});
});
httpServer.listen(port, (err) => {
if (err) throw err;
console.log(`> Ready on http://${hostname}:${port}`);
});
});
package.json을 수정합니다.
{
"scripts": {
"dev": "node server.js",
"build": "next build",
"start": "NODE_ENV=production node server.js"
}
}
2단계: 클라이언트 연결
Socket.io 클라이언트 로직을 Hook으로 감쌉니다.
// hooks/useSocket.ts
'use client';
import { useEffect, useState } from 'react';
import io, { Socket } from 'socket.io-client';
export function useSocket() {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socketInstance = io('http://localhost:3000', {
transports: ['websocket', 'polling'] // WebSocket 우선, polling으로 폴백
});
socketInstance.on('connect', () => {
console.log('Socket 연결됨');
setIsConnected(true);
});
socketInstance.on('disconnect', () => {
console.log('Socket 연결 종료');
setIsConnected(false);
});
setSocket(socketInstance);
return () => {
socketInstance.disconnect();
};
}, []);
return { socket, isConnected };
}
3단계: 채팅 컴포넌트
// app/chat/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { useSocket } from '@/hooks/useSocket';
interface Message {
id: number;
user: string;
message: string;
timestamp: string;
}
export default function ChatPage() {
const { socket, isConnected } = useSocket();
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [username] = useState(`사용자${Math.floor(Math.random() * 1000)}`);
const roomId = 'general'; // 고정 방. 실제 프로젝트에서는 동적으로 생성 가능
useEffect(() => {
if (!socket) return;
// 방 입장
socket.emit('join_room', roomId);
// 새 메시지 수신
socket.on('receive_message', (data: Message) => {
setMessages((prev) => [...prev, data]);
});
return () => {
socket.off('receive_message');
};
}, [socket, roomId]);
const sendMessage = () => {
if (!socket || !inputMessage.trim()) return;
socket.emit('send_message', {
room: roomId,
user: username,
message: inputMessage
});
setInputMessage('');
};
return (
<div className="max-w-2xl mx-auto p-4">
<div className="mb-4">
<span className={`inline-block w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="ml-2">{isConnected ? '연결됨' : '연결 안 됨'}</span>
</div>
<div className="border rounded-lg p-4 h-96 overflow-y-auto mb-4 bg-gray-50">
{messages.map((msg) => (
<div key={msg.id} className="mb-2">
<span className="font-semibold text-blue-600">{msg.user}:</span>
<span className="ml-2">{msg.message}</span>
<span className="ml-2 text-xs text-gray-500">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="메시지를 입력하세요..."
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={sendMessage}
disabled={!isConnected}
className="bg-blue-500 text-white px-6 py-2 rounded disabled:bg-gray-300"
>
보내기
</button>
</div>
</div>
);
}
직접 겪은 문제
-
Hot Reload 문제: 개발 중 코드를 저장할 때마다 Socket 연결이 끊깁니다. Next.js Hot Reload의 부작용이라 완전히 피할 수는 없으므로 익숙해지는 편이 낫습니다.
-
CORS 오류: 클라이언트와 서버의 포트가 다르면 Socket.io 설정에
cors옵션을 추가해야 합니다. -
TypeScript 타입:
@types/socket.io-client를 설치하지 않으면 타입 지원이 제대로 되지 않습니다. -
배포 주의사항: Custom Server는 Vercel에 배포할 수 없습니다. VPS나 Railway, Render처럼 WebSocket을 지원하는 플랫폼을 사용해야 합니다.
SSE(Server-Sent Events) 구현 방식
SSE는 Vercel에서 실시간 기능을 구현할 때 제 구원투수였습니다. 코드는 WebSocket보다 훨씬 간단하고 Custom Server도 필요 없습니다.
서버: Route Handler로 SSE 구현
App Router의 Route Handler는 ReadableStream을 반환할 수 있어 SSE에 알맞습니다.
// app/api/sse/route.ts
import { NextRequest } from 'next/server';
// 메시지 큐 모의 구현(실제 프로젝트에서는 Redis Pub/Sub 사용)
const messageQueue: { id: string; message: string }[] = [];
const listeners = new Set<(message: any) => void>();
export async function GET(request: NextRequest) {
// 읽기 가능한 스트림 생성
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
// 최초 연결 메시지 전송
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
);
// 새 메시지 수신
const listener = (message: any) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
);
};
listeners.add(listener);
// 연결 종료를 막기 위해 주기적으로 하트비트 전송
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
}, 15000); // 15초마다 한 번
// 정리 함수
request.signal.addEventListener('abort', () => {
listeners.delete(listener);
clearInterval(heartbeat);
controller.close();
});
}
});
// SSE 응답 반환
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
}
// 메시지를 전송하는 POST API
export async function POST(request: NextRequest) {
const body = await request.json();
const message = {
id: Date.now().toString(),
user: body.user,
message: body.message,
timestamp: new Date().toISOString()
};
// 모든 리스너에 알림
listeners.forEach(listener => listener(message));
return Response.json({ success: true });
}
클라이언트: EventSource로 SSE 소비
// app/sse-chat/page.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
interface Message {
id: string;
user: string;
message: string;
timestamp: string;
}
export default function SSEChatPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [isConnected, setIsConnected] = useState(false);
const [username] = useState(`사용자${Math.floor(Math.random() * 1000)}`);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
// SSE 연결 생성
const eventSource = new EventSource('/api/sse');
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('SSE 연결 생성됨');
setIsConnected(true);
};
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'connected') {
console.log('서버 연결 확인 수신');
return;
}
// 새 메시지 수신
setMessages((prev) => [...prev, data]);
};
eventSource.onerror = () => {
console.error('SSE 연결 오류');
setIsConnected(false);
};
// 정리 함수
return () => {
eventSource.close();
};
}, []);
const sendMessage = async () => {
if (!inputMessage.trim()) return;
try {
await fetch('/api/sse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user: username,
message: inputMessage
})
});
setInputMessage('');
} catch (error) {
console.error('메시지 전송 실패:', error);
}
};
return (
<div className="max-w-2xl mx-auto p-4">
<div className="mb-4">
<span className={`inline-block w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="ml-2">{isConnected ? 'SSE 연결됨' : 'SSE 연결 안 됨'}</span>
</div>
<div className="border rounded-lg p-4 h-96 overflow-y-auto mb-4 bg-gray-50">
{messages.map((msg) => (
<div key={msg.id} className="mb-2">
<span className="font-semibold text-purple-600">{msg.user}:</span>
<span className="ml-2">{msg.message}</span>
<span className="ml-2 text-xs text-gray-500">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
<div className="flex gap-2">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="메시지를 입력하세요..."
className="flex-1 border rounded px-3 py-2"
/>
<button
onClick={sendMessage}
disabled={!isConnected}
className="bg-purple-500 text-white px-6 py-2 rounded disabled:bg-gray-300"
>
보내기
</button>
</div>
</div>
);
}
SSE를 실제로 사용해 본 경험
솔직히 SSE는 완벽한 해법이 아닙니다. 사용하면서 몇 가지 문제를 발견했습니다.
-
Vercel의 25초 시간 제한: Edge Function은 25초가 지나면 강제로 종료됩니다. 클라이언트가 연결 종료를 감지하면 자동으로 다시 연결하도록 처리했습니다.
-
브라우저 연결 수 제한: 같은 도메인에는 최대 6개의 HTTP/1.1 연결만 허용됩니다. 탭을 여러 개 열면 멈출 수 있습니다. HTTP/2를 사용하거나(Vercel은 기본 지원) 단일 탭 감지를 구현하면 됩니다.
-
메시지 브로드캐스트 문제: 위 코드는 단일 인스턴스에서는 작동하지만 Vercel은 다중 인스턴스로 배포됩니다. 실제 메시지 브로드캐스트를 구현하려면 Redis Pub/Sub이나 Upstash가 필요합니다.
Redis로 인스턴스 간 메시지 브로드캐스트 구현
// lib/redis.ts
import { Redis } from '@upstash/redis';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!
});
// app/api/sse/route.ts(개선 버전)
import { redis } from '@/lib/redis';
export async function GET(request: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Redis 구독
const channelName = 'chat_messages';
// Redis 폴링(Upstash는 기본 SUBSCRIBE를 지원하지 않음)
const interval = setInterval(async () => {
const messages = await redis.lrange(channelName, 0, -1);
// 메시지 처리...
}, 1000);
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
}
});
}
SSE는 언제 사용해야 할까요?
제 권장 사항은 다음과 같습니다.
- ✅ 실시간 알림 시스템
- ✅ 주가 푸시
- ✅ 로그 실시간 조회
- ✅ 진행률 업데이트
- ❌ 빈번한 양방향 채팅(WebSocket 사용)
- ❌ 온라인 게임(WebSocket 사용)
메시지 상태와 데이터 동기화
실시간 통신은 메시지를 주고받는 것만으로 끝나지 않습니다. 사용자는 메시지가 전송됐는지, 상대에게 도착했는지, 네트워크가 끊겼을 때 메시지가 사라지는지 궁금해합니다.
이 문제들은 모두 메시지 상태 관리와 데이터 동기화에 관한 것입니다. 채팅 기능을 직접 만들 때 이 부분에서 며칠 동안 막혔습니다.
메시지의 네 가지 상태
WeChat의 설계를 참고하면 메시지에는 최소 네 가지 상태가 필요합니다.
- 전송 중: 사용자가 전송을 눌렀지만 아직 서버 응답을 받지 못함
- 전송됨: 서버가 받았지만 상대방은 아직 받지 못했을 수 있음
- 전달됨: 상대방의 클라이언트가 받음
- 전송 실패: 네트워크 오류 또는 서버 거부
간단한 상태 머신으로 관리합니다.
// types/message.ts
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'failed';
export interface Message {
id: string;
localId: string; // 클라이언트에서 생성한 임시 ID
user: string;
content: string;
timestamp: string;
status: MessageStatus;
}
낙관적 업데이트+재시도
서버 응답을 기다린 다음 메시지를 표시하면 사용자 경험이 좋지 않습니다. 낙관적 업데이트를 사용해 전송 전에 먼저 ‘전송 중’ 상태로 표시하고 서버가 성공 응답을 보내면 상태를 갱신합니다.
// hooks/useChat.ts
'use client';
import { useState } from 'react';
import { Message, MessageStatus } from '@/types/message';
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const sendMessage = async (content: string, username: string) => {
// 임시 ID 생성
const localId = `local_${Date.now()}_${Math.random()}`;
// 낙관적 업데이트: 메시지를 즉시 표시
const tempMessage: Message = {
id: '',
localId,
user: username,
content,
timestamp: new Date().toISOString(),
status: 'sending'
};
setMessages((prev) => [...prev, tempMessage]);
try {
// 서버에 전송
const response = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: username, message: content })
});
const data = await response.json();
// '전송됨'으로 갱신
setMessages((prev) =>
prev.map((msg) =>
msg.localId === localId
? { ...msg, id: data.id, status: 'sent' }
: msg
)
);
} catch (error) {
// 전송 실패
setMessages((prev) =>
prev.map((msg) =>
msg.localId === localId ? { ...msg, status: 'failed' } : msg
)
);
}
};
const retryMessage = async (localId: string) => {
const message = messages.find((msg) => msg.localId === localId);
if (!message) return;
// 상태를 '전송 중'으로 초기화
setMessages((prev) =>
prev.map((msg) =>
msg.localId === localId ? { ...msg, status: 'sending' } : msg
)
);
// 다시 전송
await sendMessage(message.content, message.user);
};
return { messages, sendMessage, retryMessage };
}
연결 복구와 메시지 영속화
네트워크가 불안정하면 연결이 끊어질 수 있습니다. 다시 연결한 뒤 메시지가 사라지지 않게 하려면 어떻게 해야 할까요?
제가 사용한 방식은 클라이언트의 IndexedDB에 확인되지 않은 메시지를 저장하고 재연결 뒤 다시 보내는 것입니다.
// lib/indexedDB.ts
const DB_NAME = 'ChatDB';
const STORE_NAME = 'pendingMessages';
export async function openDB() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'localId' });
}
};
});
}
export async function savePendingMessage(message: Message) {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).add(message);
}
export async function removePendingMessage(localId: string) {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(localId);
}
export async function getAllPendingMessages(): Promise<Message[]> {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
return new Promise((resolve) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
});
}
실전에서 얻은 교훈
- 처음부터 완벽을 추구하지 마세요: 기본 송수신부터 구현한 뒤 상태 관리를 추가합니다.
- 전송 실패를 먼저 처리하세요: 사용자는 메시지가 제대로 나갔는지를 가장 중요하게 생각합니다. 읽음 기능은 오히려 덜 중요합니다.
- IndexedDB는 구원투수입니다: 네트워크가 불안정할 때 로컬 저장소가 큰 도움이 됩니다.
- 오프라인 환경을 테스트하세요: Chrome DevTools의 Network 옵션에서 Offline을 시뮬레이션해 여러 번 테스트합니다.
프로덕션 배포와 성능 최적화
개발 환경에서는 잘 작동하던 기능이 배포한 순간 여러 문제를 일으키는 일은 실시간 통신에서 흔합니다. 시행착오를 줄이는 핵심 사항을 정리했습니다.
배포 플랫폼 선택과 제약
앞서 설명했듯 Vercel은 WebSocket을 지원하지 않습니다. 플랫폼별 차이는 다음과 같습니다.
| 플랫폼 | WebSocket | SSE | 특별한 제약 |
|---|---|---|---|
| Vercel | ❌ | ✅ | Edge: 25초 시간 제한, Serverless: 60초 시간 제한 |
| Netlify | ❌ | ✅ | Function 10초 시간 제한 |
| Railway | ✅ | ✅ | 강제 시간 제한 없음, 트래픽 기준 과금 |
| Render | ✅ | ✅ | 무료 플랜 절전 모드 |
| Cloudflare Pages | ❌ | ✅ | Workers CPU 시간 제한 |
| 자체 호스팅 VPS | ✅ | ✅ | 서버를 직접 관리 |
제 권장 사항은 다음과 같습니다.
- 예산이 적고 동시 접속이 낮음: Vercel+SSE(무료 할당량이 넉넉함)
- WebSocket 필요: Railway 또는 Render($5~$10/월)
- 높은 동시 접속+충분한 예산: 자체 호스팅 VPS+Nginx 리버스 프록시
Vercel에서 SSE 최적화
Vercel Edge Function의 시간 제한은 25초뿐입니다. 자동 재연결+하트비트 유지로 해결했습니다.
// hooks/useSSE.ts
'use client';
import { useEffect, useRef, useState } from 'react';
export function useSSE(url: string) {
const [isConnected, setIsConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const connect = () => {
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('SSE 연결됨');
setIsConnected(true);
};
eventSource.onerror = () => {
console.error('SSE 연결 오류. 3초 후 다시 연결합니다...');
setIsConnected(false);
eventSource.close();
// 자동 재연결
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, 3000);
};
return eventSource;
};
useEffect(() => {
const eventSource = connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
eventSource.close();
};
}, [url]);
return { eventSource: eventSourceRef.current, isConnected };
}
다중 인스턴스 배포의 메시지 동기화
Vercel은 여러 인스턴스를 자동으로 만듭니다. 사용자 A는 인스턴스 1에, 사용자 B는 인스턴스 2에 연결됐다면 어떻게 메시지를 주고받을까요?
정답은 Redis Pub/Sub입니다.
Serverless Redis인 Upstash로 다음 방식을 구현한 적이 있습니다.
// lib/redis.ts
import { Redis } from '@upstash/redis';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!
});
// Redis에 메시지 전송
export async function publishMessage(channel: string, message: any) {
await redis.lpush(channel, JSON.stringify(message));
await redis.ltrim(channel, 0, 99); // 최근 100개만 유지
}
// 최근 메시지 가져오기
export async function getRecentMessages(channel: string) {
const messages = await redis.lrange(channel, 0, -1);
return messages.map((m) => JSON.parse(m as string)).reverse();
}
성능 최적화 체크리스트
-
메시지 중복 제거: 클라이언트가 같은 메시지를 여러 번 받을 수 있으므로 받은 메시지 ID를
Set에 기록합니다. -
가상 스크롤: 메시지가 100개를 넘으면
react-window나react-virtualized로 렌더링합니다. -
이전 메시지 지연 로딩: 전체 기록을 한 번에 불러오지 말고 맨 위로 스크롤할 때 추가로 가져옵니다.
-
속도 제한: 사용자가 메시지를 지나치게 많이 보내지 못하도록 클라이언트와 서버 양쪽에서 제한합니다.
// 간단한 클라이언트 속도 제한
let lastSendTime = 0;
const SEND_INTERVAL = 500; // 500ms에 한 메시지만 전송
const sendMessage = async (content: string) => {
const now = Date.now();
if (now - lastSendTime < SEND_INTERVAL) {
alert('너무 빠르게 전송했습니다. 잠시 후 다시 시도하세요.');
return;
}
lastSendTime = now;
// ... 전송 로직
};
- 모니터링과 로그: Sentry나 LogRocket으로 SSE 연결 실패, 메시지 전송 실패 같은 오류를 추적합니다.
비용 관리
실시간 기능은 특히 WebSocket 서비스를 사용할 때 비용이 많이 듭니다. 비용을 줄일 수 있는 방법은 다음과 같습니다.
- WebSocket 대신 SSE 사용: 별도 WebSocket 서버 비용을 절약할 수 있습니다.
- 메시지 묶음 전송: 메시지를 하나씩 푸시하지 말고 1초마다 모아 일괄 전송합니다.
- Redis는 Upstash 사용: 요청량에 따라 과금되므로 직접 Redis 서버를 구축하는 것보다 저렴합니다.
- 정적 리소스에 CDN 사용: Next.js 앱의 정적 리소스를 CDN으로 제공해 서버 부하를 줄입니다.
제가 만든 동시 접속자 500명 규모의 채팅방은 Vercel+Upstash로 월 $15 미만에 운영했습니다. 핵심은 상황에 맞는 방식을 선택하는 것입니다.
마무리
글을 시작할 때 이야기한 새벽의 배포 실패로 돌아가 보겠습니다. 그때 이 내용을 알았다면 WebSocket만 붙잡고 씨름하지 않았을 것입니다.
Next.js 실시간 통신의 핵심은 현실적인 선택입니다.
- Vercel에 배포하나요? WebSocket을 억지로 쓰지 말고 SSE를 사용하세요.
- 예산이 부족한가요? 처음부터 기술을 쌓아 올리지 말고 가장 간단한 방식으로 먼저 실행하세요.
- 사용자 경험이 우선인가요? 화려한 기술보다 메시지 상태 관리와 연결 복구가 훨씬 중요합니다.
이 글의 코드는 바로 가져다 쓸 수 있지만 더 중요한 것은 그 뒤의 판단 기준을 이해하는 것입니다. 기술 선택에 만능 해법은 없습니다. 자신의 프로젝트에 맞는 방식이 가장 좋은 방식입니다.
실시간 기능을 만들고 있다면 이 글이 시행착오를 줄이는 데 도움이 되길 바랍니다. 궁금한 점은 댓글로 남겨 주세요. 가능한 한 답변하겠습니다.
다음 단계는 다음과 같습니다.
- 먼저 로컬에서 가장 간단한 SSE 예제를 실행합니다.
- Vercel에 배포해 테스트합니다.
- WebSocket이 필요하다면 Railway나 자체 호스팅을 검토합니다.
기술 부채 자체는 두렵지 않습니다. 처음부터 불필요한 부채를 떠안는 것이 더 큰 문제입니다. 힘내세요!
FAQ
Vercel은 왜 WebSocket을 지원하지 않나요?
해결 방법은 세 가지입니다.
• Vercel이 기본 지원하는 SSE(Server-Sent Events)로 대체
• 독립 서버(VPS)를 임대하거나 WebSocket 지원 플랫폼(Railway, Render) 사용
• 타사 WebSocket 서비스(Pusher, Ably) 사용. 단 비용이 높음($49-99/월)
대부분의 경우 SSE로 충분하며 비용도 더 낮습니다.
채팅 앱에는 SSE와 WebSocket 중 어느 쪽이 더 적합한가요?
• SSE가 적합한 경우: 실시간 알림이나 채팅 메시지 수신처럼 단방향 푸시가 중심이고, Vercel/Netlify 같은 Serverless 플랫폼에 배포하며, 사용자 수가 1000명 미만일 때
• WebSocket이 적합한 경우: 다중 사용자 공동 편집이나 온라인 게임처럼 빈번한 양방향 통신과 낮은 지연 시간이 필요하고 자체 서버를 운영할 때
채팅 앱은 보통 SSE(메시지 수신)+HTTP POST(메시지 전송) 조합으로 구현할 수 있어 비용이 낮고 배포도 간단합니다. 제가 만든 동시 접속자 500명 규모의 채팅방은 SSE+Upstash Redis로 월 $15 미만에 운영했습니다.
Vercel의 25초 시간 제한은 어떻게 처리하나요?
• 서버가 하트비트 패킷 전송: 15초마다 한 번 보내 연결이 유휴 상태로 판단되지 않게 함
• 클라이언트가 연결 종료 감지: EventSource의 onerror 이벤트로 연결 중단 감지
• 자동 재연결: 중단을 감지하면 3초 뒤 자동 연결
• Redis에 메시지 저장: 재연결 뒤 읽지 않은 메시지를 가져오도록 보장
실제로는 사용자가 재연결 과정을 거의 느끼지 못하며 지속 연결과 다르지 않은 경험을 제공합니다.
다중 인스턴스 배포에서 메시지를 어떻게 동기화하나요?
• 사용자 A가 메시지 전송 → 인스턴스 1이 Redis에 기록
• 인스턴스 1, 2, 3이 모두 Redis를 폴링해 새 메시지 확인
• 각 인스턴스가 자신에게 연결된 사용자에게 메시지 푸시
요청량 기반으로 과금되는 Serverless Redis인 Upstash를 권장합니다. 자체 Redis보다 저렴합니다. 폴링 간격은 1초로 설정해 실시간성과 비용 사이의 균형을 맞춥니다.
메시지 유실을 어떻게 방지하나요?
• 낙관적 업데이트: 전송 전에 화면에 즉시 표시하고 상태를 '전송 중'으로 설정
• IndexedDB 로컬 저장: 확인되지 않은 메시지를 로컬 데이터베이스에 보관
• 서버 응답 확인: 성공 응답을 받으면 상태를 '전송됨'으로 변경
• 연결 복구: 재연결 후 IndexedDB의 미확인 메시지를 읽어 자동 재전송
• 재시도: 실패한 메시지에 재전송 버튼을 표시해 사용자가 직접 재시도
WeChat의 메시지 상태 설계를 참고해 전송 중, 전송됨, 전달됨, 전송 실패 네 가지 상태를 사용합니다.
Socket.io Custom Server를 Vercel에 배포할 수 있나요?
대안은 다음과 같습니다.
• WebSocket 지원 플랫폼 사용: Railway($5/월부터), Render($7/월부터)
• 자체 호스팅 VPS: Vultr, DigitalOcean($5/월부터)
• 혼합 배포: Next.js 앱은 Vercel에, WebSocket 서비스는 별도 배포
Vercel을 꼭 사용해야 한다면 대부분의 실시간 통신 요구를 충족하는 SSE 방식을 권장합니다.
실시간 채팅 앱의 성능 병목은 어디에서 발생하나요?
• 메시지 목록 렌더링: 100개가 넘으면 가상 스크롤(react-window) 사용
• 이전 메시지 로딩: 지연 로딩하고 맨 위로 스크롤할 때 페이지 단위로 로드
• 클라이언트 속도 제한: 악의적인 도배를 막기 위해 500ms에 한 메시지만 전송
• 서버 속도 제한: API Route에 Rate Limiting 적용
• 메시지 중복 제거: 받은 메시지 ID를 Set에 기록해 중복 렌더링 방지
• 연결 수 제한: HTTP/1.1은 도메인당 최대 6개 연결이므로 HTTP/2 또는 단일 탭 감지 사용
모니터링 도구로는 Sentry(오류 추적), LogRocket(세션 재생), Vercel Analytics(성능 모니터링)를 권장합니다.
8분 읽기 · 게시일: 2026년 1월 7일 · 수정일: 2026년 9월 4일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js API 인증과 보안: JWT부터 속도 제한까지 완벽 실전 가이드
JWT 인증, CORS 설정, 속도 제한, 입력 검증부터 최신 보안 취약점 대응까지 안전하고 신뢰할 수 있는 프로덕션급 Next.js API를 구축하는 실전 가이드입니다.
45편 중 17편
다음
Next.js 데이터베이스 선택 가이드: PostgreSQL, MySQL, MongoDB 및 클라우드 서비스 완전 비교
Next.js 프로젝트에 어떤 데이터베이스를 선택해야 할지 고민되시나요? 이 가이드는 PostgreSQL, MySQL, MongoDB를 비교하고 Vercel Postgres, Supabase, PlanetScale, MongoDB Atlas를 분석해, 세 가지 질문만으로 빠르게 결정하고 시행착오를 피하도록 도와드립니다.
45편 중 19편



댓글
GitHub로 로그인하여 댓글을 남기세요