현재 서버 공부를 할 때, 여러 요청을 동시에 받을 수 있는 애플리케이션을 만들기는 쉽습니다. 스프링 mvc, webflux를 설정만하면 바로 시작할 수 있으니까요. 하지만, 서버는 어떻게 동시에 요청을 받아들이고 처리할까? 라는 고민을 해보신적 있으신가요? 저는 이 원리가 궁금해서 찾아보게 됐습니다.
서버의 이야기는 소켓으로 시작합니다. 소켓은 "네트워크 상에서 돌아가는 두 개의 프로그램 간 양방향 통신의 하나의 엔드 포인트"입니다. 그러면 이 엔드 포인트에 대해서 클라이언트와 서버는 어떻게 연결할까요? 우선 클라이언트가 서버의 ip 주소와 포트 번호를 알고 있어야 합니다. 이 정보와 함께 다음과 같은 과정을 통해 서버와 클라이언트는 소켓 연결을 진행합니다.

연결 수락 시, 새로운 소캣을 생성하는 이유는 서버가 여러 클라이언트와 동시에 통신할 수 있도록 하기 위함입니다.
또한, 기존 listen()을 담당하는 소켓은 지정된 포트에서 들어오는 연결 요청을 대기하고, 새로운 소캣을 생성하는 역할을 담당합니다.
자바에서는 java.net 패키지를 통해 Socket클래스를 제공합니다. java.net 패키지 내의 클래스들을 이용해서 단일 프로세스, 단일 스레드로 동작하는 소켓 서버를 만들어보겠습니다.
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){
...
}
}
}
Socket 인스턴스를 생성합니다.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) {
...
}
}
}
ServerSocket을 통해 특정 포트에 bind한 뒤 클라이언트 요청에 대해 listen 합니다.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의 메모리 공간을 점유하게 됩니다. 즉, 다음과 같은 문제가 있습니다.
위 문제는 일반적으로 스레드 풀을 통해 해결할 수 있습니다. 특히 자바에서는 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) {
...
}
}
}
하지만 스레드풀을 사용하게 된다면, 최대 사용가능한 스레드의 수만큼의 요청을 처리할 수 있습니다. 즉, 다음과 같은 문제가 발생할 수 있습니다.
이러한 문제는 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