[백엔드] I/O 병목 처리 관련 필수 지식

목차

1. 네트워크 I/O와 자원 효율성

1. 서버 아키텍처와 스레드 모델

  • 대부분의 서버 애플리케이션은 네트워크 통신을 기반으로 동작함.
    • HTTP 프로토콜을 이용한 클라이언트와의 통신.
    • TCP 기반 프로토콜을 이용한 데이터베이스(DB)와의 통신.
  • 네트워크 통신 로직을 로우 레벨(Low-level) 관점에서 살펴보면, 결국 다음 두 가지 연산으로 요약됨.
    • outputStream.write(...): 데이터를 송신하는 출력 연산.
    • inputStream.read(...): 데이터를 수신하는 입력 연산.
  • 핵심적인 문제는 스레드가 I/O 작업(write/read)의 시작부터 완료 시점까지 Blocking 상태로 대기한다는 점임.
    • 이로 인해 네트워크 통신 과정에서 CPU가 유휴 상태로 대기하는 시간이 필연적으로 발생함 (전체 트랜잭션 시간의 90% 이상이 I/O 대기 시간인 경우도 빈번함).
  • 대기 중인 스레드로 인한 CPU 유휴 자원을 활용하기 위해, 요청마다 스레드를 할당하여 문맥 교환(Context Switching)을 통해 CPU 가동률을 높이는 방식이 사용됨.
    • 이를 요청당 스레드(thread per request) 모델이라 칭함.
    • Java 진영의 대표적인 웹 컨테이너인 Tomcat(Spring Boot 내장) 역시 기본적으로 이 방식을 채택하고 있음.

2. 기존 스레드 모델의 비용과 한계

  • ‘요청당 스레드’ 방식의 결정적인 문제는 스레드 생성 및 유지 비용이 매우 높다는 점임.
  • 스레드 하나당 수 KB에서 수 MB의 메모리를 점유하므로, 동시 접속 요청이 1만 개에 달할 경우 스레드 생성만으로 약 10GB 이상의 메모리가 소모될 수 있음.
  • 또한 활성 스레드 수가 증가할수록 컨텍스트 스위칭(Context Switching) 빈도가 높아져, 오히려 CPU 처리 효율이 저하되는 오버헤드가 발생함.
  • 그럼에도 불구하고 대다수의 서버가 이 방식을 고수해 온 이유는 무엇일까?
    • 냉정하게 말해, 동시 접속 1만 명을 감당해야 하는 대규모 서비스가 흔치 않기 때문임.
    • 즉, 대부분의 서비스는 하드웨어 리소스 낭비를 감수하더라도 운영이 가능한 트래픽 규모를 가짐.

3. 스레드는 왜 무거울까? (구조적 원인)

  • 운영체제(OS) 수준의 스레드가 무거운 이유는 크게 두 가지 구조적 요인에 기인함.
  • 1. 고정 크기의 스택(Fixed-size Stack) 할당
    • 각 스레드는 생성 시 함수 호출 스택 및 지역 변수 저장을 위한 메모리 공간을 할당받음.
    • 실제 사용량과 무관하게 고정된 크기(Windows 기준 통상 1MB 이상)가 예약되므로 메모리 비효율이 발생함.
  • 2. 커널 리소스와 연동된 TCB (Thread Control Block)
    • 스레드 생성 시 커널 영역에 해당 스레드를 관리하기 위한 TCB가 생성됨.
    • TCB는 실행 상태, 스케줄링 정보, CPU 레지스터 컨텍스트 등을 관리하며, 이를 위해 전용 커널 스택과 메타데이터(우선순위, 프로그램 카운터 등)를 필요로 함.
    • 스레드 개수가 증가할수록 커널 리소스 사용량이 선형적으로 증가하여 시스템 전체의 부하를 가중시킴.

2. 가상 스레드 (Virtual Thread)

1. 플랫폼 스레드 vs 가상 스레드

