컴퓨터 구조 운영체제 네트워크 자료구조 알고리즘 데이터베이스 내 학습
network · 09

WebSocket

네트워크WebSocket실시간

WebSocket은 하나의 TCP 연결 위에서 클라이언트와 서버가 양방향으로 실시간 통신할 수 있는 프로토콜입니다. HTTP Upgrade 핸드셰이크로 연결을 수립한 뒤, 이후부터는 HTTP 오버헤드 없이 프레임 단위로 데이터를 주고받습니다.

핵심 개념

HTTP vs WebSocket

HTTP는 요청-응답 모델로, 서버가 먼저 데이터를 보낼 수 없습니다. 실시간 데이터가 필요하면 클라이언트가 계속 폴링해야 합니다. WebSocket은 연결 수립 후 양방향 채널이 열려 서버도 언제든 데이터를 푸시할 수 있습니다. 프레임 헤더가 작아 효율적입니다.

핵심 개념

HTTP Upgrade 핸드셰이크

WebSocket 연결은 HTTP GET 요청으로 시작합니다. 클라이언트가 Upgrade: websocket, Connection: Upgrade 헤더와 Sec-WebSocket-Key를 전송합니다. 서버가 101 Switching Protocols로 응답하면 프로토콜이 전환됩니다. 이후 HTTP는 사용하지 않습니다.

핵심 개념

WebSocket 프레임

WebSocket 데이터는 프레임 단위로 전송됩니다. 프레임 헤더는 서버→클라이언트 2~10바이트, 클라이언트→서버 마스킹 키가 더해져 6~14바이트로 작습니다. OpCode로 Text(0x1), Binary(0x2), Ping(0x9), Pong(0xA), Close(0x8) 등을 구분합니다. 클라이언트→서버 메시지는 반드시 마스킹해야 합니다.

핵심 개념

Ping / Pong

연결 유지(Keep-alive) 메커니즘입니다. 한쪽이 Ping 프레임을 보내면 상대는 Pong으로 응답합니다. 네트워크 장애나 상대의 비정상 종료를 감지하는 데 쓰입니다. Pong 응답이 없을 때 연결을 닫을지는 구현이 정합니다.

핵심 개념

서브프로토콜과 확장

Sec-WebSocket-Protocol 헤더로 서브프로토콜을 협상합니다. STOMP(메시지 브로커), MQTT(IoT) 같은 프로토콜이 WebSocket 위에서 동작할 수 있습니다. permessage-deflate 확장으로 메시지를 압축해 대역폭을 절약할 수 있습니다.

핵심 개념

SSE vs WebSocket

SSE(Server-Sent Events)는 서버→클라이언트 단방향 스트림으로, HTTP 위에서 동작합니다. 자동 재연결, EventSource API 등 간단한 장점이 있습니다. 단방향 알림(주식 시세, 뉴스 피드)에는 SSE가, 채팅·게임 같은 양방향 통신에는 WebSocket이 적합합니다.

폴링 vs 롱폴링 vs WebSocket — 폴링은 주기적으로 요청을 보냅니다. 롱폴링은 서버가 데이터가 생길 때까지 응답을 지연합니다. WebSocket은 연결을 유지하며 즉시 전달합니다. 메시지가 잦을수록 요청마다 헤더와 핸드셰이크를 반복하지 않는 WebSocket이 유리하지만, 연결을 유지하는 서버 자원이 듭니다.
Spring WebSocket + STOMP — Spring에서 @EnableWebSocketMessageBroker로 STOMP 메시지 브로커를 활성화합니다. /topic 구독으로 브로드캐스트, /queue로 1:1 메시지를 구현합니다. SockJS는 WebSocket을 쓸 수 없을 때 HTTP 스트리밍이나 롱폴링 같은 대체 전송으로 폴백합니다.
로드 밸런서와 WebSocket — WebSocket 연결 하나는 한 서버에 계속 붙어 있습니다. SockJS 폴백에서는 여러 HTTP 요청이 한 SockJS 세션에 속하므로 같은 서버로 보낼지 로드 밸런서 설정을 따로 확인해야 합니다. nginx에서는 Upgrade 헤더 전달 설정이 필요합니다(1.29.6 이하는 proxy_http_version 1.1도 명시). Spring의 기본 simple broker는 서버가 여러 대이면 쓸 수 없어 RabbitMQ·ActiveMQ 같은 외부 브로커를 연결하고, Redis Pub/Sub 같은 방식으로 직접 전달하기도 합니다.
보안 — wss://(WebSocket Secure)는 TLS 위에서 동작합니다. WebSocket 핸드셰이크에는 CORS 검사가 없고 브라우저가 Origin 헤더를 보낼 뿐이라 서버가 Origin을 검증해야 합니다. 다른 사이트에서 연결을 맺게 하는 공격도 가능하므로 토큰 기반 인증을 함께 씁니다.

