[Java] Tomcat 서버에서 소켓 서버를 만드는 방법


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

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


이 글은 Tomcat 서버에서 소켓 서버를 만드는 방법에 대한 글입니다.


많은 사람들이 tomcat이라고 하면 웹 어플리케이션 서버로써 웹 서버 기능만 있는 것으로 생각합니다. tomcat이 웹 어플리케이션 서버 능력이 뛰어나기 때문에 그렇게만 생각해도 문제없습니다.

그런데 저의 경우는 조금 다르게 생각해서 이용해 보았습니다.


C/S(Server client) 프로그램을 만든다고 했을 때, 저는 아마도 Window 환경이 가장 많은 것 같습니다. 리눅스도 뛰어난 서버이지만, 일반 프로그램의 server로 사용하기에는 조금 다루기가 어려운 면이 있네요.

예를 들면, 일반 서버를 만든다고 하면 예전에는 당연히 C++를 택했습니다.

이유는 java와 C#도 꽤 좋은 언어이긴 하지만 장점이자 단점인 GC, 즉 가비지 컬렉션이 문제네요. 서버 프로그램의 경우는 장시간과 확실한 메모리 관리가 필요합니다. 가바지 컬렉션이 자동으로 메모리 해제를 해준다는 장점이지만 이게 언제 발생하는지 제어하기 힘듭니다. 예전에는 거진 메모리가 꽉 차야지만 GC가 발동되고 GC 자체도 메모리와 리소스를 잡아먹는 프로그램이기 때문에 GC가 실행될 때면 거의 서버가 만신창이가 되버리네요.

특히 게임 서버나 은행 서버처럼 순간 시스템이 멈추거나 하는 렉에 대해 매우 민감하기 때문에 java의 GC는 반대로 매우 단점이 되는 경우입니다. 또 거의 리소스가 위험할 때 발생하기 때문에 혹시라도 서버가 다운되면 그것대로 큰일입니다.


그렇게 생각하면 당연히 C++를 택하고, C++ 중에서는 당연히 라이브러리가 풍부한 MFC를 선택하게 되고 그렇게 되면 서버는 window로 정해져 버리네요.


최근에는 java의 버젼도 많이 올라갔고 tomcat도 이제 9.x이네요. 프로그램의 버젼보다 역시나 대단한 건 예전보다 하드웨어 성능이 엄청나다는 것입니다. 최근 제가 본 서버로는 약 3년전에 1테라 메모리 서버였습니다. 아예 그 서버는 하드 디스크가 없고, 부팅할 때마다 메모리에 프로그램을 디플로이하고 사용하는 것이였습니다. 그 정도의 스팩이면, 메모리 해제를 하지 않아도 1년은 사용할 수 있을 듯 싶었습니다. 뭐 시스템과 설계 및 코딩을 어떻게 하냐에 따라 다르겠지만요..

(이 정도 스팩이면 어차피 프로그램 버전 업을 해야하기 때문에 가끔 버젼 업하는 서버 재 부팅으로도 따로 운영상의 재기동은 필요없을 듯 싶습니다.)


그래서 java를 이용한 socket 서버를 만들어도 괜찮겠네요. 그런데 java라는 녀석은 web 서비스야 워낙 발달했지만, 일반 콘솔과 swing은 또 별로네요. 그래서 tomcat에 socket 서버를 만들면 운영하면 괜찮겠다 생각했습니다.


먼저 web service 프로젝트를 만듭니다.

참조 - [Java강좌 - 40] Java에서 웹 서비스 프로젝트(Jsp servlet)를 작성하는 방법

그리고 나서 서버가 기동할 때 실행할 수 있는 클래스를 생성합니다.

참조 - [Java강좌 - 58] Java servlet에서 인스턴스 초기화하는 방법

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns="http://java.sun.com/xml/ns/javaee"
  xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
  version="3.0">
  <display-name>SocketWebServer</display-name>
  <welcome-file-list>
    <welcome-file>index.html</welcome-file>
  </welcome-file-list>
  <!-- startup 클래스를 설정한다. -->
  <servlet>
    <servlet-name>startup</servlet-name>
    <servlet-class>socketserver.InitServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>
