웹소켓(WebSockets) 작동 원리: 실시간 연결 아키텍처와 라이프사이클의 완벽 이해

초기 웹 환경에서 브라우저는 단순한 문서 뷰어에 불과했습니다. 페이지를 요청하면 서버가 이를 렌더링하고 연결을 종료했습니다. 이 요청-응답 주기는 HTTP (Hypertext Transfer Protocol) 의 핵심입니다.

하지만 웹 애플리케이션이 실시간 채팅, 금융 데이터 실시간 표시, 공동 문서 편집, 멀티플레이어 게임 등 다채롭고 상호작용적인 경험으로 발전하면서 기존의 HTTP 모델은 한계를 보이기 시작했습니다.

실시간 업데이트를 받기 위해 초기 개발자들은 임시방편에 의존했습니다.

  • 단기 폴링 (Short Polling): 브라우저가 몇 초마다 서버에 HTTP 요청을 보내 새 데이터가 있는지 묻습니다. 이는 엄청난 헤더 오버헤드를 발생시키고 서버 리소스를 낭비합니다.
  • 장기 폴링 (Long Polling / Comet): 브라우저가 요청을 보내면 서버는 새 데이터가 준비될 때까지 연결을 열어둡니다. 데이터가 전송되면 연결이 끊어지고 브라우저는 즉시 새 요청을 보냅니다. 관리하기 복잡하고 연결 연결 설정 시의 오버헤드가 여전히 큽니다.

**웹소켓(WebSockets)**은 단일 TCP 연결에서 지속적이고 양방향인 전이중(Full-Duplex) 통신을 위한 표준화된 프로토콜을 도입하여 이러한 한계를 완벽하게 해결했습니다.


웹소켓이란 무엇인가요?

웹소켓(RFC 6455에 정의됨)은 HTTP와 함께 작동합니다. HTTP는 클라이언트만 요청을 시작할 수 있는 무상태(stateless) 프로토콜인 반면, 웹소켓 연결은 한 번 수립되면 무기한 열려 있어 클라이언트와 서버가 최소한의 대기 시간으로 언제든지 서로 데이터를 전송할 수 있습니다.

웹소켓의 가장 기본적인 규칙은 다음과 같습니다.

연결이 수립되면 어느 쪽에서든 새로운 연결 요청 없이 언제든지 메시지를 전송할 수 있습니다.


단계별 가이드: 연결 라이프사이클

웹소켓 연결은 세 가지 고유한 단계인 핸드셰이크(Handshake), 데이터 전송, 연결 종료를 거치게 됩니다.

WebSocket Connection Lifecycle Diagram

1. HTTP 핸드셰이크 (프로토콜 업그레이드)

방화벽과 라우터는 일반적으로 포트 80(HTTP) 및 443(HTTPS)의 표준 웹 트래픽을 허용하도록 설정되므로, 웹소켓은 표준 HTTP/1.1 요청으로 시작됩니다. 이를 업그레이드 핸드셰이크라고 부릅니다.

클라이언트 요청

클라이언트는 프로토콜 전환을 요청하는 특정 헤더와 함께 HTTP GET 요청을 보냅니다.

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
  • Upgrade: websocketConnection: Upgrade: 서버에 프로토콜을 전환하겠다는 의사를 알립니다.
  • Sec-WebSocket-Key: Base64로 인코딩된 임의의 16바이트 값입니다. 서버가 핸드셰이크를 수신하고 웹소켓 프로토콜을 이해하고 있음을 증명하는 용도로 사용됩니다.
  • Sec-WebSocket-Version: 웹소켓 프로토콜 버전을 지정합니다 (보통 13).
  • Origin: 서버가 연결 허용 여부를 결정하는 데 사용됩니다 (승인되지 않은 웹사이트의 교차 사이트 연결 시도를 차단하는 보안 검사).

서버 응답

서버가 웹소켓을 지원하면 요청을 검증하고 HTTP 상태 코드 101 Switching Protocols로 응답합니다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
  • 서버의 Sec-WebSocket-Accept 계산 방법:
    1. 클라이언트의 Sec-WebSocket-Key 값(예: dGhlIHNhbXBsZSBub25jZQ==)을 가져옵니다.
    2. 이를 표준 마법 문자열 GUID인 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"과 연결합니다.
    3. 결합된 문자열의 SHA-1 해시 값을 계산합니다.
    4. 해시 값을 Base64로 인코딩합니다.
    5. 클라이언트가 이 값이 자신의 예상과 일치함을 확인하면 핸드셰이크가 성공하고, HTTP 연결이 원시 TCP 소켓으로 전환되며, 양측 모두 웹소켓 프로토콜을 사용하게 됩니다.

