1. 네트워크 IO와 자원 효율

서버는 위처럼 다양한 구성 요소와 네트워크로 데이터를주고 받는다.

HTTP 프로토콜을 이용해 클라이언트와 데이터 주고 받음

TCP 기반 프로토콜을 이용해 DB와 데이터 주고 받음

네트워크를 통해 레디스 메모리 캐시와 데이터 주고 받음

 

서버 API를 만들 때 개발자가 직접 네트워크 프로그램을 작성하지는 않지만, 서버는 네트워크 통신을 기반으로 동작한다.

 

네트워크를 통해 데이터를 주고받는 과정은 간단하게 다음 두 줄로 정리할 수 있다.

outputStream.write(...); // 출력 스트림으로 데이터 보내기
inputStream.read(...); // 입력 스트림으로 데이터 받기

 

 

예시) SELECT 쿼리 실행

SELECT 쿼리를 실행한다고 할때, 서버는 DB와 연결된 출력 스트림을 이용해 DB에 SELECT 쿼리를 전송한다. 그리고 DB가 보내는 데이터를 입력 스트림을 통해서 받는다.

 

이때 데이터 입출력이 완료될 때까지 스레드는 아무 작업도 하지 않고 입출력이 끝나기를 기다린다. 즉, 입출력이 끝날 때까지 스레드가 블로킹(blocking) 된다. 보통 입출력에 소요되는 시간은 코드를 실행하는 시간보다 훨씬 길다.

 

블로킹

작업이 완료될 때까지 스레드가 대기하는 것을 의미한다. 주로 데이터 입출력 과정에서 블로킹이 발생하고, 입출력(IO) 과정에서 블로킹이 발생하기 때문에 블로킹 IO라고도 한다.

 

스레드가 대기하는 데 시간을 소요한다는 것은, 그 스레드를 실행하는 CPU는 아무것도 하지 않는 시간이 생긴다는 의미이다. CPU 사용률을 높이려면 CPU가 실행할 스레드를 많이 만들면 되는데, 이는 요청당 스레드 방식(thread per reqeust)으로 구현한 서버가 이에 해당한다. 동시에 실행되는 스레드 개수를 늘려 IO 대기에 따른 CPU 낭비를 줄일 수 있다.

 

하지만 스레드 증가로 CPU 효율을 높이는 방법에는 2가지 한계가 있다.

  • 메모리 용량
    • 사용자 요청 수만큼 스레드를 생성할 경우 메모리 용량이 커져 메모리가 수용하지 못할 수준이 될 수 있고, 메모리가 병목이 될 수 있다.
  • 컨텍스트 스위칭
    • 동시에 실행되는 스레드가 증가하면 컨텍스트 스위칭에 사용되는 시간이 증가하는 문제가 발생한다.

 

컨텍스트 스위칭

스레드의 상태 정보를 변경하고 스레드를 전환하는 과정을 의미한다. 동시에 실행되는 프로세스와 스레드가 많아지면 컨텍스트 스위칭 시간이 증가하고, 그만큼 CPU가 실질적인 작업을 못하게 된다.

 

 

정리

위 내용을 정리하자면 트래픽이 증가할 때 다음 2가지 이유로 자원의 효율이 떨어지게 된다.

  • IO 대기와 컨텍스트 스위칭에 따른 CPU 낭비
  • 요청마다 스레드 할당으로 높은 메모리 사용량

 

위 문제를 다음과 같은 방법으로 해결할 수 있다.

  • 가상 스레드나 고루틴 같은 경량 스레드 사용
  • 논블로킹 또는 비동기IO 사용

 

트래픽이 수백 수천만이 되는 서비스가 아니라면 사실 자원 낭비/부족 문제를 신경쓰지 않아도 된다. 하지만 만약 트래픽이 기하급수적으로 증가해 성능 개선이 필요할 때 수평/수직 확장과 함께 자원 효율을 높이는 방법을 고려해볼 수 있다. 이때 자원 효율을 높이기 위해 IO 대기로 인한 CPU 낭비를 줄이고, 요청을 처리하는데 필요한 메모리를 줄이는 방법을 시도할 수 있다.

 

 