HTTP만으로는 왜 부족했나

HTTP는 클라이언트가 요청해야 서버가 응답하는 구조라서, 서버가 먼저 소식을 전하려면 클라이언트가 계속 물어보는 폴링에 기대야 했습니다. WebSocket은 HTTP 요청으로 시작한 연결을 양방향 메시지 채널로 바꿔 이 문제를 줄입니다. 프로토콜은 2011년 RFC 6455로 표준화되었고, 브라우저의 JavaScript API는 WHATWG HTML 표준이 정의합니다.

숫자로 확인하기

프레임 헤더 크기 — 페이로드가 125바이트 이하이면 서버에서 클라이언트로 가는 프레임 헤더는 2바이트입니다. 길이가 126~65,535바이트이면 확장 길이 2바이트가 붙어 4바이트, 그보다 크면 확장 길이 8바이트가 붙어 10바이트입니다. 클라이언트에서 서버로 가는 프레임은 마스킹 키 4바이트가 더해져 6~14바이트입니다.
폴링과 헤더 비용 비교(가정) — HTTP 요청·응답 헤더가 합쳐 500바이트라고 가정하고 1초마다 폴링하면 한 시간에 3,600번 × 500바이트 = 1,800,000바이트(약 1.8MB)가 헤더로만 나갑니다. 같은 3,600개 메시지를 125바이트 이하 WebSocket 프레임으로 받으면 헤더는 3,600 × 2바이트 = 7,200바이트입니다.
핸드셰이크 키 계산 — 서버는 클라이언트가 보낸 Sec-WebSocket-Key 값 뒤에 RFC 6455가 정한 고정 문자열(GUID)을 붙여 SHA-1으로 해시하고 Base64로 인코딩해 Sec-WebSocket-Accept로 돌려줍니다. RFC의 예시 키 dGhlIHNhbXBsZSBub25jZQ==는 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=가 됩니다.

자주 하는 오해

WebSocket은 HTTP와 완전히 별개다 — 연결은 HTTP 요청으로 시작합니다. 서버가 101 응답을 주면 같은 TCP 연결에서 프로토콜이 바뀝니다.
WebSocket이면 메시지가 반드시 전달된다 — TCP 위에서 순서대로 전달되지만, 연결이 끊긴 사이의 메시지는 사라질 수 있습니다. 재연결 후 놓친 메시지를 받는 방법은 애플리케이션이 따로 만들어야 합니다.
브라우저가 동일 출처 정책으로 WebSocket도 막아 준다 — WebSocket 핸드셰이크에는 CORS 검사가 없고 브라우저는 Origin 헤더를 실어 보낼 뿐입니다. 허용할 출처는 서버가 Origin을 보고 직접 판단해야 합니다(Spring은 기본으로 같은 출처만 허용합니다).
끊기면 브라우저가 알아서 다시 연결해 준다 — WebSocket API에는 자동 재연결이 없습니다. SSE의 EventSource는 자동 재연결이 있지만 WebSocket은 애플리케이션이 직접 구현해야 합니다.

면접에서 이렇게 물어요

면접 질문

WebSocket 연결은 어떻게 맺어지나요?

클라이언트가 Upgrade: websocket 헤더와 Sec-WebSocket-Key를 담은 HTTP GET을 보내고, 서버가 101 Switching Protocols와 Sec-WebSocket-Accept로 응답하면 같은 TCP 연결이 WebSocket 프레임을 주고받는 채널로 바뀝니다.

면접 질문

폴링, 롱폴링, SSE, WebSocket은 어떻게 고르나요?

서버에서 클라이언트로만 알리면 되고 HTTP 위에서 단순하게 가고 싶으면 SSE, 클라이언트와 서버가 자주 양방향으로 주고받으면 WebSocket을 씁니다. 갱신이 드물고 단순하면 폴링도 충분합니다.

면접 질문

서버가 여러 대일 때 메시지를 모든 사용자에게 어떻게 보내나요?

사용자 연결이 서로 다른 서버에 붙어 있으므로 서버끼리 메시지를 전달할 통로가 필요합니다. 외부 메시지 브로커(RabbitMQ, ActiveMQ 등)나 Pub/Sub 방식으로 각 서버에 메시지를 퍼뜨리고, 각 서버가 자기 연결에 전달합니다.

면접 질문

연결이 끊긴 것을 어떻게 알아내나요?

