본문으로 건너뛰기
Previous
Next
WebSocket 완벽 가이드 | 실시간 통신·Socket.io·채팅·알림·게임

WebSocket 완벽 가이드 | 실시간 통신·Socket.io·채팅·알림·게임

WebSocket 완벽 가이드 | 실시간 통신·Socket.io·채팅·알림·게임

이 글의 핵심

WebSocket으로 실시간 통신을 구현하는 완벽 가이드. WebSocket API, Socket.io, 채팅, 실시간 알림, 멀티플레이 게임까지 실전 예제로 정리. WebSocket·Real-time·Socket.io 중심으로 설명합니다.

이 글의 핵심

WebSocket 쓰는 법도 중요한데, 이 글에서 먼저 박아 두고 싶은 말은 하나야. WebSocket이 항상 답은 아냐. 알림만 서버 → 클라이언트로 흘려도 되면 SSE나 심지어 짧은 폴링이 운영·디버깅·프록시 통과 측면에서 더 싸게 먹힌 적이 꽤 많았어. WS는 멋지지만, “실시간”이라는 말이 나온다고 곧장 소켓부터 열면 나중에 밤새는 일이 생긴다.

그럼에도 양방향·낮은 지연·연결 유지가 진짜로 필요하면 WebSocket이 여전히 정답 후보다. 아래엔 그때 쓰는 API·Socket.io·채팅 예제, 그리고 내가 실제로 밤에 붙잡았던 장애까지 같이 담아 뒀다.

솔직한 경험담: 예전엔 1초 폴링으로 돌리던 채팅을 WebSocket으로 갈아엎으면서 서버 부하는 확 줄고 체감 지연은 확 줄었어. 근데 그 다음에 겪은 건 “코드는 맞는데 prod만 끊긴다” 쪽이었지. 밑에 야근 디버깅 스토리가 따로 있음.

들어가며: “실시간 업데이트가 필요해요”

실무에서 자주 터지는 이야기

시나리오 1: 폴링이 비효율 — 1초마다 API 두드리면 서버도 아프고 로그도 시끄럽다. WebSocket은 연결 하나로 오래 가니까, 트래픽 패턴이 맞을 때 잘 먹힌다.

시나리오 2: 메시지가 늦게 온다 — 폴링 간격만큼 늦는 건 당연. WS는 그걸 “즉시” 쪽으로 당기려는 도구.

시나리오 3: 협업/게임 — 입력이 자주 오가면 양방향이 편하다. 다만 동시 편집만 보면 CRDT/OT 같은 게 따로 있고, WebSocket은 그걸 실어 나르는 파이프에 가깝다.


1. WebSocket이란?

핵심만

WebSocket은 양방향으로 실시간에 가깝게 주고받는 프로토콜이야. 장점을 나열하면 대략 이렇다.

  • 양방향: 서버 ↔ 클라이언트 둘 다 먼저 말 수 있음
  • 한 연결이 오래 감: 요청마다 새 TCP를 여는 식이 아님(대략적으로)
  • 지연을 줄이기 좋다: 잘 잡으면 체감이 바로 감(환경마다 다름, “< 10ms”는 목표치에 가깝다고 보면 됨)
  • 브라우저에 API가 있음

HTTP vs WebSocket만 짚으면: HTTP는 기본이 요청/응답, WebSocket은 한 번 업그레이드한 뒤 그 소켓에서 계속 이야기하는 그림에 가깝다.

내 취향: “실시간”이라는 단어가 나온다고 무조건 WebSocket부터 고르지 말고, 서버 → 클라이언트만이면 SSE 후보, 가끔만 갱신이면 캐시+짧은 폴링도 솔직히 괜찮다. WebSocket이 항상 정답은 아니다.


2. 프로토콜 내부: 업그레이드·프레임·하트비트 (RFC 6455)

ws나 Socket.io, 브라우저 WebSocket이 프레임·마스킹은 대신해 준다. 그런데 401/101 안 뜸, 중간에 조용히 끊김, 갑자기 1006 같은 건 결국 RFC 6455 뼈대를 알아야 추측이 줄어든다. 나도 처음엔 “라이브러리가 다 해주겠지” 하다가, 프록시 앞에 서면 그만큼 말이 달라진다.

2.1 HTTP에서 WebSocket으로: 업그레이드 핸드셰이크

  1. 클라이언트는 GETUpgrade: websocket, Connection: Upgrade, Sec-WebSocket-Version: 13, Sec-WebSocket-Key 등을 싣는다.
  2. 서버는 키 + 고정 문자열을 SHA-1 → Base64로 Sec-WebSocket-Accept에 넣고 101을 준다.
  3. 그다음부터는 HTTP가 아니라 WebSocket 프레임이 오간다.

