Skip to main content

다중 접속 서버로의 여정

· 13 min read
Haril Song
Owner, Software Engineer at 42dot

banner

Overview​

여러 클라이언트의 요청을 동시에 핸들링할 수 있는 서버 애플리케이션을 구현하는 건 이제 너무나 쉽습니다. Spring MVC만 사용해도 뚝딱 만들어낼 수 있으니까요. 하지만, 엔지니어로서, 그 이면의 원리가 너무나 궁금합니다 🤔. 이번 글에서는 당연한 것처럼 느껴지는 것들에 '왜' 라는 질문을 던져보며, 다중 접속 서버를 구현하기 위해 어떤 고민이 있었는지 되짚어보는 여정을 떠나봅니다.

info

예제 코드는 GitHub 에서 확인하실 수 있습니다.

소켓(Socket)​

먼저 '소켓' 에서 출발합니다. 네트워크 프로그래밍 관점에서 소켓은, '네트워크상에서 데이터를 주고받기 위해 파일처럼 사용되는 통신 엔드포인트' 입니다. '파일처럼 사용되는' 이라는 설명이 중요한데, 파일 디스크립터(file descriptor, fd) 를 통해 접근되고 파일과 유사한 I/O 연산을 지원하기 때문입니다.

  • 파일과 유사하게 다뤄야 하다 보니 많은 요청을 위해서는 그만큼의 파일을 열 수 있어야 하겠습니다.
  • 예전에는 1개의 프로세스가 열 수 있는 파일 개수가 4096개로 제한되어 있었습니다.
  • ulimit 을 사용하면 이 제한을 확인할 수 있습니다.
  • 요즘은 unlimited 가 기본이라 크게 신경쓸 필요는 없지만, 오래된 리눅스 버전을 사용하는 경우는 주의해야 합니다.
  • Too Many Open Files 라는 에러가 발생하면 이를 확인해보세요.
왜 소켓을 port 가 아닌 fd 로 식별할까요?

자신의 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());
}
  1. ServerSocket 에 port 를 bind 한 뒤 무한루프를 돌며 클라이언트의 요청을 기다립니다.
  2. 클라이언트 요청이 발생하면 accept 를 호출하여 연결을 수락하고 새로운 Socket 을 생성합니다.
  3. 데이터를 읽거나 쓸 때는 Socket 을 Stream 과 함께 사용합니다.

앞서 살펴본 소켓 개념을 바탕으로 간단한 서버 애플리케이션이 완성되었습니다. 이제 이 글을 보고 계신 여러분도 프레임워크를 사용하지 않고도 서버 애플리케이션을 구현하실 수 있게 되었네요 🎉

하지만 이 서버 애플리케이션에는 몇 가지 아쉬운 점이 있습니다. 여러 요청을 동시에 처리하기 어렵다는 점이에요. 단일 프로세스로 동작하기 때문에 한 번에 하나의 요청만 처리할 수 있고, 이후 요청을 처리하려면 앞선 연결이 종료되어야 가능합니다.

예시를 통해 살펴보겠습니다.

hello1 응답은 잘 돌아오지만, hello2 응답은 hello1 연결이 종료되어야 돌아오는 걸 확인할 수 있습니다.

  • 하나의 클라이언트가 연결할 때는 문제가 없지만 다수의 클라이언트가 연결하는 경우에는 문제
  • 처음 연결한 클라이언트가 연결을 종료하기 전까진 큐에 들어가 대기해야 하기 때문
  • 여러 요청을 동시에 처리할 수 없으므로 리소스를 효율적으로 사용할 수 없다

이 문제를 해결하려면 2가지 방법을 고려해볼 수 있습니다.

  • 멀티 프로세스
  • 멀티 스레드

Java 에서 멀티 프로세스를 직접 다루기는 어렵습니다. 아쉬움을 뒤로 하고, 멀티 스레드로 발걸음을 옮겨봅니다.

멀티 스레드 서버​

멀티 스레드 방식은, 하나의 프로세스 안에서 요청이 들어올 때마다 별개의 스레드를 생성한 뒤 처리를 위임하는 방식으로 구현됩니다. 이를 그림으로 나타내면 다음과 같습니다.

