싱글스레드 서버를 멀티스레드 서버로 바꾸기
느린 요청이 전체 서버를 막는 문제를 재현하고 제한된 Worker와 채널로 ThreadPool을 구현해 요청을 병렬 처리합니다.
지금의 서버는 요청을 하나씩 차례로 처리하므로, 첫 번째 연결의 처리가 끝날 때까지 두 번째 연결은 처리되지 않습니다. 서버가 받는 요청이 늘어날수록 이렇게 하나씩 실행하는 방식은 점점 나빠집니다. 서버가 처리하는 데 오래 걸리는 요청을 받으면, 뒤따르는 요청은 새 요청을 빨리 처리할 수 있더라도 그 긴 요청이 끝날 때까지 기다려야 합니다. 이것을 고쳐야 하지만, 먼저 문제를 직접 봅니다.
현재의 서버 구현에서 느린 요청 시뮬레이션
지금의 서버 구현에서 처리가 느린 요청이 다른 요청에 어떤 영향을 줄 수 있는지 봅니다. 다음 코드는 서버가 응답하기 전에 5초 동안 잠들게 하는 흉내 낸 느린 응답으로 /sleep 요청을 처리합니다.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
// --생략--
fn handle_connection(mut stream: TcpStream) {
// --생략--
let (status_line, filename) = match &request_line[..] {
"GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
"GET /sleep HTTP/1.1" => {
thread::sleep(Duration::from_secs(5));
("HTTP/1.1 200 OK", "hello.html")
}
_ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
};
// --생략--
}경우가 셋이 되었으므로 if를 match로 바꿨습니다. 문자열 리터럴 값과 패턴을 맞추려면 request_line의 슬라이스에 명시적으로 매칭해야 합니다. match는 등호 메서드처럼 자동으로 참조와 역참조를 하지 않기 때문입니다.
첫 갈래는 앞의 코드의 if 블록과 같습니다. 둘째 갈래는 /sleep 요청에 매칭합니다. 그 요청을 받으면 서버는 5초 동안 잠든 뒤 성공 HTML 페이지를 그립니다. 셋째 갈래는 앞의 코드의 else 블록과 같습니다.
서버가 얼마나 원시적인지 볼 수 있습니다. 실제 라이브러리라면 여러 요청을 훨씬 덜 장황한 방식으로 알아볼 것입니다.
cargo run으로 서버를 시작합니다. 그다음 브라우저 창을 두 개 엽니다. 하나는 http://127.0.0.1:7878을, 다른 하나는 http://127.0.0.1:7878/sleep을 엽니다. 앞에서처럼 / URI를 몇 번 입력하면 빨리 응답하는 것을 볼 수 있습니다. 하지만 /sleep을 입력하고 /를 불러오면, /가 불러와지기 전에 sleep이 5초 동안 잠드는 것이 끝날 때까지 기다리는 것을 볼 수 있습니다.
느린 요청 뒤에 요청이 밀리는 것을 피하는 데 쓸 수 있는 기법은 여러 가지이며, 15장에서 쓴 async도 그 가운데 하나입니다. 여기서 구현할 것은 스레드 풀입니다.
스레드 풀로 처리량 개선하기
스레드 풀(thread pool)은 작업을 처리할 준비를 하고 기다리는, 만들어 둔 스레드의 무리입니다. 프로그램이 새 작업을 받으면, 풀에 있는 스레드 가운데 하나에 그 작업을 맡기고, 그 스레드가 작업을 처리합니다. 풀의 남은 스레드는 첫 스레드가 처리하는 동안 들어오는 다른 작업을 처리할 수 있습니다. 첫 스레드가 작업 처리를 마치면, 쉬고 있는 스레드의 풀로 돌아가 새 작업을 처리할 준비를 합니다. 스레드 풀로 연결을 동시에 처리할 수 있어, 서버의 처리량이 늘어납니다.
서비스 거부(Denial of Service, DoS) 공격으로부터 지키기 위해 풀의 스레드 수는 적은 수로 제한합니다. 요청이 들어올 때마다 새 스레드를 만들게 했다면, 서버에 1,000만 개의 요청을 보내는 누군가가 서버의 모든 자원을 다 써 버려 요청의 처리를 멈추게 해 서버를 망가뜨릴 수 있을 것입니다.
그래서 스레드의 수를 제한하지 않고 만드는 대신, 정해진 수의 스레드가 풀에서 기다리게 합니다. 들어오는 요청은 처리를 위해 풀로 보내집니다. 풀은 들어오는 요청의 대기열(queue)을 관리합니다. 풀의 각 스레드는 이 대기열에서 요청을 꺼내 처리하고, 대기열에 다른 요청을 달라고 합니다. 이 설계로 N이 스레드의 수일 때 최대 N개의 요청을 동시에 처리할 수 있습니다. 각 스레드가 오래 실행되는 요청에 응답하고 있다면 뒤의 요청은 여전히 대기열에 밀릴 수 있지만, 그 지점에 이르기 전에 처리할 수 있는 오래 실행되는 요청의 수를 늘렸습니다. 아래 그림은 연결마다 스레드를 만드는 방식과 스레드 풀 방식을 나란히 비교한 것입니다.
요청별 스레드와 스레드 풀 경계
연결마다 spawn 하는 방식과 ThreadPool::new(4)의 고정 워커 재사용을 요청 유입·바쁜 워커·대기 작업 상태로 비교할 수 있다. 4는 실행 작업 상한이며 무제한 mpsc 대기열의 메모리 상한이 아니라는 조건을 보존한다.
RUST · 실행 방식 비교
연결마다 스레드를 만들거나, 네 워커를 재사용한다
같은 연결을 처리해도 스레드 생성 수와 작업 대기 위치가 달라집니다.
ThreadPool::new(0)은 panic이다. 이 예제의 queue는 별도 backpressure·용량 제한을 구현하지 않는다.
코드·조건·비교표와 보충 설명
연결마다 thread::spawn
새 연결을 받는다
새 TcpStream을 handler 클로저에 담습니다.
새 OS 스레드에서 처리한다
연결마다 스레드를 생성합니다. 동시에 살아 있는 스레드 수에 고정된 상한을 두지 않습니다.
ThreadPool::new(4)
execute에 작업을 보낸다
구현될 execute는 작업을 채널 대기열에 넣습니다.
네 워커 중 하나가 처리한다
워커가 작업을 받고 실행한 뒤 다음 작업을 받습니다. 실행 중인 작업은 최대 네 개이며, 바쁜 동안 나머지 작업은 대기합니다.
워커 수와 대기열 크기는 별개다
뒤에서 구현하는 mpsc::channel()은 무제한 버퍼입니다. 워커를 네 개로 제한해도 대기 작업과 연결 자원의 메모리까지 제한되지는 않습니다. ThreadPool::new(0)은 생성자 구현에서 패닉으로 거부합니다.
이 절은 사용하고 싶은 API를 먼저 정합니다. 이어지는 Worker·채널 구현에서 실제 작업 전달을 완성합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 연결 유입 두 독립 실행 방식에 새 연결이 들어온다. |
|
|
| 바쁜 실행 자원 풀은 최대 네 job을 실행하고 나머지를 대기시킨다. |
|
|
| 워커 재사용 job을 마친 워커가 다음 job을 받는다. |
|
|
요청별 스레드와 스레드 풀 경계
연결마다 spawn 하는 방식과 ThreadPool::new(4)의 고정 워커 재사용을 요청 유입·바쁜 워커·대기 작업 상태로 비교할 수 있다. 4는 실행 작업 상한이며 무제한 mpsc 대기열의 메모리 상한이 아니라는 조건을 보존한다.
RUST · 실행 방식 비교
연결마다 스레드를 만들거나, 네 워커를 재사용한다
같은 연결을 처리해도 스레드 생성 수와 작업 대기 위치가 달라집니다.
ThreadPool::new(0)은 panic이다. 이 예제의 queue는 별도 backpressure·용량 제한을 구현하지 않는다.
코드·조건·비교표와 보충 설명
연결마다 thread::spawn
새 연결을 받는다
새 TcpStream을 handler 클로저에 담습니다.
새 OS 스레드에서 처리한다
연결마다 스레드를 생성합니다. 동시에 살아 있는 스레드 수에 고정된 상한을 두지 않습니다.
ThreadPool::new(4)
execute에 작업을 보낸다
구현될 execute는 작업을 채널 대기열에 넣습니다.
네 워커 중 하나가 처리한다
워커가 작업을 받고 실행한 뒤 다음 작업을 받습니다. 실행 중인 작업은 최대 네 개이며, 바쁜 동안 나머지 작업은 대기합니다.
워커 수와 대기열 크기는 별개다
뒤에서 구현하는 mpsc::channel()은 무제한 버퍼입니다. 워커를 네 개로 제한해도 대기 작업과 연결 자원의 메모리까지 제한되지는 않습니다. ThreadPool::new(0)은 생성자 구현에서 패닉으로 거부합니다.
이 절은 사용하고 싶은 API를 먼저 정합니다. 이어지는 Worker·채널 구현에서 실제 작업 전달을 완성합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 연결 유입 두 독립 실행 방식에 새 연결이 들어온다. |
|
|
| 바쁜 실행 자원 풀은 최대 네 job을 실행하고 나머지를 대기시킨다. |
|
|
| 워커 재사용 job을 마친 워커가 다음 job을 받는다. |
|
|
두 방식은 같은 연결이 들어올 때의 서로 다른 정책입니다. 연결마다 thread::spawn을 쓰면 새 연결의 TcpStream을 처리 클로저에 담아 새 운영체제 스레드에서 처리하므로, 연결이 늘어나는 만큼 스레드도 늘어나며 동시에 살아 있는 스레드의 수에 정해진 상한이 없습니다. ThreadPool::new(4)로 만든 풀에서는 execute가 작업을 채널의 대기열에 넣고, 네 워커 가운데 하나가 작업을 받아 실행한 뒤 다음 작업을 받습니다. 실행 중인 작업은 많아야 네 개이고, 워커가 모두 바쁜 동안 나머지 작업은 기다립니다. 다만 뒤에서 구현할 mpsc::channel()은 크기 제한이 없는 버퍼이므로, 워커를 네 개로 제한해도 기다리는 작업과 연결이 차지하는 메모리까지 제한되지는 않습니다. ThreadPool::new(0)은 생성 함수에서 패닉으로 거부합니다.
이 기법은 웹 서버의 처리량을 높이는 여러 방법 가운데 하나일 뿐입니다. 다른 방법으로는 포크/조인(fork/join) 모델, 단일 스레드 비동기 I/O 모델, 멀티스레드 비동기 I/O 모델 같은 것이 있습니다. 이 주제에 관심이 있다면 다른 해결책을 더 찾아보고 구현해 볼 수 있습니다. 러스트 같은 저수준 언어에서는 이 방법들을 모두 쓸 수 있습니다.
스레드 풀을 구현하기 전에, 풀을 쓰는 모습이 어떠해야 하는지 이야기합니다. 코드를 설계하려 할 때는 클라이언트 인터페이스를 먼저 쓰는 것이 설계를 이끄는 데 도움이 될 수 있습니다. 코드의 API를 부르고 싶은 방식으로 구조화되도록 쓰고, 그 구조 안에서 기능을 구현합니다. 기능을 구현한 다음 공개 API를 설계하는 것과 반대입니다.
테스트 주도 라이브러리 개발에서 테스트 주도 개발을 썼던 것과 비슷하게, 여기서는 컴파일러 주도 개발(compiler-driven development)을 씁니다. 원하는 함수를 부르는 코드를 쓰고, 그다음 컴파일러의 오류를 보며 코드가 동작하려면 다음에 무엇을 바꿔야 할지 정합니다. 하지만 그전에, 출발점으로 쓰지 않을 기법을 먼저 봅니다.
각 요청마다 스레드 생성하기
먼저 모든 연결에 새 스레드를 만든다면 코드가 어떻게 보일지 봅니다. 앞에서 말했듯 스레드의 수를 제한하지 않고 만들 수 있다는 문제 때문에 이것은 최종 계획이 아니지만, 먼저 동작하는 멀티스레드 서버를 얻는 출발점입니다. 그다음 개선으로 스레드 풀을 더하면, 두 해결책을 비교하기 쉬울 것입니다. 다음 코드는 for 루프 안에서 각 스트림을 처리하는 새 스레드를 만들도록 main을 바꾼 것입니다.
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
thread::spawn(|| {
handle_connection(stream);
});
}
}스레드 동시 실행에서 배웠듯, thread::spawn은 새 스레드를 만들고 클로저의 코드를 그 새 스레드에서 실행합니다. 이 코드를 실행하고 브라우저에서 /sleep을 불러온 뒤 다른 브라우저 탭 두 개에서 /를 불러오면, /에 대한 요청이 /sleep이 끝나기를 기다리지 않는 것을 볼 수 있습니다. 하지만 앞에서 말했듯, 이렇게 하면 스레드를 제한 없이 만들게 되므로 결국 시스템을 압도할 것입니다.
15장에서 이야기한 async와 await가 바로 이런 상황에서 빛을 발한다는 것도 떠올릴 수 있을 것입니다. 스레드 풀을 만들면서, async를 썼다면 무엇이 달라지거나 같을지 생각해 보세요.
유한한 개수의 스레드 생성하기
스레드 풀이 비슷하고 익숙한 방식으로 동작하기를 원합니다. 스레드에서 스레드 풀로 바꿀 때, 우리 API를 쓰는 코드를 크게 바꿀 필요가 없도록 하고 싶습니다. 다음 코드는 thread::spawn 대신 쓰고 싶은 ThreadPool 구조체의 가상 인터페이스를 보여 줍니다.
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
let pool = ThreadPool::new(4);
for stream in listener.incoming() {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream);
});
}
}ThreadPool::new로 설정할 수 있는 스레드 수(이 경우 4)를 가진 새 스레드 풀을 만듭니다. 그다음 for 루프에서 pool.execute는 thread::spawn과 비슷한 인터페이스를 가지며, 풀이 각 스트림에 대해 실행해야 할 클로저를 받습니다. pool.execute가 클로저를 받아 풀의 한 스레드에 주어 실행하게 하도록 구현해야 합니다. 이 코드는 아직 컴파일되지 않지만, 시도해 봐서 컴파일러가 고치는 방법을 안내하게 합니다.
이어서 보기
이제 컴파일러의 오류를 따라가며 ThreadPool을 구현합니다. 다음 두 글에서 차례로 이어집니다.
| 글 | 다루는 것 |
|---|---|
| ThreadPool과 Worker 구현 | 컴파일러 오류를 따라 ThreadPool을 만들고, 스레드를 담을 Worker 구조체를 더합니다. |
| 채널과 execute 구현 | 채널로 작업을 Worker에 보내고, execute 메서드를 완성합니다. |
정리
- 단일 스레드 서버는 요청을 하나씩 처리하므로,
/sleep같은 느린 요청이 뒤따르는 빠른 요청까지 기다리게 만듭니다. - 연결마다
thread::spawn으로 스레드를 만들면 느린 요청이 다른 요청을 막지 않지만, 스레드 수에 상한이 없어 많은 요청에 자원이 바닥날 수 있습니다. - 스레드 풀은 정해진 수의 스레드가 대기열에서 작업을 꺼내 처리하므로, 동시에 실행되는 작업의 수를 제한하면서 처리량을 높입니다.
- 워커 수를 제한해도 크기 제한이 없는 채널의 대기열은 계속 쌓일 수 있으므로, 메모리까지 제한되지는 않습니다.
- 쓰고 싶은 API인
ThreadPool::new(4)와pool.execute(...)를 먼저 쓰고, 컴파일러의 오류를 따라 구현하는 컴파일러 주도 개발로 진행합니다.
연습 문제
1. 얼마나 기다릴까
스레드가 4개인 풀에 /sleep 요청 5개를 거의 동시에 보낸 뒤 곧바로 /를 요청합니다. 각 요청은 대략 몇 초 뒤에 응답을 받나요? 연결마다 스레드를 만드는 방식이라면 어떻게 되나요?
해설 보기
풀에서는 처음 4개의 /sleep이 네 워커를 차지해 약 5초 뒤에 응답하고, 다섯 번째 /sleep과 /는 대기열에서 기다립니다. 5초 뒤 워커가 비면 그 둘이 처리되므로, 다섯 번째 /sleep은 약 10초 뒤에, /는 약 5초 남짓 뒤에 응답합니다. 연결마다 스레드를 만드는 방식에서는 다섯 /sleep이 모두 약 5초 뒤에, /는 곧바로 응답하지만, 그 대가로 요청 수만큼 스레드가 생깁니다.
2. 왜 API부터 쓸까
ThreadPool을 구현하기 전에 main에서 pool.execute(|| { handle_connection(stream); })처럼 쓰는 코드부터 쓴 까닭은 무엇인가요?
해설 보기
쓰는 쪽에서 편한 모양을 먼저 정해 두면, 구현이 그 모양에 맞춰지므로 thread::spawn에서 풀로 바꿀 때 바꿀 코드가 적습니다. 또 아직 없는 타입과 메서드를 부르는 코드를 컴파일하면, 컴파일러가 무엇이 없는지 하나씩 오류로 알려 주므로, 그 오류를 따라 필요한 것만 차례로 구현할 수 있습니다.
다음 글 ThreadPool과 Worker 구현에서는 컴파일러의 오류를 따라 ThreadPool과 Worker를 만듭니다.