Java로 HTTP 서버 구현 - (1) Thread-Create-per-Request 모델

Sunwoo Bae·2025년 3월 10일

스프링 부트

목록 보기
2/6

개요

스프링 부트가 백엔드 REST API 서버를 구현하는 데 어떤 역할을 하는 지를 알아보기 위해 쓰는 글이다. Tomcat 서버가 어떻게 동작하는 지를 알아보기 전에, 실제로 Java로 HTTP 서버를 구현해 보면서 왜 스레드풀의 개념이 등장했는지, Thread-Create-Per-Request 모델의 한계는 무엇인지 알아보자.

TCP 계층과의 소통

먼저, 클라이언트가 보내는 HTTP 요청을 받아오기 위해선 서버 프로세스가 사용할 포트를 열고, 소켓을 만들어서 해당 포트에 대응 시켜야 한다.
이 과정을 간단하게 java 코드로 적어 보자.

NaiveHttpServer

package tomcat;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;

public class NaiveHttpServer {
    public static void main(String[] args) {
        int port = 8080; // 사용할 포트 번호

        // ServerSocket(8080)는 자동으로 JNI(Java Nativer Interface)를 호출해서
        // listen, bind를 호출하여 서버 소켓을 만들고 8080 포트를 열어서 바인딩한다.
        try (ServerSocket serverSocket = new ServerSocket(port)) {

            while (true) {
                // 클라이언트의 연결을 대기
                // 클라이언트가 연결을 보낼 때까지, 스레드는 Block됨.
                Socket clientSocket = serverSocket.accept();

                // 클라이언트가 요청을 보냈다면, 서버는 이를 accept()하여 
                // 새로운 소켓을 만들어서 이후 클라이언트와의 통신에 활용
                // 
                handleClient(clientSocket);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    private static void handleClient(Socket clientSocket) {
        try {
            // 요청 파싱
            BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            // 첫 번째 줄에서 METHOD, URL 파싱
            String requestLine = reader.readLine();
            if (requestLine == null || requestLine.isEmpty()) {
                clientSocket.close();
                return;
            }
            String[] requestParts = requestLine.split(" ");
            String method = requestParts[0]; 
            String url = requestParts[1];
            System.out.println("Received request: METHOD = " + method + ", URL = " + url);
            // HTTP 응답 전송
            String httpResponse = "HTTP/1.1 200 OK\r\n" +
                    "Content-Length: 13\r\n" +
                    "Content-Type: text/plain\r\n" +
                    "\r\n" +
                    "Hello, World!";
            clientSocket.getOutputStream().write(httpResponse.getBytes());
            clientSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

아주 간단하게 구현한 HTTP 서버이다. 동작 과정을 간단히 설명하면 다음과 같다.

  1. serverSocket = ServerSocket(8080) 를 호출해서 서버 소켓을 생성하고, 이를 8080포트에 바인딩하고, 해당 포트에서 Listen을 시작한다.
  2. while 루프를 통해서 클라이언트가 요청을 보낼 때까지 무한 대기한다.
  3. 클라이언트가 요청을 보냈다면, 서버는 이를 serverSocket.accept()를 통해서 가져온다.
  4. 가져온 소켓에서, HTTP 메시지를 parsing한다.
    • TCP 계층에서 가져온 데이터는 바이트 스트림일뿐, HTTP 메시지가 아님을 유의한다.
  5. 가져온 소켓에서 parsing한 데이터에 따라, 적절한 응답을 만든 뒤(String을 생성하는 과정), 해당 데이터를 소켓에 적어서 내보낸다.

NaiveHttpServer의 문제점

간단하게 NaiveHttpServer를 구현해 봤는데, 이 서버는 치명적인 단점이 있다. 바로, 동시에 두 개의 요청을 처리할 수 없다.는 것이다.
하나의 요청을 처리하는 동안, 어떤 요청도 처리할 수 없다.

이를 개선하기 위해서 요청이 올 때마다 스레드를 생성하도록 서버 구조를 바꾸어 보자!

ThreadCreateHttpServer

package tomcat;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;

public class ThreadCreateHttpServer {
    public static void main(String[] args) {
        int port = 8080; // 사용할 포트 번호

        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("Server started on port " + port);

            while (true) {
                // 클라이언트 연결 대기
                Socket clientSocket = serverSocket.accept();

                // 요청이 들어올 때마다 새로운 스레드 생성
                new Thread(() -> handleClient(clientSocket)).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private static void handleClient(Socket clientSocket) {
        try {
            // 요청 파싱
            BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            String requestLine = reader.readLine();
            if (requestLine == null || requestLine.isEmpty()) {
                clientSocket.close();
                return;
            }

            String[] requestParts = requestLine.split(" ");
            String method = requestParts[0];
            String url = requestParts[1];

            System.out.println("Received request: METHOD = " + method + ", URL = " + url);

            // HTTP 응답 전송
            String httpResponse = "HTTP/1.1 200 OK\r\n" +
                    "Content-Length: 13\r\n" +
                    "Content-Type: text/plain\r\n" +
                    "\r\n" +
                    "Hello, World!";
            clientSocket.getOutputStream().write(httpResponse.getBytes());
            clientSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

요청이 올 때마다, 스레드를 생성해서 요청을 처리하도록 한 서버이다. 이 서버는 이제 동시에 여러 개의 요청을 처리할 수 있지만, 몇 가지 문제가 더 생겼다.

ThreadCreateHttpServer의 문제점

위 서버 구조에서 요청이 계속 온다고 해 보자. 스레드는 요청이 올 때마다 무한히 생성될 것이다. 이에 따른 문제점은 크게 두 개이다.

1. CPU가 동시에 처리할 수 있는 스레드는 정해져 있다.

  • CPU의 코어 수가 정해져 있는데, 스레드를 무한히 생성하는 것이 의미가 있을까?

2. 스레드 생성 작업이 매번 반복된다.

  • 그리고, 스레드 생성 작업 또한 CPU 리소스를 쓰는 일인데, 새로 요청이 들어올 때마다 스레드를 생성하고, 이후에 폐기하게 되면, 불필요한 생성 작업이 반복되는 것이 아닐까?

이를 개선하기 위해서 스레드 풀 이라는 개념이 등장했다. 미리 설정한만큼 스레드를 미리 생성해 놓은 뒤, 클라이언트 요청이 들어오면 해당 스레드에게 작업을 처리하라고 시키는 것이다. 만약 모든 스레드가 작업 중이라면, 작업 큐에 해당 작업을 넣어놓고 처리하면 될 것이다.
스레드 풀을 이용한 Tomcat 서버의 요청 처리 과정은 다음 포스트에서 설명해 보도록 하겠다.

profile
오늘보다 더 나은 내일

0개의 댓글