주니어 백엔드 개발자가 반드시 알아야 할 실무 지식 ‐ IO 병목, 어떻게 해결하지 - woojin-playground/Backend-PlayGround GitHub Wiki

네트워크 IO와 자원 효율

  • 데이터를 보내고 받는 동안 코들르 실행하는 쓰레드는 다음과 같은 흐름을 가져가게 된다.
  • 데이터 입출력이 완료될 때까지 쓰레드는 아무 작업도 하지 않고 입출력이 끝나기를 기다린다.
  • 즉, 입출력이 끝날 때까지 쓰레드는 블로킹된다. 보통 입출력에 소요되는 시간은 코드를 실행하는 시간보다 훨씬 길다.
  • 서버처럼 네트워크 연동이 많은 프로그램은 전체 실행 시간의 90% 이상을 입출력 대기에 사용하는 경우도 있다.
  • 쓰레드가 대기하는 데 시간을 소요한다는 것은 그 쓰레드를 실행하는 CPU도 아무것도 하지 않는 시간이 생긴다는 의미이다.
  • CPU 사용률을 높이려면 CPU가 실행할 쓰레드를 많이 만들면 된다.
  • 하지만 쓰레드를 생성하는 데에는 한계가 있다. 쓰레드는 수백 KB에서 수 MB 메모리를 사용한다.
  • 메모리를 늘려 쓰레드를 많이 만들 수 있다고 하더라도 여전히 문제가 되는데 바로 컨텍스트 스위칭이다.
  • 동시에 실행되는 쓰레드 수가 증가하면 컨텍스트 스위칭에 사용되는 시간도 비례해 증가한다.

Blocking

  • 작업이 완료될 때까지 쓰레드가 대기하는 것을 블로킹이라고 한다.
  • 주로 데이터 입출력 과정에서 블로킹이 발생한다.
  • 입출력 과정에서 블로킹이 발생하기 때문에 이런 방식을 블로킹 IO라고 한다.

컨텍스트 스위칭(Context Switching)

  • 운영체제는 여러 쓰레드를 번갈아가면서 CPU에 할당한다.
  • 한 쓰레드를 짧은 시간 동안 실행하고 다음 쓰레드를 짧은 시간 실행하는 식이다.
  • CPU가 쓰레드를 전환하려면 현재 실행 중인 쓰레드 상태를 기록하고 다음에 실행할 쓰레드 상태 정보를 불러와야 한다.
  • 이렇게 상태 정보를 변경하고 쓰레드를 전환하는 과정을 컨텍스트 스위칭이라고 한다.
  • 컨텍스트 스위칭은 MS 단위로 실행되지만 컨텍스트 스위칭을 하는 동안 CPU는 실질적인 작업을 하지 않는다.
  • 그래서 동시에 실행되는 프로세스와 쓰레드 수가 많으면 컨텍스트 스위칭에 소요되는 시간도 무시하기 힘들만큼 커질 수 있다.

정리하자면 트래픽이 증가하면 다음 2가지 이유로 자원 효율이 떨어지게 된다.

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

가상 쓰레드로 자원 효율 높이기

