싱글 스레드 웹 서버 만들기
싱글 스레드 웹 서버 만들기 (Building a Single-Threaded Web Server)
먼저 싱글 스레드 웹 서버를 동작시키는 것부터 시작할게요. 시작하기 전에 웹 서버를 만드는 데 관련된 프로토콜을 간단히 훑어볼게요. 이 프로토콜들의 세부사항은 이 책의 범위를 벗어나지만, 간단한 개요만으로도 필요한 정보는 얻을 수 있답니다.
웹 서버에 관련된 두 주요 프로토콜은 하이퍼텍스트 전송 프로토콜(HTTP) 과 전송 제어 프로토콜(TCP) 이에요. 두 프로토콜 모두 요청-응답(request-response) 프로토콜이에요. 즉 _클라이언트(client)_가 요청을 시작하고 _서버(server)_가 요청을 듣고 클라이언트에 응답을 제공한다는 뜻이죠. 요청과 응답의 내용은 프로토콜이 정의해요.
TCP는 정보가 한 서버에서 다른 서버로 어떻게 전달되는지의 세부사항을 설명하는 더 저수준의 프로토콜이지만, 그 정보가 무엇인지는 지정하지 않아요. HTTP는 요청과 응답의 내용을 정의함으로써 TCP 위에 구축돼요. 기술적으로는 다른 프로토콜과 함께 HTTP를 쓸 수도 있지만, 대부분의 경우 HTTP는 데이터를 TCP 위로 보내요. 우리는 TCP와 HTTP 요청·응답의 원시 바이트(raw byte)를 다룰 거예요.
출처: The Rust Book
TCP 연결 듣기 (Listening to the TCP Connection)
우리 웹 서버는 TCP 연결을 들어야 하니, 그게 우리가 먼저 작업할 첫 부분이에요. 표준 라이브러리는 이를 가능하게 해주는 std::net 모듈을 제공해요. 평소 방식대로 새 프로젝트를 만들어 볼게요.
$ cargo new hello
Created binary (application) `hello` project
$ cd hello
이제 _src/main.rs_에 Listing 21-1의 코드를 입력해 시작하세요. 이 코드는 로컬 주소 127.0.0.1:7878에서 들어오는 TCP 스트림을 들어요. 들어오는 스트림을 받으면 Connection established!라고 출력해요.
use std::net::TcpListener;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
println!("Connection established!");
}
}
TcpListener를 사용해 주소 127.0.0.1:7878에서 TCP 연결을 들을 수 있어요. 주소에서 콜론 앞부분은 여러분의 컴퓨터를 나타내는 IP 주소예요(모든 컴퓨터에서 동일하며, 저자들의 컴퓨터를 특별히 나타내는 게 아니에요). 7878은 포트예요. 우리가 이 포트를 고른 이유는 두 가지예요. HTTP는 보통 이 포트에서 처리되지 않아 우리 서버가 여러분의 머신에서 실행 중인 다른 웹 서버와 충돌할 가능성이 낮고, 7878은 전화기에 _rust_를 친 것이거든요.
bind 함수는 새 TcpListener 인스턴스를 반환한다는 점에서 new 함수처럼 동작해요. 함수 이름이 bind인 이유는 네트워킹에서 들을 포트에 연결하는 것을 "포트에 바인딩하는 것(binding to a port)"이라 부르기 때문이에요.
bind 함수는 Result<T, E>를 반환하는데, 이는 바인딩이 실패할 수 있음을 나타내요. 예를 들어 프로그램을 두 번 실행해서 두 프로그램이 같은 포트를 듣게 된다면 실패할 수 있죠. 우리는 배움을 위한 기본 서버를 작성하고 있으므로 이런 종류의 오류 처리에는 신경 쓰지 않을 거예요. 대신 오류가 나면 unwrap을 사용해 프로그램을 멈춰요.
TcpListener의 incoming 메서드는 일련의 스트림(TcpStream 타입의 스트림)을 주는 이터레이터를 반환해요. 하나의 _스트림(stream)_은 클라이언트와 서버 사이의 열린 연결을 나타내요. _연결(connection)_은 클라이언트가 서버에 연결하고, 서버가 응답을 생성하고, 서버가 연결을 닫는 전체 요청·응답 과정의 이름이에요. 그래서 우리는 TcpStream에서 읽어 클라이언트가 보낸 것을 보고, 스트림에 응답을 써서 데이터를 클라이언트로 다시 보내게 돼요. 전반적으로 이 for 루프는 각 연결을 차례로 처리하고, 우리가 다룰 일련의 스트림을 만들어내요.
지금으로서는 스트림 처리가, 스트림에 오류가 있으면 프로그램을 종료하는 unwrap 호출로 구성돼요. 오류가 없으면 프로그램은 메시지를 출력하죠. 다음 리스팅에서 성공의 경우에 기능을 더 추가할게요. 클라이언트가 서버에 연결할 때 incoming 메서드에서 오류를 받을 수 있는 이유는, 우리가 실제로 연결을 반복하는 게 아니기 때문이에요. 대신 _연결 시도(connection attempts)_를 반복하고 있어요. 여러 가지 이유로 연결이 성공하지 못할 수 있고, 그 중 많은 이유가 운영 체제에 특정적이에요. 예를 들어 많은 운영 체제는 동시에 열 수 있는 연결 수에 제한을 두는데, 그 수를 넘는 새 연결 시도는 열린 연결 중 일부가 닫힐 때까지 오류를 만들어내요.
이 코드를 실행해 봅시다! 터미널에서 cargo run을 호출하고 웹 브라우저에서 _127.0.0.1:7878_을 로드하세요. 서버가 현재 어떤 데이터도 다시 보내지 않으므로 브라우저는 "Connection reset" 같은 오류 메시지를 보여줄 거예요. 하지만 터미널을 보면, 브라우저가 서버에 연결했을 때 출력된 메시지 여러 개가 보일 거예요!
Running `target/debug/hello`
Connection established!
Connection established!
Connection established!
때로는 브라우저 요청 하나에 메시지가 여러 번 출력되는 걸 보게 될 텐데, 그 이유는 브라우저가 페이지 요청과 함께 브라우저 탭에 나타나는 favicon.ico 아이콘 같은 다른 리소스도 요청하기 때문일 수 있어요.
또한 서버가 어떤 데이터로도 응답하지 않기 때문에 브라우저가 서버에 여러 번 연결을 시도할 수도 있어요. stream이 스코프 밖으로 나가 루프 끝에서 드롭되면, 연결은 drop 구현의 일부로 닫혀요. 브라우저는 문제가 일시적일 수 있으므로 닫힌 연결을 재시도로 처리하기도 해요.
브라우저는 또 나중에 요청을 보내면 그 요청이 더 빨리 일어날 수 있도록, 어떤 요청도 보내지 않은 채 서버에 여러 연결을 열어두기도 해요. 이런 일이 생기면 우리 서버는 그 연결을 통해 요청이 있는지와 무관하게 각 연결을 보게 돼요. 예를 들어 많은 버전의 Chrome 기반 브라우저가 이런 동작을 해요. 비공개 브라우징 모드를 쓰거나 다른 브라우저를 쓰면 그 최적화를 끌 수 있어요.
중요한 것은 TCP 연결에 대한 핸들을 성공적으로 얻었다는 점이에요!
특정 버전의 코드 실행을 마쳤으면 ctrl-C를 눌러 프로그램을 멈추는 걸 잊지 마세요. 그리고 각 코드 변경 세트 이후 cargo run 명령을 호출해 가장 최신 코드를 실행하고 있는지 확인하세요.
요청 읽기 (Reading the Request)
브라우저에서 요청을 읽는 기능을 구현해 볼게요! 먼저 연결을 얻는 것과 그 연결로 어떤 조치를 취하는 것의 관심사를 분리하기 위해, 연결을 처리하는 새 함수를 시작할게요. 이 새 handle_connection 함수에서 TCP 스트림의 데이터를 읽고 출력해서, 브라우저가 보내는 데이터를 볼 수 있게 할 거예요. 코드를 Listing 21-2처럼 바꿔보세요.
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
println!("Request: {http_request:#?}");
}
std::io::BufReader와 std::io::prelude를 스코프로 가져와서, 스트림에서 읽고 쓰게 해주는 트레이트와 타입에 접근해요. main 함수의 for 루프에서는 연결을 만들었다는 메시지를 출력하는 대신, 새 handle_connection 함수를 호출하고 stream을 넘겨요.
handle_connection 함수에서는 stream에 대한 참조를 감싸는 새 BufReader 인스턴스를 만들어요. BufReader는 std::io::Read 트레이트 메서드에 대한 호출을 관리해 버퍼링을 추가해줘요.
브라우저가 우리 서버로 보내는 요청의 줄들을 수집하기 위해 http_request라는 변수를 만들어요. Vec<_> 타입 어노테이션을 추가해 이 줄들을 벡터로 수집하고 싶다고 나타내요.
BufReader는 lines 메서드를 제공하는 std::io::BufRead 트레이트를 구현해요. lines 메서드는 새줄(newline) 바이트를 볼 때마다 데이터 스트림을 나누어 Result<String, std::io::Error>의 이터레이터를 반환해요. 각 String을 얻기 위해 각 Result를 map하고 unwrap해요. Result는 데이터가 유효한 UTF-8이 아니거나 스트림에서 읽는 데 문제가 있으면 오류일 수 있어요. 다시 말하지만 운영 프로그램은 이런 오류를 더 우아하게 처리해야 하지만, 단순함을 위해 우리는 오류의 경우 프로그램을 멈추기로 선택했어요.
브라우저는 새줄 문자 두 개를 연달아 보내 HTTP 요청의 끝을 알리므로, 스트림에서 요청 하나를 얻으려면 빈 문자열인 줄을 얻을 때까지 줄들을 가져와요. 줄들을 벡터로 수집하고 나면 예쁜 디버그 형식으로 출력해서, 웹 브라우저가 우리 서버로 보내는 지시들을 살펴볼 수 있어요.
이 코드를 실행해 봅시다! 프로그램을 시작하고 웹 브라우저에서 다시 요청을 만드세요. 브라우저에서는 여전히 오류 페이지를 보게 되겠지만, 터미널의 프로그램 출력은 이제 이렇게 생겼을 거예요.
$ cargo run
Compiling hello v0.1.0 (file:///projects/hello)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/hello`
Request: [
"GET / HTTP/1.1",
"Host: 127.0.0.1:7878",
"User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language: en-US,en;q=0.5",
"Accept-Encoding: gzip, deflate, br",
"DNT: 1",
"Connection: keep-alive",
"Upgrade-Insecure-Requests: 1",
"Sec-Fetch-Dest: document",
"Sec-Fetch-Mode: navigate",
"Sec-Fetch-Site: none",
"Sec-Fetch-User: ?1",
"Cache-Control: max-age=0",
]
브라우저에 따라 조금 다른 출력을 얻을 수도 있어요. 이제 요청 데이터를 출력하고 있으니, 요청 첫 줄의 GET 뒤 경로를 보면 브라우저 요청 하나에서 왜 여러 연결이 일어나는지 알 수 있어요. 반복되는 연결이 모두 _/_를 요청하고 있다면, 브라우저가 우리 프로그램에서 응답을 받지 못해 _/_를 반복해서 가져오려 하고 있음을 알 수 있어요.
이 요청 데이터를 분석해서 브라우저가 우리 프로그램에 무엇을 요청하는지 이해해 볼게요.
HTTP 요청 자세히 보기 (Looking More Closely at an HTTP Request)
HTTP는 텍스트 기반 프로토콜이고, 요청은 이런 형식을 가져요.
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
첫 줄은 클라이언트가 무엇을 요청하는지에 대한 정보를 담은 _요청 줄(request line)_이에요. 요청 줄의 첫 부분은 사용 중인 메서드를 나타내는데, GET이나 POST 같은 것이며 클라이언트가 이 요청을 어떻게 만드는지 설명해요. 우리 클라이언트는 GET 요청을 사용했는데, 이는 정보를 요청하고 있다는 뜻이에요.
요청 줄의 다음 부분은 _/_인데, 클라이언트가 요청하는 _유니폼 리소스 식별자(URI)__를 나타내요. URI는 _유니폼 리소스 로케이터(URL)_와 거의, 그러나 완전히는 같지 않아요. URI와 URL의 차이는 이번 장의 목적에는 중요하지 않아요. 하지만 HTTP 규격은 _URI_라는 용어를 쓰므로, 여기서는 _URL_을 _URI_로 마음속으로 바꿔 읽으면 돼요.
마지막 부분은 클라이언트가 사용하는 HTTP 버전이고, 그다음 요청 줄은 CRLF 시퀀스로 끝나요. (_CRLF_는 _캐리지 리턴_과 _라인 피드_를 뜻하는데, 타자기 시대의 용어예요!) CRLF 시퀀스는 \r\n으로도 쓸 수 있는데, \r은 캐리지 리턴이고 \n은 라인 피드예요. _CRLF 시퀀스_는 요청 줄과 요청 데이터의 나머지를 구분해줘요. CRLF가 출력될 때는 \r\n이 아니라 새 줄이 시작하는 걸 보게 된다는 점을 주목하세요.
지금까지 프로그램을 실행하면서 받은 요청 줄 데이터를 보면, GET이 메서드, _/_가 요청 URI, HTTP/1.1이 버전임을 알 수 있어요.
요청 줄 다음에 Host:부터 시작하는 나머지 줄들은 헤더예요. GET 요청에는 본문(body)이 없어요.
다른 브라우저에서 요청을 만들거나 127.0.0.1:7878/test 같은 다른 주소를 요청해서 요청 데이터가 어떻게 바뀌는지 확인해 보세요.
이제 브라우저가 무엇을 요청하는지 알았으니, 몇 가지 데이터를 다시 보내 볼게요!
응답 쓰기 (Writing a Response)
클라이언트 요청에 대한 응답으로 데이터를 보내는 것을 구현할 거예요. 응답은 이런 형식을 가져요.
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
첫 줄은 응답에 사용된 HTTP 버전, 요청의 결과를 요약하는 숫자 상태 코드(status code), 상태 코드의 텍스트 설명을 제공하는 이유 문구(reason phrase)를 담은 _상태 줄(status line)_이에요. CRLF 시퀀스 뒤에는 어떤 헤더들, 또 다른 CRLF 시퀀스, 응답의 본문이 이어져요.
다음은 HTTP 1.1 버전과 상태 코드 200, OK 이유 문구, 헤더 없음, 본문 없음을 사용한 응답 예시예요.
HTTP/1.1 200 OK\r\n\r\n
상태 코드 200은 표준 성공 응답이에요. 이 텍스트는 아주 작은 성공 HTTP 응답이죠. 이걸 성공 요청에 대한 응답으로 스트림에 써 볼게요! handle_connection 함수에서 요청 데이터를 출력하던 println!을 제거하고 Listing 21-3의 코드로 바꾸세요.
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let response = "HTTP/1.1 200 OK\r\n\r\n";
stream.write_all(response.as_bytes()).unwrap();
}
새 줄 하나가 성공 메시지의 데이터를 담는 response 변수를 정의해요. 그다음 response에 as_bytes를 호출해 문자열 데이터를 바이트로 변환해요. stream의 write_all 메서드는 &[u8]을 받아 그 바이트를 연결로 직접 보내요. write_all 연산은 실패할 수 있으므로, 이전처럼 오류 결과에 unwrap을 사용해요. 다시 말하지만 실제 애플리케이션에서는 여기에 오류 처리를 추가할 거예요.
이 변경들로 코드를 실행하고 요청을 만들어 보세요. 이제 터미널에 어떤 데이터도 출력하지 않으므로, Cargo의 출력 외에는 아무것도 보지 못할 거예요. 웹 브라우저에서 _127.0.0.1:7878_을 로드하면 오류 대신 빈 페이지를 보게 돼요. HTTP 요청을 받고 응답을 보내는 것을 손수 구현한 거예요!
실제 HTML 반환하기 (Returning Real HTML)
빈 페이지보다 더 많은 것을 반환하는 기능을 구현해 볼게요. 프로젝트 디렉토리의 루트(src 디렉토리가 아니라)에 _hello.html_이라는 새 파일을 만드세요. 원하는 HTML은 뭐든 넣을 수 있어요. Listing 21-4가 한 가능성을 보여줘요.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from Rust</p>
</body>
</html>
제목과 텍스트 몇 개가 있는 최소한의 HTML5 문서예요. 요청을 받으면 서버에서 이걸 반환하도록, Listing 21-5처럼 handle_connection을 수정해 HTML 파일을 읽고, 응답의 본문으로 추가하고, 보낼 거예요.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
// --snip--
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
표준 라이브러리의 파일시스템 모듈을 스코프로 가져오기 위해 use 문에 fs를 추가했어요. 파일 내용을 문자열로 읽는 코드는 익숙해 보일 거예요. 12장의 I/O 프로젝트에서 파일 내용을 읽을 때 썼거든요(Listing 12-4).
다음으로 format!을 사용해 파일의 내용을 성공 응답의 본문으로 추가해요. 유효한 HTTP 응답을 보장하기 위해 Content-Length 헤더를 추가하는데, 이는 응답 본문의 크기—이 경우 hello.html의 크기—로 설정돼요.
cargo run으로 이 코드를 실행하고 브라우저에서 _127.0.0.1:7878_을 로드하세요. 여러분의 HTML이 렌더링되는 걸 보게 될 거예요!
현재 우리는 http_request의 요청 데이터를 무시하고 HTML 파일의 내용을 무조건 다시 보내고 있어요. 즉 브라우저에서 _127.0.0.1:7878/something-else_를 요청해도 이 같은 HTML 응답을 받게 된다는 뜻이에요. 지금으로선 우리 서버는 매우 제한적이고, 대부분의 웹 서버가 하는 일을 하지 못해요. 요청에 따라 응답을 사용자화하고, _/_에 대한 형식이 올바른 요청에만 HTML 파일을 다시 보내기를 원해요.
요청 검증하고 선택적으로 응답하기 (Validating the Request and Selectively Responding)
지금 우리 웹 서버는 클라이언트가 무엇을 요청하든 파일의 HTML을 반환해요. HTML 파일을 반환하기 전에 브라우저가 _/_를 요청하는지 확인하고, 다른 걸 요청하면 오류를 반환하는 기능을 추가해 볼게요. 이를 위해 Listing 21-6처럼 handle_connection을 수정해야 해요. 이 새 코드는 받은 요청의 내용을 _/_에 대한 요청이 어떻게 생겼는지 우리가 아는 것과 비교하고, if와 else 블록을 추가해 요청을 다르게 처리해요.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
// --snip--
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
if request_line == "GET / HTTP/1.1" {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
} else {
// some other request
}
}
우리는 HTTP 요청의 첫 줄만 볼 거예요. 그래서 전체 요청을 벡터로 읽는 대신 next를 호출해 이터레이터에서 첫 항목을 얻어요. 첫 번째 unwrap은 Option을 처리하고, 이터레이터에 항목이 없으면 프로그램을 멈춰요. 두 번째 unwrap은 Result를 처리하며, Listing 21-2에서 map에 추가했던 unwrap과 같은 효과를 가져요.
다음으로 request_line이 / 경로에 대한 GET 요청의 요청 줄과 같은지 확인해요. 같으면 if 블록이 우리 HTML 파일의 내용을 반환해요.
request_line이 / 경로에 대한 GET 요청과 같지 않다면, 우리가 다른 요청을 받았다는 뜻이에요. 잠시 후 else 블록에 다른 모든 요청에 응답하는 코드를 추가할게요.
지금 이 코드를 실행하고 _127.0.0.1:7878_을 요청하세요. _hello.html_의 HTML을 얻을 거예요. 127.0.0.1:7878/something-else 같은 다른 요청을 만들면, Listing 21-1과 Listing 21-2의 코드를 실행할 때 봤던 것 같은 연결 오류를 얻을 거예요.
이제 Listing 21-7의 코드를 else 블록에 추가해, 요청에 대한 내용을 찾을 수 없음을 알리는 상태 코드 404를 가진 응답을 반환해 볼게요. 최종 사용자에게 응답을 나타내는 페이지를 브라우저에서 렌더링하기 위해 몇 가지 HTML도 반환할 거예요.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
if request_line == "GET / HTTP/1.1" {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
// --snip--
} else {
let status_line = "HTTP/1.1 404 NOT FOUND";
let contents = fs::read_to_string("404.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
}
}
여기서 우리의 응답은 상태 코드 404와 NOT FOUND 이유 문구를 가진 상태 줄을 가져요. 응답의 본문은 404.html 파일의 HTML이 될 거예요. 오류 페이지를 위해 hello.html 옆에 404.html 파일을 만들어야 해요. 다시 한 번 원하는 HTML은 뭐든 써도 되고, Listing 21-8의 예시 HTML을 사용해도 돼요.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>
이 변경들로 서버를 다시 실행하세요. _127.0.0.1:7878_을 요청하면 _hello.html_의 내용이 반환되어야 하고, 127.0.0.1:7878/foo 같은 다른 어떤 요청이든 _404.html_의 오류 HTML이 반환되어야 해요.
리팩터링 (Refactoring)
지금 if와 else 블록에는 반복이 많아요. 둘 다 파일을 읽고 파일의 내용을 스트림에 써요. 유일한 차이는 상태 줄과 파일 이름뿐이에요. 이 차이들을 별도의 if와 else 줄로 꺼내 상태 줄과 파일 이름의 값을 변수에 할당해 코드를 더 간결하게 만들어 볼게요. 그러면 파일을 읽고 응답을 쓰는 코드에서 그 변수들을 무조건 사용할 수 있어요. Listing 21-9는 큰 if와 else 블록을 바꾼 결과 코드를 보여줘요.
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
// --snip--
fn handle_connection(mut stream: TcpStream) {
// --snip--
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
이제 if와 else 블록은 상태 줄과 파일 이름의 적절한 값을 튜플로 반환하기만 해요. 그런 다음 19장에서 다룬 것처럼 let 문의 패턴으로 해체(destructuring)를 사용해 이 두 값을 status_line과 filename에 할당해요.
이전에 중복됐던 코드는 이제 if와 else 블록 밖에 있고 status_line과 filename 변수를 사용해요. 이렇게 하면 두 경우의 차이를 더 쉽게 볼 수 있고, 파일 읽기와 응답 쓰기가 어떻게 동작하는지 바꾸려면 코드를 업데이트할 곳이 한 곳뿐이라는 뜻이에요. Listing 21-9의 코드 동작은 Listing 21-7과 같을 거예요.
멋지죠! 이제 약 40줄의 Rust 코드로 된 간단한 웹 서버가 생겼어요. 이 서버는 한 요청에는 콘텐츠 페이지로, 다른 모든 요청에는 404 응답으로 응답하죠.
현재 우리 서버는 싱글 스레드로 실행되므로, 한 번에 요청 하나만 처리할 수 있어요. 느린 요청 몇 개를 시뮬레이션해 이게 어떻게 문제가 될 수 있는지 살펴볼게요. 그다음 우리 서버가 여러 요청을 한 번에 처리할 수 있도록 고칠 거예요.