[Java] Tomcat의 websocket을 이용해서 socket서버 만들기


Development note/Java  2019. 10. 30. 09:00

안녕하세요. 명월입니다.


이 글은 Tomcat의 websocket을 이용해서 socket서버 만들기에 대한 글입니다.


제가 최근에 websocket에 대해 쓸 일이 있어서 사양에 대해서 다시 살펴봤습니다.

이전에 제가 websocket에 관한 글을 남긴 적도 있습니다.

링크 - [Java] 웹 소켓 (WebSocket)


근데 이 웹 소캣의 개요가 사실은 브라우져와 웹 서버간의 동기화를 위해서 기존 웹 프로토콜 사양인 비동기, 즉 요청하고 응답이 오면 서버와 클라이언트간의 커넥션을 끊는 즉 동기(同期)가 되지 않는 사양을 최근의 실시간 커뮤니케이션을 위해 동기 형식의 프로토콜로 통신을 가능하게 하는 것이 이 웹 소켓입니다. 근데, 바꾸어 이야기하면 WebSocket을 통해서 웹 서버를 동기화 소켓 서버를 만들 수 있다라는 소리입니다.

꼭 브라우져 용이 아니고 그 프로토콜을 알면 C/S 프로그램의 Server, 즉 소켓 서버를 구현할 수 있지 않을까 하는 생각으로 시작했습니다.


웹 소켓의 프로토콜에 관해서는 아래 링크를 참조했습니다.

링크 - https://tools.ietf.org/html/rfc6455

링크 - https://m.blog.naver.com/eztcpcom/220070508655


이제 프로토콜을 알았으니 먼저 tomcat을 이용한 웹 소켓 서버를 만들겠습니다.

링크 - [Java] 웹 소켓 (WebSocket)


예전 포스팅을 참고했습니다.

import java.io.IOException;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;

@ServerEndpoint("/server")
public class Server {

  // websocket 커넥션 open이 발생하면 요청되는 함수
  @OnOpen
  public void handleOpen(Session session) {
    // session의 timeout을 무제한으로 변경한다. 시간을 넣으면 ping, pong의 사양이 제대로 걸리지 않고, 어느 순간 갑자기 커넥션을 종료시켜버린다.
    session.setMaxIdleTimeout(0);
    // binary의 buffer size를 설정한다. 혹시라도 파일 업로드 서버를 만들고 싶다고 한다면 이 설정을 확인해야 한다.
    session.setMaxBinaryMessageBufferSize(1024 * 1024 * 10);
    System.out.println("client is now connected...");
  }

  // string형식의 OPCODE가 1일 경우입니다.
  @OnMessage
  public String handleMessage(String message, Session session) throws IOException {
    System.out.println(message);
    return "echo - " + message;
  }

  // byte형식의 OPCODE가 2일 경우입니다.
  @OnMessage
  public byte[] handleMessage(byte[] message, Session session) {
    
    if(message.length <= 125) {
      String msg = new String(message);
      System.out.println(msg);
      msg = "echo - " + msg;
      return msg.getBytes();
    }else {
      String msg = new String(message);
      System.out.println(message.length);
      System.out.println(msg.substring(msg.length() - 10));
      return message;
    }
  }

  // 커넥션이 종료가 발생하면 요청되는 함수
  @OnClose
  public void handleClose(Session session) {
    System.out.println("client is now disconnected...");
  }

  // 이건 에러가 발생할때... 사양상 에러가 발생하면 바로 handleClose가 요청됩니다.
  @OnError
  public void handleError(Throwable e) {
    e.printStackTrace();
  }
}

웹 소켓 서버는 jsp파일도 만들지 않고 그냥 웹 소켓 서버만 만들어서 서버를 기동했습니다.


클라이언트 소스는 C#으로 작성했습니다.

먼저 OPCODE 플래그를 enum으로 작성합니다.

namespace WebSocketConnector
{
  enum OPCode : byte
  {
    // OPCODE가 0이면 다음 패킷을 연결해서 사용하는 것인데.. 별로 의미가 없습니다.
    CONTINUEPIECE = 0x00,
    // 1은 String형식의 메시지입니다.
    MESSAGE = 0x01,
    // 2는 byte형식의 메시지입니다. (실질적으로 자주 사용되는 OPCode입니다.)
    BINARY = 0x02,
    // 8은 종료 메시지입니다. OPCode 8를 클라이언트에서 보내면 브라우저에서 8을 응답하는 것으로 커넥션이 종료됩니다.
    EXIT = 0x08,
    // Ping Pong에서 
    PING = 0x09,
    PONG = 0x0A
  }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Net.Sockets;
using System.Net;
using System.Threading;

namespace WebSocketConnector
{
  class WebSocket : Socket
  {
    // 서버 접속 EndPoint정보
    private IPEndPoint ipep;
    // 마스크 코드
    private byte[] mask = new byte[4] { 1, 1, 1, 1 };
    // 서버로 부터 메시지가 오면 발생시킬 이벤트
    public event Action<OPCode, byte[]> RecieveEvent;