Thread[] threads = new Thread[100_000];
long start = System.currentTimeMillis();
for (int i = 0; i < threads.length; i++) { 
    // 가상 쓰레드는 Thread.ofVirtual()로 생성한다.
    Thread thread = Thread.ofPlatform().start(() -> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    threads[i] = thread;
}
long end = System.currentTimeMillis();
  • 가상 쓰레드는 플랫폼 쓰레드에 비해 훨씬 적은 비용(자원, 시간)이 들기 때문에 한 장비에서 수십만에서 백만 개에 이르는 가상 쓰레드를 생성할 수 있다.

Java의 가상 쓰레드란?

  • 가상 쓰레드와 고루틴은 경량 쓰레드(LightWeight)라는 공통점을 가진다.
  • 경량 쓰레드는 OS가 관리하는 쓰레드가 아니라 JVM같은 언어의 런타임이 관리하는 쓰레드이다.
  • 쓰레드는 기본적으로 최대 2MB의 스택 메모리 사이즈를 가지기 때문에, 컨텍스트 스위칭 시 메모리 이동량이 크다.
  • 또한 생성을 위해서 커널과 통신하여 스케줄링해야 하므로, 시스템 콜을 이용하기 때문에 생성 비용도 적지 않다.
  • 하지만 Virtual Thread는 JVM에 의해 생성되기 때문에 시스템 콜과 같은 커널 영역의 호출이 적고, 메모리 크기가 일반 쓰레드의 1%에 불과하다.
  • 따라서 Thread에 비해 컨텍스트 스위칭 비용이 적다.

Java의 가상 쓰레드 동작 원리

  1. 실행될 Virtual Thread의 작업인 runContinuationcarrier threadWork Queue에 push한다.
  2. Work Queue에 있는 runContinuation들은 ForkJoinPool에 의해 작업 훔치기 방식으로 carrier thread에 의해 처리된다.
  3. 처리되던 runContinuation들은 I/O, sleep으로 인한 Interrupt나 작업 완료 시, Work Queue에서 pop되어 park 과정에 의해 다시 힙 메모리로 되돌아간다.

네트워크 IO와 가상 쓰레드

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

블로킹 연산과 synchronized 키워드

  • 블로킹 연산에는 IO 기능, ReentrantLock, Thread.sleep() 등이 포함된다.
  • 이들 연산을 사용해서 가상 쓰레드가 블로킹되면 플랫폼 쓰레드는 대기 중인 다른 가상 쓰레드를 실행한다.
  • 반면에 자바 23 또는 이전 버전에서 synchronized로 인해 블로킹되면 가상 쓰레드는 플랫폼 쓰레드로부터 언마운트 되지 않는다.
  • 즉, 플랫폼 쓰레드도 같이 블로킹된다. 이렇게 가상 쓰레드가 플랫폼 쓰레드까지 블로킹할 때, 이를 가상 쓰레드가 플랫폼 쓰레드에 고정되었다고 말한다.
  • 자바 21 기준으로 synchronized 외에도 JNI 호출 등 가상 쓰레드가 플랫폼 쓰레드에 고정되는 경우가 있는데, 가상 쓰레드가 고정되면 CPU 효율을 높일 수 없다.

가상 쓰레드와 성능

  • IO 중심 작업과 CPU 중심 작업으로 나눌 수 있다.
  • 네트워크 프로그래밍처럼 입출력이 주를 이루는 작업은 IO 중심 작업에 해당하고 반대로 정렬처럼 계산이 주를 이루는 작업은 CPU 중심 작업에 해당한다.
  • 가상 쓰레드는 IO 중심 작업일 때 효과가 있다.
  • IO는 가상 쓰레드가 지원하는 블로킹 연산이므로 IO 중심 작업일 때, 플랫폼 쓰레드가 CPU 낭비 없이 효율적으로 여러 가상 쓰레드를 실행할 수 있다.
  • CPU 중심 작업에 가상 쓰레드를 사용하면 성능 개선 효과를 얻을 수 없다. 오히려 성능이 나빠질 수도 있다.

가상 쓰레드와 쓰레드 풀

  • 요청별 쓰레드 방식을 사용하는 서버는 쓰레드 풀을 사용할 때가 많다.
  • 미리 쓰레드를 생성해서 요청이 들어왔을 때 쓰레드 생성 부하를 줄이기 위함이다.
  • 또한 쓰레드 풀 크기에 최대치를 설정해서 요청이 급격히 늘어나도 쓰레드가 무한정 생성되는 것을 막는다.
  • CPU와 메모리 같은 자원을 일정 수준으로 제한해서 서버 자원이 포화되는 것을 방지하려는 목적이다.
  • 가상 쓰레드는 플랫폼 쓰레드보다 생성 비용이 적기 때문에 쓰레드 풀을 미리 구성할 필요가 없다. 필요한 시점에 가상 쓰레드를 생성하고 필요 없으면 제거하면 된다.

가상 쓰레드의 중요한 장점

  • 가상 쓰레드의 중요한 장점은 코드를 크게 수정할 필요가 없다는 것이다.
  • 스프링 프레임워크나 MySQL JDBC 드라이버같이 많이 사용하는 프레임워크와 라이브러리도 이미 가상 쓰레드를 지원하고 있다.

논블로킹 IO로 성능 높이기

  • 가상 쓰레드와 고루틴같은 경량 쓰레드를 사용하면 IO 중심 작업을 하는 서버의 처리량을 높일 수 있다.
  • 하지만 경량 쓰레드 자체도 메모리를 사용하고 스케줄링이 필요하다.
  • 경량 쓰레드가 많아질수록 더 많은 메모리를 사용하고 스케줄링에 더 많은 시간을 사용하게 된다.
  • 사용자가 폭발적으로 증가하면 어느 순간 경량 쓰레드로도 한계가 온다. 이 때는 서버의 IO 구현 방식을 구조적으로 변경해야하는데, 바로 논블로킹 IO를 사용하는 것이다.

논블로킹 IO 동작 개요

  • 논블로킹 IO는 입출력이 끝날 때까지 쓰레드가 대기하지 않는다.
  • channel.read() 코드는 데이터를 읽을 때까지 대기하지 않는다.
  • channel.read() 코드는 읽을 데이터가 없으면 바로 0을 리턴한다. 이는 데이터를 읽을 때까지 대기하는 블로킹 IO와는 동작 방식이 다르다.