Netty로 WebSocket 서버개발

Daeyoung Nam·2021년 8월 12일
2

프로젝트

목록 보기
7/16
post-thumbnail

현재 웹사이트에서 온라인게임 비슷한 플랫폼을 개발하고있다.
그래서 실시간으로 유저들끼리 통신할 수 있는 게임 서버를 개발해야하는데
클라이언트에서 업데이트될때마다 패킷을 날려줘야해서 조금이라도 성능이 더 좋은 WebSocket을 사용하기로 하였다.
또한 서버측에서는 Netty로 socket.io 같은 room 기능을 구현하기로 하였다.

WebSocket?

HTTP의 경우 연결이 유지되지 않기 때문에 실시간으로 상호작용을 하려는 웹사이트를 만드려면 여러가지 꼼수를 이용해서 개발해야만 했다. (히든 프레임, Long Polling, Stream)
그래서 웹과 웹 서버 사이에서 양방향 송수신을 지원하는 WebSocket이 등장하였고 HTML5 표준이다.

Netty?

자바의 NIO(Non-Blocking input/output) 방식을 이용해 개발된 소켓통신 프레임워크이다.

기존의 소켓통신 방식은 연결시 한개의 쓰레드를 계속 생성하여 많은 양의 커넥션이 일어날 시 쓰레드 갯수가 많이 늘어나게 된다.

하지만 Netty는 소켓 등록시 Selector가 작업을 할당해주기 때문에 많은 양의 쓰레드가 필요없어도 비동기 처리가 가능하게 된다.

이미 내부적으로 네트워크 통신이 구현되어있기때문에 개발자는 비즈니스 로직에만 집중하면 된다는것이 큰 장점이다.

완성된 Netty WebSocket서버 소개

HttpInitializer

public class HttpInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        ChannelPipeline pipeline = ch.pipeline();
        pipeline.addLast("httpServerCodec", new HttpServerCodec());
        pipeline.addLast("httphandler", new HttpServerHandler());
    }

}

ChannelInitializer는 소켓 채널이 생성될 때 호출된다.
웹소켓 연결 요청은 HTTP 1.1로 받기 때문에 HttpServerHandler로 파이프라인에 추가시켜주었다.
먼저 Handshake하기 전 HttpServerHandler로 요청을 받게 된다.

HttpServerHandler


    private WebSocketServerHandshaker handshaker;

    private static Logger logger = LoggerFactory.getLogger(HttpServerHandler.class);

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        if(msg instanceof HttpRequest) {
            HttpRequest httpRequest = (HttpRequest) msg;
            HttpHeaders headers = httpRequest.headers();

            logger.info("HttpRequest Received");
            logger.info("Connection: " + headers.get("Connection"));
            logger.info("Upgrade: " + headers.get("Upgrade"));

            if(headers.get(HttpHeaderNames.CONNECTION).equalsIgnoreCase("Upgrade") &&
                    headers.get(HttpHeaderNames.UPGRADE).equalsIgnoreCase("WebSocket")) {
                ctx.pipeline().replace(this, "websocketHandler", new WebSocketHandler());

                logger.info("WebSocketHandler added to the pipeline");
                logger.info("Opened Channel: " + ctx.channel());
                logger.info("Handshaking...");

                handleHandshake(ctx, httpRequest);
                logger.info("Handshake is done");
            }
            else {
                logger.info("Incoming request is unknown");
            }
        }
    }

WebSocket Client에서 요청시 HttpServerHandler로 오게되고 요청 패킷의 헤더를 검사한다.

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

클라이언트 측에서 요청을 보낼 때 다음과 같이 보낸다.
그렇게 되면 HttpServerHandler에서는 핸드쉐이킹을 진행한다.

    protected void handleHandshake(ChannelHandlerContext ctx, HttpRequest req) {
        WebSocketServerHandshakerFactory wsFactory =
                new WebSocketServerHandshakerFactory(getWebSocketURL(req), null, true);
        handshaker = wsFactory.newHandshaker(req);

        if(handshaker == null) {
            WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel());
        }
        else {
            handshaker.handshake(ctx.channel(), req);
        }
    }

    protected String getWebSocketURL(HttpRequest req) {
        return "ws://" + req.headers().get("Host") + req.getUri();
    }

연결이 성립되면 현재 Pipeline에 있는 HttpServerHandler는 WebSocketServerHandler로 바뀌게 된다.
즉 요청을 받을 수 있는 위치가 바뀌게 된다.

핸드쉐이크 응답

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

channelActive(), channelRegistered() 같은 이벤트 함수는 최초 연결 시 호출이 되므로 HttpServerHandler에서만 동작한다.

WebSocketHandler

Handshaking을 수행하여 연결이 성립되었다.
그렇다면 이제 웹 소켓의 요청을 처리하고 돌려줘야하는데
이를 수행할 수 있게 구현한 부분이 WebSocketHandler이다.

public class WebSocketHandler extends ChannelInboundHandlerAdapter {

    private MessageBroadcaster messageBroadcaster;
    private MessageHandler messageHandler;
    
