안동민 개발노트

안동민 개발노트

국제화 라우팅PWA 설정서버리스 함수 활용WebSocket 통합
본문 시작
  1. 홈
  2. 문서
  3. Next.js
  4. 16장 : 고급 주제
  5. WebSocket 통합
  1. Next.js
  2. WebSocket 통합

WebSocket 통합

HTTP와 WebSocket의 연결 방식을 비교하고 별도 Socket.IO 서버와 Next.js 클라이언트를 연결해 채팅을 구현합니다.

현대 웹 애플리케이션에서는 실시간 통신이 필수인 경우가 많습니다.

채팅, 실시간 알림, 주식 시세, 온라인 게임, 협업 도구는 서버-클라이언트 간 양방향 데이터 교환을 요구합니다.

HTTP도 요청과 응답에서 양쪽이 데이터를 보내지만, 일반적인 HTTP 메시지 교환은 클라이언트가 요청을 시작해야 합니다.

서버와 클라이언트가 독립적으로 메시지를 보내야 하는 지속 연결에서는 WebSocket이 적합합니다.

이 절에서는 WebSocket과 HTTP의 차이, Next.js 통합 방식과 배포 경계를 구분하고 별도 Socket.IO 서버를 연결하는 예제를 살펴봅니다.


WebSocket이란? HTTP와의 차이점

WebSocket은 웹 브라우저와 웹 서버 간에 단일 TCP 연결을 통해 전이중(full-duplex) 통신 채널을 제공하는 통신 프로토콜입니다.

일단 연결이 수립되면, 클라이언트와 서버는 독립적으로 데이터를 주고받을 수 있으며, 이는 HTTP의 요청-응답 모델과는 근본적으로 다릅니다.

HTTP와의 주요 차이점
  • 메시지 시작: 일반 HTTP 요청은 클라이언트가 시작합니다. WebSocket은 연결이 열린 동안 양쪽이 독립적으로 메시지를 보낼 수 있습니다.
  • 연결 수명: HTTP도 연결을 재사용하며, WebSocket의 지속 연결도 네트워크·서버 정책에 따라 끊길 수 있습니다.
  • 메시지 비용: HTTP는 메시지 헤더를 사용하고 WebSocket은 핸드셰이크 뒤 프레임을 사용합니다. 실제 오버헤드는 HTTP 버전·압축·전송량에 따라 비교합니다.
  • 사용 사례: 일반 페이지와 API에는 HTTP가 맞고, 양방향 채팅·게임·협업에는 WebSocket을 검토합니다. 서버에서만 보내는 알림에는 SSE나 HTTP 스트리밍도 선택지입니다.

WebSocket은 초기 핸드셰이크(HTTP 기반)를 통해 연결을 수립한 후, ws:// 또는 wss:// (보안 연결) 프로토콜로 전환하여 지속적인 통신 채널을 유지합니다.


Next.js에 WebSocket 통합 전략

Next.js App Router의 일반 Route Handler는 HTTP Request/Response 인터페이스입니다. 연결 upgrade를 처리할 서버와 배포 방식은 별도로 선택합니다.

주요 통합 전략

직접 운영하는 HTTP 서버에 통합: 자체 Node.js 호스팅에서는 http.createServer()에 Next.js 요청 처리기와 Socket.IO를 함께 연결할 수 있습니다.

이는 next start의 Route Handler에 붙이는 방식이 아니라 별도의 custom server 진입점을 실행하는 방식입니다. 프레임워크 최적화와 배포 지원 범위를 먼저 확인합니다.

별도의 WebSocket 서버 구축 (일반적인 방식): Next.js 애플리케이션과 독립적으로 동작하는 실시간 서버를 운영하는 방식입니다.

이 서버는 Node.js(Express + socket.io 또는 ws), Python(Flask-SocketIO), Go 등 어떤 기술 스택으로든 구축할 수 있습니다.

  • 장점: WebSocket 서버의 확장성과 관리가 Next.js 앱과 분리되어 유연합니다. 연결 시간·확장 한도는 선택한 서버 플랫폼에서 관리합니다.
  • 단점: 별도의 서버를 배포하고 관리해야 하는 추가적인 오버헤드가 있습니다.

WebSocket 서비스 사용: Pusher, Ably, PubNub, AWS IoT Core 등 관리형 WebSocket 서비스를 사용하는 방법입니다.

