WebSocket 완전정복 — 핸드셰이크부터 재연결, 스케일링까지

HTTP의 한계에서 출발해 WebSocket의 핸드셰이크, 프레임 구조, 하트비트, 재연결 전략, 수평 확장까지 실시간 통신의 모든 것을 정리합니다.

왜 WebSocket이 필요한가?

HTTP는 본질적으로 요청-응답(request-response) 모델이다. 클라이언트가 물어봐야만 서버가 대답하고, 서버가 먼저 말을 걸 수 없다.

[클라이언트] ──요청──▶ [서버]
             ◀──응답── 
             (연결 종료)

하지만 채팅, 주식 시세, 협업 툴, 게임처럼 서버가 먼저 알려줘야 하는 기능이 있다. 새 메시지가 도착했을 때, 주가가 바뀌었을 때, 다른 사용자가 커서를 움직였을 때 — 클라이언트는 그 사실을 모른다.

HTTP로 흉내 내본 실시간 통신

1. Polling (주기적 폴링)

클라이언트가 "새 소식 있어?"를 계속 물어본다.

[클라이언트] ──"있어?"──▶ [서버]  (1초마다)
             ◀──"없음"──
[클라이언트] ──"있어?"──▶ [서버]
             ◀──"없음"──

단순하지만 대부분의 요청이 낭비이고, 실시간성도 떨어진다.

2. Long Polling

서버가 새 데이터가 생길 때까지 응답을 미룬다.

[클라이언트] ──요청──▶ [서버]  (데이터 없으면 대기)
                         ...
             ◀──응답──── (데이터 생기면 응답)
[클라이언트] ──재요청──▶ [서버]  (즉시 다시 요청)

응답이 올 때마다 매번 새로운 연결을 맺어야 하고, 헤더 오버헤드가 크다.

그래서 나온 WebSocket

WebSocket은 한 번 연결을 맺으면 그 연결을 계속 유지하면서, 클라이언트와 서버가 양방향으로 자유롭게 메시지를 주고받는 프로토콜이다.

[클라이언트] ◀═══════ 양방향 통신 ═══════▶ [서버]
             (연결 유지)

RFC 6455로 표준화되었고, 브라우저에서는 WebSocket 객체로 사용한다.


HTTP vs WebSocket 한눈에 비교

항목 HTTP WebSocket
통신 방향 단방향 (요청→응답) 양방향 (full-duplex)
연결 유지 요청마다 열고 닫음 (keep-alive 제외) 한 번 열면 유지
오버헤드 매 요청마다 헤더 전송 첫 핸드셰이크 이후 프레임만
URL 스킴 http://, https:// ws://, wss://
포트 80 / 443 80 / 443 (동일)
주 용도 문서/API 조회 채팅, 알림, 게임, 스트리밍

WebSocket도 기본 포트는 80/443이라 방화벽 통과가 쉽다. 암호화된 버전이 wss://이며, 운영에서는 무조건 wss://를 써야 한다.


WebSocket 핸드셰이크 과정

WebSocket 연결은 HTTP 요청으로 시작한 뒤, 프로토콜을 업그레이드해서 만들어진다. 이 "HTTP → WebSocket"으로 갈아타는 과정을 핸드셰이크라고 한다.

1. 클라이언트 요청 (Upgrade)

GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
  • Upgrade: websocket — "이 연결을 WebSocket으로 바꾸고 싶어요"
  • Sec-WebSocket-Key — 클라이언트가 보낸 랜덤 Base64 값
  • Sec-WebSocket-Version: 13 — 표준 버전

2. 서버 응답 (101 Switching Protocols)

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
  • 101 — "프로토콜 바꿨어요"
  • Sec-WebSocket-Accept — 클라이언트 Key + 고정 GUID를 SHA-1 해시한 값

이 값은 서버가 WebSocket 프로토콜을 진짜로 이해하고 있다는 증명이다. 일반 HTTP 서버가 실수로 101을 돌려보내는 걸 막기 위한 장치다.

3. 이후 통신

HTTP 연결을 그대로 TCP 소켓으로 재활용해서, 양쪽 모두 프레임 단위로 데이터를 주고받는다.

[브라우저] ──HTTP 요청──▶ [서버]
           ◀──101 응답──
           ═══ 이후부터는 WebSocket 프레임 ═══
           ──"안녕"──▶
           ◀──"반가워"──

