싱글스레드 서버를 멀티스레드 서버로 바꾸기
느린 요청이 전체 서버를 막는 문제를 재현하고 제한된 Worker와 채널로 ThreadPool을 구현해 요청을 병렬 처리합니다.
현재의 서버는 각각의 요청을 차례대로 처리하므로, 첫 번째 요청 처리가 완료될 때까지 두 번째 연결은 처리되지 않습니다.
서버가 점점 더 많은 요청을 수신하면, 이 순차 실행 방식은 점점 더 최적화되지 않을 것입니다.
서버가 처리하는 데 시간이 오래 걸리는 요청을 받으면, 새로운 요청을 빠르게 처리할 수 있더라도 후속 요청은 긴 요청이 완료될 때까지 기다려야 할 것입니다.
이를 해결할 필요가 있지만, 그전에 먼저 이 문제를 실제로 살펴보겠습니다.
현재의 서버 구현에서 느린 요청 시뮬레이션
현재의 서버 구현에서 느리게 처리되는 요청이 다른 요청들에 어떤 영향을 미칠 수 있는지 살펴보겠습니다.
예제 19-10은 /sleep 요청에 대해 서버가 응답하기 전에 5초 동안 서버를 슬립시키는 시뮬레이션된 느린 응답으로 처리하는 것을 구현한 것입니다.
예제 19-10: 5초 동안 슬립하여 느린 요청 시뮬레이션하기use std::{
fs,
io::{prelude::*, BufReader},
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는 동등 메서드가 작동하는 것과 같은 자동 참조 및 역참조를 수행하지 않습니다.
첫 번째 갈래는 예제 19-9의 if 블록과 동일합니다.
두 번째 갈래는 /sleep에 대한 요청과 매칭됩니다.
해당 요청이 수신되면, 서버는 5초 동안 슬립한 후 성공 HTML 페이지를 렌더링합니다.
세 번째 갈래는 예제 19-9의 else 블록과 동일합니다.
우리 서버가 얼마나 원시적인지 알 수 있습니다.
실제 라이브러리라면 훨씬 덜 장황한 방식으로 여러 가지 요청에 대한 인식을 처리할 것입니다!
cargo run을 사용하여 서버를 시작하세요.
그런 다음 두 개의 브라우저 창을 여세요: 하나는 http://127.0.0.1:7878/ 용, 다른 하나는 *http://127.0.0.1:7878/sleep*용입니다.
이전처럼 / URI를 몇 번 입력해 보면, 빠르게 응답하는 것을 볼 수 있습니다.
하지만 /sleep을 입력한 다음 / 을 로드하면, sleep이 5초 동안 완전히 슬립을 끝낼 때까지 / 는 기다렸다가 로드되는 것을 볼 수 있습니다.
느린 요청 뒤의 요청들이 뒤로 밀리는 것을 방지하기 위해 사용할 수 있는 기술은 여러 가지 있습니다; 우리가 구현해 볼 것은 스레드 풀입니다.
스레드 풀로 처리량 개선하기
스레드 풀(thread pool) 은 작업 처리가 준비된 대기 중인 생성된 스레드의 그룹입니다.
프로그램이 새 작업을 받으면, 해당 작업을 풀에 있는 스레드 중 하나에게 할당하고, 해당 스레드가 작업을 처리합니다.
풀의 나머지 스레드는 첫 번째 스레드가 처리하는 동안 들어오는 다른 작업을 처리하는 데 사용될 수 있습니다.
첫 번째 스레드가 작업 처리를 완료하면, 유휴 스레드 풀로 돌아가서 새 작업을 처리할 준비가 됩니다.
스레드 풀을 사용하면 연결을 동시에 처리할 수 있으므로 서버의 처리량이 증가합니다.
풀의 스레드 개수를 적은 수로 제한하여 서비스 거부(Denial of Services, DoS) 공격으로부터 보호하겠습니다; 만일 요청이 들어올 때마다 프로그램이 새 스레드를 생성하도록 하면, 누군가 서버에 천만 건의 요청을 했을 때 서버의 모든 리소스를 사용하고 요청 처리가 중단되어 혼란을 일으킬 수 있습니다.
그래서 무제한 스레드를 생성하는 대신, 풀에 고정된 수의 스레드가 대기하도록 할 것입니다.
들어오는 요청은 처리를 위해 풀로 전송됩니다.
풀은 들어오는 요청의 대기열을 관리합니다.
풀의 각 스레드는 이 대기열에서 요청을 꺼내서, 요청을 처리한 다음, 대기열에게 다른 요청을 달라고 합니다.
이 설계를 사용하면 최대 N 개의 요청을 동시에 처리할 수 있으며, 여기서 N은 스레드 개수입니다.
각 스레드가 길게 실행되는 요청에 응답하는 경우에는 후속 요청이 여전히 대기열 뒤편에 있을 수 있지만, 그 지점에 도달하기 전에 처리 가능한 장기간 실행되는 요청의 개수를 늘렸습니다.
이 기술은 웹 서버의 처리량을 개선하는 여러 가지 방법 중 하나일 뿐입니다.
탐구해 볼만한 다른 옵션으로는 포크/조인(fork/join) 모델, 싱글스레드 비동기(single-threaded async) I/O 모델 또는 멀티스레드 비동기(multi-threaded async) I/O 모델 이 있습니다.
이 주제에 관심이 있다면, 다른 솔루션에 대한 자세한 내용을 읽고 구현을 시도해 볼 수 있습니다; 러스트와 같은 저수준 언어를 사용하면 이 모든 옵션이 가능합니다.
스레드 풀 구현을 시작하기 전에, 풀을 사용하는 것이 어떤 모습이어야 하는지에 대해 이야기해봅시다.
코드를 설계할 때 클라이언트 인터페이스를 먼저 작성하면 설계에 도움이 될 수 있습니다.
코드의 API를 호출하고자 하는 방식대로 구조화되도록 작성하세요; 그런 다음 그 구조를 지키면서 기능을 구현하는 것이지요.
기능을 구현한 다음에 공개 API를 설계하는 방식이 아니고요.
12장의 프로젝트에서 테스트 중심 개발을 사용한 것과 유사하게, 여기서는 컴파일러 중심 개발을 사용하겠습니다.
원하는 함수를 호출하는 코드를 작성한 다음, 컴파일러의 에러를 살펴보고 코드가 작동하도록 하기 위해서는 다음에 무엇을 변경해야 하는지 결정하는 것입니다.
하지만 착수 단계에서는 사용하지 않을 기법을 먼저 살펴보겠습니다.
각 요청마다 스레드 생성하기
먼저 모든 연결에 대해 새 스레드를 생성하면 코드가 어떻게 생기게 되는지 살펴봅시다.
앞서 언급했듯이, 이 방법은 스레드 수가 무제한으로 생성될 수 있는 문제 때문에 우리의 최종 계획은 아니지만, 먼저 멀티스레드 서버를 작동시키기 위한 출발점은 될 수 있습니다.
그런 다음 스레드 풀을 개선 사항으로 추가하면 두 솔루션을 비교하기가 더 쉬워질 것입니다.
예제 19-11은 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);
});
}
}
16장에서 배웠던 것처럼, thread::spawn은 새 스레드를 생성한 다음 새 스레드의 클로저에서 코드를 실행합니다.
이 코드를 실행하고 브라우저에서 /sleep을 로드한 다음, 두 개의 브라우저 탭에서 / 을 추가로 로드하면, / 에 대한 요청이 /sleep이 완료될 때까지 기다릴 필요가 없다는 것을 실제로 확인할 수 있습니다.
하지만 앞서 언급했듯이, 이렇게 하면 아무런 제한 없이 새 스레드를 만들게 되므로 결국 시스템에 과부하가 걸리게 됩니다.
유한한 개수의 스레드 생성하기
여기서는 스레드 풀이 유사하고 익숙한 방식으로 작동하여 스레드에서 스레드 풀로 전환할 때 API를 사용하는 코드가 크게 변경될 필요는 없도록 하려고 합니다.
예제 19-12는 thread::spawn 대신 사용하고자 하는 ThreadPool 구조체에 대한 가상 인터페이스를 보여줍니다.
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를 구현할 필요가 있습니다.
이 코드는 아직 컴파일되지 않지만, 컴파일러가 문제를 해결하는 방법을 안내할 수 있도록 시도해보겠습니다.
아래 다이어그램은 직접 스레드 생성 방식에서 스레드 풀 API로 책임이 어떻게 옮겨가는지 비교합니다.
스레드 풀 API를 먼저 정하면, 이후 구현은 컴파일러가 알려주는 빠진 타입과 메서드를 하나씩 채우는 작업으로 바뀝니다.