WebSocket 서버 구현
채팅, 알림, 실시간 대시보드... 서버가 먼저 클라이언트에게 데이터를 보내야 할 때, HTTP의 요청-응답 모델로는 어떻게 해결할 수 있을까? 왜 WebSocket이라는 별도의 프로토콜이 필요했을까?
WebSocket이란
WebSocket은 하나의 TCP 연결 위에서 클라이언트와 서버가 양방향으로 자유롭게 메시지를 주고받는 프로토콜입니다. HTTP와의 핵심 차이를 비교하면 이렇습니다.
| 구분 | HTTP | WebSocket |
|---|---|---|
| 통신 방향 | 클라이언트 → 서버 (요청-응답) | 양방향 (서버도 먼저 전송 가능) |
| 연결 유지 | 요청마다 새 연결 또는 Keep-Alive | 한 번 연결하면 계속 유지 |
| 오버헤드 | 매 요청마다 헤더 전송 | 핸드셰이크 이후 프레임 헤더만 (2~14바이트) |
| 프로토콜 | 텍스트 기반 | 바이너리 프레임 기반 |
| 용도 | 일반 웹 요청 | 실시간 통신 (채팅, 게임, 알림) |
HTTP는 "클라이언트가 물어봐야 서버가 답하는" 구조입니다. 서버가 먼저 데이터를 보내려면 폴링(polling)이나 SSE(Server-Sent Events) 같은 우회 방법이 필요합니다. WebSocket은 이 제약을 근본적으로 해결합니다.
핸드셰이크 과정
WebSocket 연결은 HTTP 업그레이드 요청 으로 시작합니다. 기존 HTTP 인프라(80/443 포트, 프록시, 로드 밸런서)를 그대로 활용하기 위한 설계입니다.
클라이언트 요청
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
서버 응답
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)** 단위로 전송됩니다. 네티에서 제공하는 주요 프레임 타입을 정리합니다.
| 프레임 클래스 | 역할 |
|---|---|
TextWebSocketFrame | UTF-8 텍스트 데이터 전송. JSON 메시지에 주로 사용 |
BinaryWebSocketFrame | 바이너리 데이터 전송. 이미지, 파일, Protobuf 등 |
PingWebSocketFrame | 연결 상태 확인 요청. 상대방은 Pong으로 응답해야 함 |
PongWebSocketFrame | Ping에 대한 응답. 연결이 살아있음을 확인 |
CloseWebSocketFrame | 연결 종료 요청. 상태 코드와 사유를 포함 가능 |
ContinuationWebSocketFrame | 큰 메시지를 여러 프레임으로 나눌 때 후속 프레임 |
실무에서 가장 많이 쓰는 건 TextWebSocketFrame입니다. JSON 형태의 메시지를 주고받는 패턴이 대부분이기 때문입니다.
WebSocketServerProtocolHandler
네티는 WebSocketServerProtocolHandler를 통해 ** 핸드셰이크를 자동으로 처리 **해 줍니다. 직접 HTTP 요청을 파싱하고 101 응답을 만들 필요가 없습니다.
// 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 서버의 파이프라인은 다음 순서로 구성합니다.
바이트 스트림
↓
[HttpServerCodec] ← HTTP 인코딩/디코딩 (핸드셰이크용)
↓
[HttpObjectAggregator] ← HTTP 조각을 FullHttpRequest로 합침
↓
[WebSocketServerProtocolHandler] ← 핸드셰이크 자동 처리 + Ping/Pong
↓
[비즈니스 핸들러] ← TextWebSocketFrame 처리
각 핸들러의 역할을 정리합니다.
- HttpServerCodec: HTTP 요청/응답을 인코딩·디코딩합니다. 핸드셰이크가 HTTP로 시작하기 때문에 반드시 필요합니다.
- HttpObjectAggregator: HTTP 메시지 조각들을 하나의
FullHttpRequest로 합쳐줍니다.WebSocketServerProtocolHandler가 완전한 HTTP 요청을 기대하므로 필수입니다. - WebSocketServerProtocolHandler: 핸드셰이크를 처리하고, 완료 후 HTTP 핸들러를 파이프라인에서 제거합니다.
- ** 비즈니스 핸들러 **: 실제 WebSocket 프레임을 처리하는 우리의 로직입니다.
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 모드)
[HttpServerCodec] → [HttpObjectAggregator] → [WebSocketServerProtocolHandler] → [ChatWebSocketHandler]
이 상태에서는 HTTP 요청이 들어오면 HttpServerCodec이 파싱하고, HttpObjectAggregator가 합치고, WebSocketServerProtocolHandler가 핸드셰이크 응답을 보냅니다.
핸드셰이크 후 (WebSocket 모드)
[WebSocketFrameDecoder] → [WebSocketFrameEncoder] → [WebSocketServerProtocolHandler] → [ChatWebSocketHandler]
핸드셰이크가 완료되면 WebSocketServerProtocolHandler가 자동으로 다음을 수행합니다.
HttpServerCodec을 ** 제거 **하고WebSocket13FrameDecoder/WebSocket13FrameEncoder로 ** 교체 **합니다HttpObjectAggregator를 ** 제거 **합니다- 이후 들어오는 데이터는 WebSocket 프레임으로 디코딩됩니다
이 전환이 자동으로 일어나기 때문에 개발자는 비즈니스 핸들러에서 TextWebSocketFrame만 처리하면 됩니다. HTTP 관련 코드를 신경 쓸 필요가 없습니다.
핸드셰이크 완료 이벤트를 감지하고 싶다면
userEventTriggered()에서WebSocketServerProtocolHandler.HandshakeComplete이벤트를 확인하면 됩니다.
비즈니스 핸들러 구현
WebSocket 프레임을 처리하는 핸들러를 구현합니다. 가장 간단한 에코 서버부터 시작합니다.
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)하면 안 됩니다.SimpleChannelInboundHandler가channelRead0()호출 후 프레임을 release하기 때문에 이미 해제된 버퍼를 전송하려는 셈이 됩니다. 항상 ** 새 프레임을 생성 **해서 보내야 합니다.
간단한 채팅 예시 — ChannelGroup 브로드캐스트
에코 서버를 확장해서 ** 간단한 채팅 서버 **를 만들어 보겠습니다. 핵심은 ChannelGroup입니다.
ChannelGroup이란
ChannelGroup은 여러 채널을 한 그룹으로 묶어서 ** 동시에 메시지를 브로드캐스트 **할 수 있게 해주는 네티의 유틸리티입니다.
group.add(channel): 채널을 그룹에 추가합니다group.writeAndFlush(msg): 그룹의 모든 채널에 메시지를 전송합니다- 채널이 닫히면 그룹에서 ** 자동으로 제거 **됩니다
채팅 핸들러
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();
}
}
채팅 서버 부트스트랩
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 같은 도구로 테스트할 수 있습니다.
# wscat 설치
npm install -g wscat
# 클라이언트 1 접속
wscat -c ws://localhost:8080/chat
# 클라이언트 2 접속 (다른 터미널에서)
wscat -c ws://localhost:8080/chat
클라이언트 1에서 메시지를 보내면 클라이언트 1과 2 모두에게 브로드캐스트됩니다.
전체 파이프라인 흐름 요약
[클라이언트] [서버]
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 프레임의 최대 크기를 반드시 설정해야 합니다. 설정하지 않으면 악의적인 클라이언트가 거대한 프레임을 보내 서버 메모리를 고갈시킬 수 있습니다.
// WebSocketServerProtocolHandler 생성자에서 maxFrameSize를 지정한다
new WebSocketServerProtocolHandler("/chat", null, true, 65536)
// ↑ 최대 64KB
기본값은 65536(64KB)이지만, 용도에 따라 조정합니다. 채팅 서버라면 64KB면 충분하고, 바이너리 파일 전송이 필요하다면 늘려야 합니다.
Ping/Pong 처리
WebSocketServerProtocolHandler가 Ping에 대한 Pong 응답을 ** 자동으로** 처리해 주지만, 서버 측에서 ** 능동적으로 Ping을 보내야** 하는 경우도 있습니다. 클라이언트가 살아있는지 확인하기 위해서입니다.
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도 함께 추가해야 합니다.
// 60초 동안 읽기 이벤트가 없으면 IdleStateEvent 발생
pipeline.addLast("idleState", new IdleStateHandler(60, 0, 0));
pipeline.addLast("pingHandler", new WebSocketPingHandler());
SSL + WebSocket (wss://)
프로덕션 환경에서는 반드시 SSL/TLS 위에서 WebSocket을 사용해야 합니다. ws://가 아닌 wss://로 접속합니다.
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 파싱 |
HttpObjectAggregator | HTTP 조각을 합쳐 FullHttpRequest 생성 |
WebSocketServerProtocolHandler | 핸드셰이크 자동 처리 + HTTP→WebSocket 파이프라인 전환 |
TextWebSocketFrame | 텍스트 메시지 전송의 기본 단위 |
ChannelGroup | 다수 채널에 브로드캐스트 |
IdleStateHandler + Ping | 유휴 연결 감지 및 연결 상태 확인 |
SslHandler | wss:// 지원 (파이프라인 맨 앞) |
WebSocket 서버의 핵심은 ** 파이프라인 구성과 전환 **입니다. 핸드셰이크 전에는 HTTP 핸들러가, 핸드셰이크 후에는 WebSocket 프레임 핸들러가 동작하는 이 전환 과정을 이해하면, 네티의 파이프라인이 얼마나 유연한 구조인지 체감할 수 있습니다.