단일 요청 서버에서 MVC까지

심규민·2024년 7월 16일

현재 서버 공부를 할 때, 여러 요청을 동시에 받을 수 있는 애플리케이션을 만들기는 쉽습니다. 스프링 mvc, webflux를 설정만하면 바로 시작할 수 있으니까요. 하지만, 서버는 어떻게 동시에 요청을 받아들이고 처리할까? 라는 고민을 해보신적 있으신가요? 저는 이 원리가 궁금해서 찾아보게 됐습니다.

소켓(Socket)

서버의 이야기는 소켓으로 시작합니다. 소켓은 "네트워크 상에서 돌아가는 두 개의 프로그램 간 양방향 통신의 하나의 엔드 포인트"입니다. 그러면 이 엔드 포인트에 대해서 클라이언트와 서버는 어떻게 연결할까요? 우선 클라이언트가 서버의 ip 주소와 포트 번호를 알고 있어야 합니다. 이 정보와 함께 다음과 같은 과정을 통해 서버와 클라이언트는 소켓 연결을 진행합니다.

  • socket()으로 소켓 생성
  • bind(), listen()으로 연결 준비
  • 클라이언트의 연결 요청에 대해 accept()로 연결 수락
  • 연결 수락 시, 클라이언트와 통신할 수 있는 새로운 소캣을 생성.

연결 수락 시, 새로운 소캣을 생성하는 이유는 서버가 여러 클라이언트와 동시에 통신할 수 있도록 하기 위함입니다.
또한, 기존 listen()을 담당하는 소켓은 지정된 포트에서 들어오는 연결 요청을 대기하고, 새로운 소캣을 생성하는 역할을 담당합니다.

자바에서는 java.net 패키지를 통해 Socket클래스를 제공합니다. java.net 패키지 내의 클래스들을 이용해서 단일 프로세스, 단일 스레드로 동작하는 소켓 서버를 만들어보겠습니다.

단일 프로세스

client

public class Client {
	...
    public static void main(String[] args) {
        try(Socket socket = new Socket(SERVER_HOST, SERVER_PORT);
            BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            Scanner scanner = new Scanner(System.in);
            PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
        ){
            while(true){
                String message = scanner.nextLine();
                if("exit".equalsIgnoreCase(message)){
                    break;
                }
            }
        }catch (IOException e){
			...
        }
    }
}
  1. 서버 정보를 통해 Socket 인스턴스를 생성합니다.
  2. 연결이 되면 서버로 메시지를 전송합니다.

server

public class SingleThreadBlockingSocketServer {

    public static void main(String[] args) {
        try (ServerSocket serverSocket = new ServerSocket(SOCKET_SERVER_PORT)) {
            while (true) {
                try (Socket clientSocket = serverSocket.accept();
                     BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
                     PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
                ) {
                    String receiveData;
                    while ((receiveData = in.readLine()) != null) {
						...
                    }
                }catch (IOException e) {
                    ...
                }
            }
        } catch (IOException e) {
			...
        }
    }
}
  1. ServerSocket을 통해 특정 포트에 bind한 뒤 클라이언트 요청에 대해 listen 합니다.
  2. 클라이언트로부터 요청이 들어오면 accept() 호출을 통해 새로운 Socket을 생성한 뒤 클라이언트와 데이터를 읽기,쓰기를 진행합니다.

위처럼 간단한 코드로 단일 요청에 대한 서버를 만들 수 있습니다. 하지만 이 서버에는 여러 요청을 동시에 받을 수 없다는 문제가 있습니다. 즉, 다음과 같은 문제가 있습니다.

  • 다수의 클라이언트 요청을 동시에 받을 수 없다.
  • 앞선 연결이 모두 종료할 때까지 나머지 요청들은 큐에 들어가 대기해야 한다.

위 문제는 결국 효율적인 리소스 사용을 힘들게하는 원인이 됩니다. 이를 멀티 프로세스, 멀티 스레드를 통해 해결할 수 있습니다. 간단하게 구현할 수 있는 멀티 스레드를 통해 해결해보겠습니다.

멀티 스레드

멀티 스레드를 통해서 여러 요청을 받기 위해서는 클라이언트와 연결을 처리할 클래스를 만들고, 해당 클래스는 Runnable 인터페이스를 구현하면 됩니다. 추가로 클라이언트 소켓 연결 요청을 수신할 때, Socket을 생성하고 해당 소캣을 처리할 클래스와 함께 스레드를 생성한 후, 실행시키면 됩니다.