상대가 정상 종료하면 Close 프레임이 오지만 비정상 종료나 네트워크 단절은 알려지지 않을 수 있습니다. Ping/Pong 프레임이나 애플리케이션 하트비트를 주기적으로 주고받아 응답이 없으면 끊긴 것으로 처리합니다.

실제 시스템에서는

Spring 설정 — Spring Framework 문서는 WebSocket 핸들러와 STOMP 메시지 브로커 설정을 설명하며, 허용 출처는 setAllowedOrigins로 지정하고 SockJS는 WebSocket을 쓸 수 없을 때의 대체 전송을 제공한다고 안내합니다.
nginx 프록시 — nginx 문서는 WebSocket을 프록시하려면 Upgrade와 Connection 헤더를 직접 전달해야 하고, 1.29.6 이하에서는 proxy_http_version 1.1도 명시해야 한다고 설명합니다(1.29.7부터 기본값이 1.1). 프록시된 서버가 60초 동안 데이터를 보내지 않으면 연결이 닫히며 proxy_read_timeout으로 조절합니다.
서브프로토콜 — Sec-WebSocket-Protocol로 서브프로토콜을 협상하고, 이름은 IANA 레지스트리에 등록됩니다. 압축은 RFC 7692의 permessage-deflate 확장이 정의합니다.

함께 보면 좋은 토픽

HTTP — WebSocket이 시작되는 업그레이드 요청과 상태 코드를 봅니다.
TCP/IP — WebSocket이 올라가는 TCP 연결을 봅니다.
HTTPS / TLS — wss:// 연결이 쓰는 TLS를 봅니다.
로드 밸런서 — 오래 유지되는 연결을 분산할 때의 고려점을 봅니다.
TCP 혼잡 제어 — 오래 유지되는 연결에서 전송 속도가 정해지는 방식을 봅니다.

참고 자료

Java — Spring WebSocket + STOMP 서버 구현

import org.springframework.web.socket.*;
import org.springframework.messaging.handler.annotation.*;
import org.springframework.messaging.simp.SimpMessagingTemplate;

/* ── WebSocket 설정 ──────────────────────────────────────── */
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 인메모리 메시지 브로커 — /topic(브로드캐스트), /queue(1:1)
        registry.enableSimpleBroker("/topic", "/queue");
        // 클라이언트 → 서버 메시지 prefix
        registry.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOriginPatterns("*")
                .withSockJS(); // WebSocket 미지원 환경 폴백
    }
}

/* ── 채팅 컨트롤러 ──────────────────────────────────────── */
@Controller
public class ChatController {

    private final SimpMessagingTemplate messagingTemplate;

    public ChatController(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    // 클라이언트 → /app/chat.send → 브로드캐스트
    @MessageMapping("/chat.send")
    @SendTo("/topic/chat")
    public ChatMessage handleMessage(ChatMessage message) {
        message.setTimestamp(Instant.now());
        return message; // /topic/chat 구독자 전체에 전달
    }

    // 서버에서 특정 사용자에게 1:1 메시지 전송
    public void sendToUser(String userId, String message) {
        messagingTemplate.convertAndSendToUser(
            userId, "/queue/notification",
            new Notification(message)
        );
    }

    // 연결/해제 이벤트 처리
    @EventListener
    public void handleConnect(SessionConnectedEvent event) {
        log.info("WebSocket 연결: {}", event.getUser());
    }

    @EventListener
    public void handleDisconnect(SessionDisconnectEvent event) {
        log.info("WebSocket 해제: {}", event.getSessionId());
    }
}

@EnableWebSocketMessageBroker로 STOMP 메시지 브로커를 활성화합니다.
enableSimpleBroker는 인메모리 브로커로, 운영 환경에서는 Redis를 브로커로 사용해 여러 서버 인스턴스 간 메시지를 공유합니다.
@MessageMapping은 /app/chat.send로 오는 메시지를 처리하고, @SendTo는 /topic/chat 구독자 전체에 결과를 브로드캐스트합니다.
withSockJS()는 WebSocket을 지원하지 않는 환경에서 롱폴링 등으로 자동 폴백합니다.
현대 브라우저에서는 대부분 불필요하지만 기업 네트워크 프록시 환경에서 유용합니다.
setAllowedOriginPatterns("*")는 개발 환경용입니다. 운영 환경에서는 실제 도메인만 허용해야 합니다.

JavaScript — WebSocket 클라이언트 + 재연결 로직

// ── 기본 WebSocket API ───────────────────────────────────
const ws = new WebSocket('wss://example.com/ws');

ws.onopen = () => {
    console.log('연결 수립', ws.readyState); // OPEN = 1
    ws.send('안녕하세요!');               // 텍스트 전송
    ws.send(new Uint8Array([1,2,3]));    // 바이너리 전송
};

ws.onmessage = (event) => {
    if (typeof event.data === 'string') {
        const msg = JSON.parse(event.data);
        console.log('수신:', msg);
    }
};

ws.onclose = (event) => {
    // event.code: 1000(정상), 1006(비정상), 1011(서버오류)
    console.log(`종료: ${event.code} ${event.reason}`);
};

ws.onerror = (error) => console.error('오류:', error);

// ── 자동 재연결 래퍼 ─────────────────────────────────────
class ReconnectingWebSocket {
    constructor(url, options = {}) {
        this.url        = url;
        this.maxRetries = options.maxRetries || 5;
        this.retryDelay = options.retryDelay || 1000;
        this.retries    = 0;
        this.connect();
    }