    public WebSocketHandler() {
        this.messageBroadcaster = new MessageBroadcaster(new SessionMiddlewareImpl());
        this.messageHandler = new MessageHandler(messageBroadcaster);
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if(msg instanceof WebSocketFrame) {
            try {
                WebSocketFrame webSocketFrame = (WebSocketFrame) msg;
                AbstractMessageReceiver receiver = MessageReceiverFactory.getFactory().getReceiver(webSocketFrame);
                MessageWrapper messageWrapper = receiver.receive(ctx.channel(), webSocketFrame);

                messageHandler.handleMessage(messageWrapper);
            } catch (UnsupportedWebSocketFrameException e) {
                e.printStackTrace();
                // TODO: 에러 처리
            }
        }
    }
}

Netty에서 WebSocket Client와 통신을 하려고 한다면 패킷 단위는 WebSocketFrame이다.
WebSocket의 패킷 구조는 다음과 같다.

 0               1               2               3
 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 4               5               6               7
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
 8               9               10              11
+ - - - - - - - - - - - - - - - +-------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
 12              13              14              15
+-------------------------------+-------------------------------+
| Masking-key (continued)       |          Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

MessageReceiver

WebSocketFrame은 interface이기때문에 여러가지 구현체가 존재한다.
이에따라 유연하게 WebSocketFrame을 읽기 위해서는 각 구현체 별로 기능을 나누는게 좋다고 생각하였고 이를 추상화시켜서 개발자 측에서는 WebSocketFrame만 넘겨주면 상황에 맞는 WebSocketFrame을 Wrapper로 감싸서 리턴하도록 설계하였다.

또한 Message의 경우 여러가지 타입이 있기 때문에 추가적으로 기능을 확장하거나 중간에 변경되도 하위 클래스에 영향을 주지 않게 하기 위해
AbstractMessageReceiver를 상속받게 하도록 하였다.

Message

Message의 경우 인터페이스인데 Json으로 바꾸고 Json -> Object로 바꿔주는 기능을 구현해야한다.

MessageWrapper

MessageWrapper는 AbstractMessageReceiver를 통해 데이터를 받게되면 데이터를 요청한(sender) 채널과 Message가 포장되서 반환되게 된다.
두개를 담고있는 Wrapper 클래스이다.

@Getter
@AllArgsConstructor
public class MessageWrapper<T> {

    private Message<T> message;
    private Channel channel;

    @Override
    public String toString() {
        return "MessageWrapper{" +
                "message=" + message +
                ", channel=" + channel +
                '}';
    }
}

MessageHandler, MessageBroadcaster

MessageReceiver가 데이터를 받아 해석하게되면 다음으로 이 데이터를 처리할 곳인 MessageHandler로 넘어가게 된다.
하지만 지금 만들고있는 서버에서는 채널그룹별로 브로드 캐스팅을 해줘야하기 때문에 MessageHandler에서 MessageBroadcaster를 수행하는 방식을 따르고 있지만
왜 굳이 MessageHandler 계층을 거치냐면 추후 기능 확장및 기능 추가시 더욱 유연하게 대처하기 위함이다

public class MessageHandler<T> {

    private MessageBroadcaster messageBroadcaster;

    public MessageHandler(MessageBroadcaster messageBroadcaster) {
        this.messageBroadcaster = messageBroadcaster;
    }

    public void handleMessage(MessageWrapper<T> wrapper) {
        messageBroadcaster.broadcast(wrapper);
    }

}

BroadcasterMiddleware

Broadcaster Middleware는 브로드캐스팅하기전에 실행할 핸들러와 브로드캐스터의 중간 계층이다.

public interface BroadcasterMiddleware {

    void bridge(MessageWrapper wrapper, SessionContext sessionContext);

}

미들웨어 함수에는 MessageWrapper와, SessionContext가 넘어오게된다.

SessionMiddlewareImpl

public class SessionMiddlewareImpl implements BroadcasterMiddleware {

    private static Logger logger = LoggerFactory.getLogger(SessionMiddlewareImpl.class);

    @Override
    public void bridge(MessageWrapper wrapper, SessionContext sessionContext) {
        Message message = wrapper.getMessage();

        if(message instanceof TextMessage) {
            TextMessage textMessage = (TextMessage) message;
            sessionContext.registerClassSession(textMessage.getClassName(), wrapper.getChannel());
        }
        else if(message instanceof CloseMessage) {
            sessionContext.disconnect(wrapper.getChannel());
        }
    }

}

SessionMiddlewareImpl은 브로드캐스트 하기전 해당 sender를 채널에 추가하고 패킷이 CloseMessage라면 세션에서 삭제하는 미들웨어이다.

전체적인 구조

사진에서는 전체 클라이언트들에게 브로드캐스팅이라고 되어있지만
자신이 속해있는 채널에 브로드캐스팅을 해준다.

소스코드

https://github.com/InoFlexin/Webgl-Minecraft-Websocket-Server

profile
내가 짠 코드가 제일 깔끔해야하고 내가 만든 서버는 제일 탄탄해야한다 .. 😎

0개의 댓글