싱글스레드 웹 서버 구축
먼저 싱글스레드 웹 서버를 구동하는 방법부터 알아보자. 시작하기 전에, 웹 서버 구축에 사용되는 주요 프로토콜을 간략히 살펴보자. 이 프로토콜들의 세부 사항은 이 책의 범위를 벗어나지만, 간단한 개요를 통해 필요한 기본 지식을 얻을 수 있다.
웹 서버와 관련된 두 가지 주요 프로토콜은 **HTTP(Hypertext Transfer Protocol)**와 **TCP(Transmission Control Protocol)**다. 두 프로토콜 모두 요청-응답 방식으로 동작한다. 즉, 클라이언트가 요청을 시작하면, 서버는 해당 요청을 듣고 클라이언트에게 응답을 제공한다. 요청과 응답의 내용은 프로토콜에 의해 정의된다.
TCP는 정보가 한 서버에서 다른 서버로 어떻게 전달되는지에 대한 세부 사항을 설명하는 저수준 프로토콜이지만, 정보의 내용은 정의하지 않는다. HTTP는 TCP 위에 구축되어 요청과 응답의 내용을 정의한다. 기술적으로 HTTP를 다른 프로토콜과 함께 사용할 수 있지만, 대부분의 경우 HTTP는 TCP를 통해 데이터를 전송한다. 우리는 TCP와 HTTP 요청 및 응답의 원시 바이트를 직접 다룰 것이다.
TCP 연결 수신하기
웹 서버는 TCP 연결을 수신해야 한다. 이 부분부터 시작해보자. 표준 라이브러리는 std::net
모듈을 제공하며, 이를 통해 TCP 연결을 처리할 수 있다. 일반적인 방식으로 새로운 프로젝트를 생성해보자:
$ cargo new hello
Created binary (application) `hello` project
$ cd hello
이제 src/main.rs 파일에 목록 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
함수는 new
함수와 유사하게 새로운 TcpListener
인스턴스를 반환한다. 네트워크에서 포트에 연결해 수신하는 것을 “포트에 바인딩한다“고 하기 때문에 이 함수는 bind
라고 불린다.
bind
함수는 Result<T, E>
를 반환하며, 이는 바인딩이 실패할 수 있음을 나타낸다. 예를 들어, 포트 80에 연결하려면 관리자 권한이 필요하다(비관리자는 1023보다 높은 포트만 수신할 수 있음). 따라서 관리자 권한 없이 포트 80에 연결하려고 하면 바인딩이 실패한다. 또한 프로그램을 두 번 실행해 같은 포트를 수신하려고 해도 바인딩이 실패한다. 학습 목적으로 기본적인 서버를 작성 중이므로 이러한 오류를 처리하지 않고, 대신 unwrap
을 사용해 오류가 발생하면 프로그램을 중단한다.
TcpListener
의 incoming
메서드는 스트림 시퀀스(더 정확히는 TcpStream
타입의 스트림)를 제공하는 이터레이터를 반환한다. 단일 _스트림_은 클라이언트와 서버 간의 열린 연결을 나타낸다. _연결_은 클라이언트가 서버에 연결하고, 서버가 응답을 생성한 후 연결을 닫는 전체 요청 및 응답 프로세스를 의미한다. 따라서 TcpStream
에서 클라이언트가 보낸 데이터를 읽고, 스트림에 응답을 작성해 클라이언트에게 데이터를 보낸다. 이 for
루프는 각 연결을 순차적으로 처리하고 처리할 스트림 시퀀스를 생성한다.
현재는 스트림 처리 시 unwrap
을 호출해 스트림에 오류가 있으면 프로그램을 종료한다. 오류가 없으면 프로그램이 메시지를 출력한다. 다음 목록에서 성공 사례에 대한 기능을 추가할 것이다. 클라이언트가 서버에 연결할 때 incoming
메서드에서 오류를 받을 수 있는 이유는 연결을 반복하는 것이 아니라 _연결 시도_를 반복하기 때문이다. 여러 가지 이유로 연결이 성공하지 못할 수 있으며, 그 중 많은 이유가 운영체제에 따라 다르다. 예를 들어, 많은 운영체제는 동시에 열 수 있는 연결 수에 제한이 있다. 그 수를 초과하는 새로운 연결 시도는 일부 열린 연결이 닫힐 때까지 오류를 발생시킨다.
이 코드를 실행해보자! 터미널에서 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
명령을 다시 실행해 최신 코드가 실행되도록 한다.
요청 읽기
브라우저로부터 요청을 읽는 기능을 구현해 보자! 연결을 먼저 얻고, 그 연결을 통해 어떤 작업을 수행하는 두 가지 관심사를 분리하기 위해, 새로운 함수를 만들어 연결을 처리한다. 이 새로운 handle_connection
함수에서 TCP 스트림으로부터 데이터를 읽고, 브라우저가 보낸 데이터를 확인할 수 있도록 출력한다. 코드를 리스트 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:#?}"); }
TcpStream
에서 데이터를 읽고 출력하기std::io::prelude
와 std::io::BufReader
를 스코프로 가져와 스트림에서 데이터를 읽고 쓰는 데 필요한 트레잇과 타입에 접근한다. main
함수의 for
루프에서, 연결이 성공했다는 메시지를 출력하는 대신, 새로운 handle_connection
함수를 호출하고 stream
을 인자로 전달한다.
handle_connection
함수에서는 stream
에 대한 참조를 감싸는 새로운 BufReader
인스턴스를 생성한다. BufReader
는 std::io::Read
트레잇 메서드 호출을 관리하며 버퍼링을 추가한다.
http_request
라는 변수를 만들어 브라우저가 서버로 보낸 요청의 라인들을 수집한다. Vec<_>
타입 어노테이션을 추가하여 이 라인들을 벡터로 수집할 것임을 명시한다.
BufReader
는 std::io::BufRead
트레잇을 구현하며, 이 트레잇은 lines
메서드를 제공한다. lines
메서드는 데이터 스트림에서 개행 바이트를 만날 때마다 분할하여 Result<String, std::io::Error>
의 이터레이터를 반환한다. 각 String
을 얻기 위해, 각 Result
를 map
하고 unwrap
한다. 데이터가 유효한 UTF-8이 아니거나 스트림에서 읽는 데 문제가 발생하면 Result
는 에러가 될 수 있다. 프로덕션 프로그램에서는 이러한 에러를 더 우아하게 처리해야 하지만, 간단함을 위해 에러 발생 시 프로그램을 중단하도록 선택한다.
브라우저는 두 개의 연속된 개행 문자를 보내어 HTTP 요청의 끝을 알린다. 따라서 스트림에서 하나의 요청을 얻기 위해, 빈 문자열 라인을 만날 때까지 라인을 수집한다. 라인들을 벡터로 수집한 후, 웹 브라우저가 서버로 보낸 명령어를 확인할 수 있도록 디버그 포맷으로 출력한다.
이 코드를 실행해 보자! 프로그램을 시작하고 웹 브라우저에서 다시 요청을 보낸다. 브라우저에서는 여전히 에러 페이지가 표시되지만, 터미널에서 프로그램의 출력은 다음과 비슷하게 나타난다:
$ cargo run
Compiling hello v0.1.0 (file:///projects/hello)
Finished dev [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 요청 자세히 살펴보기
HTTP는 텍스트 기반 프로토콜이며, 요청은 다음과 같은 형식을 따른다:
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
첫 번째 줄은 클라이언트가 요청하는 내용을 담고 있는 _요청 라인_이다. 요청 라인의 첫 부분은 사용되는 _메서드_를 나타내며, GET
이나 POST
와 같은 값이 들어간다. 이는 클라이언트가 어떻게 요청을 보내는지 설명한다. 여기서는 GET
요청을 사용했는데, 이는 클라이언트가 정보를 요청하고 있다는 의미이다.
요청 라인의 다음 부분은 _/_로, 클라이언트가 요청하는 _통합 자원 식별자(URI)_를 나타낸다. URI는 _통합 자원 위치(URL)_와 거의 동일하지만 완전히 같지는 않다. URI와 URL의 차이는 이 장에서 중요하지 않지만, HTTP 스펙에서는 URI라는 용어를 사용하므로 여기서는 _URI_를 _URL_로 이해하면 된다.
마지막 부분은 클라이언트가 사용하는 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_와 같은 다른 주소를 요청해 보면 요청 데이터가 어떻게 바뀌는지 확인할 수 있다.
이제 브라우저가 무엇을 요청하는지 알았으니, 데이터를 보내보자!
응답 작성하기
클라이언트 요청에 대한 응답으로 데이터를 보내는 기능을 구현해 보자. HTTP 응답은 다음과 같은 형식을 따른다:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
첫 번째 줄은 _상태 줄_로, 응답에 사용된 HTTP 버전, 요청 결과를 요약한 숫자 상태 코드, 그리고 상태 코드에 대한 설명인 텍스트 문구로 구성된다. CRLF 시퀀스 이후에는 헤더, 또 다른 CRLF 시퀀스, 그리고 응답 본문이 이어진다.
다음은 HTTP 버전 1.1을 사용하고, 상태 코드 200, OK 문구, 헤더와 본문이 없는 간단한 응답 예제다:
HTTP/1.1 200 OK\r\n\r\n
상태 코드 200은 표준 성공 응답이다. 이 텍스트는 매우 간단한 성공적인 HTTP 응답이다. 이제 이 응답을 스트림에 작성해 요청에 대한 응답으로 보내보자. handle_connection
함수에서 요청 데이터를 출력하던 println!
을 지우고, 아래 코드로 대체한다.
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 반환하기
이제 빈 페이지 대신 실제 HTML을 반환하는 기능을 구현해보자. 프로젝트 루트 디렉토리(소스 디렉토리가 아님)에 hello.html 파일을 생성한다. 원하는 HTML을 입력할 수 있으며, 목록 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>
이 HTML은 제목과 텍스트가 포함된 간단한 HTML5 문서다. 요청을 받으면 이 HTML을 서버에서 반환하기 위해 handle_connection
함수를 수정한다. 목록 21-5는 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(); }
fs
를 use
문에 추가해 표준 라이브러리의 파일 시스템 모듈을 범위로 가져왔다. 파일 내용을 문자열로 읽는 코드는 익숙할 것이다. 목록 12-4에서 I/O 프로젝트를 위해 파일 내용을 읽을 때 사용했던 것과 동일하다.
다음으로, 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 파일을 반환하도록 수정할 것이다.
요청 검증과 선택적 응답
현재 웹 서버는 클라이언트가 어떤 요청을 하든 상관없이 파일에 있는 HTML을 반환한다. 이제 브라우저가 / 경로를 요청할 때만 HTML 파일을 반환하고, 그 외의 요청에는 오류를 반환하는 기능을 추가해 보자. 이를 위해 handle_connection
함수를 수정해야 한다. Listing 21-6에서 보듯이, 새로운 코드는 수신된 요청의 내용을 검사하고 / 요청과 일치하는지 확인한 후, 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이 반환될 것이다.
리팩토링 적용하기
현재 if
와 else
블록에는 많은 중복 코드가 있다. 둘 다 파일을 읽고 파일의 내용을 스트림에 쓰는 동일한 작업을 수행한다. 유일한 차이는 상태 라인과 파일명뿐이다. 이 차이를 별도의 if
와 else
라인으로 분리하여 상태 라인과 파일명을 변수에 할당하고, 이후에는 이 변수를 무조건적으로 사용해 파일을 읽고 응답을 작성하는 코드를 더 간결하게 만들어보자. 리스팅 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
블록 리팩토링이제 if
와 else
블록은 상태 라인과 파일명을 튜플로 반환한다. 그리고 19장에서 다룬 패턴을 사용해 let
문에서 이 두 값을 status_line
과 filename
변수에 구조 분해 할당한다.
이전에 중복되었던 코드는 이제 if
와 else
블록 밖에 위치하며, status_line
과 filename
변수를 사용한다. 이를 통해 두 경우의 차이를 더 쉽게 파악할 수 있고, 파일 읽기와 응답 쓰기 방식을 변경하고 싶을 때 한 곳만 수정하면 된다. 리스팅 21-9의 코드 동작은 리스팅 21-7과 동일하다.
훌륭하다! 이제 약 40줄의 Rust 코드로 간단한 웹 서버를 만들었다. 이 서버는 하나의 요청에 대해 콘텐츠 페이지를 반환하고, 다른 모든 요청에 대해 404 응답을 반환한다.
현재 우리의 서버는 단일 스레드에서 실행되기 때문에 한 번에 하나의 요청만 처리할 수 있다. 몇 가지 느린 요청을 시뮬레이션해 이 문제를 살펴보고, 이후에 서버가 여러 요청을 동시에 처리할 수 있도록 수정해보자.