한 줄 정의
서버 실행 시간의 대부분은 IO 대기이므로, 대기하는 스레드를 줄이는 것이 자원을 늘리지 않고 처리량을 올리는 길입니다.
쉽게 말하면
카페 직원 한 명이 주문을 받고, 커피 머신에 넣고, 다 될 때까지 머신 앞에 멀뚱히 서 있는다 고 해 봅시다. 손님이 늘면 직원을 더 뽑습니다. 그런데 직원마다 설 자리가 필요하고(메모리), 좁은 매장에서 서로 비켜 주느라 시간이 샙니다(컨텍스트 스위칭). 이게 요청당 스레드 방식의 한계입니다.
여기서 갈 수 있는 길이 두 갈래입니다.
| 해법 | 매장에서 벌어지는 일 |
|---|---|
| 가상 스레드 | 직원 수는 그대로 두고 “기다리는 동안 다음 주문을 받으라”고 시킵니다. 직원 매뉴얼(코드)은 손대지 않습니다 |
| 논블로킹 IO | 매뉴얼 자체를 바꿉니다. 손님에게 진동벨을 주고, 직원은 울린 벨만 골라 처리합니다 |
앞의 것은 값싸고 뒤의 것은 강력합니다. 이 장은 “언제 진동벨까지 가야 하는가”를 판단하는 이야기입니다.
왜 중요한가?
서버 프로그램은 본질적으로 네트워크 프로그램입니다. 클라이언트와는 HTTP로, DB와는 TCP 기반 프로토콜로, 레디스·외부 API와도 전부 네트워크로 데이터를 주고받습니다. 직접 소켓 코드를 짜지 않을 뿐이지 서버가 하는 일의 대부분은 IO입니다.
문제는 이 IO 대기 시간이 압도적 이라는 점입니다. 실측된 API 하나는 전체 800ms 중 CPU 사용 시간이 4ms였습니다. 나머지 99.5%가 쿼리 실행과 외부 API 호출을 기다리는 시간입니다. 3초 가까이 실행되는데 CPU는 5ms만 쓴 경우도 있습니다.
스레드가 대기한다는 것은 그 스레드를 실행할 CPU도 놀고 있다 는 뜻입니다. 서버를 증설하면 해결되지만 비용과 직결됩니다. 가상화 서버를 2대에서 4대로 늘리면 비용도 두 배입니다. 그래서 자원을 늘리는 대신 이미 있는 자원의 효율을 끌어올리는 선택지가 필요합니다.
핵심 내용
블로킹 IO가 자원을 낭비하는 구조
블로킹은 작업이 완료될 때까지 스레드가 대기하는 것을 말합니다. outputStream.write() 로 쿼리를 보내고 inputStream.read() 로 결과를 받는 동안, 코드를 실행하던 스레드는 아무 일도 하지 않고 멈춰 있습니다.
sequenceDiagram participant T as 스레드 participant DB T->>DB: write() 시작 Note over T: 대기 (데이터 전송) T-->>T: write() 리턴 · read() 시작 Note over T: 대기 (데이터 수신) DB-->>T: read() 리턴
CPU를 놀리지 않으려면 스레드를 많이 만들면 됩니다. 톰캣 같은 요청당 스레드(thread per request) 방식이 이 전략입니다. 하지만 두 벽에 부딪힙니다.
| 벽 | 내용 |
|---|---|
| 메모리 | 스레드 1개가 수백 KB~수 MB. 커넥션당 스레드 방식의 웹소켓 서버에 1만 명이 붙으면 스레드만으로 10G에 육박합니다 |
| 컨텍스트 스위칭 | 동시 실행 스레드가 늘수록 상태 저장·복원에 쓰는 시간이 커집니다. 이 시간 동안 CPU는 실질적인 작업을 하지 않습니다 |
정리하면 트래픽이 늘 때 자원 효율이 떨어지는 이유는 IO 대기와 컨텍스트 스위칭에 따른 CPU 낭비, 그리고 요청마다 스레드를 할당해서 생기는 높은 메모리 사용량 두 가지입니다.
그런데 왜 다들 톰캣을 쓰는가
대다수 서비스는 이 낭비를 걱정할 필요가 없습니다. CPU와 메모리에 영향을 줄 만큼 트래픽이 나오지 않기 때문입니다. 수백만~수천만 사용자 규모가 아니라면 성능 문제는 대개 자원 부족이 아니라 다른 원인에서 옵니다.
가상 스레드 — 코드를 그대로 두고 처리량 올리기
가상 스레드와 고루틴은 OS가 아니라 언어 런타임이 관리하는 경량 스레드 입니다. OS가 CPU에 실행할 스레드를 스케줄링하듯, JVM이 OS 스레드 위에서 실행할 경량 스레드를 스케줄링합니다.
동작 구조
JVM은 플랫폼 스레드(OS 스레드에 1-1로 대응하는 래퍼)로 구성된 풀을 유지하고, 그 위에서 여러 가상 스레드를 번갈아 실행합니다. 풀은 기본적으로 CPU 코어 개수만큼 만들어지고 필요에 따라 늘어나며, 최대값 기본 설정은 256입니다.
flowchart LR subgraph JVM subgraph P["스케줄러 풀"] P1["플랫폼 스레드 1"] P2["플랫폼 스레드 2"] end V1["가상 스레드 1"] V2["가상 스레드 2"] V3["가상 스레드 3"] V4["가상 스레드 4"] end OS1["OS 스레드"] --- P1 OS2["OS 스레드"] --- P2 P1 --> V1 P1 --> V2 P2 --> V3 P2 --> V4
핵심은 블로킹되는 순간의 처리 입니다. 가상 스레드가 IO 대기 같은 블로킹 연산을 만나면 플랫폼 스레드에서 언마운트되고 실행을 멈춥니다. 언마운트된 플랫폼 스레드는 그 블로킹이 끝나기를 기다리지 않고 실행 가능한 다른 가상 스레드를 찾아 연결합니다. 대기하던 CPU가 놀지 않게 되는 지점이 여기입니다.
캐리어 스레드와 마운트
가상 스레드를 실행하는 플랫폼 스레드를 캐리어(carrier) 스레드라고 부릅니다. 가상 스레드가 캐리어 스레드에 연결되는 것이 마운트(mount), 떨어지는 것이 언마운트(unmount)입니다.
언마운트되지 않는 경우 — pinned
블로킹 연산에는 IO,
ReentrantLock,Thread.sleep()이 포함되며 이들은 정상적으로 언마운트됩니다. 반면 자바 23 이전의synchronized로 블로킹되면 가상 스레드가 플랫폼 스레드에 고정(pinned) 되어 플랫폼 스레드까지 같이 멈춥니다. JNI 호출도 마찬가지입니다. 고정되면 CPU 효율을 얻을 수 없으므로 가상 스레드 도입 시 반드시 확인해야 할 지점입니다.
얼마나 가벼운가
| 플랫폼 스레드 1만 개 | 가상 스레드 1만 개 | |
|---|---|---|
| 메모리 | 스택 1MB × 10,000 = 약 9.8GB(예약 기준) | 힙 20MB + 캐리어 스레드 8개 스택 8MB = 28MB |
| 10만 개 생성·시작 시간 | 21,467ms | 196ms |
메모리는 300배 넘게, 생성 시간은 100배 넘게 차이가 납니다. 가상 스레드는 호출 스택 깊이에 따라 수백 바이트에서 수십 KB의 힙을 동적으로 늘렸다 줄이며 쓰기 때문입니다. 고루틴도 같은 방식입니다.
생성 비용이 이렇게 낮으므로 가상 스레드에는 스레드 풀이 필요 없습니다. 필요할 때 만들고 끝나면 버리면 됩니다. 요청당 스레드 서버가 풀을 두는 이유가 생성 부하 절감이었으니, 그 전제 자체가 사라집니다.
효과가 있는 조건
가상 스레드가 언제나 이득인 것은 아닙니다. 두 가지 조건이 모두 맞아야 합니다.
첫째, IO 중심 작업이어야 합니다. 가상 스레드가 이득을 보는 지점은 블로킹 연산에서의 언마운트인데, 썸네일 생성 같은 CPU 중심 작업에는 블로킹 연산 자체가 없습니다. 플랫폼 스레드는 계속 하나의 가상 스레드만 붙들고 있게 되므로, 가상 스레드를 아무리 많이 만들어도 동시 실행 효과가 없고 오히려 느려질 수 있습니다.
둘째, 가상 스레드 개수가 플랫폼 스레드 개수보다 많아야 합니다. 책의 예시가 이 함정을 잘 보여 줍니다.
- CPU 코어 16개 · 평소 TPS 500 · 요청당 20ms · 전부 IO 중심 작업
- 스레드 1개가 1초에 50개 요청을 처리하므로 초당 500개를 처리하려면 동시 10개면 충분
- 그런데 플랫폼 스레드는 기본으로 16개가 생기고, 실행할 가상 스레드는 10개뿐
플랫폼 스레드 16개 중 실제로 쓰이는 것이 10개도 안 됩니다. 이 상태에서는 가상 스레드의 이점이 전혀 나오지 않습니다. 이점을 얻으려면 CPU 코어 수를 줄이거나(16 → 4) 트래픽이 더 늘어야 합니다. 앞의 방향으로 가면 더 적은 비용으로 같은 트래픽을 처리하는 것이고, 뒤의 방향으로 가면 같은 자원으로 처리량을 10배 늘리는 것입니다.
한 가지 더 분명히 해 둘 것은, 가상 스레드가 높이는 것은 처리량이지 실행 속도가 아니라는 점 입니다. 결국 실행하는 것은 같은 CPU이므로 개별 요청이 빨라지지는 않습니다.
진짜 장점은 기존 코드를 안 바꿔도 된다는 것
스프링이나 MySQL JDBC 드라이버처럼 많이 쓰는 프레임워크와 라이브러리는 이미 가상 스레드를 지원합니다. 블로킹 IO로 짜인 기존 코드를 그대로 두고 스레드 생성 방식만 바꾸는 것으로 자원 효율을 올릴 수 있습니다. 성능을 얻기 위해 별도의 기술을 익히거나 코드를 뒤엎지 않아도 된다는 것이 가상 스레드의 가장 큰 실무적 가치입니다.
논블로킹 IO — 구조를 바꿔서 한계를 넘기
경량 스레드도 결국 메모리를 쓰고 스케줄링이 필요합니다. 개수가 늘수록 메모리와 스케줄링 시간이 함께 늘어나므로, 사용자가 폭발적으로 증가하면 여기에도 한계가 옵니다. 그 지점에서는 IO 구현 방식 자체를 바꿔야 합니다.
폴링이 아니라 “가능한 연산만 골라 처리”
논블로킹 IO에서 channel.read() 는 읽을 데이터가 없으면 대기하지 않고 바로 0을 리턴합니다. 그렇다고 while 루프로 계속 읽기를 시도하면 데이터가 없어도 루프가 무한히 도므로 CPU 낭비가 심합니다.
실제 구현은 반대 방향입니다. 먼저 실행 가능한 IO 연산 목록을 구하고, 그 목록만 순회하며 처리합니다. 자바에서는 Selector 가 이 역할을 합니다.
Selector selector = Selector.open();
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress(7031));
serverSocket.configureBlocking(false); // 서버 소켓 비동기 설정
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 가능한 IO 연산이 있을 때까지 대기
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
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()) { // 읽기 연산 가능하면
// channel.read(inBuffer) → 처리 → channel.write(outBuffer)
}
}
}Selector#select() 는 처리 가능한 연산이 존재할 때까지 대기하고, 리턴하면 selectedKeys() 로 그 목록을 얻습니다. 각 SelectionKey 를 보고 어떤 연산이 가능한지 확인해 해당 연산만 수행합니다.
블로킹 IO와의 차이는 스레드 개수에서 갈립니다. 블로킹 IO는 동시 연결 1,000개면 스레드도 1,000개를 만들지만, 논블로킹 IO는 클라이언트 수와 무관하게 소수의 스레드만 씁니다. 위 코드는 스레드 1개로 여러 클라이언트의 요청을 처리합니다. 연결이 늘어도 스레드 개수가 일정하므로 같은 메모리로 훨씬 많은 연결을 감당할 수 있습니다.
IO 멀티플렉싱(multiplexing)
단일 이벤트 루프에서 여러 IO 작업을 처리하는 개념입니다. 논블로킹 IO와 Selector를 이용한 입출력 처리가 여기에 해당하며, OS에 따라 epoll(리눅스)이나 IOCP(윈도우)로 구현됩니다.
다만 스레드 1개면 동시성이 떨어집니다. 두 채널에 읽기 연산이 가능해도 한 번에 한 채널만 처리되기 때문입니다. 그래서 실제로는 채널을 N개 그룹으로 나누고 그룹마다 스레드를 할당 합니다. 보통 CPU 개수만큼 그룹을 나눕니다.
리액터 패턴
앞의 코드에서 SelectionKey 를 이벤트로 바꿔 읽으면 그대로 리액터(reactor) 패턴 입니다. 리액터는 이벤트가 발생할 때까지 대기하다 알맞은 핸들러에 전달하고, 핸들러가 로직을 수행합니다.
while (isRunning) {
List<Event> events = getEvents(); // 이벤트가 발생할 때까지 대기
for (Event event : events) {
Handler handler = getHandler(event);
handler.handle(event);
}
}이벤트를 대기하고 핸들러에 전달하는 과정을 반복하므로 리액터를 이벤트 루프(event loop) 라고도 부릅니다. Netty, Nginx, Node.js가 모두 이 패턴을 씁니다.
이벤트 루프는 단일 스레드로 실행되므로 두 가지 한계가 따라옵니다. 멀티 코어 서버에서 처리량을 다 내지 못하고, 핸들러에서 CPU 연산이나 블로킹 연산을 수행하면 그 시간만큼 전체 이벤트 처리가 밀립니다. Netty가 이벤트 루프를 여러 개 만들어 멀티 코어를 활용하고, Node.js가 별도 스레드 풀을 두는 이유가 이것입니다.
프레임워크를 쓰는 이유
줄 단위로 데이터를 받는 서버를 생각해 봅시다. 블로킹 IO라면 BufferedReader.readLine() 한 줄이면 끝납니다. 논블로킹 IO에서는 읽은 데이터에 개행 문자가 있는지 확인하고, 없으면 별도 버퍼에 계속 누적하고, 여러 개 있으면 그것도 처리하고, 채널마다 누적 버퍼를 관리해야 합니다.
이런 저수준 코드를 직접 들고 있으면 주고받는 데이터 형식이 조금만 바뀌어도 IO 처리 코드를 고쳐야 합니다. 그래서 논블로킹 IO API를 직접 쓰기보다 리액터 네티 같은 프레임워크를 쓰는 편이 낫습니다.
DisposableServer server = TcpServer.create()
.port(7031)
.doOnConnection(conn ->
conn.addHandlerFirst(new LineBasedFrameDecoder(1024)) // 줄 단위 읽기 처리
)
.handle((in, out) -> in.receive()
.asString()
.flatMap(line -> out.sendString(Mono.just(line + "\n"))))
.bindNow();리액티브 API(스프링 리액터)를 익혀야 한다는 비용은 있지만, 한번 익숙해지면 논블로킹 IO API를 직접 다루는 것보다 훨씬 간단한 코드로 같은 결과를 냅니다.
실제 성능 차이
| 비교 | 조건 | 결과 |
|---|---|---|
| 블로킹 IO vs 논블로킹 IO (자바 푸시 서버) | JVM 힙 1.5G | 최대 동접 6,000 → 120,000 (약 20배) |
| 고루틴 vs gnet (Go 서버) | 메모리 500MB | 최대 동접 20,000 → 180,000 (약 9배) |
경량 스레드와 논블로킹 IO 사이에도 이만큼의 차이가 있다는 점이 중요합니다. 가상 스레드가 요청당 스레드의 대안이라면, 논블로킹 IO는 경량 스레드마저 한계에 부딪혔을 때의 다음 카드입니다.
언제 어떤 방법을 택할까
성능이라는 단어는 개발자를 자극합니다. 하지만 논블로킹 IO나 가상 스레드를 적용하기 전에 세 가지를 순서대로 따져야 합니다.
- 문제가 있는가? 성능 문제가 없거나 당분간 트래픽 증가 가능성이 없다면 검토할 필요가 없습니다. 논블로킹/비동기 방식은 코드를 복잡하게 만들고 유지보수 난이도를 올리므로, 단순한 호기심으로 구현을 바꾸는 것은 시간 낭비입니다.
- 그 문제가 네트워크 IO 관련 자원 문제인가? 트래픽은 그대로인데 DB 쿼리가 느려져 응답 시간이 길어진 것이라면 가상 스레드도 논블로킹 IO도 응답 시간을 줄이지 못합니다. 쿼리 최적화나 캐시가 답입니다. 썸네일 생성 같은 CPU 중심 작업도 마찬가지입니다.
- 구현 변경이 가능한가? 신기능 개발에 인력이 몰려 있어 성능 개선에 쓸 손이 없거나, 팀이 해당 기술에 익숙하지 않다면 구현을 바꾸는 대신 서버 확장으로 우선 막고 여유가 생겼을 때 개선하는 편이 현실적입니다.
처음부터 적용하지 않습니다
성능을 높이겠다고 처음부터 비동기 IO로 개발하거나 가상 스레드를 적용하지 않습니다. 실제로 IO 성능을 높여야 할 만큼 트래픽이 증가하고 있거나, 예상 트래픽이 높은 경우에만 적용 여부를 고민합니다.
비교 / 트레이드오프
| 요청당 스레드(블로킹) | 가상 스레드 | 논블로킹/비동기 IO | |
|---|---|---|---|
| 스레드 수 | 동시 요청 수에 비례 | 캐리어 스레드는 CPU 코어 수준 | 클라이언트 수와 무관하게 소수 |
| 기존 코드 변경 | — | 거의 없음 | 구조적 재작성 |
| 코드 복잡도 | 낮음 | 낮음(그대로) | 높음 — 프레임워크로 완화 |
| 한계 | 메모리 · 컨텍스트 스위칭 | 경량 스레드도 메모리·스케줄링 비용 존재 | 이벤트 루프에서 블로킹하면 전체가 밀림 |
| 도입 판단 | 기본값 | 트래픽이 늘고 IO 중심일 때 | 경량 스레드로도 감당이 안 될 때 |
세 방식은 우열 관계가 아니라 적용 순서 입니다. 대다수 서비스는 첫 번째에서 끝나고, 트래픽이 붙으면 두 번째가 코드 변경 없이 붙으며, 그마저 부족한 규모에서만 세 번째로 넘어갑니다.
가상 스레드와 논블로킹 IO의 근본적 차이는 누가 대기를 관리하는가 입니다. 가상 스레드는 대기를 런타임에 위임해 개발자에게 블로킹 코드의 겉모습을 그대로 남겨 주고, 논블로킹 IO는 대기를 이벤트 루프로 끌어올려 개발자가 직접 다루게 합니다. 코드 복잡도의 차이는 전부 여기서 나옵니다.
내 생각
- “CPU 4ms / 전체 800ms”라는 숫자가 이 장의 전부입니다. 서버 코드를 튜닝할 때 CPU 프로파일링부터 붙잡는 습관이 있는데, 실제로 갈아 낼 시간은 거의 항상 IO 대기 쪽에 있으므로 어디를 볼지부터 먼저 정해야 합니다.
- 가상 스레드는 “성능 기술”이 아니라 “비용 절감 기술”에 가깝습니다. 처리량만 오르고 응답 시간은 그대로이므로, 도입 근거를 “빨라집니다”가 아니라 “같은 트래픽을 더 적은 인스턴스로 받습니다”로 세워야 설득도 검증도 명확해집니다.
synchronized로 인한 pinning은 도입 전 반드시 확인해야 할 항목입니다. 애플리케이션 코드보다 오래된 라이브러리 내부에 숨어 있을 때가 많고, 그 경우 가상 스레드를 켜도 효과가 나오지 않는데 원인은 프로파일링 없이 보이지 않습니다.- CPU 코어 16개 예시는 “가상 스레드를 켰는데 왜 그대로지?”의 답을 미리 알려 줍니다. 플랫폼 스레드보다 가상 스레드가 많아야 한다는 조건은 흔히 간과되는데, 이걸 모르면 효과 없는 도입을 두고 잘못된 결론을 내리게 됩니다.
- 논블로킹 IO는 성능이 아니라 유지보수 비용으로 판단해야 합니다. 20배라는 숫자는 매력적이지만 데이터 형식이 바뀔 때마다 저수준 IO 코드를 고쳐야 하는 부담이 따라오므로, 직접 구현보다 리액터 네티 같은 프레임워크를 전제로 검토하는 게 현실적입니다.
- “문제가 있는가”를 첫 질문으로 두는 판단 순서가 기술 선택의 기본형입니다. 가상 스레드나 논블로킹 IO뿐 아니라 캐시·샤딩·이벤트 기반 아키텍처까지, 문제 없이 도입한 기술은 예외 없이 유지보수 부채로 돌아옵니다.
관련 개념
- Ch02 느려진 서비스, 어디부터 봐야 할까 — 수평·수직 확장과 성능 문제 진단
- Ch03 성능을 좌우하는 DB 설계와 쿼리 — IO 대기의 주요 원인인 쿼리 최적화
- Ch04 외부 연동이 문제일 때 살펴봐야 할 것들 — 외부 API 호출과 응답 대기
- Ch06 동시성, 데이터가 꼬이기 전에 잡아야 한다 — 잠금과 단일 스레드 처리