2. 가상 스레드

코드를 블로킹IO로 작성했는데 입출력 동안 스레드가 대기하지 않고 다른 일을 하면 CPU 유휴 시간이 감소하고 더 많은 작업을 처리할 수 있게 된다.

 

이런 일을 별도의 노력 없이 자바의 가상 스레드, Go의 고루틴 같은 경량 스레드를 사용해 CPU 효율을 높일 수 있다. 가상 스레드와 고루틴은 경량 스레드라는 공통점이 있다. 경량 스레드는 OS가 관리하는 스레드가 아니라 JVM 같은 언어의 런타임이 관리하는 스레드이다. OS가 CPU로 실행할 스레드를 스케줄링하듯, 언어 런타임이 OS 스레드로 실행할 경량 스레드를 스케줄링한다.

 

OS 스케줄러에 의해 여러 스레드를 번갈아 실행하는 것처럼 플랫폼 스레드도 JVM 스케줄러에 의해 여러 가상 스레드를 번갈아 실행한다. JVM은 기본적으로 풀에 CPU 코어 개수만큼 플랫폼 스레드를 생성하고 필요에 따라 플랫폼 스레드를 증가시킨다.

 

이때 플랫폼 스레드를 캐리어(carrier) 스레드라고도 한다. CPU가 여러 스레드를 실행하는 것처럼, 한 개의 캐리어 스레드도 여러 가상 스레드를 실행하게 된다. 특정 가상 스레드가 특정 캐리어 스레드에 연결되는 것을 마운트(mount) 되었다고 표현한다. 가상 스레드가 캐리어 스레드에 마운트되면 가상 스레드가 실행된다. 반대로 가상 스레드가 캐리어 스레드로부터 언마운트(unmount) 되면 가상 스레드는 실행을 멈춘다.

 

메모리

가상 스레드는 플랫폼 스레드보다 더 적은 자원(메모리)을 사용한다. 비교하면 플랫폼 스레드 한 개의 메모리 사용량이 1MB, 가상 스레드 1개의 힙 메모리 사용량이 2KB(플랫폼 스레드 메모리 사용량의 1/500) + NMB(N개의 플랫폼 스레드 메모리 사용량)으로 플랫폼 스레드 대비 가상 스레드를 사용할 때 메모리 사용량이 월등히 적다.

 

스레드 생성 시간

10만개의 스레드 생성 및 시작에 걸린 평균 시간은 각각 다음과 같다.

플랫폼 스레드 : 21,467ms

가상 스레드 : 196ms

 

이처럼 가상 스레드는 플랫폼 스레드에 비해 훨씬 적은 비용(자원, 시간)이 들기 때문에 한 장비에서 수십 만에서 백만 개에 이르는 가상 스레드를 생성할 수 있다.

 

3. 네트워크IO와 가상 스레드

가상 스레드 실행되는 과정에서 블로킹되면 플랫폼 스레드와 언마운트되고 실행이 멈춘다. 이때 언마운트된 플랫폼 스레드는 실행 대기 중인 다른 가상 스레드와 연결된 뒤 실행을 재개한다.

 

위 그림에서 가상 스레드가 진행 중 블로킹을 만났을 때 다른 대기 중인 스레드로 이동하는 걸 확인할 수 있다.

 

블로킹 연산과 synchronized

블로킹 연산에는 IO 기능, ReentrantLock, Thread.sleep() 등이 포함된다. 이들 연산을 사용해서 가상 스레드가 블로킹 되면, 플랫폼 스레드는 대기 중인 다른 가상 스레드를 실행한다. 

 

자바 23이하 버전에서 synchronized로 인해 블로킹 될 경우, 가상 스레드는 플랫폼 스레드로부터 언마운트되지 않는다. 즉, 플랫폼 스레드도 같이 블로킹 된다. 이렇게 가상 스레드가 플랫폼 스레드까지 블로킹할 때 가상 스레드가 플랫폼 스레드에 고정됐다(pinned)고 한다.

 