    public WebSocket(String ipaddress, int port) : base(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.IP)
    {
      // 인스턴스가 생성되면 접속 ip와 port를 설정한다.
      this.ipep = new IPEndPoint(IPAddress.Parse(ipaddress), port);
    }
    public void Connect(String host)
    {
      // 접속한다.
      base.Connect(this.ipep);
      // Http websocket 프로토콜 영역이다.
      StringBuilder msg = new StringBuilder();
      // 이 부분은 host 설정인데, 가상 호스트 + @ServerEndpoint의 path 설정이다.
      msg.AppendLine("GET " + host + " HTTP/1.1");
      msg.AppendLine("Host: localhsot");
      msg.AppendLine("Upgrade: websocket");
      msg.AppendLine("Connection: Upgrade");
      msg.AppendLine("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==");
      msg.AppendLine("Sec-WebSocket-Version: 13");
      msg.AppendLine();
      byte[] data = Encoding.UTF8.GetBytes(msg.ToString());
      base.Send(data, SocketFlags.None);
      data = new byte[1024];
      base.Receive(data, SocketFlags.None);
      //Console.WriteLine(Encoding.UTF8.GetString(data));
      Reciever();
    }
    // String식으로 보낼 때,, 예로 OPCode가 1일이여서 String 형식으로 주고 받는다고 하여도 기본적으로 소켓은 byte형식으로 주고 받기 때문에 변형을 해야 한다.
    public void Send(OPCode opcode, String messgae)
    {
      this.Send(opcode, Encoding.UTF8.GetBytes(messgae));
    }
    // OPCode만 보낼때
    public void Send(OPCode opcode)
    {
      this.Send(opcode, new byte[0]);
    }
    // 이게 WebSocket으로 보낼 메시지 사양이다.
    public void Send(OPCode opcode, byte[] message)
    {
      byte[] header = null;
      if (message == null)
      {
        message = new byte[0];
      }
      if (message.Length <= 125 /*0x7D*/)
      {
        header = new byte[2];
        // byte[1]에는 FIN코드와 opcode가 있다.
        header[0] = (byte)(0x80 | (byte)opcode);
        // byte[2] 에는 데이터의 길이가 있습니다.
        header[1] = (byte)message.Length;
      }
      // 여기서부터 데이터의 크기에 따라 달라지는데 125이하면 byte[2]부터 데이터 값이지만
      // 65535이하면 배열 [2],[3]번을 length를 사용하고 [1]은 0x7E의 고정값으로 확장의 플러그를 사용로 사용한다.
      else if (message.Length <= 65535 /*0xffff*/)
      {
        header = new byte[4];
        header[0] = (byte)(0x80 | (byte)opcode);
        header[1] = 0x7E;
        byte[] buffer = BitConverter.GetBytes((short)message.Length);
        Array.Reverse(buffer);
        Array.Copy(buffer, 0, header, 2, buffer.Length);
      }
      // 65535 이상이면 [1]은 0x7F 고정에 2,3,4,5,6,7,8,9의 8byte가 데이터 길이가 되고 [10]부터는 데이터 값이 됩니다.
      // 그 이상은...2의 64승이니깐.18,446,744,073,709,551,616인데 아마 메모리가 그 이상으로 할당이 불가능 할 것입니다.
      else
      {
        header = new byte[10];
        header[0] = (byte)(0x80 | (byte)opcode);
        byte[] buffer = BitConverter.GetBytes((long)message.Length);
        header[1] = 0x7F;
        Array.Reverse(buffer);
        Array.Copy(buffer, 0, header, 2, buffer.Length);
      }
      // 사양에는 Mask를 OPCode가 1과 2일 때만 사용하라고 되어있는데, Ping,Pong 할때 mask를 사용안하면 에러난다.
      if (opcode == OPCode.BINARY || opcode == OPCode.MESSAGE || true)
      {
        header[1] |= 0x80;
      }
      // 데이터를 마스크화 한다.
      for (int i = 0; i < message.Length; i++)
      {
        message[i] = (byte)(message[i] ^ mask[i % 4]);
      }
      // 헤더를 보내고
      base.Send(header);
      if (message.Length > 0)
      {
        // 마스크를 보내고...
        base.Send(mask);
        // 데이터를 보낸다.
        base.Send(message, message.Length, SocketFlags.None);
      }
    }
    private void Reciever()
    {
      // ThreadPool를 생성하여 서버로 부터 오는 메시지를 대기한다.(동기화를 하는 거죠..)
      ThreadPool.QueueUserWorkItem(x =>
      {
        while (true)
        {
          // 수신은 송신의 역이다.
          byte[] buffer = null;
          byte[] header = new byte[2];
          base.Receive(header, 2, SocketFlags.None);
          bool isContinueFlag = (header[0] & 0x80) != 0x80;
          OPCode opcode = (OPCode)(header[0] & 0x0f);
          var isMask = (header[1] & 0x80) == 0x80;
          int length = header[1] & 0x7F;
          if (length == 0x7E)
          {
            var lengthBuffer = new byte[2];
            Receive(lengthBuffer, 2, SocketFlags.None);
            Array.Reverse(lengthBuffer);
            length = BitConverter.ToInt16(lengthBuffer, 0);
          }
          else if (length == 0x7F)
          {
            var lengthBuffer = new byte[8];
            Receive(lengthBuffer, 8, SocketFlags.None);
            Array.Reverse(lengthBuffer);
            length = (int)BitConverter.ToInt64(lengthBuffer, 0);
          }
          if (length != 0)
          {
            buffer = new byte[length];
          }
          int pos = 0;
          while (true)
          {
            byte[] buffer2 = new byte[length - pos];
            int rpos = Receive(buffer2, 0, length - pos, SocketFlags.None);
            Array.Copy(buffer2, 0, buffer, pos, rpos);
            pos += rpos;
            if (length == pos)
            {
              break;
            }
          }
          RecieveEvent(opcode, buffer);
          //ping이 오면 pong을..
          if (opcode == OPCode.PING)
          {
            Send(OPCode.PONG, buffer);
          }
          //pong이 오면 ping을..
          if (opcode == OPCode.PONG)
          {
            Send(OPCode.PONG, buffer);
          }
          if (opcode == OPCode.EXIT)
          {
            return;
          }
        }
      });
    }
  }
}

참조 - https://m.blog.naver.com/eztcpcom/220070508655

위의 Send와 Receive는 위의 이미지의 구조체를 참조하여 작성되었습니다.

using System;
using System.Text;
using System.IO;

namespace WebSocketConnector
{
  class Program
  {
    public Program()
    {

    }