2. 데이터 프레임 구성 및 전송

일반 텍스트 헤더 뒤에 바디를 보내는 HTTP와 달리, 웹소켓은 **프레임(frames)**이라고 불리는 구조화된 바이너리 패킷 단위로 데이터를 전송합니다.

웹소켓 프레임은 매우 가벼운 헤더(2~14바이트)와 페이로드(payload) 데이터로 구성됩니다. 헤더에는 다음 필드가 포함됩니다.

  • FIN 비트 (1비트): 이 프레임이 메시지의 마지막 프레임인지 여부를 나타냅니다.
  • Opcode (4비트): 프레임의 유형을 정의합니다.
    • 0x1: 텍스트 프레임 (UTF-8 인코딩)
    • 0x2: 바이너리 프레임
    • 0x8: 연결 종료 요청
    • 0x9: Ping
    • 0xA: Pong
  • Mask 비트 (1비트): 페이로드 데이터가 마스킹되었는지 여부를 지정합니다.
  • Payload Length: 데이터의 크기입니다.
  • Masking Key (4바이트): 가장 중요한 보안 요구 사항: 클라이언트에서 서버로 전송되는 모든 프레임은 임의의 4바이트 키로 마스킹(XOR 난독화)되어야 합니다. 이는 프록시 캐시 장치가 트래픽을 읽거나 캐시 오염 공격을 수행하는 것을 차단합니다. 서버에서 클라이언트로 보내는 프레임은 마스킹되지 않아야 합니다.

하트비트 (Ping/Pong)

라우터나 로드 밸런서가 유휴 상태의 연결을 임의로 닫지 않도록 어느 한쪽에서 Ping 프레임을 보낼 수 있습니다. 수신 측은 즉시 동일한 페이로드를 포함하는 Pong 프레임으로 응답해야 합니다.

3. 연결 종료

연결을 안전하고 깨끗하게 종료하는 방법은 다음과 같습니다.

  1. 한쪽 피어가 상태 코드(정상 종료는 1000, 비정상 종료는 1006 등)와 선택적 사유 텍스트가 포함된 Close 프레임을 전송합니다.
  2. 상대 피어가 자체 Close 프레임으로 회신합니다.
  3. 기본 TCP 소켓이 닫힙니다.

코드 예제: Node.js 웹소켓 구현

웹소켓이 실제로 어떻게 작동하는지 보기 위해 간단한 Node.js 애플리케이션을 작성해 보겠습니다. 수신한 메시지를 그대로 다시 보내는(에코) 로컬 웹소켓 서버와 이에 연결하는 클라이언트 스크립트를 생성합니다.

웹소켓 서버 (server.js)

const { WebSocketServer } = require('ws');
const http = require('http');

// 1. 표준 HTTP 서버 생성
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('HTTP Server running. Use WebSocket to connect.\n');
});

// 2. HTTP 서버에 웹소켓 서버 연결
const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
    const clientIp = req.socket.remoteAddress;
    console.log(`[Server] 새 클라이언트 연결됨. IP: ${clientIp}`);

    // 클라이언트에 환영 메시지 전송
    ws.send(JSON.stringify({ type: 'welcome', message: 'Ghaznix 웹소켓 서버에 연결되었습니다!' }));

    // 클라이언트로부터 들어오는 메시지 감지
    ws.on('message', (message) => {
        console.log(`[Server] 수신 메시지: ${message}`);
        
        try {
            const data = JSON.parse(message);
            ws.send(JSON.stringify({
                type: 'echo',
                message: `서버 에코: ${data.text.toUpperCase()}`,
                timestamp: new Date().toISOString()
            }));
        } catch (e) {
            ws.send(JSON.stringify({ type: 'error', message: '올바르지 않은 JSON 형식입니다' }));
        }
    });

    // 클라이언트 연결 해제 처리
    ws.on('close', (code, reason) => {
        console.log(`[Server] 클라이언트 연결 끊김 (코드: ${code}, 사유: ${reason.toString() || '없음'})`);
    });

    ws.on('error', (error) => {
        console.error(`[Server] 소켓 에러: ${error.message}`);
    });
});

