WebSocket 통합
HTTP와 WebSocket의 연결 방식을 비교하고 별도 Socket.IO 서버와 Next.js 클라이언트를 연결해 채팅을 구현합니다.
현대 웹 애플리케이션에는 실시간 통신이 꼭 필요한 경우가 많습니다. 채팅, 실시간 알림, 주식 시세, 온라인 게임, 협업 도구는 서버와 클라이언트 사이에 양방향으로 데이터를 주고받아야 합니다. HTTP도 요청과 응답에서 양쪽이 데이터를 보내지만, 일반적인 HTTP 메시지 교환은 클라이언트가 요청을 시작해야 합니다. 서버와 클라이언트가 각자 메시지를 보내야 하는 지속 연결에는 WebSocket이 알맞습니다. 이 글에서는 WebSocket과 HTTP의 차이, Next.js에 통합하는 방식과 배포할 때 실시간 서버를 어디에서 실행하는지 구분하고, 별도의 Socket.IO 서버를 연결하는 예제를 살펴봅니다.
WebSocket이란? HTTP와의 차이점
WebSocket은 웹 브라우저와 웹 서버 사이에 하나의 TCP 연결로 전이중(full-duplex) 통신 채널을 제공하는 프로토콜입니다. 일단 연결이 이루어지면 클라이언트와 서버가 각자 데이터를 주고받을 수 있으며, 이는 HTTP의 요청-응답 모델과 근본적으로 다릅니다.
일반 HTTP 요청은 클라이언트가 시작하지만, WebSocket은 연결이 열려 있는 동안 양쪽이 각자 메시지를 보낼 수 있습니다. 연결 수명에서는 HTTP도 연결을 다시 쓰며, WebSocket의 지속 연결도 네트워크나 서버 정책에 따라 끊길 수 있습니다. 메시지 비용에서는 HTTP가 메시지마다 헤더를 쓰고 WebSocket은 핸드셰이크 뒤에 프레임을 쓰지만, 실제 오버헤드는 HTTP 버전, 압축, 전송량에 따라 비교해야 합니다. 일반 페이지와 API에는 HTTP가 알맞고, 양방향 채팅, 게임, 협업에는 WebSocket을 검토하며, 서버에서만 보내는 알림에는 SSE나 HTTP 스트리밍도 선택지가 됩니다.
WebSocket은 HTTP 기반의 초기 핸드셰이크로 연결을 맺은 뒤, ws://나 보안 연결인 wss:// 프로토콜로 바꾸어 지속적인 통신 채널을 유지합니다. 핸드셰이크에서 오가는 헤더와 연결 뒤에 주고받는 프레임의 구조는 WebSocket과 실시간 통신에서 자세히 다룹니다.
Next.js에 WebSocket을 통합하는 전략
App Router의 일반 Route Handler는 HTTP의 요청과 응답 인터페이스입니다. 연결 업그레이드(upgrade)를 처리할 서버와 배포 방식은 따로 골라야 하며, 주요 전략은 세 가지입니다.
첫째는 직접 운영하는 HTTP 서버에 통합하는 방식입니다. 자체 Node.js 호스팅에서는 http.createServer()에 Next.js 요청 처리기와 Socket.IO를 함께 연결할 수 있습니다. 이는 next start의 Route Handler에 붙이는 방식이 아니라, 별도의 사용자 정의 서버(custom server) 진입점을 실행하는 방식이므로 프레임워크 최적화와 배포 지원 범위를 먼저 확인합니다.
둘째는 Next.js 애플리케이션과 독립적으로 동작하는 별도의 WebSocket 서버를 운영하는 방식으로, 가장 일반적입니다. 이 서버는 Node.js(Express와 socket.io 또는 ws), Python(Flask-SocketIO), Go 등 어떤 기술로든 만들 수 있습니다. WebSocket 서버의 확장과 관리를 Next.js 앱과 나누어 유연하게 다룰 수 있고, 연결 시간과 확장 한도는 고른 서버 플랫폼에서 관리합니다. 대신 별도의 서버를 배포하고 관리하는 부담이 더해집니다.
셋째는 Pusher, Ably, PubNub, AWS IoT Core 같은 관리형 WebSocket 서비스를 쓰는 방식입니다. 이런 서비스는 WebSocket 서버를 만들고 관리하는 복잡함을 감싸 주고, 메시지 중개, 확장, 인증 등을 처리합니다. 연결 인프라의 운영을 서비스에 맡길 수 있지만 지원 기능, 가용성 조건, 사용량 한도는 서비스마다 확인해야 하며, 서비스 비용이 들고 특정 서비스에 묶이게 됩니다.
이 글의 코드는 별도의 Socket.IO 서버를 실행하는 구성을 씁니다.
socket.io를 사용한 통합
socket.io는 WebSocket을 전송 방식 가운데 하나로 쓰며, WebSocket 연결이 불가능하면 HTTP 롱 폴링(long-polling) 같은 다른 전송 방식으로 바꿀 수 있는 실시간 통신 라이브러리입니다. 다만 Socket.IO는 자체 프로토콜을 쓰므로, 순수 WebSocket 클라이언트가 Socket.IO 서버에 그대로 연결되지는 않습니다. 여기서는 클라이언트가 메시지를 보내면 연결된 모든 클라이언트에게 그 메시지를 방송(broadcast)하는 간단한 채팅 애플리케이션을 구현합니다.
별도의 WebSocket 서버 구축
Node.js, Express, socket.io로 구현하려고 프로젝트 루트에 server 디렉터리를 만들고, 그 안에 WebSocket 서버 코드를 작성합니다. server 폴더는 Next.js 앱과 별개의 Node.js 프로젝트이므로, 아래 명령은 이 폴더 안에서 npm init -y로 따로 package.json을 만들고 의존성을 설치합니다. 그래서 Next.js 앱의 package.json에는 express나 socket.io가 추가되지 않습니다.
# 프로젝트 루트에서
mkdir server
cd server
npm init -y
npm install express socket.ioserver/index.js에 WebSocket 서버를 작성합니다. 아래 코드는 Express 앱으로 HTTP 서버를 만들고, 그 서버에 Socket.IO를 붙여 같은 포트에서 실시간 연결을 받습니다. 브라우저의 Next.js 페이지(localhost:3000)와 이 서버(localhost:4100)는 포트가 달라 출처가 다르므로, cors.origin에 Next.js 주소를 허용해야 브라우저가 연결 요청을 막지 않습니다. io.on('connection') 안의 socket은 연결된 클라이언트 하나를 뜻하며, 메시지를 받으면 형식과 길이를 검사한 뒤 io.emit으로 연결된 모든 클라이언트에게 보냅니다.
const express = require('express');
const http = require('http');
const { Server } = require('socket.io'); // socket.io 서버 임포트
const app = express();
const server = http.createServer(app);
// CORS 설정: Next.js 앱이 실행되는 도메인 허용
const io = new Server(server, {
cors: {
origin: "http://localhost:3000", // Next.js 개발 서버 주소
methods: ["GET", "POST"]
}
});
// 클라이언트 연결 이벤트 처리
io.on('connection', (socket) => {
console.log('새로운 WebSocket 클라이언트가 연결되었습니다.', socket.id);
// 'chat message' 이벤트 수신
socket.on('chat message', (rawMessage) => {
if (typeof rawMessage !== 'string') return;
const message = rawMessage.trim();
if (!message || message.length > 500) return;
console.log('메시지 수신:', message);
// 모든 연결된 클라이언트에게 메시지 브로드캐스트
io.emit('chat message', message);
});
// 클라이언트 연결 해제 이벤트 처리
socket.on('disconnect', () => {
console.log('WebSocket 클라이언트 연결 해제됨:', socket.id);
});
});
const PORT = process.env.PORT || 4100; // WebSocket 서버 포트
server.listen(PORT, () => {
console.log(`WebSocket 서버가 포트 ${PORT}에서 실행 중입니다.`);
});이 서버는 Next.js 앱과 따로 실행해야 하며, cd server && node index.js로 실행할 수 있습니다. 포트 4100은 앞에서 정한 대로 별도 백엔드와 실시간 서버에 쓰는 포트입니다.
Next.js 클라이언트 애플리케이션 설정
Next.js 앱에서는 socket.io-client로 WebSocket 서버에 연결합니다. 서버 쪽 패키지인 socket.io와 달리, 브라우저에서 서버에 연결하는 클라이언트 라이브러리는 별도 패키지입니다. 이번에는 server 폴더가 아니라 Next.js 프로젝트 루트에서 설치합니다.
# Next.js 프로젝트 루트에서
npm install socket.io-client
# 또는
yarn add socket.io-client채팅 페이지 src/app/[locale]/chat/page.tsx는 클라이언트 컴포넌트로, 마운트될 때 서버에 연결하고 정리 함수에서 연결을 끊습니다. 소켓 인스턴스는 화면에 그릴 값이 아니므로 상태가 아니라 useRef에 담아, 다시 렌더링되어도 같은 연결을 계속 씁니다. 받은 메시지는 setMessages에 이전 배열을 받는 함수를 넘겨 추가합니다. 이 처리기는 첫 렌더링 때 한 번 등록되므로, 바깥의 messages를 직접 읽으면 처음의 빈 배열만 보게 되기 때문입니다.
"use client";
import React, { useState, useEffect, useRef, FormEvent } from 'react';
import { io, Socket } from 'socket.io-client';
// WebSocket 서버 주소 (환경 변수로 관리하는 것이 좋음)
const SOCKET_SERVER_URL = process.env.NEXT_PUBLIC_SOCKET_SERVER_URL || 'http://localhost:4100';
export default function ChatPage() {
const [messageInput, setMessageInput] = useState('');
const [messages, setMessages] = useState<string[]>([]);
const socketRef = useRef<Socket | null>(null); // Socket 인스턴스를 저장할 ref
const messagesEndRef = useRef<HTMLDivElement>(null); // 메시지 스크롤을 위한 ref
useEffect(() => {
// 1. Socket.IO 클라이언트 연결
// Effect 설정 시 연결하고 정리 함수에서 해제
socketRef.current = io(SOCKET_SERVER_URL);
// 2. 'chat message' 이벤트 리스너 등록
socketRef.current.on('chat message', (msg: string) => {
setMessages((prevMessages) => [...prevMessages, msg]);
});
// 3. 연결 성공/실패 로깅 (선택 사항)
socketRef.current.on('connect', () => {
console.log('Socket.IO 서버에 연결되었습니다.', socketRef.current?.id);
});
socketRef.current.on('disconnect', () => {
console.log('Socket.IO 서버와 연결이 끊어졌습니다.');
});
socketRef.current.on('connect_error', (err) => {
console.error('Socket.IO 연결 오류:', err.message);
});
// 4. 컴포넌트 언마운트 시 소켓 연결 해제 (클린업)
return () => {
if (socketRef.current) {
socketRef.current.disconnect();
console.log('Socket.IO 연결이 해제되었습니다.');
}
};
}, []); // 개발 Strict Mode에서는 설정·정리 과정을 추가 실행할 수 있음
// 메시지가 추가될 때마다 스크롤을 맨 아래로 이동
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// 메시지 전송 핸들러
const handleSendMessage = (e: FormEvent) => {
e.preventDefault();
if (messageInput.trim() && socketRef.current) {
socketRef.current.emit('chat message', messageInput); // 'chat message' 이벤트 전송
setMessageInput(''); // 입력 필드 초기화
}
};
return (
<div style={{ maxWidth: '600px', margin: '40px auto', padding: '20px', border: '1px solid #eee', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)' }}>
<h1 style={{ textAlign: 'center', color: '#333', marginBottom: '30px' }}>실시간 채팅</h1>
<div role="log" aria-live="polite" aria-label="채팅 메시지" style={{ border: '1px solid #ddd', height: '300px', overflowY: 'scroll', padding: '15px', marginBottom: '20px', backgroundColor: '#f9f9f9', borderRadius: '4px' }}>
{messages.length === 0 ? (
<p style={{ textAlign: 'center', color: '#888' }}>메시지가 없습니다.</p>
) : (
messages.map((msg, index) => (
<div key={index} style={{ marginBottom: '8px', padding: '5px 10px', backgroundColor: '#fff', border: '1px solid #eee', borderRadius: '4px' }}>
{msg}
</div>
))
)}
<div ref={messagesEndRef} /> {/* 스크롤 위치를 잡기 위한 빈 div */}
</div>
<form onSubmit={handleSendMessage} style={{ display: 'flex', gap: '10px' }}>
<label htmlFor="chat-message">메시지</label>
<input
id="chat-message"
type="text"
maxLength={500}
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
placeholder="메시지를 입력하세요..."
style={{ flexGrow: 1, padding: '10px', borderRadius: '4px', border: '1px solid #ccc' }}
/>
<button
type="submit"
style={{ padding: '10px 20px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
>
보내기
</button>
</form>
</div>
);
}채팅 예제의 이벤트 처리와 전달 경계
A가 입력을 emit하고 서버가 trim한 뒤 io.emit으로 A·B·C에 방송하여 각 UI가 append합니다. 작성자의 지역 입력 지우기와 서버 방송·수신을 별개 변화로 표시하면 ACK나 영속 저장이 없다는 경계를 유지할 수 있습니다.
Next · Chat event guarantees
Socket.IO 채팅 전달의 범위
별도 한 프로세스 서버가 문자열을 검사한 뒤 io.emit으로 보낸 사용자까지 포함해 연결된 클라이언트에 전달을 시도합니다. 입력 칸을 비우는 것과 상대 수신·영구 저장은 다른 결과입니다.
서버 방송은 보낸 A도 포함한다
입력 clear는 상대 수신 ACK·영속 저장 완료가 아닙니다. 기본 전달은 at most once이며 이 예제에는 인증·수신 확인·영구 이력과 누락 재생을 구현하지 않습니다.
원래 설명과 상세 기준
| 실행 지점 | 코드의 동작 | 보장하지 않는 것 |
|---|---|---|
| 서버 메시지 수신 | 문자열을 trim하고 길이 1~500만 허용 | 사용자 인증·메시지 영구 저장 |
| io.emit 호출 | 현재 연결된 클라이언트에게 전달 시도 | 연결이 끊긴 수신자의 나중 재생 |
| 클라이언트 emit 호출 | 이벤트 전송 후 입력 칸 비우기 | 상대 수신 확인 · 저장 완료 |
| 연결 해제 상태 | 기본 클라이언트가 이벤트를 버퍼링할 수 있음 | 새로고침 후 보존 · 전송 중 단절 복구 |
- 서버 메시지 수신
- 코드의 동작
- 문자열을 trim하고 길이 1~500만 허용
- 보장하지 않는 것
- 사용자 인증·메시지 영구 저장
- io.emit 호출
- 코드의 동작
- 현재 연결된 클라이언트에게 전달 시도
- 보장하지 않는 것
- 연결이 끊긴 수신자의 나중 재생
- 클라이언트 emit 호출
- 코드의 동작
- 이벤트 전송 후 입력 칸 비우기
- 보장하지 않는 것
- 상대 수신 확인 · 저장 완료
- 연결 해제 상태
- 코드의 동작
- 기본 클라이언트가 이벤트를 버퍼링할 수 있음
- 보장하지 않는 것
- 새로고침 후 보존 · 전송 중 단절 복구
Socket.IO는 도착한 이벤트의 순서를 보장하지만 기본 전달 방식은 at most once입니다. 이 예제에는 수신 확인과 이력 재생 구현이 없습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 4 | 별도의 단일 프로세스 서버가 문자열을 검사한 뒤 io.emit으로 보낸 사용자까지 포함해 연결된 클라이언트에 전달을 시도합니다. 입력 칸을 비우는 것과 상대 수신·영구 저장은 다른 결과입니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 작성자가 emit 연결된 A가 메시지를 emit하고 지역 입력 칸을 비웁니다. | 발신: A · 입력 칸: 지역 clear · ACK·저장 완료: 아님 | 작성자 UI의 입력 상태와 전송 호출이 바뀝니다. |
| 서버 문자열 검사 한 프로세스 서버가 trim과 길이 1~500을 확인합니다. | 처리: trim · 길이: 1~500 · 인증·영속 저장: 없음 | 허용할 방송 문자열이 준비됩니다. |
| 연결 대상 방송 io.emit이 작성자를 포함한 현재 연결 A·B·C에 전달을 시도합니다. | 대상: A·B·C · 호출: io.emit · 단절 수신자 재생: 없음 | 현재 연결 대상으로 메시지가 나갑니다. |
| 수신 UI append 도착한 이벤트를 각 UI가 append합니다. | UI: 메시지 append · 기본 전달: at most once · 이력 재생: 없음 | 수신한 화면의 목록이 바뀝니다. |
채팅 예제의 이벤트 처리와 전달 경계
A가 입력을 emit하고 서버가 trim한 뒤 io.emit으로 A·B·C에 방송하여 각 UI가 append합니다. 작성자의 지역 입력 지우기와 서버 방송·수신을 별개 변화로 표시하면 ACK나 영속 저장이 없다는 경계를 유지할 수 있습니다.
Next · Chat event guarantees
Socket.IO 채팅 전달의 범위
별도 한 프로세스 서버가 문자열을 검사한 뒤 io.emit으로 보낸 사용자까지 포함해 연결된 클라이언트에 전달을 시도합니다. 입력 칸을 비우는 것과 상대 수신·영구 저장은 다른 결과입니다.
서버 방송은 보낸 A도 포함한다
입력 clear는 상대 수신 ACK·영속 저장 완료가 아닙니다. 기본 전달은 at most once이며 이 예제에는 인증·수신 확인·영구 이력과 누락 재생을 구현하지 않습니다.
원래 설명과 상세 기준
| 실행 지점 | 코드의 동작 | 보장하지 않는 것 |
|---|---|---|
| 서버 메시지 수신 | 문자열을 trim하고 길이 1~500만 허용 | 사용자 인증·메시지 영구 저장 |
| io.emit 호출 | 현재 연결된 클라이언트에게 전달 시도 | 연결이 끊긴 수신자의 나중 재생 |
| 클라이언트 emit 호출 | 이벤트 전송 후 입력 칸 비우기 | 상대 수신 확인 · 저장 완료 |
| 연결 해제 상태 | 기본 클라이언트가 이벤트를 버퍼링할 수 있음 | 새로고침 후 보존 · 전송 중 단절 복구 |
- 서버 메시지 수신
- 코드의 동작
- 문자열을 trim하고 길이 1~500만 허용
- 보장하지 않는 것
- 사용자 인증·메시지 영구 저장
- io.emit 호출
- 코드의 동작
- 현재 연결된 클라이언트에게 전달 시도
- 보장하지 않는 것
- 연결이 끊긴 수신자의 나중 재생
- 클라이언트 emit 호출
- 코드의 동작
- 이벤트 전송 후 입력 칸 비우기
- 보장하지 않는 것
- 상대 수신 확인 · 저장 완료
- 연결 해제 상태
- 코드의 동작
- 기본 클라이언트가 이벤트를 버퍼링할 수 있음
- 보장하지 않는 것
- 새로고침 후 보존 · 전송 중 단절 복구
Socket.IO는 도착한 이벤트의 순서를 보장하지만 기본 전달 방식은 at most once입니다. 이 예제에는 수신 확인과 이력 재생 구현이 없습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 4 | 별도의 단일 프로세스 서버가 문자열을 검사한 뒤 io.emit으로 보낸 사용자까지 포함해 연결된 클라이언트에 전달을 시도합니다. 입력 칸을 비우는 것과 상대 수신·영구 저장은 다른 결과입니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 작성자가 emit 연결된 A가 메시지를 emit하고 지역 입력 칸을 비웁니다. | 발신: A · 입력 칸: 지역 clear · ACK·저장 완료: 아님 | 작성자 UI의 입력 상태와 전송 호출이 바뀝니다. |
| 서버 문자열 검사 한 프로세스 서버가 trim과 길이 1~500을 확인합니다. | 처리: trim · 길이: 1~500 · 인증·영속 저장: 없음 | 허용할 방송 문자열이 준비됩니다. |
| 연결 대상 방송 io.emit이 작성자를 포함한 현재 연결 A·B·C에 전달을 시도합니다. | 대상: A·B·C · 호출: io.emit · 단절 수신자 재생: 없음 | 현재 연결 대상으로 메시지가 나갑니다. |
| 수신 UI append 도착한 이벤트를 각 UI가 append합니다. | UI: 메시지 append · 기본 전달: at most once · 이력 재생: 없음 | 수신한 화면의 목록이 바뀝니다. |
위 그림은 채팅 예제에서 이벤트가 어떻게 처리되고 어디까지 전달되는지 보여 줍니다. 작성자 A가 메시지를 emit하고 자신의 입력 칸을 비우면, 한 프로세스로 실행되는 서버가 문자열을 trim하고 길이가 1자 이상 500자 이하인지 확인한 뒤 허용된 메시지를 io.emit으로 방송합니다. 방송 대상은 지금 연결된 클라이언트이며 보낸 A도 포함되므로, A, B, C의 UI에 각각 메시지가 추가됩니다. 연결이 끊긴 클라이언트에게는 이력을 다시 보내지 않습니다. 그림의 단계도 작성자의 emit, 서버의 문자열 검사, 연결 대상 방송, 수신 UI 추가의 순서입니다.
아래쪽 표는 실행 지점별로 코드가 하는 일과 보장하지 않는 것을 정리합니다. 서버는 메시지를 받아 검사하지만 사용자 인증이나 메시지 영구 저장은 하지 않고, io.emit은 지금 연결된 클라이언트에게 전달을 시도할 뿐 끊긴 수신자에게 나중에 다시 보내지 않습니다. 클라이언트의 emit 뒤에 입력 칸을 비우는 것은 상대의 수신 확인이나 저장 완료가 아닙니다. 연결이 끊긴 동안에는 기본 클라이언트가 이벤트를 버퍼에 담아 둘 수 있지만, 새로 고친 뒤의 보존이나 전송 중 단절의 복구는 보장하지 않습니다. 그림의 설명처럼 Socket.IO는 도착한 이벤트의 순서를 보장하지만 기본 전달 방식은 최대 한 번(at most once)이며, 이 예제에는 수신 확인과 이력 재생이 구현되어 있지 않습니다.
예를 들어 한 서버에 A, B, C 세 클라이언트가 연결되어 있고 재전송이나 단절이 없다고 합시다. A가 안녕 을 보내면 서버는 문자열을 trim해 안녕이라는 이벤트 하나를 io.emit으로 보냅니다. A도 대상에 포함되므로 각 클라이언트의 수신 처리기가 한 번씩 실행되어 세 화면에 메시지가 하나씩 추가됩니다. 화면에 항목이 세 개 생겼다고 서버가 메시지 세 개를 영구 저장한 것은 아닙니다.
클라이언트 컴포넌트에서 읽을 소켓 서버 주소는 .env.local에 NEXT_PUBLIC_ 접두사를 붙인 공개 환경 변수로 둡니다. 이 값은 브라우저 번들에 들어갈 수 있으므로 비밀 토큰이나 API 키를 넣으면 안 됩니다. 또한 NEXT_PUBLIC_ 환경 변수는 빌드할 때 클라이언트 코드에 들어가므로, 배포한 뒤 값을 바꾸려면 새 값으로 다시 배포해야 합니다.
배포 환경에서의 고려사항 (Vercel)
지금 Vercel Functions는 WebSocket을 지원합니다. 연결은 함수의 최대 실행 시간에 이르면 닫히며, 다시 연결할 때 같은 인스턴스로 간다고 보장하지 않습니다. 이 기능은 2026년 10월에 정식 기능이 되었으며, Fluid compute를 켜야 쓸 수 있다는 조건도 확인합니다. 다만 위 코드는 별도 프로세스의 listen()과 Socket.IO의 기본 전송 설정을 쓰는 예제로, Vercel Functions의 HTTP 서버 export와 경로, WebSocket 전용 클라이언트 설정 예제와는 다르므로 그대로 배포된다고 생각하지 않습니다.
다음은 이 글의 코드에 맞춘 별도 서버 배포 구성입니다. 먼저 Next.js 애플리케이션은 chat-app-frontend.vercel.app처럼 평소대로 Vercel에 배포합니다. WebSocket 서버는 AWS EC2, Google Cloud Run, DigitalOcean Droplet, Heroku처럼 일반 서버를 호스팅할 수 있는 플랫폼에 배포하고, socket.yourdomain.com 같은 도메인을 연결합니다. 기본 포트는 80과 443이며 플랫폼이 제공하는 다른 포트도 쓸 수 있고, HTTPS 페이지에서는 실시간 연결에도 TLS를 적용합니다.
그다음 Next.js 앱이 쓰는 NEXT_PUBLIC_SOCKET_SERVER_URL을 배포된 실시간 서버의 URL로 바꿉니다. Vercel 대시보드에서 이 값을 https://socket.yourdomain.com처럼 Socket.IO 서버 주소로 바꾼 뒤 새 배포에 반영하며, 순수 WebSocket 서버를 직접 연결하는 구조라면 wss://ws.yourdomain.com 같은 주소를 씁니다. 브라우저에서 Socket.IO의 HTTP 롱 폴링을 쓴다면 프론트엔드의 출처를 cors.origin에 설정합니다. WebSocket 전송에는 CORS 제한이 적용되지 않으므로, 이 설정을 연결 인증이나 접근 통제로 쓰지 않습니다.
WebSocket 통합 시 고려사항 및 팁
운영 환경에서는 암호화된 연결을 씁니다. 순수 WebSocket은 wss://, Socket.IO 서버는 보통 https:// 엔드포인트로 TLS가 적용된 상태에서 연결하며, 별도의 서버를 배포한다면 Nginx 같은 웹 서버나 플랫폼의 인증서 기능으로 SSL/TLS를 설정합니다. 네트워크 문제로 연결이 끊어졌을 때 클라이언트가 자동으로 다시 연결하도록 해야 하며, socket.io는 기본적으로 재연결 기능을 제공합니다. WebSocket 연결에도 사용자 인증과 메시지 전송 권한의 인가 로직을 구현해야 하며, 예를 들어 JWT로 Socket.IO 연결을 인증할 수 있습니다.
서버 인스턴스를 늘리면, 서로 다른 서버에 연결된 사용자에게도 이벤트가 전달되도록 어댑터를 검토합니다. @socket.io/redis-adapter가 이런 용도이며, 메시지 이력을 영구 저장하는 기능과는 다릅니다. 한 프로세스에 방이 여러 개라는 이유만으로 외부 브로커가 필요한 것은 아닙니다. WebSocket은 지속 연결이며, Socket.IO에서 HTTP 롱 폴링 전송까지 함께 쓰는 여러 노드 환경에서는 특정 클라이언트의 요청이 같은 서버 인스턴스로 가도록 하는 고정 세션(sticky session)이 특히 중요합니다. WebSocket 서버의 연결 수, 메시지 처리량, 오류도 모니터링해 서비스를 안정적으로 유지합니다.
정리
- WebSocket은 핸드셰이크 뒤에 하나의 연결로 서버와 클라이언트가 각자 메시지를 보내는 전이중 프로토콜이며, 서버에서만 보내는 알림에는 SSE도 선택지입니다.
- App Router의 Route Handler는 HTTP 인터페이스이므로, WebSocket은 사용자 정의 서버, 별도의 실시간 서버, 관리형 서비스 가운데 하나로 통합합니다.
- Socket.IO는 자체 프로토콜과 폴백 전송을 쓰며, 예제 서버는 문자열을 검사한 뒤
io.emit으로 보낸 사람을 포함한 모든 연결에 방송합니다. - 입력 칸을 비우는 것은 수신 확인이 아니며, 기본 전달은 최대 한 번이고 끊긴 클라이언트에게 이력을 다시 보내지 않습니다.
- 운영에서는 TLS, 인증, 재연결, 여러 서버의 어댑터와 고정 세션을 함께 설계하며, CORS 설정은 접근 통제가 아닙니다.
연습 문제
1. 조건을 바꿔 추적하기
B가 연결되지 않았을 때 A가 정상 문자열을 보냈다면, 나중에 B가 다시 연결하는 것만으로 그 메시지가 보일까요? 서버가 공백 문자열이나 trim 뒤에 길이가 501인 문자열을 받았을 때도 채팅 목록에 추가될까요? A의 입력 칸이 비워진 것은 상대의 수신 확인일까요?
해설 보기
B가 끊긴 동안 보낸 서버 이벤트를 다시 보내는 이력 구현은 없으므로, 다시 연결해도 그 메시지는 보이지 않습니다. 공백과 길이 501인 문자열은 서버 검사에서 돌아가므로 io.emit까지 가지 않습니다. A의 입력 칸은 클라이언트가 emit을 호출한 뒤 비우는 것이므로, 수신 확인이나 저장 완료를 뜻하지 않습니다.
실제 채팅에서 수신 확인이 필요하다면 메시지 ID, 확인 응답, 기한을 정하고, 다시 연결한 뒤 이력이 필요하다면 별도의 저장과 조회 정책을 설계합니다. 여러 서버 사이에서 이벤트를 나누는 것과 이력을 영구 저장하는 것은 서로 다른 기능입니다.
2. 개발 모드에서 두 번 찍히는 연결 로그
개발 서버에서 채팅 페이지를 열면, 페이지를 떠나지 않았는데도 브라우저 콘솔에 연결과 관련된 로그가 두 번, 곧 "Socket.IO 연결이 해제되었습니다."와 "Socket.IO 서버에 연결되었습니다."가 차례로 찍힙니다. 코드에 문제가 있는 걸까요?
해설 보기
개발 모드의 Strict Mode는 Effect의 설정과 정리를 한 번 더 실행해, 정리 함수가 제대로 동작하는지 확인하게 합니다. 이 추가 실행은 첫 설정 직후 곧바로 이어지므로, 처음 만든 소켓은 서버와 연결을 마치기 전에 정리 함수의 disconnect()로 닫혀 connect 이벤트가 일어나지 않고 정리 함수의 해제 로그만 남습니다. 이어서 다시 실행된 설정이 새 소켓을 만들어 연결하므로 연결 로그는 한 번 찍힙니다. 정리 함수에서 disconnect()를 부르므로 연결이 두 개 남지는 않으며, 프로덕션 빌드에서는 이 추가 실행이 일어나지 않습니다. 오히려 정리 함수가 없었다면 연결이 겹쳐 메시지를 두 번 받는 문제가 생길 수 있습니다.
3. 서버를 두 대로 늘렸을 때
트래픽이 늘어 예제의 Socket.IO 서버를 두 대로 늘리고 로드 밸런서 뒤에 두었습니다. A는 1번 서버에, B는 2번 서버에 연결되었습니다. A가 보낸 메시지가 B에게 전달될까요?
해설 보기
전달되지 않습니다. 예제의 io.emit은 그 서버 프로세스에 연결된 클라이언트에게만 방송하므로, 1번 서버의 이벤트는 2번 서버의 B에게 닿지 않습니다. 서버 사이에 이벤트를 나누려면 @socket.io/redis-adapter 같은 어댑터로 서버들을 연결해야 합니다. 또 롱 폴링 전송을 함께 쓴다면, 한 클라이언트의 요청이 같은 서버로 가도록 로드 밸런서에 고정 세션을 설정해야 연결이 유지됩니다.
Socket.IO의 서버와 클라이언트 설정은 Socket.IO 공식 문서에서 확인할 수 있습니다.
이것으로 16장을 마칩니다. 16장에서는 국제화 라우팅, PWA 설정, 서버리스 함수와 런타임 선택, WebSocket 통합으로 Next.js 애플리케이션의 기능을 넓히는 방법을 정리했습니다. 다음 장의 프로젝트 기획 및 설계부터는 지금까지 배운 내용으로 실전 프로젝트를 기획하고 만들어 봅니다.