채팅, 알림, 실시간 대시보드... 서버가 먼저 클라이언트에게 데이터를 보내야 할 때, HTTP의 요청-응답 모델로는 어떻게 해결할 수 있을까? 왜 WebSocket이라는 별도의 프로토콜이 필요했을까?

WebSocket이란

WebSocket은 하나의 TCP 연결 위에서 클라이언트와 서버가 양방향으로 자유롭게 메시지를 주고받는 프로토콜입니다. HTTP와의 핵심 차이를 비교하면 이렇습니다.

구분HTTPWebSocket
통신 방향클라이언트 → 서버 (요청-응답)양방향 (서버도 먼저 전송 가능)
연결 유지요청마다 새 연결 또는 Keep-Alive한 번 연결하면 계속 유지
오버헤드매 요청마다 헤더 전송핸드셰이크 이후 프레임 헤더만 (2~14바이트)
프로토콜텍스트 기반바이너리 프레임 기반
용도일반 웹 요청실시간 통신 (채팅, 게임, 알림)

HTTP는 "클라이언트가 물어봐야 서버가 답하는" 구조입니다. 서버가 먼저 데이터를 보내려면 폴링(polling)이나 SSE(Server-Sent Events) 같은 우회 방법이 필요합니다. WebSocket은 이 제약을 근본적으로 해결합니다.


핸드셰이크 과정

WebSocket 연결은 HTTP 업그레이드 요청 으로 시작합니다. 기존 HTTP 인프라(80/443 포트, 프록시, 로드 밸런서)를 그대로 활용하기 위한 설계입니다.

클라이언트 요청

PLAINTEXT
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

서버 응답

PLAINTEXT
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

핵심 포인트를 정리하면 이렇습니다.

  • 101 Switching Protocols: "이제부터 HTTP가 아니라 WebSocket으로 통신하겠다"는 의미입니다
  • Upgrade: websocket: 프로토콜 전환을 요청/수락하는 헤더입니다
  • Sec-WebSocket-Key / Accept: 보안 검증용입니다. 서버가 Key를 특정 GUID와 합쳐 SHA-1 해시를 만들어 Accept로 반환합니다. 잘못된 서버가 WebSocket인 척하는 걸 방지합니다
  • 핸드셰이크가 완료되면 ** 같은 TCP 연결 **을 그대로 사용하면서 WebSocket 프레임을 주고받습니다

핸드셰이크는 HTTP로 시작하지만, 완료 후에는 HTTP가 더 이상 관여하지 않습니다. 이 전환 과정을 "HTTP Upgrade"라고 부릅니다.


WebSocketFrame 종류

핸드셰이크가 끝나면 데이터는 ** 프레임(Frame)** 단위로 전송됩니다. 네티에서 제공하는 주요 프레임 타입을 정리합니다.

프레임 클래스역할
TextWebSocketFrameUTF-8 텍스트 데이터 전송. JSON 메시지에 주로 사용
BinaryWebSocketFrame바이너리 데이터 전송. 이미지, 파일, Protobuf 등
PingWebSocketFrame연결 상태 확인 요청. 상대방은 Pong으로 응답해야 함
PongWebSocketFramePing에 대한 응답. 연결이 살아있음을 확인
CloseWebSocketFrame연결 종료 요청. 상태 코드와 사유를 포함 가능
ContinuationWebSocketFrame큰 메시지를 여러 프레임으로 나눌 때 후속 프레임

실무에서 가장 많이 쓰는 건 TextWebSocketFrame입니다. JSON 형태의 메시지를 주고받는 패턴이 대부분이기 때문입니다.


WebSocketServerProtocolHandler

네티는 WebSocketServerProtocolHandler를 통해 ** 핸드셰이크를 자동으로 처리 **해 줍니다. 직접 HTTP 요청을 파싱하고 101 응답을 만들 필요가 없습니다.