server.listen(8080, () => {
    console.log('웹소켓 서버가 포트 8080에서 대기 중: ws://localhost:8080');
});

브라우저 클라이언트 (클라이언트 사이드 JavaScript)

이 클라이언트 코드는 브라우저 콘솔에서 즉시 테스트할 수 있습니다.

// 1. 서버에 대한 연결 수립
const socket = new WebSocket('ws://localhost:8080');

// 2. 연결이 성공적으로 열렸을 때의 이벤트 핸들러
socket.addEventListener('open', (event) => {
    console.log('[Client] 서버에 연결되었습니다.');
    
    const payload = JSON.stringify({ text: 'hello, server!' });
    socket.send(payload);
    console.log(`[Client] 전송함: ${payload}`);
});

// 3. 서버로부터 메시지 수신 감지
socket.addEventListener('message', (event) => {
    const response = JSON.parse(event.data);
    console.log('[Client] 서버로부터 수신된 메시지:', response);
});

// 4. 연결이 닫혔을 때의 이벤트 핸들러
socket.addEventListener('close', (event) => {
    console.log(`[Client] 연결이 닫혔습니다 (코드: ${event.code})`);
});

// 5. 에러 이벤트 핸들러
socket.addEventListener('error', (error) => {
    console.error('[Client] 웹소켓 에러 발생:', error);
});

HTTP vs. 웹소켓: 상세 비교

기능 HTTP/1.1 웹소켓
통신 방향 단방향 (클라이언트가 요청 시작) 양방향 (클라이언트 및 서버 모두 시작 가능)
연결 모델 요청-응답 (단발성) 지속적 연결 유지 (장기 유지)
오버헤드 높음 (매 요청마다 큰 헤더가 전송됨) 매우 낮음 (최소한의 프레임 오버헤드)
상태 유지 무상태 (Stateless) 상태 유지 (Stateful, 연결 컨텍스트 유지)
프로토콜 http:// 또는 https:// ws:// 또는 wss://
적합한 분야 일반 문서 조회, REST API 실시간 채팅, 대시보드, 게임, 라이브 데이터 피드

웹소켓 보안 고려 사항

웹소켓은 핸드셰이크 완료 후 기존 HTTP 라우팅을 우회하기 때문에 다음과 같은 특수한 보안 리스크가 발생합니다.

  1. 보안 웹소켓 사용 (wss://): 항상 TLS/SSL(포트 443) 위에서 웹소켓을 실행해야 합니다. WSS는 전송 프레임의 내용을 암호화하여 패킷 스니핑과 중간자 변조 공격을 방지합니다.
  2. 오리진 검증 (Origin Validation): 웹소켓은 동일 출처 정책(SOP)의 적용을 받지 않습니다. 악의적인 외부 사이트에서 우리 API에 직접 웹소켓 연결을 요청할 수 있으므로, 핸드셰이크 단계에서 반드시 서버측 Origin 헤더를 검증해야 합니다.
  3. 핸드셰이크 시점에 사용자 인증: 연결이 완전히 수립되기 전에 사용자를 식별해야 합니다. 보통 쿼리 매개변수로 토큰(JWT 등)을 넘기거나 세션 쿠키를 검증하는 방식으로 수행됩니다.
  4. 입력 데이터 정화 (Sanitization): 웹소켓을 통해 수신하는 모든 메시지는 신뢰할 수 없는 데이터로 취급해야 합니다. 교차 사이트 스크립팅(XSS)을 방지하기 위해 페이로드 데이터를 항상 검증하고 정화해야 합니다.

요약

웹소켓은 기존 HTTP 폴링의 과도한 오버헤드를 완전히 걷어내며 실시간 웹 애플리케이션의 새로운 시대를 열었습니다. 하나의 지속적인 TCP 연결을 통해 즉각적인 양방향 메시지 전송을 지원하며, 오늘날의 라이브 대시보드, 멀티플레이어 게임, 채팅 앱의 근간이 되었습니다. HTTP 업그레이드 원리, 프레임 구조, 핵심 보안 수칙을 이해함으로써 더욱 빠르고 안전한 실시간 서비스를 개발할 수 있습니다.


Ghaznix 블로그에서 더 많은 개발자 튜토리얼과 가이드를 확인해보세요 →