
WebSocket은 실시간 양방향 통신을 지원하는 프로토콜로, 채팅, 알림, 실시간 데이터 갱신 등 다양한 애플리케이션에 적합합니다.
Netty는 비동기 이벤트 기반 네트워크 프레임워크로, 뛰어난 성능과 설계를 바탕으로 고성능 통신 영역의 표준으로 자리잡았습니다.
Java NIO 프로그래밍의 복잡성을 단순화할 뿐만 아니라 수만 개의 동시 연결을 쉽게 지원하여 Dubbo, Elasticsearch 등 수많은 유명 프로젝트에서 채택되고 있습니다.
Spring Boot 3와 Netty를 결합하여 고성능 실시간 채팅 시스템을 구축하는 방법과
Netty 프레임워크를 활용하여 WebSocket 기반 채팅 서비스를 구현하는 방법을 포스팅 하였습니다.
Netty를 본격적으로 알아보기 전에, IO 모델의 기본 개념을 먼저 살펴보겠습니다.
이미 개념에 익숙하신 분들은 다음으로 넘어가셔도 좋습니다.
스레드가 자원에 접근할 때, 해당 자원의 준비 상태에 따른 처리 방식을 의미합니다
블로킹: 스레드가 자원을 요청했을 때 자원이 준비되지 않았다면, 스레드는 계속 대기하며 이 기간 동안 다른 작업을 수행할 수 없습니다.
논블로킹: 스레드가 자원을 요청했을 때 자원의 준비 상태와 관계없이 즉시 반환되어, 스레드는 계속해서 다른 작업을 수행할 수 있습니다.
동기와 비동기는 데이터 접근 메커니즘을 가리킵니다.
간단히 정리하자면
전통적인 동기 블로킹 IO 모델로, 다음과 같은 특징이 있습니다.

JDK 1.4에서 도입된 동기 논블로킹 IO 모델
작동 방식: Selector(선택기)가 여러 Channel(채널)을 모니터링 → 이벤트 발생 시 해당 Channel 처리 → 처리 완료 후 계속 모니터링
자원 효율성: 단일 스레드로 여러 연결을 관리하여 시스템의 동시 처리 능력을 크게 향상
적용 사례: 고부하, 저지연 네트워크 애플리케이션(채팅 서버, 게임 서버 등)

JDK 7에서 도입된 비동기 논블로킹 IO 모델
작동 방식: 애플리케이션이 IO 요청 → 다른 작업 계속 실행 → IO 완료 후 콜백 알림
특징: 완전히 운영체제 알림 메커니즘에 의해 구동되며, 애플리케이션이 적극적으로 폴링할 필요 없음
한계: API 설계는 앞서 있지만 Linux 시스템에서 예상만큼의 성능을 내지 못하며, 여전히 다중 멀티플렉싱에 의존
적용 사례: 극도로 높은 처리량이 필요한 애플리케이션, 특히 Windows 플랫폼에서

