스프링 부트가 백엔드 REST API 서버를 구현하는 데 어떤 역할을 하는 지를 알아보기 위해 쓰는 글이다. Tomcat 서버가 어떻게 동작하는 지를 알아보기 전에, 실제로 Java로 HTTP 서버를 구현해 보면서 왜 스레드풀의 개념이 등장했는지, Thread-Create-Per-Request 모델의 한계는 무엇인지 알아보자.
먼저, 클라이언트가 보내는 HTTP 요청을 받아오기 위해선 서버 프로세스가 사용할 포트를 열고, 소켓을 만들어서 해당 포트에 대응 시켜야 한다.
이 과정을 간단하게 java 코드로 적어 보자.
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 서버이다. 동작 과정을 간단히 설명하면 다음과 같다.
serverSocket = ServerSocket(8080) 를 호출해서 서버 소켓을 생성하고, 이를 8080포트에 바인딩하고, 해당 포트에서 Listen을 시작한다. serverSocket.accept()를 통해서 가져온다.간단하게 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 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();
}
}
}
요청이 올 때마다, 스레드를 생성해서 요청을 처리하도록 한 서버이다. 이 서버는 이제 동시에 여러 개의 요청을 처리할 수 있지만, 몇 가지 문제가 더 생겼다.
위 서버 구조에서 요청이 계속 온다고 해 보자. 스레드는 요청이 올 때마다 무한히 생성될 것이다. 이에 따른 문제점은 크게 두 개이다.
1. CPU가 동시에 처리할 수 있는 스레드는 정해져 있다.
2. 스레드 생성 작업이 매번 반복된다.
이를 개선하기 위해서 스레드 풀 이라는 개념이 등장했다. 미리 설정한만큼 스레드를 미리 생성해 놓은 뒤, 클라이언트 요청이 들어오면 해당 스레드에게 작업을 처리하라고 시키는 것이다. 만약 모든 스레드가 작업 중이라면, 작업 큐에 해당 작업을 넣어놓고 처리하면 될 것이다.
스레드 풀을 이용한 Tomcat 서버의 요청 처리 과정은 다음 포스트에서 설명해 보도록 하겠다.