클라이언트 관점에서 표현해보면 아래처럼 표현할 수 있습니다.

thread blocking i o

코드로 구현해보면 아래와 같습니다.

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 입니다.
  • 더 이상 커널 영역에서의 복사가 필요하지 않게 되었습니다. Zero copy 가 실현된 것이죠.

Java NIO 에는 3가지 핵심적인 컴포넌트가 있습니다. 바로 Channel, Buffer, Selector 입니다.

Channel​

서버에서 클라이언트와 데이터를 주고 받을 때 채널을 통해서 버퍼(ByteBuffer)를 이용해 읽고 씁니다.

  • FileChannel: 파일에 데이터를 읽고 쓴다
  • DatagramChannel: UDP 를 이용해 네트워크에서 데이터를 읽고 쓴다
  • SocketChannel: TCP 를 이용해 네트워크에서 데이터를 읽고 쓴다.
  • ServerSocketChannel: 클라이언트의 TCP 연결 요청을 수신(listening)할 수 있으며, SocketChannel 은 각 연결마다 생성된다.

Buffer​

데이터를 읽고 쓰는데 사용하는 컴포넌트입니다. 양방향으로 동작해야하기 때문에 flip() 이라는 메서드로 쓰기 모드와 읽기 모드를 전환합니다. 모든 데이터를 읽은 후에는 버퍼를 지우고 다시 쓸 준비를 해야 하며, 이 때 clear() 메서드를 호출해서 전체 버퍼를 지울 수 있습니다.

버퍼는 몇 가지 특징을 가지고 있습니다.

  • capacity: 생성할 때 크기를 지정해야하며, 변경할 수 없습니다.
  • position: 다음에 읽거나 쓸 요소의 인덱스를 나타냅니다.
  • limit: 버퍼 내에서 데이터를 읽거나 쓸 수 있는 첫 번째 제한 위치를 나타냅니다.
  • mark: 위치를 한 번 표시하고 나중에 다시 그 위치로 되돌아갈 수 있게 해주는 기능입니다.

Selector​

Selector 는 멀티플렉싱을 가능하게 하는 핵심 컴포넌트입니다. C 로 구현된 select, epoll, kqueue 를 Java 에서도 쉽게 사용할 수 있게 합니다.

selector channel non blocking io

  • 여러 개의 채널에서 발생하는 이벤트를 모니터링합니다.
  • 하나의 스레드로 여러 채널을 모니터링하는게 가능합니다.
  • 내부적으로 SelectorProvider 에서 운영체제와 버전에 따라서 사용가능한 멀티플렉싱 기술을 선택해 사용합니다.
    • select, poll, epoll, kqueue 등이 있으며 Linux 라면 epoll 이, MacOS 라면 kqueue 가 사용됩니다.
    • debug 하는 머신에 따라서 다른 구현 기술이 사용될 수 있기 때문에 debug 시 주의해야하는 부분입니다.

코드로 살펴볼까요?

셀렉터 생성​
Selector selector = Selector.open();

open 메서드를 통해 selector 를 생성할 수 있습니다. 상술했듯이 SelectorProvider 를 통해 OS 에 맞는 멀티플렉싱 기술이 선택되어 반환됩니다.

채널 등록​
ServerSocketChannel channel = ServerSocketChannel.open();
channel.bind(new InetSocketAddress("localhost", 8080));
channel.configureBlocking(false); // non-blocking mode
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

ServerSocketChannel 을 생성하고 port 를 binding 하고 나면 non-blocking 모드로 동작할 수 있도록 false 를 설정합니다. 이후 셀렉터에 채널을 등록할 때는 어떤 이벤트를 모니터링할 지 전달해줄 수 있는데, 이벤트에는 네 가지 종류가 있으며 SelectionKey 에 상수값으로 등록되어 있습니다.

  • OP_CONNECT
  • OP_ACCEPT
  • OP_READ
  • OP_WRITE
셀렉터를 이용하여 채널 선택​