flowchart LR
    %% OS Threads
    OS1[OS 스레드]
    OS2[OS 스레드]
    OS3[OS 스레드]

    %% JVM boundary
    subgraph JVM
        direction LR

        %% Scheduler Pool
        subgraph SCHEDULER[스케줄러 풀]
            direction TB
            PT1[플랫폼 스레드 1]
            PT2[플랫폼 스레드 2]
            PT3[플랫폼 스레드 3]
        end

        %% Virtual Threads
        VT1[가상 스레드 1]
        VT2[가상 스레드 2]
        VT3[가상 스레드 3]
        VT4[가상 스레드 4]
        VT5[가상 스레드 5]
    end

    %% OS → Platform Threads
    OS1 --> PT1
    OS2 --> PT2
    OS3 --> PT3

    %% Platform Threads → Virtual Threads
    PT1 --> VT1
    PT1 --> VT2

    PT2 --> VT3

    PT3 --> VT4
    PT3 --> VT5
  • 플랫폼 스레드 (Platform Thread)
    • OS 스레드와 1:1로 매핑되는 전통적인 JVM 스레드임.
    • 가상 스레드의 실행을 담당하며, 실제 CPU 작업을 수행하는 주체임.
    • 가상 스레드를 실어 나른다는 의미에서 캐리어 스레드(Carrier Thread)라고도 불림.
  • 가상 스레드 (Virtual Thread)
    • JVM 내부에서 생성 및 관리되는 경량 논리적 스레드임.
    • OS 스레드를 독점하지 않으며, 메모리 사용량은 수백 바이트에서 수 KB 수준으로 매우 적음.
    • JVM 스케줄러가 플랫폼 스레드 위에서 M:N 방식으로 가상 스레드를 스케줄링함.
  • 가상 스레드의 경량화 원리
    • 가변 스택(Resizable Stack): OS 스레드처럼 거대한 고정 메모리를 예약하지 않음. 실행에 필요한 스택 프레임만큼만 Heap 메모리에 동적으로 저장하여 메모리 효율을 극대화함.
    • 커널 리소스 미사용: 커널 스택이나 TCB를 생성하지 않고, JVM 내부 객체로만 관리됨. 따라서 커널 모드 진입이 필요한 컨텍스트 스위칭 비용이 발생하지 않음.
  • 동작 방식: Mount & Unmount
    • 가상 스레드가 실행될 때 플랫폼 스레드에 마운트(Mount)되어 CPU를 점유함.
    • I/O 대기(Blocking)가 발생하면 즉시 언마운트(Unmount)되어 플랫폼 스레드에서 분리됨 (연결을 끊음).
    • I/O 작업이 완료되면 다시 유휴 상태의 플랫폼 스레드에 마운트되어 작업을 재개함. 결과적으로 스레드 블로킹 비용이 사실상 제거됨.

2. Non-blocking I/O 처리 메커니즘

  • 가상 스레드는 I/O 대기 시 플랫폼 스레드를 점유하지 않음.
  • 가상 스레드 자체가 I/O 완료를 능동적으로 확인하는 것이 아니라, JVM과 OS의 협력을 통해 이벤트 기반으로 깨어나는 방식임.
sequenceDiagram
    participant VT as 가상 스레드
    participant PT as 플랫폼 스레드
    participant OS as OS I/O 서브시스템
    participant JVM as JVM 이벤트 처리기

    %% I/O 요청
    VT ->> PT: 실행 중 (mount)
    VT ->> OS: I/O 요청
    note right of VT: 블로킹 I/O 호출

    %% 언마운트
    PT -->> VT: unmount
    note right of PT: 플랫폼 스레드 반환
    note right of VT: 실행 상태를 힙에 저장\n(Continuation)

    %% OS가 I/O 대기
    OS ->> OS: I/O 대기 및 감시

    %% I/O 완료
    OS -->> JVM: I/O 완료 이벤트\n(epoll / kqueue / IOCP 등)

    %% JVM이 가상 스레드 식별
    JVM ->> JVM: 이벤트 ↔ 가상 스레드 매핑
    JVM -->> VT: Runnable 상태로 전환

    %% 재마운트
    JVM ->> PT: 빈 플랫폼 스레드 할당
    VT ->> PT: mount
    note right of PT: 중단 지점부터 실행 재개