자바 21 기준으로 synchronized 오에도 JNI 호출 등 가상 스레드가 플랫폼 스레드에 고정되는 경우가 있는데, 가상 스레드가 고정되면 CPU 효율을 높일 수 없다. 가상 스레드를 사용할 때 이 점에 유의하자.

 

4. 가상 스레드와 성능

우리가 작성하는 코드는 크게 IO(입출력) 중심 작업과 CPU(계산) 중심 작업으로 나눌 수 있다. 이 두 작업 중 가상 스레드IO 중심 작업일 때 효과가 있다.

 

  • IO작업
    • 가상 스레드가 지원하는 블로킹 연산이므로, IO 중심 작업일 때 플랫폼 스레드가 CPU 낭비 없이 효율적으로 여러 가상 스레드를 실행할 수 있다.???
    • 스케줄링에 사용되는 플랫폼 스레드 개수 < 동시 생성 가상 스레드 개수 인 경우에만 사용하는 의미가 있다. 즉, CPU 수가 적거나 트래픽이 많은 경우에 가상 스레드 사용이 적합하다.
  • CPU 중심 작업
    • 이 때 가상 스레드를 사용하면 성능 개선 효과를 얻을 수 없다. 오히려 성능이 나빠질 수도 있다.
    • 예시로 사용자가 업로드한 이미지의 썸네일을 생성해주는 연산은 전형적인 CPU 중심 작업이고 이미지를 처리하는 코드에는 블로킹 연산이 없다. 블로킹 연산이 없으므로 이미지 연산을 실행하는 동안 플랫폼 스레드는 계속 1개의 가상 스레드만 실행해 가상 스레드의 동시 실행 효과를 얻을 수 없다.

 

가상 스레드를 사용해서 높일 수 있는 것은 처리량이다. 플랫폼 스레드나 가상 스레드가 동일한 CPU를 사용하기 때문에 가상 스레드를 사용한다고 해서 실행 속도가 플랫폼 스레드보다 더 빨라지지는 않는다.

 

스레드 풀

요청별 스레드 방식을 사용하는 서버는 스레드 풀을 사용할 때가 많다. 스레드 풀을 사용함으로써 스레드 생성 부하를 줄이고, 급격한 요청 증가에도 스레드가 무한정 생성되는 것을 예방할 수 있고 CPU와 메모리 같은 자원을 이정 수준으로 제한해서 서버 자원 포화 방지 목적도 있다.

 

하지만 가상 스레드는 플랫폼 스레드보다 생성 비용이 적기 때문에 필요할 때 가상 스레드 생성하고 필요 없으면 제거하면 되기 때문에 가상 스레드의 경우 스레드 풀을 생성할 필요가 없다.

 

장점

기존 코드 크게 수정할 필요 없이(중요) 서버의 성능을 향상시킬 수 있다.

 

5. 논블로킹IO

경량 스레드 사용으로 IO 중심 작업에서 서버의 처리량을 향상시킬 수는 있지만, 결국 경량 스레드도 메모리를 사용하고 스케줄링이 필요하기 때문에 폭발적인 사용자 증가 상황에선 한계가 발생한다.

 

논블로킹IO는 새로운 방식이라기 보다 오래 전부터 네트워크 서버의 성능을 높이기 위해 사용한 방식이다. 여기에 비동기 API를 같이 사용하면 덜 복잡한 코드로 높은 성능을 낼 수 있다. 이 방법을 사용하는 서버로는 Nginx, Netty, Node.js 등이 있다.

 

논블로킹IO 동작 개요

논블로킹IO는 입출력이 끝날 때까지 스레드가 대기하지 않는다. 

 

// channel : SocketChannel, buffer : ByteBuffer
int byteReads = channel.read(buffer); // 데이터를 읽을 때까지 대기하지 않음
// 읽은 데이터가 없어도 다음 코드 계속 실행

위 코드처럼 channel.read() 코드는 데이터를 읽을 때까지 대기하지 않고 읽을 데이터가 없으면 바로 0을 리턴한다. 이처럼 데이터 조회 여부에 상관없이 대기하지 않고 바로 다음 코드를 실행하므로 블로킹IO처럼 데이터를 조회했다는 가정하에 코드를 작성할 수 없다. 대신 루프 안에서 조회를 반복해서 호출한 뒤 데이터를 읽었을 때만 처리하는 방식으로 구현할 수 있다.

 