셀렉터는 어떤 채널이 준비가 완료되었는지 알기 위해서 select 를 호출합니다. select 는 데이터가 준비된 채널이 있다면 준비된 채널의 수를 반환해주고, 준비된 채널이 하나도 없다면 준비된 채널이 생길 때까지 블로킹되게 됩니다.

selector.select(); // blocking

재밌지 않나요? 논블로킹 I/O 의 구현이라 해서 모든 내부 동작이 논블로킹 I/O 로만 동작하는 것은 아니였습니다. 준비된 채널이 없는데 데이터를 읽으려한다면 정상적으로 동작할 수 있을까요? 생각해보면 이런 상황에서는 블로킹을 통해 데이터가 준비될 때까지 기다리는 동작이 오히려 자연스럽습니다.

여기서 한 가지 의문이 생깁니다. 읽을 수 있는 데이터가 존재할 때까지 블로킹된다면 멀티 스레드와 도대체 무슨 차이가 있는걸까요? 🤔

멀티 스레드 모델에서는 하나의 스레드가 하나의 요청만 처리할 수 있었지만, 멀티 플렉싱 모델에서는 하나의 스레드가 다수의 요청을 처리할 수 있다는 점이 다릅니다. 여러 요청 중 먼저 준비된 요청부터 스레드가 처리할 수 있는 메커니즘을 셀렉터를 통해 제공하기 때문에, 스레드가 블로킹되는 시간을 멀티 스레드 모델에 비해 크게 줄일 수 있게 되는 것이지요.

tip

민속 놀이인 '무궁화 꽃이 피었습니다' 로 비유해보면 어떨까요?

일반적으로 술래(= Thread) 는 게임 참가자(= Channel)들을 모두 기억해뒀다가(= Selector) 참가자들을 동시에 감시합니다. 동시에 감시할 수 있는 능력이 없어서 술래가 맨투맨(man to man)으로 붙어있게 된다면 멀티 스레드 모델이 되겠네요 😂.

물론 필요하다면 select 도 논블로킹으로 동작시킬 수 있습니다. selectNow 를 호출하면 됩니다.

selector.selectNow(); // non-blocking

이후 selectedKeys() 메서드를 사용해 준비된 채널의 집합을 받아올 수 있습니다.

Set<SelectionKey> keys = selector.selectedKeys();

멀티 플렉싱 서버 구현​

지금까지 살펴본 개념을 바탕으로 멀티 플렉싱 기반의 서버를 다음처럼 구현할 수 있습니다.

try (
ServerSocketChannel channel = ServerSocketChannel.open();
Selector selector = Selector.open()
) {
channel.bind(new InetSocketAddress(PORT));
channel.configureBlocking(false); // non-blocking mode
LOGGER.info("Server started on port " + PORT);

channel.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buffer = ByteBuffer.allocate(256);

while (true) {
selector.select(); // blocking

// 선택된 키 셋 반복
Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
keys.remove();

if (key.isAcceptable()) {
// 새로운 클라이언트 연결 수락
accept(channel, selector);
} else if (key.isReadable()) {
// 클라이언트로부터 데이터 읽기
read(key, buffer);
}
}
}
}
  • ServerSocketChannel 을 논블로킹 모드로 설정합니다.
  • 채널에 이벤트와 함께 셀렉터를 등록합니다.
  • 등록된 이벤트가 발생할 때까지 대기합니다. select 를 사용하면 이벤트가 발생했을 때 블로킹에서 해제되도록 할 수 있습니다.
  • 이벤트를 트리거한 채널 목록을 가져옵니다. 채널 정보는 SelectionKey 를 통해 확인할 수 있습니다.
  • 적절한 핸들러에 위임하여 이벤트를 처리합니다.

Blocking I/O 와 어떤 차이가 있는지 간단하게 정리해보면 아래와 같습니다.