BIO (블로킹 I/O): 식당에서 주문한 후 자리에 앉아 다른 일을 하지 못하고 음식이 나올 때까지 기다려야 하는 상황. 각 손님마다 전담 서버가 필요합니다.
NIO (논블로킹 I/O): 식당에서 주문하고 진동벨을 받은 후, 주변을 둘러보거나 스마트폰을 할 수 있지만 가끔 전광판을 확인해야 하는 상황.
한 명의 직원이 여러 손님의 주문을 처리할 수 있습니다.
AIO (비동기 I/O): 식당에서 주문하고 전화번호를 남긴 후 자유롭게 활동하다가, 음식이 준비되면 직원이 전화로 알려주는 상황.
진행 상태를 신경 쓸 필요가 전혀 없습니다.
Netty는 고성능 비동기 이벤트 기반 네트워크 프레임워크로, 클라이언트/서버 애플리케이션 개발을 위한 간결하면서도 강력한 API를 제공합니다.
원시 NIO 프로그래밍의 복잡한 API, 네트워크 예외 처리의 어려움, JDK 내장 NIO의 버그 등 여러 문제점을 성공적으로 해결했습니다.
Netty는 NIO를 기반으로 최적화하고 캡슐화하여, NIO의 동시성 특성을 유지하면서도 더 친숙한 개발 경험을 제공합니다.
제로 복사 기술을 통해 전송 효율성을 높이고, 다양한 프로토콜을 지원하며, 강력한 기능 사용자 정의 기능을 제공합니다.
참고: 제로 복사 기술은 메모리 간 데이터 복사 횟수를 줄여 데이터 전송을 효율적으로 만듭니다.
일반 IO로 파일을 읽고 쓸 때는 커널 공간과 사용자 공간 사이에 데이터를 여러 번 복사해야 하지만, 제로 복사는 데이터가 디스크에서 네트워크로 직접 전송되게 하여 중간 단계를 건너뛰어 성능을 크게 향상시킵니다.
이는 대용량 파일 전송에 특히 유용합니다.
Netty는 세 가지 핵심 스레드 모델을 제공하여 애플리케이션 요구사항에 따라 유연하게 선택할 수 있습니다.
모든 IO 작업(연결 설정, 데이터 읽기/쓰기, 비즈니스 로직 처리)을 하나의 NIO 스레드가 처리합니다.
구조가 단순하며 연결 수가 적고 비즈니스 로직이 가벼운 시나리오에 적합합니다.

NIO 스레드 그룹이 모든 IO 작업을 공동으로 처리하여 시스템 처리량을 향상시킵니다.
각 연결은 서로 다른 스레드에서 처리될 수 있지만, 동일한 시점에는 하나의 스레드만 처리하여 다중 스레드 경합을 방지합니다.

Boss 스레드 풀은 연결 요청 수락을 담당하고, Worker 스레드 풀은 IO와 비즈니스 로직 처리를 담당합니다.
이 모델은 고부하 시나리오에서 최상의 성능을 발휘합니다.

