다중 접속 서버로의 여정
Overview
여러 클라이언트의 요청을 동시에 핸들링할 수 있는 서버 애플리케이션을 구현하는 건 이제 너무나 쉽습니다. Spring MVC만 사용해도 뚝딱 만들어낼 수 있으니까요. 하지만, 엔지니어로서, 그 이면의 원리가 너무나 궁금합니다 🤔. 이번 글에서는 당연한 것처럼 느껴지는 것들에 '왜' 라는 질문을 던져보며, 다중 접속 서버를 구현하기 위해 어떤 고민이 있었는지 되짚어보는 여정을 떠나봅니다.
예제 코드는 GitHub 에서 확인하실 수 있습니다.
소켓(Socket)
먼저 '소켓' 에서 출발합니다. 네트워크 프로그래밍 관점에서 소켓은, '네트워크상에서 데이터를 주고받기 위해 파일처럼 사용되는 통신 엔드포인트' 입니다. '파일처럼 사용되는' 이라는 설명이 중요한데, 파일 디스크립터(file descriptor, fd) 를 통해 접근되고 파일과 유사한 I/O 연산을 지원하기 때문입니다.
- 파일과 유사하게 다뤄야 하다 보니 많은 요청을 위해서는 그만큼의 파일을 열 수 있어야 하겠습니다.
- 예전에는 1개의 프로세스가 열 수 있는 파일 개수가 4096개로 제한되어 있었습니다.
ulimit
을 사용하면 이 제한을 확인할 수 있습니다.- 요즘은
unlimited
가 기본이라 크게 신경쓸 필요는 없지만, 오래된 리눅스 버전을 사용하는 경우는 주의해야 합니다. Too Many Open Files
라는 에러가 발생하면 이를 확인해보세요.
자신의 ip, port, 상대방의 ip, port 를 사용하여 소켓을 식별하는 데 사용할 수 있지만 fd 를 사용하는 이유는 연결이 수락되기 전 소켓에는 아무런 정보가 없기 때문이고 ip 와 port 의 조합은 단순한 정수인 fd 보다 많은 데이터가 필요하기 때문입니다.
소켓을 사용하여 서버 애플리케이션을 구현하려면 다음과 같은 과정을 거쳐야 합니다.
- socket() 으로 소켓 생성
- bind(), listen() 으로 연결 준비
- accept() 로 연결 수락
- 수락 후 바로 다른 소켓을 할당 = 다른 연결을 수락할 수 있어야 하기 때문
이 때 연결에 사용되는 소켓을 리스닝 소켓이라고 합니다. 이 리스닝 소켓은 연결을 수락하는 역할만 하므로, 클라이언트와의 연결에는 다른 소켓이 별도로 생성되어 사용됩니다.
서버에서 클라이언트와의 연결을 어떻게 생성하고 유지하는지 알아보았으니, Java 로 서버 애플리케이션을 구현해보겠습니다.
단일 프로세스 서버
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
while (true) {
try (
Socket clientSocket = serverSocket.accept();
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)
) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println("Echo: " + inputLine);
}
System.out.println("Client disconnected.");
} catch (IOException e) {
System.out.println("Exception in connection with client: " + e.getMessage());
}
}
} catch (IOException e) {
System.out.println("Could not listen on port " + PORT + ": " + e.getMessage());
}
ServerSocket
에 port 를 bind 한 뒤 무한루프를 돌며 클라이언트의 요청을 기다립니다.- 클라이언트 요청이 발생하면
accept
를 호출하여 연결을 수락하고 새로운Socket
을 생성합니다. - 데이터를 읽거나 쓸 때는
Socket
을Stream
과 함께 사용합니다.
앞서 살펴본 소켓 개념을 바탕으로 간단한 서버 애플리케이션이 완성되었습니다. 이제 이 글을 보고 계신 여러분도 프레임워크를 사용하지 않고도 서버 애플리케이션을 구현하실 수 있게 되었네요 🎉
하지만 이 서버 애플리케이션에는 몇 가지 아쉬운 점이 있습니다. 여러 요청을 동시에 처리하기 어렵다는 점이에요. 단일 프로세스로 동작하기 때문에 한 번에 하나의 요청만 처리할 수 있고, 이후 요청을 처리하려면 앞선 연결이 종료되어야 가능합니다.
예시를 통해 살펴보겠습니다.
hello1 응 답은 잘 돌아오지만, hello2 응답은 hello1 연결이 종료되어야 돌아오는 걸 확인할 수 있습니다.
- 하나의 클라이언트가 연결할 때는 문제가 없지만 다수의 클라이언트가 연결하는 경우에는 문제
- 처음 연결한 클라이언트가 연결을 종료하기 전까진 큐에 들어가 대기해야 하기 때문
- 여러 요청을 동시에 처리할 수 없으므로 리소스를 효율적으로 사용할 수 없다
이 문제를 해결하려면 2가지 방법을 고려해볼 수 있습니다.
- 멀티 프로세스
- 멀티 스레드
Java 에서 멀티 프로세스를 직접 다루기는 어렵습니다. 아쉬움을 뒤로 하고, 멀티 스레드로 발걸음을 옮겨봅니다.
멀티 스레드 서버
멀티 스레드 방식은, 하나의 프로세스 안에서 요청이 들어올 때마다 별개의 스레드를 생성한 뒤 처리를 위임하는 방식으로 구현됩니다. 이를 그림으로 나타내면 다음과 같습니다.
클라이언트 관점에서 표현해보면 아래처럼 표현할 수 있습니다.
코드로 구현해보면 아래와 같습니다.
try (ServerSocket serverSocket = new ServerSocket(PORT)) {
LOGGER.info("Server is running on port " + PORT);
while (true) {
Socket clientSocket = serverSocket.accept(); // 메인 스레드가 요청을 수락하면서 클라이언트 소켓 생성
new Thread(new ClientHandler(clientSocket)).start(); // 워커 스레드에 위임
}
} catch (IOException e) {
LOGGER.severe("Could not listen on port " + PORT + ": " + e.getMessage());
}
public class ClientHandler implements Runnable {
// 생략...
@Override
public void run() {
try (
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)
) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
out.println("Echo: " + inputLine); // Echo back the received message
}
} catch (IOException e) {
LOGGER.severe("Error handling client: " + e.getMessage());
} finally {
try {
clientSocket.close();
} catch (IOException e) {
LOGGER.severe("Failed to close client socket: " + e.getMessage());
}
}
}
}
요청이 들어올 때마다 스레드를 하나씩 생성하므로 이제는 동시에 여러 요청이 들어와도 처리할 수 있습니다. 그럼 우리의 여정은 여기까지인 걸까요? JVM 의 특성을 고려해보면, 조금 더 최적화할 수 있을 것 같습니다.
- 스레드 생성 및 유지는 서버 리소스를 사용하는 작업이고 그렇게 저렴한 동작은 아닙니다. Java 에서는 스레드를 생성하면 stack 공간이 할당되는데, 이 stack 공간은 CPU 아키텍처에 따라 다르지만 약 1MB 정도의 공간이 할당됩니다.
- 10000개의 요청이 동시에 발생한다면, 단순 계산으로도 서버에는 10GB 이상의 메모리가 필요해진다는 의미가 됩니다.
- 서버 리소스는 무한하지 않기 때문에 최대 스레드 개수를 제한하기로 합니다. 스레드 풀 개념이 등장합니다.
- Spring MVC 가 바로 이런 생각들을 바탕으로 구현된 프레임워크입니다.
최적화까지 완료했습니다. 몇 가지 실험 을 통해 c10k problem 정도는 가볍게 해결할 수 있다는 것 또한 증명했습니다. 하지만, 뭔가 찝찝합니다 🤔.
- 스레드가 블로킹 되면 될수록, 애플리케이션은 점점 비효율적으로 동작합니다. context switching 과정에서 경합이 발생하기 때문입니다.
- 소켓에 데이터가 들어왔는지 확인하고 읽어들이기 위해 스레드들은 모두 한정된 CPU 자원을 가지고 polling 경쟁을 벌입니다.
- 즉, 네트워크 요청이 많을수록 애플리케이션은 느려집니다.
- 스레드풀을 사용한다는 건, 결국 동시에 처리할 수 있는 최대 요청 수에 천장이 있다는 의미입니다.
- 우리는 더 높은 목표를 추구하는 엔지니어로서, 이 천장을 돌파하고 싶어집니다.
멀티 플렉싱 서버
스레드가 블로킹되는 것은 서버 애플리케이션 입장에서는 부담스러운 오버헤드였습니다. 어떻게 해야 스레드가 블로킹되지 않게 하면서 많은 요청을 처리할 수 있을까요?
답은 멀티 플렉싱에 있습니다. 멀티 플렉싱은 적은 스레드로 많은 요청을 처리할 수 있게 하는 기술입니다. 멀티플렉싱을 사용하면 10만 동시접속 서버 정도는 우습게 구현할 수 있습니다. 이번 여정의 메인 메뉴이기도 하지요.
멀티 플렉싱에 자세히 살펴보기 전에 먼저 Java 의 I/O 에 대해 이해할 필요가 있습니다.
Java NIO
Java NIO 는 너무 느렸던 기존의 I/O API 를 대체하기 위해 jdk 1.4 부터 도입된 API 입니다. Java I/O 는 왜 느렸을까요?
- JVM 이 커널 메모리 영역에 직접 접근할 수없었기 때문에, 커널 버퍼를 JVM 메모리에 복사해야하는 과정이 필요했고, 이 과정이 블로킹으로 동작했습니다.
- JVM 메모리(heap)에 복사된 이후 GC 가 필요했기 때문에 추가적인 오버헤드도 있었습니다.
- NIO 에서는 커널 메모리 영역에 직접 접근할 수 있는 API 가 추가되었고, 이것이 ByteBuffer 입니다.