Blocking I/O​

  • read 를 호출한 순간에는 데이터가 도착하지 않았을 수 있다.
  • 네트워크는 그 특성상 응답이 언제 돌아올지 확신할 수 없다.
  • 데이터가 네트워크를 통해 커널 공간에 도착해 사용자 공간의 프로세스 버퍼에 복사될 때까지 시스템콜이 반환되지 않는다.
  • 스레드가 블로킹되어 다른 작업을 처리할 수 없다.

Non-blocking 기반의 I/O 멀티플렉싱 모델​

  • select 함수를 호출해서 여러 개의 소켓 중 읽을 준비가 된 소켓이 생길 때까지 대기 (blocking)
  • 준비가 된 소켓이 반환되면, read 함수를 호출
  • 여러 소켓을 바라보다가 준비가 된 소켓부터 반환받아서 데이터를 처리하기 때문에, 블로킹되는 시간이 짧다.

지금까지 멀티 플렉싱에 대해 살펴봤습니다. 너무 어렵게 느껴지시나요? 여러분이 한 번이라도 Netty 를 사용해보신 적이 있다면, 이미 멀티 플렉싱을 사용하고 계셨던 겁니다. Netty 가 바로 nio 기반의 멀티 플렉싱을 사용하기 쉽도록 만들어진 프레임워크이기 때문입니다.

이벤트 루프(Event Loop)​

Netty 를 살펴보다보면 이벤트 루프라는 개념이 등장합니다. 이벤트 루프는 뭘까요? NodeJS 를 접해보셨다면 이 단어는 흔히 들어보셨을겁니다. 위키피디아에 이벤트루프를 검색해보면 아래처럼 설명해줍니다.

큐와 같은 자료구조에 이벤트가 발생하는지 무한루프를 돌며 지켜보다가, 이벤트를 처리할 수 있는 적절한 핸들러에 동작을 위임하여 처리하는 것

무한루프, 이벤트 처리... 뭔가 우리가 지금까지 확인해봤던 동작하고 비슷하지 않나요? 이벤트루프가 바로 멀티 플렉싱을 바탕으로 구현된 개념이기 때문입니다.

// Event Loop
while (true) {
selector.select();
Set<SelectionKey> selected = selector.selectedKeys();
for (SelectionKey selectionKey : selected) {
dispatch(selectionKey);
}
selected.clear();
}

JVM 의 main 스레드는 루프 안에서 이벤트가 발생할 때까지 대기했다가 적절한 핸들러에 이벤트를 위임합니다.

이제 우리는 논블로킹 멀티 플렉싱 서버에서 왜 블로킹 동작을 발생시키면 안되는지에 대해서도 이해할 수 있습니다. 기본적으로 main 스레드만 동작하기 때문에 루프 밖에서 스레드가 블로킹될 경우, select 를 호출하여 이벤트를 위임하는 동작 자체가 점점 느려질 수 있기 때문이지요.

마찬가지로 계산 위주의 작업(= CPU intensive) 은 스레드의 동작 자체를 지연시키기 때문에 이벤트 루프와 어울리지 않습니다. 이런 작업이 빈번하게 발생할 경우에는 별도의 스레드를 생성해서 작업을 위임하는 것으로, main 스레드의 루프를 방해하지 않도록 구현해야 합니다.

Conclusion​

지금까지 다중 접속 서버를 구현하기 위해 어떤 고민들이 있었는지, 한 단계씩 살펴보았습니다.

  • 단일 프로세스는 다중 접속을 처리할 수 없었고,
  • 멀티 프로세스, 멀티 스레드는 리소스가 너무 많이 필요했으며,
    • 스레드풀은 리소스 문제는 해결했지만, 많은 요청을 동시에 처리하기는 여전히 부족했습니다.
  • 멀티플렉싱을 사용하여 하나의 스레드로도 많은 요청을 처리할 수 있게 합니다.
    • 어떤 요청이 처리할 준비가 되었는지 알아낼 수 있는 셀렉터가 핵심이였습니다.

이번 여정은 여기까지입니다. 뭔가 홀가분한 느낌이 드네요.

아, 사실 우리가 방문하지 않은 곳이 하나 남았습니다. 정말 흥미로운 주제지만, 다음 여정을 위해 남겨두도록 하겠습니다.

Reference​