WebSocket 프레임 구조

WebSocket 메시지는 프레임(frame) 단위로 쪼개서 전송된다.

 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| op    |M|  Payload len |  Extended payload length     |
|I|S|S|S| code  |A|   (7 bits)   |        (16/64 bits)          |
|N|V|V|V|(4bit) |S|              |                              |
| |1|2|3|       |K|              |                              |
+-+-+-+-+-------+-+-------------+-------------------------------+
|            Masking-key (32 bits, 클라이언트→서버일 때만)      |
+---------------------------------------------------------------+
|                      Payload Data                             |
+---------------------------------------------------------------+

주요 필드

필드 의미
FIN 이 프레임이 메시지의 마지막인가? (큰 메시지는 여러 프레임으로 쪼갬)
opcode 프레임 타입 (텍스트/바이너리/종료/핑/퐁 등)
MASK 클라이언트→서버 방향은 반드시 마스킹 (보안용)
Payload length 실제 데이터 길이
Payload 보낼 데이터

opcode 종류

opcode 이름 설명
0x1 Text UTF-8 텍스트
0x2 Binary 바이너리 데이터 (이미지, 파일 등)
0x8 Close 연결 종료 알림
0x9 Ping 살아 있는지 확인
0xA Pong Ping에 대한 응답

왜 마스킹이 필요한가?

클라이언트→서버 방향 프레임은 반드시 랜덤 키로 XOR 마스킹해서 보내야 한다. 이유는 웹 환경에서 프록시 캐시 포이즈닝 공격을 막기 위해서다. 마스킹이 없으면 공격자가 WebSocket 페이로드를 HTTP 요청처럼 위장해 중간 프록시를 속일 수 있다.


브라우저 클라이언트 예제

브라우저에는 WebSocket 객체가 내장돼 있어 라이브러리 없이 바로 쓸 수 있다.

const socket = new WebSocket('wss://example.com/chat')

// 연결 열림
socket.addEventListener('open', () => {
  console.log('연결됨')
  socket.send(JSON.stringify({ type: 'join', room: 'general' }))
})

// 메시지 수신
socket.addEventListener('message', (event) => {
  const data = JSON.parse(event.data)
  console.log('받음:', data)
})

// 에러
socket.addEventListener('error', (err) => {
  console.error('에러:', err)
})

// 연결 종료
socket.addEventListener('close', (event) => {
  console.log(`종료: code=${event.code}, reason=${event.reason}`)
})

readyState

소켓의 현재 상태를 나타내는 숫자값.

상수 의미
0 CONNECTING 연결 중
1 OPEN 연결됨 (전송 가능)
2 CLOSING 닫는 중
3 CLOSED 닫힘
if (socket.readyState === WebSocket.OPEN) {
  socket.send('hello')
}

주의: CONNECTING 상태에서 send()를 호출하면 예외가 발생한다. 반드시 open 이벤트 이후에 전송하거나, readyState를 확인해야 한다.


Node.js 서버 예제 (ws 라이브러리)

Node.js에서 WebSocket 서버를 만들 때 가장 널리 쓰는 라이브러리는 ws다.

npm install ws
import { WebSocketServer } from 'ws'

const wss = new WebSocketServer({ port: 8080 })

wss.on('connection', (socket, request) => {
  const ip = request.socket.remoteAddress
  console.log(`클라이언트 접속: ${ip}`)

  // 메시지 수신
  socket.on('message', (data) => {
    const msg = data.toString()
    console.log('받음:', msg)

    // 모든 클라이언트에게 브로드캐스트
    wss.clients.forEach((client) => {
      if (client.readyState === 1) {
        client.send(msg)
      }
    })
  })

  socket.on('close', () => {
    console.log('클라이언트 종료')
  })

  // 접속하자마자 환영 메시지
  socket.send(JSON.stringify({ type: 'welcome' }))
})

이 코드만으로 간단한 채팅 서버가 완성된다. 누군가 메시지를 보내면 접속된 모든 클라이언트에게 그대로 전달된다.

방(room) 개념 구현

실전에서는 전체 브로드캐스트보다 특정 방에만 전송하는 경우가 많다.

const rooms = new Map<string, Set<WebSocket>>()