// CPU 낭비가 심한 방식
while (true) {
    int byteReads = channel.read(buffer);
    if (byteReads > 0) {
        handleData(chanel, buffer);
    }
}

그렇다고 위 코드처럼 데이터 조회가 될 때까지 확인하는 방식은 CPU 낭비가 심하다. 실제로 논블로킹IO를 사용할 때는 데이터 읽기를 바로 시도하기보다는 어떤 연산을 수행할 수 있는지 확인하고 해당 연산을 실행하는 방식으로 구현한다. 실행 흐름은 다음과 같다.

 

1. 실행 가능한 IO 연산 목록을 구한다(실행 가능한 연산을 구할 때까지 대기)

2. 1에서 구한 IO 연산 목록을 차례대로 순회한다.

3. 각 IO 연산을 처리한다.

4. 위 과정을 반복한다.

Selector selector = Selector.open();

ServerSocketChannel serverSockeet = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress(7031));
serverSocket.configureBlocking(false); // 서버 소켓 비동기 설정

serverSocket.register(selector, SelectionKey.OP_ACCEPT); // 연결 연산 등록

while (true) {
    selector.select(); // 가능한 IO 연산이 있을 때까지 대기
    Set<SelectionKey> selectedKeys = selector.selectedKeys());
    Iterator<SelectionKey> iterator = selectedKeys.iterator();
    while(iterator.hasNext()) { // IO 연산 순회
        SelectionKey key = iterator.next();
        iterator.remove();
        if (key.isAcceptable()) { // 클라이언트 연결 처리 가능하면
            SocketChannel client = serverSocket.accept(); // 클라이언트 연결 처리
            client.configureBlocking(false); // 소켓 비동기 설정
            client.register(selector, SelectionKey.OP_READ); // 읽기 연산 등록
        } else if (key.isReadable) { // 읽기 연산 가능하면
            SocketChannel channel = (SocketChannel) key.channel(); // 채널 구함
            int readBytes = channel.read(inBuffer); // 채널에 읽기 연산 실행
            if (readBytes == -1) {
                channel.close();
            } else {
                inBuffer.flip();
                outBuffer.put(inBuffer); // 출력 버퍼에 복사
                inBuffer.clear();
                outBuffer.flip();
                channel.write(outBuffer); // 채널에 쓰기 연산 실행
                outBuffer.clear();
           }
         }
       }
   }

위 코드에서 핵심은 Selector인데, Selector의 각 메서드는 역할이 있다.

  • Selector#select() 메서드
    • IO 처리가 가능한 연산이 존재할 때까지 대기
    • 리턴값이 있으면 수행할 수 있는 연산이 존재하는 것
  • Selector#selectedKeys() 메서드
    • 실행 가능한 연산 목록 조회
    • 이렇게 구한 SelectionKey를 이용해서 어떤 연산이 가능한지 확인하고 해당 연산을 수행한다.

 

블로킹IO vs. 논블로킹IO

블로킹IO 논블로킹IO
블로킹IO로 구현한 서버는 커넥션 또는 요청 별로 스레드를 할당한다. 동시 연결 클라이언트가 1000개일 경우 클라이언트 처리 스레드 100개 생성. 논블로킹IO로 구현한 서버는 연결 클라이언트 수에 상관없이 소수의 스레드를 사용한다. 동시 접속 클라이언트가 증가해도 스레드 개수는 일정하게 유지되므로 같은 메모리로 더 많은 클라이언트 연결을 처리할 수 있다.

논블로킹IO는 보통 CPU 개수만큼 그룹을 나누고 각 그룹마다 입출력을 처리할 스레드를 할당해 IO 처리에 대한 동시성을 높일 수 있다.

 

IO multiplexing(IO 다중화)

