목차
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에 의해 ‘깨어나는’ 구조임.
- 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)이 더 합리적인 선택일 수 있음.