역프록시(Nginx, Cloudflare)에서 Upgrade / Connection이 중간에 죽으면 101이 안 나오거나 바로 끊긴다. TLS면 wss:// + 443 쪽이 방화벽/회사망에선 훨씬덜 밟힌다. 이건 “문서에 있는 그 설정”이 자주 실제 원인이었어.

2.2 프레임 — FIN, opcode, 마스킹

  • FIN: 이 조각이 메시지 끝인지.
  • Opcode: 1 텍스트, 2 바이너리, 8 close, 9 Ping, 10 Pong.
  • 클라 → 서버 페이로드는 RFC상 마스킹 필수. 서버 → 클라는 마스킹 안 함.
  • 덩치 큰 메시지는 프레임이 여러 개로 쪼개질 수 있어서, 최대 메시지 길이 제한(read_message_max 류)은 꼭 본다.

2.3 텍스트 vs 바이너리 (표 말고 그냥 말로)

텍스트(opcode 1)는 UTF-8 문자열. 브라우저 onmessage에 문자열로 오기 쉽고, JSON 채팅/알림엔 대개 이걸 쓴다. 깨끗한 UTF-8이 아니면 프로토콜 위반이라 연결이 쳐질 수 있다—이거 한번 당해보면 “왜 죽지?” 하다가 끝.

바이너리(opcode 2)는 말 그대로 임의 바이트(Protobuf, 썸네일 덩어리, 커스텀 프레이밍). 인코딩은 전부 앱 책임. 한 연결에 둘 다 섞을 수는 있는데, 받는 쪽에서 opcode/앱 프로토콜로 구분해 줘야 한다.

2.4 하트비트: Ping/Pong vs 앱 레벨 keepalive

Ping(9) / Pong(10)은 NAT·LB가 유휴 TCP를 잘라먹기 전에 가짜 트래픽을 만든다. 앱에서 {"type":"ping"} JSON으로 때우는 팀도 많다. Socket.io는 자체 하트비트가 있다.

중요한 건 앱이 아무리 살아 있어도, 프록시의 read_timeout / ALB idle이 짧으면 그냥 잘린다는 점. 나는 Ping 주기(대략 20~30초)프록시 idle 타임아웃이랑 같이 맞춰서 “왜 45분마다 끊기지?” 같은 걸 줄였다.

2.5 프로덕션에서 쓰는 패턴 (내가 실제로 쓰는 체크)

  • WSS + 443이면 통과 잘됨(대체로)
  • 스티키가 필요한지, 아니면 Redis Pub/Sub으로 풀지 먼저 정함
  • 재연결: 지수 백오프, 오프라인 큐, 마지막 이벤트 ID로 다시 맞추기
  • 백프레셔: 느린 클라한테 send 무한정 밀지 않기
  • 관측: 연결 수, 핸드셰이크 실패, 비정상 close(1006 등)

밤 11시, 스테이징은 되는데 프로덕션만 WebSocket이 죽던 날 (실시간 디버깅 스토리)

상황을 요약하면 이랬다. 로컬·스테이징에선 Socket.io 잘 붙는데, 프로덕션만 몇십 분 지나면 끊기거나 아예 처음부터 101이 안 뜨는 느낌. 앱 로그엔 ECONNRESET도 가끔, 브라우저는 1006 (비정상 종료)만 찍고 끝.

내가 한 순서(매번 똑같진 않은데, 이때는 이 순서로 갔다):

  1. “코드가 틀렸나?”부터 의심했는데, 스테이징이 말해 줌—같은 빌드면 배포/경계 쪽.
  2. Nginx(또는 CLB) 앞인지 확인. proxy_http_version 1.1, Upgrade, Connection 헤더, WebSocket location이 실제로 그 경로에 걸리는지. (여기서 한번 걸리면 밤이 길어진다.)
  3. idle / read timeout — 앱이 살아 있어도 중간 프록시가 “조용한 연결”을 잘랐다. Ping 주기를 짧게 잡고, 프록시 timeout이랑 맞췄다.
  4. wss인지, 혼합 콘텐츠 아닌지(HTTPS 페이지에서 ws:// 등). 브라우저 콘솔이 꽤 정직하다.
  5. 그다음이 —메모리 누수, send 폭주, 한 머신에만 붙는지(스티키) 등.

결론은 그날 역프록시 WebSocket 프록싱timeout 불일치가 겹친 케이스였다. “WebSocket 쓰면 끝”이 아니라, “연결이 끊기는 이유는 대부분 TCP가 아니라 그 위에 있는 누군가”라고 배운 밤이었다. 그 뒤로는 장애 나면 먼저 Network 탭 + 프록시 access log를 연다. 웹이 아니라 인프라 싸움인 경우가 많다.

솔직한 한 줄: WebSocket이 이 아니라 도구다. 항상 필요한 건 아니다. 필요할 땐 이 보일러플레이트까지 감수할 각오가 있어야 한다.

3. 기본 WebSocket API

서버 (Node.js)

// server.ts
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
  console.log('Client connected');
  ws.on('message', (data) => {
    console.log('Received:', data.toString());
    
    // 에코
    ws.send(`Echo: ${data}`);
  });
  ws.on('close', () => {
    console.log('Client disconnected');
  });
  ws.send('Welcome!');
});
console.log('WebSocket server running on ws://localhost:8080');