단일 이벤트 루프에서 여러 IO 작업을 처리하는 개념을 표현할 때 사용한다. 위에서의 논블로킹IO와 Selector를 이용한 입출력 처리가 IO 멀티플렉싱에 해당한다. OS에 따라 리눅스는 epoll, 윈도우는 IOCP 등을 사용해 구현한다. IO 멀티플렉싱 사용으로 더 적은 자원으로 더 많은 클라이언트를 처리할 수 있어 대규모 트래픽을 처리해야 하는 서버를 구현할 때 IO 멀티 플렉싱을 사용한다.

 

리액터 패턴

리액터 패턴은 논블로킹IO를 이용해서 구현할 때 사용하는 패턴 중 하나이고, 동시에 들어오는 여러 이벤트를 처리하기 위한 이벤트 처리 방법이다.

 

리액터 패턴 구성요소

리액터 : 이벤트가 발생할 때까지 대기하다가 이벤트가 발생하면 알맞은 핸들러에 이벤트를 전달

핸들러 : 이벤트를 받은 핸들러는 필요한 로직을 수행

 

리액터는 다음과 유사한 형태를 갖는다. 

while(isRunning) {
    List<Event> events = getEvents(); // 이벤트가 발생할 때까지 대기
    for (Event event : events) {
        Handler handler = getHandler(event); // 이벤트를 처리할 핸들러 구함
        handler.handle(event); // 이벤트를 처리함
    }
}

위 코드를 보면 리액터는 이벤트를 대기하고 핸들러에 전달하는 과정을 반복하는데, 그래서 리액터를 이벤트 루프(event loop)라고 한다.

 

앞서 보았던 논블로킹IO 예제 코드 일부분을 다시 보면 리액터 패턴과 처리 방식이 동일한 것을 볼 수 있다.

Selector selector = Selector.open();
...

while (true) {
  selector.select();
  Set<SelectionKey> selectedKeys = selector.selectedKeys();
  Iterator<SelectionKey> iterator = selectedKeys.iterator();
  while (iterator.hasNext()) {
    SelectionKey key = iterator.next();
    ... // key 타입에 따른 처리
  }
}

위 코드에서 SelectionKey를 이벤트에 대응하면 리액터 패턴과 완전히 처리 방식이 동일한 것을 알 수 있다. 실제로 논블로킹IO에 기반한 Netty, Nginx, Node.js 등의 프레임워크나 서버는 리액터 패턴을 적용하고 있다.

 

리액터 패턴에서 이벤트 루프는 단일 스레드로 실행된다. 멀티 코어를 가진 서버에서 단일 스레드만 사용하면 처리량을 최대한 낼 수 없다. 또 핸들러에서 CPU 연산이나 블로킹을 유발하는 연산을 수행하면 그 시간만큼 전체 이벤트 처리 시간이 지연된다. 이런 한계를 보완하기 위해 핸들러나 블로킹 연산을 별도 스레드 풀에서 실행하기도 한다.

 

  • Netty : 여러 개의 이벤트 루프를 생성해서 멀티 코어 활용
  • Node.js : 이벤트 루프 외에 별도의 스레드 풀을 사용해서 CPU 중심 작업이나 블로킹 연산을 동시에 처리

 

프레임워크 사용하기

줄 단위로 데이터를 수신한느 서버를 구현한다고 가졍하자. 이때, 블로킹IO와 논블로킹IO의 처리 방식이 다르다.

 

  • 블로킹IO

블로킹IO일 경우, BufferedReader를 사용해서 쉽게 줄 단위 데이터로 읽을 수 있다.

BufferedReader br = new BufferedReader(
    new InputStreamReader(socket.getInputStream(), "UTF-8")
);

...
String line;
while ((line = br.readLine() != null) { // 줄 단위로 쉽게 읽을 수 있음
    // line 처리
}

 

  • 논블로킹IO

논블로킹IO일 경우엔 다음 과정을 진행해야하므로 처리가 복잡해진다.

1) 데이터 읽은 뒤 \n 문자가 있는지 확인하는 코드 구현

2) \n 문자가 없는 경우 읽은 데이터를 별도 버퍼에 계속 누적하는 처리 구현 필요

3) \n 문자가 여러 개 존재하는 경우도 처리 구현 필요

4) 채널마다 누적 처리를 위한 버퍼도 관리 필요

 