</web-app>

그리고 위 servlet 클래스는 web호출 응답용 클래스가 아닌 socket서버를 기동할 클래스이기 때문에 doGet과 doPost 함수는 삭제하고 위 어노테이션도 지웁니다.


그리고 우선 소켓 서버를 만들 클래스를 singleton 패턴으로 작성합니다.

참조 - [Java강좌 - 23] 소켓 통신 (Socket)

참조 - [Design Pattern] 싱글톤 패턴 (Singleton)

package socketserver;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SocketServer extends ServerSocket {
  // 싱글톤 패턴으로 구현한다.
  private static SocketServer instance = null;

  public static SocketServer getInstance() throws IOException {
    if (instance == null) {
      instance = new SocketServer();
    }
    return instance;
  }

  // 다중 접속을 위한 client 관리를 한다.
  private final List<Socket> clients = new ArrayList<>();
  // 소켓 메시지 대기를 위한 스레드 풀이다.
  private final ExecutorService receivePool = Executors.newCachedThreadPool();
  // 외부에서 방문자 패턴으로 리스너를 등록할 수 있게 한다.
  private final List<SocketListener> listeners = new ArrayList<>();

  private SocketServer() throws IOException {
    super();
    // 소켓은 9999포트로 오픈한다.(만약 9999포트를 사용하게 된다면 다른 포트로 수정하면 된다.)
    InetSocketAddress ipep = new InetSocketAddress(9999);
    super.bind(ipep);
    // 싱글 스레드 풀이다. 서버는 하나이기 때문에 싱글 스레드 풀을 사용한다.
    Executors.newSingleThreadExecutor().execute(() -> {
      try {
        // 무한 루프로 클라이언트를 대기를 한다.
        while(true) {
          Socket client = super.accept();
          clients.add(client);
          // 메시지 대기를 한다.
          receive(client);  
        }
      } catch (Throwable e) {
        e.printStackTrace();
      }
    });
  }

  private void receive(Socket client) {
    // 위 캐쉬 스레드 풀을 사용한다.
    receivePool.execute(() -> {
      // 클라이언트와 메시지 구조은 먼저 4byte의 문자사이즈를 받고 그 크기만큼 문자 메시지가 오는 것이다.
      ByteBuffer length = ByteBuffer.allocate(4);
      try (InputStream receiver = client.getInputStream()) {
        while (true) {
          // 메시지 길이를 받는다. (리틀 엔디언)
          receiver.read(length.array(), 0, 4);
          // 메시지 길이 만큼 배열을 선언하고 받는다.
          byte[] data = new byte[length.getInt()];
          receiver.read(data, 0, data.length);
          // 리스너에 수신받은 데이터를 넘긴다. (방문자 패턴)
          listeners.forEach(x -> x.run(client, new String(data)));
        }
      } catch (Throwable e) {
        try {
          client.close();
        } catch (IOException x) {
          x.printStackTrace();
        }
        clients.remove(client);
      }
    });
  }

  // 방문자 패턴
  public void addListener(SocketListener listener) {
    listeners.add(listener);
  }

  // 전체 클라이언트에게 메시지를 보낸다.
  public void send(String msg) {
    for (Socket client : clients) {
      send(client, msg);
    }
  }

  // socket 특정 클라이언트에게 메시지를 보낸다.
  public void send(Socket client, String msg) {
    byte[] data = msg.getBytes();
    ByteBuffer length = ByteBuffer.allocate(4);
    length.putInt(data.length);
    try (OutputStream sender = client.getOutputStream()) 
      // 문자 길이를 보내고 데이터를 보낸다. (리틀 엔디언)
      sender.write(length.array());
      sender.write(data);
    } catch (Throwable e) {
      try {
        client.close();
      } catch (IOException x) {
        x.printStackTrace();
      }
      clients.remove(client);
    }
  }
}
package socketserver;

import java.net.Socket;

// 리스너 방문자 패턴을 위한 인터페이스
public interface SocketListener {
  public void run(Socket socket, String message);
}
package socketserver;

import java.io.IOException;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;