이러한 서비스는 WebSocket 서버 구축 및 관리에 대한 복잡성을 추상화해주고, 메시지 브로커링, 스케일링, 인증 등을 처리해줍니다.

  • 장점: 연결 인프라 운영을 서비스에 맡길 수 있습니다. 지원 기능·가용성 조건·사용량 한도는 서비스별로 확인합니다.
  • 단점: 서비스 비용 발생, 특정 서비스 종속성.

이 절의 코드는 별도 Socket.IO 서버를 실행하는 구성을 사용합니다.


socket.io를 사용한 통합

socket.io는 WebSocket을 전송 방식 중 하나로 사용하며, WebSocket 연결이 불가능한 경우 HTTP 롱 폴링과 같은 다른 전송 방식으로 폴백할 수 있는 실시간 통신 라이브러리입니다.

단, Socket.IO는 자체 프로토콜을 사용하므로 순수 WebSocket 클라이언트가 Socket.IO 서버에 그대로 연결되는 구조는 아닙니다.

시나리오: 간단한 채팅 애플리케이션을 구현하여, 클라이언트가 메시지를 보내면 모든 연결된 클라이언트에게 메시지가 브로드캐스트되는 예시입니다.

별도의 WebSocket 서버 구축

Node.js + Express + socket.io의 형태로 구현하기 위해 프로젝트 루트에 server 디렉토리를 생성하고, 그 안에 WebSocket 서버 코드를 작성합니다.

# 프로젝트 루트에서
mkdir server
cd server
npm init -y
npm install express socket.io
server/index.js (WebSocket 서버)
server/index.js
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 명령으로 서버를 실행할 수 있습니다.

Next.js 클라이언트 애플리케이션 설정

Next.js 앱에서 socket.io-client를 사용하여 WebSocket 서버에 연결합니다.

# Next.js 프로젝트 루트에서
npm install socket.io-client
# 또는
yarn add socket.io-client
src/app/[locale]/chat/page.tsx (채팅 페이지 - 클라이언트 컴포넌트)
src/app/[locale]/chat/page.tsx
"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>
  );
}
채팅 예제의 이벤트 처리와 전달 경계

이 서버는 한 프로세스에서 연결된 클라이언트끼리 메시지를 중계합니다.

채팅 예제의 이벤트 처리와 전달 경계
실행 지점코드의 동작보장하지 않는 것
서버 메시지 수신문자열을 trim하고 길이 1~500만 허용사용자 인증·메시지 영구 저장
io.emit 호출현재 연결된 클라이언트에게 전달 시도연결이 끊긴 수신자의 나중 재생
클라이언트 emit 호출이벤트 전송 후 입력 칸 비우기상대 수신 확인 · 저장 완료
연결 해제 상태기본 클라이언트가 이벤트를 버퍼링할 수 있음새로고침 후 보존 · 전송 중 단절 복구
서버 메시지 수신
코드의 동작: 문자열을 trim하고 길이 1~500만 허용
보장하지 않는 것: 사용자 인증·메시지 영구 저장
io.emit 호출
코드의 동작: 현재 연결된 클라이언트에게 전달 시도
보장하지 않는 것: 연결이 끊긴 수신자의 나중 재생
클라이언트 emit 호출
코드의 동작: 이벤트 전송 후 입력 칸 비우기
보장하지 않는 것: 상대 수신 확인 · 저장 완료
연결 해제 상태
코드의 동작: 기본 클라이언트가 이벤트를 버퍼링할 수 있음
보장하지 않는 것: 새로고침 후 보존 · 전송 중 단절 복구

Socket.IO는 도착한 이벤트의 순서를 보장하지만 기본 전달 방식은 at most once입니다. 이 예제에는 수신 확인과 이력 재생 구현이 없습니다.

Next.js 환경 변수 설정 (.env.local)

클라이언트 컴포넌트에서 읽을 소켓 서버 주소는 NEXT_PUBLIC_ 접두사를 붙여 공개 환경 변수로 둡니다.

이 값은 브라우저 번들에 포함될 수 있으므로 비밀 토큰이나 API 키를 넣으면 안 됩니다.

또한 NEXT_PUBLIC_ 환경 변수는 빌드 시점에 클라이언트 코드에 인라인되므로, 배포 후 값을 바꾸려면 새 값으로 다시 배포해야 합니다.

배포 환경에서의 고려사항 (Vercel)

현재 Vercel Functions는 WebSocket을 지원합니다. 연결은 함수의 최대 실행 시간에 도달하면 닫히며, 재연결이 같은 인스턴스로 간다고 보장하지 않습니다.