wss.on('connection', (socket) => {
  let currentRoom: string | null = null

  socket.on('message', (data) => {
    const msg = JSON.parse(data.toString())

    if (msg.type === 'join') {
      currentRoom = msg.room
      if (!rooms.has(currentRoom)) rooms.set(currentRoom, new Set())
      rooms.get(currentRoom)!.add(socket)
    }

    if (msg.type === 'chat' && currentRoom) {
      const members = rooms.get(currentRoom) ?? new Set()
      members.forEach((client) => {
        if (client !== socket && client.readyState === 1) {
          client.send(JSON.stringify({ type: 'chat', text: msg.text }))
        }
      })
    }
  })

  socket.on('close', () => {
    if (currentRoom) rooms.get(currentRoom)?.delete(socket)
  })
})

하트비트 (Ping / Pong)

TCP 연결은 중간에 끊겨도 양쪽이 바로 알아채지 못한다. 특히 모바일 네트워크 전환, NAT 타임아웃, 프록시 정리 등으로 **"좀비 연결"**이 남을 수 있다.

이를 감지하기 위해 WebSocket은 Ping/Pong 프레임을 제공한다.

[서버] ──Ping──▶ [클라이언트]
       ◀──Pong── 
       (응답 없으면 죽은 연결로 간주하고 종료)

Node.js에서 구현

wss.on('connection', (socket) => {
  // @ts-expect-error 커스텀 프로퍼티
  socket.isAlive = true
  socket.on('pong', () => {
    // @ts-expect-error
    socket.isAlive = true
  })
})

// 30초마다 체크
const interval = setInterval(() => {
  wss.clients.forEach((socket) => {
    // @ts-expect-error
    if (!socket.isAlive) return socket.terminate()
    // @ts-expect-error
    socket.isAlive = false
    socket.ping()
  })
}, 30_000)

핑을 보냈는데 다음 체크 때까지 퐁이 안 오면 terminate()로 강제 종료한다.


재연결 전략

WebSocket은 네트워크 불안정, 서버 재시작 등으로 언제든 끊어질 수 있다. 클라이언트는 끊기면 자동으로 다시 연결해야 사용자 경험이 유지된다.

단순 재연결의 문제

socket.addEventListener('close', () => {
  new WebSocket(url) // 끊기자마자 즉시 재연결
})

서버가 다운됐을 때 모든 클라이언트가 동시에 재접속을 시도하면 서버가 살아나자마자 다시 터진다. 이걸 Thundering Herd 문제라 한다.

Exponential Backoff + Jitter

재시도 간격을 점점 늘리고, 약간의 무작위성을 더한다.

class ReconnectingSocket {
  private attempts = 0
  private socket: WebSocket | null = null

  constructor(private url: string) {
    this.connect()
  }

  private connect() {
    this.socket = new WebSocket(this.url)

    this.socket.addEventListener('open', () => {
      this.attempts = 0 // 성공하면 초기화
    })

    this.socket.addEventListener('close', () => {
      const delay = Math.min(1000 * 2 ** this.attempts, 30_000)
      const jitter = Math.random() * 1000
      setTimeout(() => this.connect(), delay + jitter)
      this.attempts++
    })
  }

  send(data: string) {
    if (this.socket?.readyState === WebSocket.OPEN) {
      this.socket.send(data)
    }
  }
}

재시도 간격: 1초 → 2초 → 4초 → 8초 → ... → 최대 30초. 여기에 랜덤 지터를 더해 몰려드는 재접속을 분산시킨다.


WebSocket vs SSE vs Long Polling

실시간을 구현하는 방법이 WebSocket만 있는 건 아니다.

항목 WebSocket SSE (Server-Sent Events) Long Polling
방향 양방향 서버→클라 단방향 요청마다 한 번
프로토콜 별도 (ws/wss) HTTP HTTP
자동 재연결 직접 구현 브라우저가 내장 직접 구현
바이너리 지원 텍스트만 텍스트 (base64 가능)
구현 난이도 중간 낮음 낮음
용도 채팅, 게임, 협업 실시간 피드, 알림, LLM 스트리밍 단순 알림

언제 WebSocket을 써야 하나?

  • 클라이언트도 자주 메시지를 보내야 할 때 (채팅, 게임 컨트롤)
  • 바이너리 데이터가 필요할 때 (음성, 이미지 스트림)
  • 지연(latency)이 매우 중요할 때