public class InitServlet extends HttpServlet {
  private static final long serialVersionUID = 1L;

  public InitServlet() {

  }

  public void init(ServletConfig config) throws ServletException {
    super.init(config);

    try {
      // 서버가 기동되면 소켓 서버를 기동한다.
      SocketServer server = SocketServer.getInstance();
      // 리스너를 등록한다.
      server.addListener((client, msg) -> {
        // 메시지를 받으면 echo를 붙혀서 재전송한다.
        System.out.println(msg);
        String sendmsg = "echo : " + msg;
        server.send(client, sendmsg);
      });
    } catch (IOException e) {
      throw new ServletException(e);
    }
  }
}

먼저 socket서버를 싱글톤 패턴으로 만들고 send와 receive를 만들었습니다. receive함수는 클라이언트로 부터 메시지가 호출되면 스레드로부터 호출이 되는데 이 때 호출이 되면 listener 이벤트로 메시지를 보냅니다.

그럼 클라이언트는 C#으로 작성하겠습니다.

using System;
using System.Text;
using System.Net;
using System.Net.Sockets;

namespace ClientExample
{
  class Program
  {
    static void Main(string[] args)
    {
      // socket을 만든다.
      Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
      // 로컬 서버에 접속한다. 포트는 9999이다.
      socket.Connect(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9999));
      
      //메시지만든다.
      String msg = "Hello world";
      byte[] data = Encoding.UTF8.GetBytes(msg);
      byte[] length = BitConverter.GetBytes(data.Length);
      // C#은 기본이 빅 엔디언이기 때문에 리틀 엔디언으로 변경한다.
      Array.Reverse(length);
      // 메시지 길이를 전송하고 메시지를 보낸다.
      socket.Send(length);
      socket.Send(data);

      // 받는 것도 메시지 길이를 받고 메시지를 받는다.
      socket.Receive(length, 4, SocketFlags.None);
      // 리틀 엔디언을 빅 엔디언으로 수정한다.
      Array.Reverse(length);
      int size = BitConverter.ToInt32(length, 0);
      data = new byte[size];
      socket.Receive(data, size, SocketFlags.None);
      // 결과를 콘솔에 나타낸다.
      Console.WriteLine(Encoding.UTF8.GetString(data));
      socket.Close();

      Console.WriteLine("Press any key...");
      Console.ReadKey();
    }
  }
}

C#의 코드는 단순히 서버가 올바로 작성하는 지에 대한 테스트 프로그램이기 때문에 매우 심플하게 만들었습니다.

이제 서버를 기동하고 접속을 해봅시다.

웹 페이지를 만들지 않았기 때문에 404에러가 뜨네요.

9999포트로 Listen 상태로 제대로 있습니다.

이제는 C#를 기동하겠습니다.

오.. 일단 결과는 원하는 값인 echo : hello world가 나왔네요.

java 콘솔은 어떨까요?

java 콘솔에도 결과가 나왔습니다. 이로써 tomcat에서 소켓 서버를 만드는 게 완성이 되었습니다.

생각해보니 tomcat에서 소켓 서버를 만들면 웹으로 소켓 제어할 수 있는 페이지를 만들 수 있겠네요.

즉, 예전에 힘들게 window 폼으로 된 서버 제어 프로그램을 만들 필요가 없겠습니다. (IPC 통신 한다고 뻘 짓 안해도 되네요....^ㅇ^) 저도 하기 전에는 아무 생각 없었는데 막상 만들고 보니 많은 이점이 있습니다.

쓰레드 관리야 Tomcat이 잘 해주니 문제가 없을 것이고, 문제는 자바이니 메모리 관리가 관건이겠습니다.


개인적으로 제 생각만을 코딩한 것이지 실제로 프로젝트에서 적용해 보지는 않았습니다. 언제 시간나면 C++로 만든 것과 비교해서 어떤지 테스트 해 봐야겠네요..

참조 파일(java) - SocketWebServer.zip

참조 파일(c#) - ClientExample.zip


여기까지 Tomcat 서버에서 소켓 서버를 만드는 방법에 대한 설명이었습니다.


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