    public void Run()
    {
      // 로컬의 8080포트로 WebSocket을 접속한다.
      var socket = new WebSocket("127.0.0.1", 8080);
      //Host는 /가상 디렉토리/접속 URL이다.
      socket.Connect("/WebSocketServer/server");
      socket.RecieveEvent += (opcode, message) =>
      {
        // 서버로 부터 수신이 오면 발생하는 이벤트이다.
        String ret = Encoding.UTF8.GetString(message);
        if (message.Length > 125)
        {
          ret = ret.Substring(ret.Length - 10);
        }
        Console.WriteLine("{0} - {1}", opcode, ret);
      };
      while (true)
      {
        // 콘솔로 부터 입력을 받는다.
        var msg = Console.ReadLine();
        // exit가 나오면 접속을 종료한다.
        if ("exit".Equals(msg.Trim().ToLower()))
        {
          socket.Send(OPCode.EXIT, "exit");
          break;
        }
        // ping을 보냅니다.
        else if ("ping".Equals(msg.Trim().ToLower()))
        {
          socket.Send(OPCode.PING, "ping");
        }
        else if ("pong".Equals(msg.Trim().ToLower()))
        {
          socket.Send(OPCode.PONG, "pong");
        }
        else
        {
          socket.Send(OPCode.MESSAGE, msg);
        }
      }
    }
    static void Main(string[] args)
    {
      // 프로그램 기동.
      var program = new Program();
      program.Run();
      Console.WriteLine("Press any key...");
      Console.ReadKey();
    }
  }
}

Server와 Client를 만들었습니다.

다시 설명하면 Server는 JSP servlet으로 tomcat으로 기동을 할 것이고, client는 그냥 C#으로 작성해서 socket접속을 할 것입니다.

먼저 서버부터 기동을 합니다.

서버의 경우는 Webpage던가 Servlet를 전혀 만들지 않았기 때문에 404페이지 에러가 나옵니다.

이번에는 클라이언트를 기동합니다.

보시다시피 제가 만든 클라이언트가 Tomcat의 websocket에 접속이 되어서 메시지를 주고 받고 있습니다. 역시 예상대로 WebSocket프로토콜만 알면 C/S프로그램을 만들 수가 있었네요...

종료까지 완료되었습니다.


첨부 파일 - WebSocketConnector.zip

첨부 파일 - WebSocketServer.zip


여기까지 Tomcat의 websocket을 이용해서 socket서버 만들기에 대한 설명이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.