클라이언트 소켓 연결 수신 클래스

public class MultiThreadSocketServer {
    public static void main(String[] args) {
        try (ServerSocket serverSocket = new ServerSocket(SOCKET_SERVER_PORT)) {
            while (true) {
                Socket clientSocket = serverSocket.accept();
                new Thread(ClientSocketHandler.create(clientSocket)).start();
            }
        } catch (IOException e) {
			...
        }
    }
}

클라이언트 요청 처리 클래스

public class ClientSocketHandler implements Runnable{
    private final Socket clientSocket;
    
    private ClientSocketHandler(Socket clientSocket) {
        this.clientSocket = clientSocket;
    }
    public static ClientSocketHandler create(Socket clientSocket){
        return new ClientSocketHandler(clientSocket);
    }

    @Override
    public void run() {
        try (
             BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
             PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
        ) {

            String receiveData;
            while ((receiveData = in.readLine()) != null) {
              	...
            }
        }catch (IOException e) {
          	...
        }finally {
            try{
                this.clientSocket.close();
            }catch (IOException e){
              	...
            }
        }
    }
}

하지만 이 멀티 스레드 서버에는 소켓 연결 요청이 들어올 때마다 스레드가 생성되며 스레드 생성시 일반적으로 2MB의 공간을 할당 받기 때문에, 1000개의 요청이 들어오게 되면 2GB의 메모리 공간을 점유하게 됩니다. 즉, 다음과 같은 문제가 있습니다.

  • 커넥션 수에 따른 무한 스레드 증가하여 메모리 부족을 겪을 수 있다.
  • 스레드가 기하급수적으로 많아지게되면, 스레드간의 컨텍스트 스위칭하는 과정에서 CPU 시간과 리소스를 소모한다.
  • 매번 스레드를 생성하며 스레드 생성 비용이 적지 않기 때문에 큰 요청이 들어올 때 장애로 이어질 수 있다.

위 문제는 일반적으로 스레드 풀을 통해 해결할 수 있습니다. 특히 자바에서는 ExecutorService를 통해 스레드풀을 쉽게 만들 수 있습니다. 스레드풀을 활용한 코드는 다음과 같습니다.

public class ThreadPoolSocketServer {
    private static final Integer CORE_POOL_SIZE = 10;
    private static final Integer MAX_POOL_SIZE = 200;
    private static final Integer MAX_QUEUE_SIZE = 100;
    private static final Integer KEEP_ALIVE_TIME = 60 * 60;
    private static final ExecutorService THREAD_POOL = new ThreadPoolExecutor(
            CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new LinkedBlockingQueue<>(MAX_QUEUE_SIZE)
    ); // 스레드풀 생성

    public static void main(String[] args) {
        try (ServerSocket serverSocket = new ServerSocket(SOCKET_SERVER_PORT)) {
            while (true) {
                Socket clientSocket = serverSocket.accept();
                THREAD_POOL.submit(ClientSocketHandler.create(clientSocket));
            }
        } catch (IOException e) {
			...
        }
    }
}

하지만 스레드풀을 사용하게 된다면, 최대 사용가능한 스레드의 수만큼의 요청을 처리할 수 있습니다. 즉, 다음과 같은 문제가 발생할 수 있습니다.

  • 동시에 접속 가능한 사용자는 스레드 풀에 지정된 스레드 수에 의존하게 된다.
  • 동시 접속 가능한 사용자 수를 늘리기 위해 스레드 풀의 크기를 조정할 때, 메모리 크기가 증가할 수록 GC 비용이 커지게 되며 스레드가 많아짐에 따라 컨텍스트 스위칭 비용이 커진다.

이러한 문제는 java의 nio를 통해 해결할 수 있습니다. 다음 글에서 정리하겠습니다.

관련 자료
https://docs.oracle.com/cd/E19120-01/open.solaris/816-5137/attrib-54/index.html
https://haril.dev/blog/2024/05/21/Journey-to-a-multi-connect-server
https://www.daleseo.com/what-is-a-socket/
https://engineering.linecorp.com/ko/blog/do-not-block-the-event-loop-part1

0개의 댓글