JAVA
// WebSocket 경로와 옵션을 지정하면 핸드셰이크를 자동으로 처리한다
new WebSocketServerProtocolHandler(
    "/chat",     // WebSocket 경로
    null,        // 서브프로토콜 (null이면 없음)
    true,        // allowExtensions
    65536        // 최대 프레임 크기 (바이트)
)

이 핸들러가 해주는 일은 생각보다 많습니다.

  • ** 핸드셰이크 처리 **: HTTP Upgrade 요청을 받아 101 응답을 자동으로 보냅니다
  • ** 경로 매칭 **: 지정한 경로(/chat)로 들어온 요청만 WebSocket으로 업그레이드합니다
  • **Ping/Pong 자동 응답 **: Ping 프레임을 받으면 자동으로 Pong을 반환합니다
  • **Close 프레임 처리 **: Close Handshake를 자동으로 수행합니다
  • ** 핸드셰이크 완료 후 HTTP 핸들러 제거 **: 파이프라인에서 더 이상 필요 없는 HTTP 관련 핸들러를 자동으로 제거합니다

직접 핸드셰이크를 구현하려면 WebSocketServerHandshakerFactory를 사용할 수도 있지만, 대부분의 경우 WebSocketServerProtocolHandler만으로 충분합니다.


파이프라인 구성

WebSocket 서버의 파이프라인은 다음 순서로 구성합니다.

PLAINTEXT
바이트 스트림

[HttpServerCodec]                ← HTTP 인코딩/디코딩 (핸드셰이크용)

[HttpObjectAggregator]           ← HTTP 조각을 FullHttpRequest로 합침

[WebSocketServerProtocolHandler] ← 핸드셰이크 자동 처리 + Ping/Pong

[비즈니스 핸들러]                 ← TextWebSocketFrame 처리

각 핸들러의 역할을 정리합니다.

  • HttpServerCodec: HTTP 요청/응답을 인코딩·디코딩합니다. 핸드셰이크가 HTTP로 시작하기 때문에 반드시 필요합니다.
  • HttpObjectAggregator: HTTP 메시지 조각들을 하나의 FullHttpRequest로 합쳐줍니다. WebSocketServerProtocolHandler가 완전한 HTTP 요청을 기대하므로 필수입니다.
  • WebSocketServerProtocolHandler: 핸드셰이크를 처리하고, 완료 후 HTTP 핸들러를 파이프라인에서 제거합니다.
  • ** 비즈니스 핸들러 **: 실제 WebSocket 프레임을 처리하는 우리의 로직입니다.
JAVA
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;

// WebSocket 서버의 파이프라인 초기화
public class WebSocketServerInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();

        // 1. HTTP 코덱 — 핸드셰이크 요청을 파싱하기 위해 필요
        pipeline.addLast("httpCodec", new HttpServerCodec());

        // 2. HTTP 어그리게이터 — 조각난 HTTP 메시지를 하나로 합침
        pipeline.addLast("httpAggregator", new HttpObjectAggregator(65536));

        // 3. WebSocket 프로토콜 핸들러 — 핸드셰이크 + Ping/Pong 자동 처리
        pipeline.addLast("wsProtocol",
            new WebSocketServerProtocolHandler("/chat", null, true, 65536));

        // 4. 비즈니스 핸들러 — 실제 WebSocket 메시지 처리
        pipeline.addLast("wsHandler", new ChatWebSocketHandler());
    }
}

핸들러 전환 — 핸드셰이크 전후의 파이프라인 변화

핸드셰이크 전후로 파이프라인이 어떻게 변하는지 이해하는 게 중요합니다.

핸드셰이크 전 (HTTP 모드)

PLAINTEXT
[HttpServerCodec] → [HttpObjectAggregator] → [WebSocketServerProtocolHandler] → [ChatWebSocketHandler]