    connect() {
        this.ws = new WebSocket(this.url);

        this.ws.onopen = () => {
            this.retries = 0; // 성공 시 카운터 리셋
            this.onopen?.();
        };

        this.ws.onmessage = (e) => this.onmessage?.(e);

        this.ws.onclose = (e) => {
            if (e.code !== 1000 && this.retries < this.maxRetries) {
                const delay = this.retryDelay * Math.pow(2, this.retries);
                setTimeout(() => this.connect(), delay); // 지수 백오프
                this.retries++;
            }
        };
    }

    send(data) {
        if (this.ws.readyState === WebSocket.OPEN)
            this.ws.send(JSON.stringify(data));
    }
}

WebSocket.readyState는 CONNECTING(0), OPEN(1), CLOSING(2), CLOSED(3) 네 가지 상태를 가집니다.
send() 호출 전 OPEN 상태인지 확인해야 합니다.
ws://는 평문, wss://는 TLS 암호화 WebSocket입니다. 운영 환경에서는 반드시 wss://를 사용합니다.
자동 재연결 시 지수 백오프(exponential backoff)를 적용합니다.
재연결 실패 시 점점 늘어나는 대기 시간으로 서버 부하를 줄입니다.
close 코드 1000은 정상 종료이며 재연결하지 않습니다. 1006은 비정상 종료(네트워크 단절 등)로 재연결을 시도합니다.

Q1

WebSocket 연결이 시작되는 방식은?

HTTP 핸드셰이크 후 프로토콜을 '업그레이드'합니다.
WebSocket은 먼저 일반 HTTP 요청을 보내고 Upgrade 헤더를 이용해 같은 TCP 연결을 WebSocket 프로토콜로 전환한 뒤 양방향 통신을 시작합니다.

Q2

WebSocket 연결이 시작되는 방식은?

HTTP 핸드셰이크 후 프로토콜을 '업그레이드'합니다.
WebSocket은 먼저 일반 HTTP 요청을 보내고 Upgrade 헤더를 이용해 같은 TCP 연결을 WebSocket 프로토콜로 전환한 뒤 양방향 통신을 시작합니다.

Q3

WebSocket이 일반 HTTP 요청/응답 방식과 다른 점은?

실시간 채팅처럼 서버가 먼저 메시지를 보낼 수 있어야 합니다.
HTTP는 클라이언트가 요청해야 서버가 응답하는 단방향 구조지만, WebSocket은 한 번 연결되면 서버와 클라이언트 양쪽에서 언제든 메시지를 주고받는 양방향 통신이 가능합니다.

Q4

WebSocket이 일반 HTTP 요청/응답 방식과 다른 점은?

실시간 채팅처럼 서버가 먼저 메시지를 보낼 수 있어야 합니다.
HTTP는 클라이언트가 요청해야 서버가 응답하는 단방향 구조지만, WebSocket은 한 번 연결되면 서버와 클라이언트 양쪽에서 언제든 메시지를 주고받는 양방향 통신이 가능합니다.

Q5

WebSocket이 실시간 서비스(채팅, 알림 등)에 적합한 이유는?

폴링처럼 계속 물어볼 필요가 없습니다.
WebSocket은 한 번 연결을 맺으면 계속 유지되어, 폴링처럼 반복 요청할 필요 없이 이벤트가 발생하는 즉시 데이터를 주고받을 수 있어 실시간성이 중요한 서비스에 적합합니다.

Q6

WebSocket이 실시간 서비스(채팅, 알림 등)에 적합한 이유는?

폴링처럼 계속 물어볼 필요가 없습니다.
WebSocket은 한 번 연결을 맺으면 계속 유지되어, 폴링처럼 반복 요청할 필요 없이 이벤트가 발생하는 즉시 데이터를 주고받을 수 있어 실시간성이 중요한 서비스에 적합합니다.