Netty의 주요 특징 중 하나는 "파이프라인 처리" 메커니즘입니다.
이는 마치 조립 라인과 같습니다.
각 네트워크 연결 은 독립적인 생산 라인처럼 작동합니다.
데이터 패킷이 한쪽 끝에서 들어와 여러 "작업 단계"를 거쳐 다른 쪽 끝으로 출력됩니다.
이러한 "작업 단계"는 데이터 디코딩, 보안 검사, 비즈니스 로직 등 다양한 처리 과정이 될 수 있습니다.
각 "작업 단계"는 자신의 전문 작업만 담당하고 전체 프로세스를 신경 쓸 필요가 없습니다.
현대 공장의 조립 라인처럼, 각 위치는 한 가지 작업에만 집중하여 전체 효율성이 높아지고, 전체 운영에 영향을 주지 않고도 특정 단계를 쉽게 조정하거나 교체할 수 있습니다.
개발자에게는 코드 구조가 명확하고 유지 관리가 쉬우며, 이러한 "처리 모듈"을 재사용하여 다양한 애플리케이션을 구축할 수 있다는 의미입니다.
생명주기
Netty 컴포넌트의 생명주기 관리는 프레임워크 설계의 중요한 부분으로, 이러한 생명주기를 이해하면 코딩 시 애플리케이션 동작을 더 잘 제어할 수 있습니다.
Netty에서 Channel은 네트워크 연결을 나타내며, 그 생명주기는 다음과 같은 주요 상태를 포함합니다.
이러한 상태 변화는 ChannelHandler의 해당 생명주기 메서드(channelRegistered(), channelActive() 등)를 트리거합니다.
Handler는 데이터 처리의 핵심 컴포넌트로, 명확한 생명주기를 가집니다
이 과정을 기계 조립 및 사용에 비유할 수 있습니다.
먼저 부품(컴포넌트 생성)을 준비하고,
설명서에 따라 조립(연결 구성)하고,
전원을 연결(서비스 시작)하면 기계가 작동(데이터 처리)을 시작합니다.
마지막으로 전원을 끄고 유지 보수를 위해 분해(리소스 해제)합니다.
전체 과정이 질서 정연하게 진행되며, 각 컴포넌트는 언제 무엇을 해야 하는지 알고 있습니다.
실시간 데이터 상호작용이 필요한 애플리케이션을 구축할 때, 세 가지 주요 기술 방안이 있습니다.
우리가 구축하려는 실시간 채팅 서비스에서는 WebSocket이 의심할 여지 없이 최선의 선택입니다.
실시간성에 대한 요구사항을 가장 잘 충족시킬 수 있기 때문입니다.
주목할 만한 점은 Netty가 WebSocket에 대한 네이티브 지원과 최적화된 구현을 제공한다는 것입니다.
이를 통해 기본 통신 세부 사항의 번거로움 없이 비즈니스 로직 구현에 더 집중하면서 확장 가능하고 효율적인 실시간 통신 시스템을 쉽게 구축할 수 있습니다.
이 섹션에서는 프론트엔드와 백엔드의 핵심 구현을 중심으로 Netty 기반 실시간 채팅 서비스를 개발하는 방법을 보여드리겠습니다.
본 글은 백엔드 서비스 구축에 중점을 두므로, 프론트엔드는 핵심 통신 코드만 살펴보겠습니다.
다음 코드는 Netty 서버와의 WebSocket 연결 설정, 메시지 송수신 및 상태 관리의 핵심 기능을 구현하며, 이후 백엔드 구현을 위한 상호작용 기반을 제공합니다.
// 1. WebSocket 연결 전역 설정
globalData: {
// WebSocket 서버 연결 주소
chatServerUrl: "ws://127.0.0.1:875/ws",
// 전역 WebSocket 연결 객체
CHAT: null,
// WebSocket 연결 상태 표시
chatSocketOpen: false,
},
// 2. 앱 시작 시 WebSocket 연결 초기화
onLaunch: function() {
// 프로그램 시작 시 채팅 서버 연결
this.doConnect(false);
},
// 3. 핵심 메서드: WebSocket 연결 설정
doConnect(isFirst) {
// 재연결 시 알림 표시
if (isFirst) {
uni.showToast({
icon: "loading",
title: "연결 재시도 중...",
duration: 2000
});
}
var me = this;
// 사용자가 로그인한 경우에만 WebSocket 연결
if (me.getUserInfoSession() != null && me.getUserInfoSession() != "" && me.getUserInfoSession() != undefined) {
// WebSocket 연결 생성
me.globalData.CHAT = uni.connectSocket({
url: me.globalData.chatServerUrl,
complete: ()=> {}
});
// 4. 연결 성공 이벤트 처리
me.globalData.CHAT.onOpen(function(){
// 연결 상태 플래그 업데이트
me.globalData.chatSocketOpen = true;
console.log("ws 연결이 열렸습니다. socketOpen = " + me.globalData.chatSocketOpen);
// 초기화 메시지 구성(메시지 유형 0은 연결 초기화를 나타냄)
var chatMsg = {
senderId: me.getUserInfoSession().id,
msgType: 0
}
var dataContent = {
chatMsg: chatMsg
}
var msgPending = JSON.stringify(dataContent);
// 초기화 메시지 전송, 서버에 사용자 ID 알림
me.globalData.CHAT.send({
data: msgPending
});
});
// 5. 연결 종료 이벤트 처리
me.globalData.CHAT.onClose(function(){
me.globalData.chatSocketOpen = false;
console.log("ws 연결이 닫혔습니다. socketOpen = " + me.globalData.chatSocketOpen);
});
// 6. 메시지 수신 이벤트 처리
me.globalData.CHAT.onMessage(function(res){
console.log('App.vue 서버 내용 수신: ' + res.data);
// 수신된 메시지 처리
me.dealReceiveLastestMsg(JSON.parse(res.data));
});
// 7. 연결 오류 이벤트 처리
me.globalData.CHAT.onError(function(){
me.globalData.chatSocketOpen = false;
console.log('WebSocket 연결 실패, 확인 바랍니다!');
});
}
},
// 8. WebSocket 메시지 전송 일반 메서드
sendSocketMessage(msg) {
// 연결 상태 확인, 연결이 열려 있을 때만 전송
if (this.globalData.chatSocketOpen) {
uni.sendSocketMessage({
data: msg
});
} else {
uni.showToast({
icon: "none",
title: "채팅 서버와의 연결이 끊어졌습니다"
})
}
},
// 9. 수신된 메시지 처리
dealReceiveLastestMsg(msgJSON) {
console.log(msgJSON);
var chatMsg = msgJSON.chatMsg;
var chatTime = msgJSON.chatTime;
var senderId = chatMsg.senderId;
var receiverType = chatMsg.receiverType;
console.log('chatMsg.receiverType = ' + receiverType);
var me = this;
// 발신자의 사용자 정보 가져오기
var userId = me.getUserInfoSession().id;
var userToken = me.getUserSessionToken();
var serverUrl = me.globalData.serverUrl;
// 발신자 상세 정보 요청
uni.request({
method: "POST",
header: {
headerUserId: userId,
headerUserToken: userToken
},
url: serverUrl + "/userinfo/get?userId=" + senderId,
success(result) {
if (result.data.status == 200) {
var currentSourceUserInfo = result.data.data;
me.currentSourceUserInfo = currentSourceUserInfo;
// 메시지 유형에 따라 표시 내용 설정
var msgShow = chatMsg.msg;
if (chatMsg.msgType == 2) {
msgShow = "[이미지]"
} else if (chatMsg.msgType == 4) {
msgShow = "[영상]"
} else if (chatMsg.msgType == 3) {
msgShow = "[음성]"
}
// 최신 메시지를 로컬 저장소에 저장
me.saveLastestMsgToLocal(senderId, currentSourceUserInfo, msgShow, chatTime, msgJSON);
}
}
})
},
// 10. 최신 메시지를 로컬 저장소에 저장
saveLastestMsgToLocal(sourceUserId, sourceUser, msgContent, chatTime, msgJSON) {
// 최신 메시지 객체 구성
var lastMsg = {
sourceUserId: sourceUserId, // 출처 사용자, 대화 상대
name: sourceUser.nickname,
face: sourceUser.face,
msgContent: msgContent,
chatTime: chatTime,
unReadCounts: 0,
communicationType: 1, // 1: 1:1 채팅, 2: 그룹 채팅
}
// 로컬 저장소에서 채팅 목록 가져오기
var lastestUserChatList = uni.getStorageSync("lastestUserChatList");
if (lastestUserChatList == null || lastestUserChatList == undefined || lastestUserChatList == "") {
lastestUserChatList = [];
}
// 메시지 기록 업데이트 또는 추가
var dealMsg = false;
for (var i = 0; i < lastestUserChatList.length; i++) {
var tmp = lastestUserChatList[i];
if (tmp.sourceUserId == lastMsg.sourceUserId) {
// 기존 채팅 기록이 있으면 최신 메시지로 업데이트
lastestUserChatList.splice(i, 1, lastMsg);
dealMsg = true;
break;
}
}
if (!dealMsg) {
// 새 채팅 상대라면 목록 맨 앞에 추가
lastestUserChatList.unshift(lastMsg);
}
// 업데이트된 채팅 목록 저장
uni.setStorageSync("lastestUserChatList", lastestUserChatList);
// UI 업데이트 알림
uni.$emit('reRenderReceiveMsgInMsgVue', "domeafavor");
uni.$emit('receiveMsgInMsgListVue', msgJSON);
},
// 11. WebSocket 연결 종료
closeWSConnect() {
this.globalData.CHAT.close();
}
모든 일은 시작이 있어야 하며, 먼저 의존성을 가져오겠습니다.
(참고: 실제 구현 시 Maven 저장소에서 최신 버전을 찾아보세요.)
먼저 Maven을 통해 필요한 의존성을 추가합니다.
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.2.0.Final</version>
</dependency>
서버 시작 클래스는 전체 Netty 서버의 진입점으로 WebSocket 서버를 구성하고 시작합니다.
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 ChatServer {
public static void main(String[] args) throws InterruptedException {
// 메인 및 워커 스레드 풀 정의
EventLoopGroup bossGroup = new NioEventLoopGroup(); // 클라이언트 연결 수락
EventLoopGroup workerGroup = new NioEventLoopGroup(); // I/O 처리
try {
// Netty 서버 구성
ServerBootstrap server = new ServerBootstrap();
server.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new WSServerInitializer());
// 포트 바인딩 및 서버 시작
ChannelFuture channelFuture = server.bind(875).sync();
// 채널 종료 대기
channelFuture.channel().closeFuture().sync();
} finally {
// 스레드 풀 종료
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
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;
import io.netty.handler.stream.ChunkedWriteHandler;
public class WSServerInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
// 파이프라인 가져오기
ChannelPipeline pipeline = socketChannel.pipeline();
// HTTP 프로토콜 지원
pipeline.addLast(new HttpServerCodec()); // HTTP 인코더/디코더
pipeline.addLast(new ChunkedWriteHandler()); // 대용량 데이터 처리
pipeline.addLast(new HttpObjectAggregator(1024 * 64)); // HTTP 메시지 통합
// WebSocket 프로토콜 지원
pipeline.addLast(new WebSocketServerProtocolHandler("/ws")); // WebSocket 경로 지정
// 사용자 정의 핸들러
pipeline.addLast(new ChatHandler());
}
}
사용자 ID와 채널(Channel) 간의 매핑 관계를 관리하며, 동일 사용자의 다중 로그인을 지원합니다.
import io.netty.channel.Channel;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class UserChannelSession {
// 다중 세션 지원 (한 계정이 여러 기기에서 동시 접속)
private static Map<String, List<Channel>> multiSession = new HashMap<>();
private static Map<String, String> userChannelIdRelation = new HashMap<>();
// 사용자 ID와, 채널 ID 관계 저장
public static void putUserChannelIdRelation(String userId, String channelId) {
userChannelIdRelation.put(channelId, userId);
}
// 채널 ID로 사용자 ID 조회
public static String getUserIdByChannelId(String channelId) {
return userChannelIdRelation.get(channelId);
}
// 다중 채널 저장
public static void putMultiChannels(String userId, Channel channel) {
List<Channel> channels = getMultiChannels(userId);
if (channels == null || channels.size() == 0) {
channels = new ArrayList<>();
}
channels.add(channel);
multiSession.put(userId, channels);
}
// 채널 제거
public static void removeUserChannels(String userId, String channelId) {
List<Channel> channels = getMultiChannels(userId);
if (channels == null || channels.size() == 0) {
return;
}
for (Channel channel : channels) {
if (channel.id().asLongText().equals(channelId)) {
channels.remove(channel);
break;
}
}
multiSession.put(userId, channels);
}
// 사용자 ID로 채널 목록 조회
public static List<Channel> getMultiChannels(String userId) {
return multiSession.get(userId);
}
}
클라이언트에서 전송된 WebSocket 메시지를 처리하는 핵심 비즈니스 로직 핸들러입니다.
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.util.concurrent.GlobalEventExecutor;
import java.time.LocalDateTime;
import java.util.List;
// TextWebSocketFrame: WebSocket 텍스트 데이터 처리 객체
public class ChatHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
// 모든 클라이언트 채널 관리 그룹
public static ChannelGroup clients = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) throws Exception {
// 클라이언트 메시지 파싱
String content = frame.text();
DataContent dataContent = JsonUtils.jsonToPojo(content, DataContent.class);
ChatMsg chatMsg = dataContent.getChatMsg();
String msgText = chatMsg.getMsg();
String receiverId = chatMsg.getReceiverId();
String senderId = chatMsg.getSenderId();
// 시간 보정
chatMsg.setChatTime(LocalDateTime.now());
Integer msgType = chatMsg.getMsgType();
// 현재 채널 정보
Channel currentChannel = ctx.channel();
String currentChannelId = currentChannel.id().asLongText();
// 메시지 타입에 따른 처리
if (msgType == MsgTypeEnum.CONNECT_INIT.type) {
// WebSocket 최초 연결 시 채널 초기화
UserChannelSession.putMultiChannels(senderId, currentChannel);
UserChannelSession.putUserChannelIdRelation(currentChannelId, senderId);
} else if (msgType == MsgTypeEnum.WORDS.type) {
// 텍스트 메시지 처리
List<Channel> receiverChannels = UserChannelSession.getMultiChannels(receiverId);
if (receiverChannels == null || receiverChannels.isEmpty()) {
// 수신자가 오프라인 상태
chatMsg.setIsReceiverOnLine(false);
} else {
chatMsg.setIsReceiverOnLine(true);
// 수신자의 모든 채널에 메시지 전송
for (Channel receiverChannel : receiverChannels) {
Channel findChannel = clients.find(receiverChannel.id());
if (findChannel != null) {
dataContent.setChatMsg(chatMsg);
String chatTimeFormat =
LocalDateUtils.format(chatMsg.getChatTime(), LocalDateUtils.DATETIME_PATTERN_2);
dataContent.setChatTime(chatTimeFormat);
// 온라인 사용자에게 메시지 전송
findChannel.writeAndFlush(
new TextWebSocketFrame(JsonUtils.objectToJson(dataContent)));
}
}
}
}
currentChannel.writeAndFlush(new TextWebSocketFrame(currentChannelId));
}
// 클라이언트 연결 시 호출
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Channel currentChannel = ctx.channel();
clients.add(currentChannel);
}
// 클라이언트 연결 해제 시 호출
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
Channel currentChannel = ctx.channel();
// 세션 정리
String userId = UserChannelSession.getUserIdByChannelId(currentChannel.id().asLongText());
UserChannelSession.removeUserChannels(userId, currentChannel.id().asLongText());
clients.remove(currentChannel);
}
// 예외 발생 시 처리
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
Channel channel = ctx.channel();
channel.close();
clients.remove(channel);
// 세션 정리
String userId = UserChannelSession.getUserIdByChannelId(channel.id().asLongText());
UserChannelSession.removeUserChannels(userId, channel.id().asLongText());
}
}
서버 시작: ChatServer가 Netty 서버를 생성하고 구성
채널 초기화: 새 연결이 들어오면 WSServerInitializer가 파이프라인 설정
연결 설정: ChatHandler.handlerAdded()가 연결을 ChannelGroup에 추가
메시지 처리
연결 해제: ChatHandler.handlerRemoved()가 리소스 정리
// WebSocket 연결 종료
closeWSConnect() {
this.globalData.CHAT.close();
}
이 구현으로 Netty 기반 실시간 채팅 서비스의 기본 아키텍처가 완성되었습니다.
현재 구현에는 오프라인 메시지 저장, 다양한 메시지 유형, 영구 저장소 등 추가 기능이 필요하지만, 핵심 구조를 이해하는 데 좋은 예시입니다.
이 기반 위에 메시지 큐를 추가하여 오프라인 메시지 푸시, 데이터베이스 통합으로 메시지 영구 저장, 그룹 채팅 및 멀티미디어 메시지 지원 등의 확장이 가능합니다.