위 로직을 직접 구현해도 좋겠지만 데이터 형식이 조금만 바귀어도 저수준의 IO 처리 코드를 변경해야 한다. 따라서 손쉽게 논블로킹IO를 쉽게 구현할 수 있도록 도와주는 프레임워크를 사용하는 것도 대처 방법 중 하나가 될 수 있다.

 

예시로 리액터 네티를 사용하면 아래 코드를 이용해 줄 단위로 데이터룰 주고 받는 에코 서버를 구현할 수 있다.

DisposableServer server = TcpServer.create()
    .port(7031)
    .doOnConnection(conn -> 
        conn.addHandlerFirst(new LineBasedFrameDecoder(1024)) // 줄 단위 읽기 처리
    )
    .handle((in, out) -> {
        return in.receive()
                 .asString() // byte를 문자열로 변환
                 .doOnNext(line -> {
                     log.info("received: {}", line);
                 })
                 .flatMap(line ->
                     out.sendString(Mono.just(line + "\n")) // 문자열 쓰기
                     );
                })
                .bindNow();

리액터 네티가 줄 단위 읽기와 문자열 변환 처리 기능을 제공하므로 저수준의 IO처리를 직접 구현하지 않아도 된다. 따라서 개발자는 처리할 로직에 집중할 수 있다. 물론 리액터 네티가 기반으로하는 리액티브 API(스프링 리액터)를 익혀야 하지만, 일단 익숙해지면 논블로킹IO API를 직접 사용하는 것보다 간단한 코드로 논블로킹/비동기 IO 방식으로 구현할 수 있게 된다.

 

6. 언제 논블로킹IO, 가상 스레드를 사용해야할까

논블로킹IO, 가상 스레드를 사용하면 성능이 좋아진다는 말을 듣고 바로 사용하면 안된다. 다음과 같은 항목을 고려해 적용해도 되는지 확인한 후 적용하는 것이 좋다.

 

  • 문제가 있는가?
  • 문제가 있다면 네트워크 IO 관련 성능 문제인가?
  • 구현 변경이 가능한가?

 

문제가 있는가?

성능 문제가 없거나 당분간 트래픽 증가 가능성이 없다면 논블로킹IO나 가상 스레드를 검토할 필요가 없다. 문제가 없는데 구현을 변경하는 것은 시간 낭비가 될 것이다. 또한 논블로킹/비동기IO 방식으로 구현하면 코드가 복잡해지고 유지보수 난이도도 올라간다. 따라서 단순한 호기심이나 성능 문제가 없는데 성능을 높이겠다며 복잡하게 구현하는 것은 지양하자.

 

문제가 있다면 네트워크 IO 관련 성능 문제인가?

성능 문제가 있다면 그 문제가 네트워크 IO와 관련된 자원 문제인지 확인해야 한다. 만약 DB 쿼리 성능 문제, CPU 중심 작업(ex. 썸네일 생성)에서 성능 저하가 발생할 경우엔 논블로킹IO로 해당 성능 문제를 해결할 수 없다. DB 쿼리 성능 문제는 쿼리 최적화나 캐시로 문제 해결하면 된다.

 

구현 변경이 가능한가?

성능 문제가 있고, 해당 문제가 IO 관련이라면 그때는 구현 변경이 가능한지를 따져봐야 한다. 고려할 사항으로는 가상스레드, 수평확장, 우선순위, 기술에 대한 익숙함이 있다.

 

  • 가상 스레드 적용
  • 메모리/서버 수평 확장
    • 가상 스레드를 적용할 수 없다면 메모리를 늘리거나 서버를 수평 확장해서 문제 완화하는 방법이 있다
  • 우선순위
    • 인력이 없는 문제 등으로 우선순위에 밀려 구현 변경이 불가능할 경우, 구현 방식을 바꾸기 보단 서버 확장을 통해 문제를 해결해야 한다.
  • 기술에 대한 익숙함
    • 예시로 웹소켓 서버의 동시 접속자가 증가해서 성능 문제가 발생한 경우, 이때 논블로킹IO를 적용하면 효과를 볼 수 있겠지만, 개발자가 관련 기술을 모르면 성능 개선은 어려워진다.

 

 

+ Recent posts