반대로, 서버→클라 단방향 알림이라면 SSE가 구현이 훨씬 간단하다. ChatGPT 같은 LLM 토큰 스트리밍은 대부분 SSE로 구현된다.


스케일링 — 서버가 여러 대라면?

WebSocket은 연결이 유지되는 프로토콜이라 수평 확장이 까다롭다.

[클라이언트 A] ──연결──▶ [서버 1]
[클라이언트 B] ──연결──▶ [서버 2]

서버 1에 접속한 A가 보낸 메시지를 
서버 2에 접속한 B에게 어떻게 전달할까?

1. Sticky Session

로드 밸런서가 같은 클라이언트는 항상 같은 서버로 보내도록 고정한다. 연결 유지에는 필수지만, 방 전체에 브로드캐스트하려면 여전히 서버 간 통신이 필요하다.

2. Redis Pub/Sub

서버들끼리 Redis 채널을 통해 메시지를 공유한다.

[서버 1]: 메시지 받음 → Redis PUBLISH "room:general"
                               │
                               ▼
         ┌──────────────────────────┐
         │     Redis Pub/Sub        │
         └──────────────────────────┘
             │          │         │
             ▼          ▼         ▼
         [서버 1]   [서버 2]   [서버 3]
         각 서버가 자기 방 멤버에게 브로드캐스트

Socket.IO, NestJS WebSocket Gateway 등 주요 라이브러리는 Redis 어댑터를 기본 지원한다.

3. 전용 메시징 인프라

규모가 더 커지면 Kafka, NATS, AWS IoT Core 같은 전용 메시징 시스템을 쓰거나, Ably, Pusher, PubNub 같은 WebSocket 전용 SaaS를 쓴다.


보안 체크리스트

항목 설명
wss:// 사용 평문(ws)은 도청 및 변조 가능. 운영은 무조건 wss
Origin 검증 핸드셰이크 시 Origin 헤더가 우리 도메인인지 확인
인증 쿼리 파라미터 토큰, 쿠키, 첫 메시지 인증 등으로 사용자 식별
메시지 크기 제한 거대한 프레임으로 메모리 고갈 방지 (maxPayload)
Rate Limiting IP/사용자당 연결 수, 초당 메시지 수 제한
입력 검증 WebSocket 메시지도 결국 사용자 입력. XSS/SQL Injection 주의
const wss = new WebSocketServer({
  port: 8080,
  maxPayload: 64 * 1024, // 64KB 제한
  verifyClient: (info, cb) => {
    const origin = info.origin
    const allowed = ['https://mysite.com']
    if (!allowed.includes(origin)) return cb(false, 403, 'Forbidden')
    cb(true)
  },
})

실전 사용 사례

사례 설명
채팅 / DM 메시지 즉시 전달, 타이핑 표시, 읽음 처리
실시간 알림 주문 상태 변경, 새 댓글 알림
협업 툴 구글 Docs, Figma — 커서 위치, 편집 내용 실시간 동기화
주식 / 암호화폐 틱 단위 시세 스트리밍
멀티플레이 게임 플레이어 이동/액션 상태 공유
IoT 제어 디바이스 제어 명령, 센서 값 수집

정리

개념 핵심 한 줄
등장 배경 HTTP로는 서버가 먼저 말을 못 걸어서
핸드셰이크 HTTP 요청으로 시작, 101 Switching Protocols로 업그레이드
프레임 opcode로 타입 구분, 클라→서버는 반드시 마스킹
readyState CONNECTING / OPEN / CLOSING / CLOSED
Ping / Pong 좀비 연결을 감지하는 하트비트
재연결 Exponential Backoff + Jitter로 Thundering Herd 방지
vs SSE 양방향은 WebSocket, 서버→클라 단방향 스트리밍은 SSE
스케일링 Sticky Session + Redis Pub/Sub이 기본 조합
보안 wss, Origin 검증, 인증, 메시지 크기 제한 필수

WebSocket은 실시간성이 필요한 모든 기능의 기반이다. 원리를 이해하고 나면, Socket.IO 같은 라이브러리를 써도 내부에서 무슨 일이 벌어지는지 감이 잡힌다.