실제 동작 방식 (Step-by-Step)

  • (1) I/O 요청 발생
    • 가상 스레드가 파일 읽기, 소켓 통신 등 I/O 요청을 수행함.
    • JVM은 이를 감지하고 해당 작업을 블로킹 I/O로 인식함.
    • 가상 스레드는 현재 실행 상태(Continuation)를 힙 메모리에 저장하고, 플랫폼 스레드에서 언마운트됨.
  • (2) I/O 요청 등록
    • 요청된 I/O 작업은 OS의 비동기 I/O 서브시스템에 등록됨.
    • 이후 데이터 수신 대기 및 감시는 전적으로 OS가 담당함 (JVM 스레드는 해방됨).
  • (3) I/O 완료 시 OS 이벤트 발행
    • I/O 작업이 완료되면 OS는 즉시 완료 이벤트를 발행함.
    • 시스템에 따라 epoll(Linux), kqueue(macOS/BSD), IOCP(Windows) 등의 메커니즘이 사용됨.
      • 파일 디스크 완료 인터럽트.
      • 소켓 readable / writable 이벤트.
  • (4) JVM이 이벤트를 감지
    • JVM 내부의 이벤트 처리기(Poller)가 OS로부터 완료 이벤트를 수신함.
    • 내부 매핑 정보를 조회하여, 해당 I/O 결과를 기다리던 가상 스레드를 식별함.
  • (5) 가상 스레드 재개 (Resume)
    • JVM은 대기 중이던 가상 스레드의 상태를 Runnable로 전환하고 스케줄링 큐에 등록함.
    • 가용 가능한 플랫폼 스레드가 확보되면, 가상 스레드가 마운트되어 중단되었던 지점부터 실행을 재개함.
    • 즉, 가상 스레드는 OS와 JVM에 의해 ‘깨어나는’ 구조임.

비유를 통한 이해

  • 가상 스레드: 전화기를 끄고 잠든 사람 (리소스 소모 없음).
  • OS: 전화가 오면 신호를 보내주는 전화국 교환원.
  • JVM: 신호를 받고 정확한 사람을 깨워주는 비서.

3. 성능 특성 및 효율성

  • 가상 스레드는 CPU 연산 위주의 작업(CPU-bound)에는 큰 이점이 없으나, I/O 대기 시간이 긴 작업(I/O-bound)에서 압도적인 효율을 발휘함.
  • 적합한 사용 사례:
    • 고동시성 웹 서버 요청 처리 (HTTP).
    • MSA 환경에서의 잦은 RPC / REST API 호출.
    • 데이터베이스(DB), Redis 등의 외부 저장소 연동.
    • 메시지 큐(Kafka, RabbitMQ) 컨슈머 처리.

4. 실무 도입 전략 및 가이드

  • 서비스의 트래픽 규모가 크지 않고 현재 성능에 문제가 없다면, 굳이 가상 스레드를 도입할 필요는 없음 (Over-engineering 주의).
  • 성능 이슈가 발생했다면, 원인이 ‘네트워크 I/O 대기’에 있는지 명확히 진단해야 함.
    • 단순히 슬로우 쿼리로 인해 DB 응답이 느린 것이라면, 웹 서버에 가상 스레드를 적용해도 성능 향상은 없음.
  • 코드 변경 및 아키텍처 수정 비용을 고려해야 함.
    • 팀 리소스가 부족하거나 일정이 촉박한 경우, 가상 스레드 도입보다는 서버 수평 확장(Scale-out)이 더 합리적인 선택일 수 있음.

이 글은 어떠셨나요? 자유롭게 의견을 남겨주세요! 💬