클라이언트 (브라우저)

// client.ts
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
  console.log('Connected');
  ws.send('Hello Server!');
};
ws.onmessage = (event) => {
  console.log('Received:', event.data);
};
ws.onerror = (error) => {
  console.error('Error:', error);
};
ws.onclose = () => {
  console.log('Disconnected');
};

4. Socket.io

설치

# 서버
npm install socket.io
# 클라이언트
npm install socket.io-client

서버

// server.ts
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST'],
  },
});
io.on('connection', (socket) => {
  console.log('User connected:', socket.id);
  socket.on('message', (data) => {
    console.log('Message:', data);
    
    // 모든 클라이언트에게 전송
    io.emit('message', data);
    
    // 발신자 제외
    socket.broadcast.emit('message', data);
    
    // 특정 클라이언트에게
    socket.to(targetSocketId).emit('message', data);
  });
  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
  });
});
httpServer.listen(3000, () => {
  console.log('Server running on :3000');
});

클라이언트

// client.ts
import { io } from 'socket.io-client';
const socket = io('http://localhost:3000');
socket.on('connect', () => {
  console.log('Connected:', socket.id);
});
socket.on('message', (data) => {
  console.log('Received:', data);
});
socket.emit('message', { text: 'Hello!' });

5. 실전 예제: 채팅 앱

서버

// server.ts
import { Server } from 'socket.io';
const io = new Server(3000, {
  cors: { origin: '*' },
});
interface Message {
  user: string;
  text: string;
  timestamp: string;
}
io.on('connection', (socket) => {
  console.log('User connected:', socket.id);
  // 방 입장
  socket.on('join', (room: string) => {
    socket.join(room);
    socket.to(room).emit('user-joined', socket.id);
    console.log(`${socket.id} joined ${room}`);
  });
  // 메시지 전송
  socket.on('message', (data: { room: string; message: Message }) => {
    io.to(data.room).emit('message', data.message);
  });
  // 타이핑 중
  socket.on('typing', (room: string) => {
    socket.to(room).emit('typing', socket.id);
  });
  // 연결 해제
  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
  });
});

클라이언트 (React)

// Chat.tsx
import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
let socket: Socket;
export function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const room = 'general';
  useEffect(() => {
    socket = io('http://localhost:3000');
    socket.emit('join', room);
    socket.on('message', (message: Message) => {
      setMessages((prev) => [...prev, message]);
    });
    socket.on('typing', (userId: string) => {
      console.log(`${userId} is typing...`);
    });
    return () => {
      socket.disconnect();
    };
  }, []);
  const sendMessage = () => {
    if (input.trim()) {
      const message: Message = {
        user: 'Me',
        text: input,
        timestamp: new Date().toISOString(),
      };
      socket.emit('message', { room, message });
      setInput('');
    }
  };
  const handleTyping = () => {
    socket.emit('typing', room);
  };
  return (
    <div>
      <div>
        {messages.map((msg, i) => (
          <div key={i}>
            <strong>{msg.user}:</strong> {msg.text}
          </div>
        ))}
      </div>
      <input
        value={input}
        onChange={(e) => {
          setInput(e.target.value);
          handleTyping();
        }}
        onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
      />
      <button onClick={sendMessage}>Send</button>
    </div>
  );
}

6. Redis 통합 (확장성)

import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const io = new Server(3000);
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
  io.adapter(createAdapter(pubClient, subClient));
  console.log('Redis adapter connected');
});
// 이제 여러 서버 인스턴스가 메시지를 공유합니다

정리 및 체크리스트

핵심 요약

  • RFC 6455: 101, opcode, 마스킹, Ping/Pong — “왜 끊기지?”의 지도
  • WebSocket: 항상 필요하진 않다. 양방향·지속 연결이 진짜로 필요할 때 꺼내는 것
  • Socket.io: 편하지만, 이름만 보고 “실시간 끝”이라고 보면 운영에서 맞는다
  • Room / 브로드캐스트 / Redis스케일 먹일 때의 단골 조합
  • 지연: 환마다 다르고, “10ms”는 광고 문구처럼 믿지 말고 측정해라