다만 위 코드는 별도 프로세스의 listen()과 Socket.IO 기본 전송 설정을 사용하는 예제입니다. Vercel Functions의 HTTP 서버 export·경로·WebSocket 전용 클라이언트 설정 예제와 같지 않으므로 그대로 배포되는 것으로 해석하지 않습니다.

다음 절차는 원문 코드에 맞춘 별도 서버 배포 구성입니다.

Next.js 앱 배포: Vercel에 Next.js 애플리케이션을 평소처럼 배포합니다. (예: chat-app-frontend.vercel.app)

WebSocket 서버 배포
  • 클라우드 플랫폼 사용: AWS EC2, Google Cloud Run, DigitalOcean Droplet, Heroku 등 전통적인 서버를 호스팅할 수 있는 플랫폼에 Node.js WebSocket 서버를 배포합니다.
  • 도메인 연결: 배포된 WebSocket 서버에 도메인(예: ws.yourdomain.com)을 연결합니다. 기본 포트는 80/443이며 플랫폼이 제공하는 다른 포트도 사용할 수 있습니다. HTTPS 페이지에서는 실시간 연결에도 TLS를 적용합니다.

환경 변수 업데이트: Next.js 앱에서 사용하는 NEXT_PUBLIC_SOCKET_SERVER_URL 환경 변수를 배포된 실시간 서버의 URL로 업데이트합니다.

Vercel 대시보드에서 NEXT_PUBLIC_SOCKET_SERVER_URL의 값을 https://socket.yourdomain.com처럼 Socket.IO 서버 주소로 변경한 뒤 새 배포에 반영합니다.

순수 WebSocket 서버를 직접 연결하는 구조라면 wss://ws.yourdomain.com 같은 주소를 사용합니다.

CORS 설정: Socket.IO의 HTTP long-polling을 브라우저에서 사용할 경우 프론트엔드 출처를 cors.origin에 설정합니다. WebSocket 전송에는 CORS 제한이 적용되지 않으므로 이 설정을 연결 인증·접근 통제로 사용하지 않습니다.


WebSocket 통합 시 고려사항 및 팁

  • 보안 (TLS): 운영 환경에서는 암호화된 연결을 사용합니다. 순수 WebSocket은 wss://, Socket.IO 서버는 보통 https:// 엔드포인트를 사용해 TLS가 적용된 상태에서 연결합니다. 별도의 서버를 배포하는 경우, Nginx와 같은 웹 서버나 플랫폼의 인증서 기능으로 SSL/TLS를 설정합니다.
  • 재연결 로직: 네트워크 문제 등으로 WebSocket 연결이 끊어졌을 때 클라이언트 측에서 자동으로 재연결을 시도하는 로직을 구현해야 합니다. socket.io는 기본적으로 재연결 기능을 제공합니다.
  • 인증 및 인가: WebSocket 연결 시 사용자 인증 및 메시지 전송 권한 인가 로직을 구현해야 합니다. (예: JWT 토큰을 사용하여 Socket.IO 연결 시 인증)
  • 여러 서버의 메시지 공유: 서버 인스턴스를 늘리면 서로 다른 서버에 연결된 사용자에게도 이벤트를 전달하도록 어댑터를 검토합니다. @socket.io/redis-adapter는 이런 용도이며 메시지 이력을 영구 저장하는 기능과는 다릅니다. 한 프로세스에 방이 여러 개라는 이유만으로 외부 브로커가 필요한 것은 아닙니다.
  • 로드 밸런싱: WebSocket은 지속 연결이며, Socket.IO에서 HTTP long-polling 전송 방식까지 함께 사용하는 다중 노드 환경에서는 Sticky Session이 특히 중요합니다. 특정 클라이언트의 요청이 같은 서버 인스턴스로 라우팅되도록 정책을 확인해야 합니다.
  • 모니터링: WebSocket 서버의 연결 수, 메시지 처리량, 오류 등을 모니터링하여 안정적인 서비스를 유지합니다.

서버리스 함수 활용

이전 페이지

프로젝트 기획 및 설계

다음 페이지

이 페이지의 목차

WebSocket이란? HTTP와의 차이점Next.js에 WebSocket 통합 전략socket.io를 사용한 통합별도의 WebSocket 서버 구축Next.js 클라이언트 애플리케이션 설정배포 환경에서의 고려사항 (Vercel)WebSocket 통합 시 고려사항 및 팁