이 상태에서는 HTTP 요청이 들어오면 HttpServerCodec이 파싱하고, HttpObjectAggregator가 합치고, WebSocketServerProtocolHandler가 핸드셰이크 응답을 보냅니다.

핸드셰이크 후 (WebSocket 모드)

PLAINTEXT
[WebSocketFrameDecoder] → [WebSocketFrameEncoder] → [WebSocketServerProtocolHandler] → [ChatWebSocketHandler]

핸드셰이크가 완료되면 WebSocketServerProtocolHandler가 자동으로 다음을 수행합니다.

  • HttpServerCodec을 ** 제거 **하고 WebSocket13FrameDecoder / WebSocket13FrameEncoder로 ** 교체 **합니다
  • HttpObjectAggregator를 ** 제거 **합니다
  • 이후 들어오는 데이터는 WebSocket 프레임으로 디코딩됩니다

이 전환이 자동으로 일어나기 때문에 개발자는 비즈니스 핸들러에서 TextWebSocketFrame만 처리하면 됩니다. HTTP 관련 코드를 신경 쓸 필요가 없습니다.

핸드셰이크 완료 이벤트를 감지하고 싶다면 userEventTriggered()에서 WebSocketServerProtocolHandler.HandshakeComplete 이벤트를 확인하면 됩니다.


비즈니스 핸들러 구현

WebSocket 프레임을 처리하는 핸들러를 구현합니다. 가장 간단한 에코 서버부터 시작합니다.

JAVA
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;

// WebSocket 텍스트 프레임을 처리하는 핸들러
public class WebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
        String text = frame.text();
        System.out.println("수신: " + text);

        // 에코 — 받은 메시지를 그대로 돌려보냄
        // 주의: 원본 frame을 그대로 write하면 참조 카운트 문제가 생길 수 있으므로
        //       새 TextWebSocketFrame을 생성한다
        ctx.writeAndFlush(new TextWebSocketFrame("에코: " + text));
    }

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        // 핸드셰이크 완료 이벤트 감지
        if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
            WebSocketServerProtocolHandler.HandshakeComplete complete =
                (WebSocketServerProtocolHandler.HandshakeComplete) evt;
            System.out.println("WebSocket 연결 완료 — 경로: " + complete.requestUri());
        }
        super.userEventTriggered(ctx, evt);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

** 주의할 점:**

  • SimpleChannelInboundHandler<TextWebSocketFrame>을 상속하면 TextWebSocketFrame 타입만 받고, 다른 프레임(Ping, Pong, Close)은 WebSocketServerProtocolHandler가 이미 처리했으므로 무시됩니다
  • 받은 frame을 그대로 writeAndFlush(frame)하면 안 됩니다. SimpleChannelInboundHandlerchannelRead0() 호출 후 프레임을 release하기 때문에 이미 해제된 버퍼를 전송하려는 셈이 됩니다. 항상 ** 새 프레임을 생성 **해서 보내야 합니다.

간단한 채팅 예시 — ChannelGroup 브로드캐스트

에코 서버를 확장해서 ** 간단한 채팅 서버 **를 만들어 보겠습니다. 핵심은 ChannelGroup입니다.

ChannelGroup이란

ChannelGroup은 여러 채널을 한 그룹으로 묶어서 ** 동시에 메시지를 브로드캐스트 **할 수 있게 해주는 네티의 유틸리티입니다.

  • group.add(channel): 채널을 그룹에 추가합니다
  • group.writeAndFlush(msg): 그룹의 모든 채널에 메시지를 전송합니다
  • 채널이 닫히면 그룹에서 ** 자동으로 제거 **됩니다

채팅 핸들러

JAVA
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.util.concurrent.GlobalEventExecutor;

// ChannelGroup을 활용한 채팅 브로드캐스트 핸들러
public class ChatWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {

    // 모든 연결된 WebSocket 채널을 담는 그룹
    // static으로 선언해서 모든 핸들러 인스턴스가 공유한다
    private static final ChannelGroup channels =
        new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
            // 핸드셰이크가 완료된 시점에 그룹에 추가한다
            // 핸드셰이크 전에 추가하면 HTTP 메시지가 브로드캐스트될 수 있다
            channels.add(ctx.channel());
            System.out.println("새 클라이언트 접속. 현재 " + channels.size() + "명");
        }
        super.userEventTriggered(ctx, evt);
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
        String text = frame.text();
        Channel sender = ctx.channel();

        System.out.println("메시지 수신 [" + sender.remoteAddress() + "]: " + text);

        // 모든 채널에 브로드캐스트
        // 각 채널마다 새 TextWebSocketFrame을 생성해야 한다
        // ChannelGroup.writeAndFlush()가 내부적으로 각 채널에 대해
        // 메시지를 retain()하므로 새 프레임 하나만 만들면 된다
        channels.writeAndFlush(new TextWebSocketFrame(
            "[" + sender.remoteAddress() + "] " + text
        ));
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        // 채널이 닫히면 ChannelGroup에서 자동 제거되지만
        // 로그를 남기기 위해 오버라이드한다
        System.out.println("클라이언트 퇴장. 현재 " + channels.size() + "명");
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

채팅 서버 부트스트랩

JAVA
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class WebSocketChatServer {

    private static final int PORT = 8080;

    public static void main(String[] args) throws InterruptedException {
        // boss: 연결 수락, worker: I/O 처리
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();

        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .childHandler(new WebSocketServerInitializer());

            ChannelFuture future = bootstrap.bind(PORT).sync();
            System.out.println("WebSocket 채팅 서버 시작 — ws://localhost:" + PORT + "/chat");

            future.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

테스트

브라우저 콘솔이나 wscat 같은 도구로 테스트할 수 있습니다.

BASH
# wscat 설치
npm install -g wscat

# 클라이언트 1 접속
wscat -c ws://localhost:8080/chat

# 클라이언트 2 접속 (다른 터미널에서)
wscat -c ws://localhost:8080/chat

클라이언트 1에서 메시지를 보내면 클라이언트 1과 2 모두에게 브로드캐스트됩니다.


전체 파이프라인 흐름 요약

PLAINTEXT
[클라이언트]                                      [서버]

1. HTTP Upgrade 요청  ──────────────────→  [HttpServerCodec]
   GET /chat HTTP/1.1                            ↓
   Upgrade: websocket                     [HttpObjectAggregator]

                                          [WebSocketServerProtocolHandler]
                                                 ↓ 핸드셰이크 처리
2. HTTP 101 응답  ←──────────────────────  101 Switching Protocols

   ── 여기서 파이프라인 전환 ──
   HttpServerCodec 제거 → WebSocketFrameDecoder/Encoder 추가
   HttpObjectAggregator 제거

3. TextWebSocketFrame  ─────────────────→  [WebSocketFrameDecoder]
   {"msg": "안녕하세요"}                         ↓
                                          [WebSocketServerProtocolHandler]
                                            (Ping/Pong/Close 자동 처리)

                                          [ChatWebSocketHandler]
                                                 ↓ 브로드캐스트
4. TextWebSocketFrame  ←─────────────────  [WebSocketFrameEncoder]
   [addr] 안녕하세요

주의사항

프레임 크기 제한

WebSocket 프레임의 최대 크기를 반드시 설정해야 합니다. 설정하지 않으면 악의적인 클라이언트가 거대한 프레임을 보내 서버 메모리를 고갈시킬 수 있습니다.

JAVA
// WebSocketServerProtocolHandler 생성자에서 maxFrameSize를 지정한다
new WebSocketServerProtocolHandler("/chat", null, true, 65536)
//                                                      ↑ 최대 64KB

기본값은 65536(64KB)이지만, 용도에 따라 조정합니다. 채팅 서버라면 64KB면 충분하고, 바이너리 파일 전송이 필요하다면 늘려야 합니다.

Ping/Pong 처리

WebSocketServerProtocolHandler가 Ping에 대한 Pong 응답을 ** 자동으로** 처리해 주지만, 서버 측에서 ** 능동적으로 Ping을 보내야** 하는 경우도 있습니다. 클라이언트가 살아있는지 확인하기 위해서입니다.

JAVA
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.websocketx.PingWebSocketFrame;
import io.netty.handler.timeout.IdleState;
import io.netty.handler.timeout.IdleStateEvent;

// 일정 시간 동안 읽기 이벤트가 없으면 Ping을 보내는 핸들러
public class WebSocketPingHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent e = (IdleStateEvent) evt;
            if (e.state() == IdleState.READER_IDLE) {
                // 읽기 타임아웃 — Ping을 보내서 연결 확인
                ctx.writeAndFlush(new PingWebSocketFrame());
            }
        }
        super.userEventTriggered(ctx, evt);
    }
}