구현 체크리스트

  • WebSocket 서버 구현
  • Socket.io 통합
  • Room 기능 구현
  • 인증 구현
  • 에러 처리
  • Redis 통합 (확장성)
  • 배포

같이 보면 좋은 글


이 글에서 다루는 키워드

WebSocket, Real-time, Socket.io, Chat, Node.js, Backend

자주 묻는 질문 (FAQ)

Q. WebSocket vs Server-Sent Events, 뭐가 “더 좋아”?

A. “더 좋다”가 아니라 요구가 다르면 답이 갈린다. 서버 → 클라이언트만이면 SSE가 단순한 경우가 많다. 양방향이 꼭 필요할 때 WebSocket 쪽이 자연스럽다. WebSocket이 항상 이기는 싸움은 아니다.

Q. WebSocket vs HTTP/2?

A. HTTP/2는 여전히 요청/응답의 세계다. “계속 열린 소켓에서 둘 다 먼저 말한다” 쪽이면 WebSocket(또는 WebTransport 같은 다른 도구) 이야기가 된다.

Q. 연결이 끊기면?

A. Socket.io는 재연결을 꽤 밀어 준다. 네이티브 WebSocket이면 직접 백오프+재시도+상태 복구를 짜야 한다. 끊김은 희귀 이벤트가 아니라 기본 시나리오로 둬라.

Q. 프로덕션 써도 되냐?

A. 쓰는 곳은 많다. 대신 “쓰인다 = 내 서비스엔 백엔드 없이 된다”는 아니다. 프록시·타임아웃·스케일까지 포함해서 봐야 한다.

심화 부록: 구현·운영 (표 없이, 솔직하게)

이 부록은 같은 내용을 운영 시선으로 다시 압축한 거다. 입력 검증 → 핵심 연산 → 부작용(I/O) → 관측으로 쪼개면 밤이 짧아진다. 도메인은 다르지만, “어디서 깨졌는지” 찍는 순서는 비슷하다.

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)

프로덕션에서 나는 이런 식으로 질문한다: 관측성 — 상관 ID, p95/p99, 의존성이 대시보드에 보이냐. 안전성 — 입력/권한/비밀 일관성. 신뢰성 — 재시도는 멱등한가, 서킷/백오프/DLQ가 있냐. 성능 — N+1, 락, 직렬화, 백프레셔. 배포 — 롤백, 카나리, 마이그레이. 용량 — FD, 스레드, 피크 때 터지는지. 표로 정리하던 걸 문장으로 쓴 것뿐이다.

확장 예시: 끝까지 가는 흐름 (체감용)

  1. 입력 계약: 스키마, 최대 페이로드, 타임아웃, 에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계 지연, 외부 호출 결과 — 로그/메트릭/트레이스로 한 줄로 잡힌다.
  3. 실패 주입: 스테이징에서 타임아웃, 5xx, 부분 데이터를 의도적으로 터뜤다.
  4. 호환·롤백: 클라 버전, 설정, 마이그레이 되돌리기가 있는지.
  5. 부하 후: p99, 에러율, 알람 임계값 — 눈으로만 믿지 말고 숫자로.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결 (표 대신, 증상 하나씩)

  • 간헐적 실패 — 레이스, 타임아웃, DNS, 외부 의존성. 최소 재현 스크립트부터, 트레이스로 어느 hop인지 잡는다.
  • 느려짐 — N+1, 동기 I/O, 락, 직렬화 덩어리. APM/프로파일러로 핫스팟 하나만 골라낸다.
  • 메모리만 늘음 — 캐시 무한, 리스너 누수, 버퍼 상한 없음, 커넥션 미반납. 힅 스냅 두 개 비교가 빠를 때가 많다.
  • 빌드/배포만 터짐 — env, 권한, lockfile, 이미지 버전. CI 로그 vs 로컬 diff.
  • 설정이 미묘하게 다름 — 시크릿, 리전, 기본값. 검증된 설정 단일 소스를 믿는다.
  • 데이터가 어긋남 — 멱등 없는 재시도, 캐시 무효화 누락, 부분 쓰기. 멱등 키·트랜잭션 다시 읽는다.

권장 순서: (1) 최소 재현 (2) 최근 변경 좁히기 (3) 환경 차이 (4) 가설 + 관측 (5) 수정 + 회귀/부하.

배포 전엔 git addgit commitgit push 하고 npm run deploy — 이건 예전에 한 번 안 해서 눈물 났던 기억이 있어서, 습관으로 박아 둔다.