이 핸들러를 사용하려면 파이프라인에 IdleStateHandler도 함께 추가해야 합니다.

JAVA
// 60초 동안 읽기 이벤트가 없으면 IdleStateEvent 발생
pipeline.addLast("idleState", new IdleStateHandler(60, 0, 0));
pipeline.addLast("pingHandler", new WebSocketPingHandler());

SSL + WebSocket (wss://)

프로덕션 환경에서는 반드시 SSL/TLS 위에서 WebSocket을 사용해야 합니다. ws://가 아닌 wss://로 접속합니다.

JAVA
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;

import java.io.File;

// SSL 컨텍스트를 만들고 파이프라인 맨 앞에 추가한다
SslContext sslCtx = SslContextBuilder.forServer(
    new File("cert.pem"),     // 인증서
    new File("key.pem")       // 개인 키
).build();

// 파이프라인 구성 — SslHandler가 반드시 맨 앞에 와야 한다
pipeline.addLast("ssl", sslCtx.newHandler(ch.alloc()));
pipeline.addLast("httpCodec", new HttpServerCodec());
pipeline.addLast("httpAggregator", new HttpObjectAggregator(65536));
pipeline.addLast("wsProtocol",
    new WebSocketServerProtocolHandler("/chat", null, true, 65536));
pipeline.addLast("wsHandler", new ChatWebSocketHandler());

wss:// 사용 시 주의할 점:

  • SslHandler는 반드시 ** 파이프라인 맨 앞 **에 배치해야 합니다. 다른 핸들러보다 먼저 바이트를 복호화해야 하기 때문입니다
  • 로드 밸런서(ALB, Nginx)에서 SSL 종료(SSL Termination)를 처리하고 있다면, 네티 서버에서는 SSL을 설정하지 않아도 됩니다
  • 브라우저는 wss://가 아닌 ws://로 HTTPS 페이지에서 WebSocket을 연결하면 Mixed Content 에러를 발생시킵니다

정리

구성 요소역할
HttpServerCodec핸드셰이크 단계에서 HTTP 파싱
HttpObjectAggregatorHTTP 조각을 합쳐 FullHttpRequest 생성
WebSocketServerProtocolHandler핸드셰이크 자동 처리 + HTTP→WebSocket 파이프라인 전환
TextWebSocketFrame텍스트 메시지 전송의 기본 단위
ChannelGroup다수 채널에 브로드캐스트
IdleStateHandler + Ping유휴 연결 감지 및 연결 상태 확인
SslHandlerwss:// 지원 (파이프라인 맨 앞)

WebSocket 서버의 핵심은 ** 파이프라인 구성과 전환 **입니다. 핸드셰이크 전에는 HTTP 핸들러가, 핸드셰이크 후에는 WebSocket 프레임 핸들러가 동작하는 이 전환 과정을 이해하면, 네티의 파이프라인이 얼마나 유연한 구조인지 체감할 수 있습니다.

댓글 로딩 중...