Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

단일 스레드 웹 서버 만들기

우선 단일 스레드 웹 서버가 동작하게 만드는 것부터 시작합시다. 본격적으로 들어가기 전에, 웹 서버를 만드는 데 관련된 프로토콜을 아주 짧게 훑어보겠습니다. 이 프로토콜들의 세부는 이 책 범위를 벗어나지만, 간단한 개요만으로도 필요한 배경은 충분히 얻을 수 있습니다.

웹 서버와 관련된 두 가지 주요 프로토콜은 HTTP(Hypertext Transfer Protocol)TCP(Transmission Control Protocol) 입니다. 둘 다 요청-응답(request-response) 프로토콜입니다. 즉 클라이언트 가 요청을 시작하고, 서버 가 요청을 받아 응답을 돌려줍니다. 요청과 응답의 내용은 각각 이 프로토콜이 정의합니다.

TCP 는 더 저수준의 프로토콜로, 정보가 한 서버에서 다른 서버로 어떻게 전달되는지에 대한 세부를 설명하지만, 그 정보가 무엇인지 까지는 정의하지 않습니다. HTTP 는 그 위에 쌓여, 요청과 응답의 내용을 정의합니다. 기술적으로 HTTP 를 다른 프로토콜 위에 올릴 수도 있지만, 현실에서는 압도적인 대부분의 경우 HTTP 가 TCP 위에서 데이터를 주고받습니다. 이 장에서는 TCP와 HTTP 요청/응답의 원시 바이트 수준을 직접 다루게 됩니다.

TCP 연결 기다리기

우리 웹 서버는 TCP 연결을 기다릴 수 있어야 하므로, 그것부터 구현해 보겠습니다. 표준 라이브러리는 이를 위한 std::net 모듈을 제공합니다. 늘 하던 방식으로 새 프로젝트를 만듭니다.

$ cargo new hello
     Created binary (application) `hello` project
$ cd hello

이제 src/main.rs 에 목록 21-1의 코드를 넣어 시작합니다. 이 코드는 로컬 주소 127.0.0.1:7878 에서 들어오는 TCP 스트림을 기다리고, 연결이 들어오면 Connection established! 를 출력합니다.

Filename: src/main.rs
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!");
    }
}
Listing 21-1: 들어오는 스트림을 기다리고, 스트림을 받으면 메시지 출력하기

TcpListener 를 사용하면 주소 127.0.0.1:7878 에서 TCP 연결을 기다릴 수 있습니다. 주소에서 콜론 앞부분은 여러분 컴퓨터를 나타내는 IP 주소이고(모든 컴퓨터에서 공통인 루프백 주소이며, 저자 개인 컴퓨터를 뜻하는 것은 아닙니다), 7878 은 포트입니다. 이 포트를 고른 이유는 두 가지입니다. 첫째, HTTP 는 보통 이 포트에서 동작하지 않기 때문에, 여러분이 이미 다른 웹 서버를 켜 두었다 하더라도 충돌할 가능성이 적습니다. 둘째, 7878 은 전화 키패드에서 rust 를 입력한 숫자와 비슷합니다.

이 문맥에서 bind 함수는 new 함수처럼 새 TcpListener 인스턴스를 반환합니다. 이 함수 이름이 bind 인 이유는, 네트워킹에서 포트에 연결해 “거기서 기다리는 것”을 보통 “포트에 바인딩한다”라고 부르기 때문입니다.

bind 함수는 Result<T, E> 를 반환하는데, 이는 바인딩이 실패할 수도 있음을 뜻합니다. 예를 들어 프로그램 두 개를 동시에 실행해 둘 다 같은 포트를 듣게 만들면 실패할 수 있습니다. 우리는 지금 학습용 기본 서버를 작성하는 중이므로, 이런 에러를 세밀하게 처리하지 않고 unwrap 으로 문제가 나면 프로그램을 중단시키기로 합니다.

TcpListenerincoming 메서드는 스트림들의 시퀀스를 만들어 주는 반복자를 반환합니다(좀 더 정확히는 TcpStream 타입 스트림입니다). 하나의 스트림 은 클라이언트와 서버 사이의 열린 연결 하나를 나타냅니다. 연결(connection) 이란, 클라이언트가 서버에 연결하고, 서버가 응답을 만들고, 다시 연결을 닫는 전체 요청-응답 과정을 뜻합니다. 따라서 우리는 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 되면, 그 연결은 Drop 구현에 의해 닫힙니다. 브라우저는 이런 닫힌 연결을 보고 일시적인 문제라고 생각해 다시 시도하기도 합니다.

또한 어떤 브라우저는, 이후 요청이 생길 경우 더 빨리 처리할 수 있도록 서버와의 연결을 여러 개 미리 열어 두고는 아무 요청도 보내지 않기도 합니다. 이런 경우 우리의 서버는 실제 요청이 없더라도 각 연결을 모두 보게 됩니다. 예를 들어 많은 Chromium 계열 브라우저가 그렇습니다. 이 최적화는 사설(프라이빗) 브라우징 모드나 다른 브라우저를 사용하면 비활성화될 수 있습니다.

어쨌든 중요한 사실은, 우리가 TCP 연결 핸들을 성공적으로 얻었다는 것입니다!

각 버전의 코드를 실행해 본 뒤에는 ctrl-C 로 프로그램을 중단하는 것을 잊지 마세요. 그리고 코드를 수정할 때마다 다시 cargo run 으로 최신 코드를 실행해야 합니다.

요청 읽기

이제 브라우저로부터 오는 요청을 실제로 읽는 기능을 구현해 봅시다! 먼저 “연결을 받는 일”과 “받은 연결에 대해 작업을 수행하는 일”을 분리하기 위해, 연결을 처리하는 새 함수 하나를 만들겠습니다. 이 handle_connection 함수에서는 TCP 스트림에서 데이터를 읽고, 브라우저가 무엇을 보내는지 확인할 수 있도록 그대로 출력하겠습니다. 코드를 목록 21-2처럼 바꿉니다.

Filename: src/main.rs
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:#?}");
}
Listing 21-2: TcpStream 에서 읽고 데이터를 출력하기

여기서 우리는 std::io::BufReaderstd::io::prelude 를 스코프로 가져옵니다. 이들은 스트림에서 읽고 쓰는 데 필요한 트레이트와 타입을 제공합니다. main 함수의 for 루프에서는 이제 단순히 연결되었다는 메시지를 출력하는 대신, 새로 만든 handle_connection 함수를 호출하고 stream 을 넘깁니다.

handle_connection 함수 안에서는 먼저 BufReader 인스턴스를 하나 만들어 stream 에 대한 참조를 감쌉니다. BufReaderstd::io::Read 트레이트의 메서드를 우리가 직접 반복 호출하는 대신, 내부에서 버퍼링을 관리해 줍니다.

우리는 http_request 라는 변수를 만들고, 브라우저가 서버로 보내는 요청의 각 줄을 거기에 모읍니다. 그 줄들을 벡터에 모으고 싶다는 뜻으로 Vec<_> 타입 주석을 붙였습니다.

BufReaderstd::io::BufRead 트레이트를 구현하는데, 이 트레이트는 lines 메서드를 제공합니다. lines 는 스트림 안에서 줄바꿈 바이트를 만날 때마다 끊어서 Result<String, std::io::Error> 반복자를 만듭니다. 각 String 값을 얻기 위해 우리는 각 Result 에 대해 mapunwrap 을 사용합니다. 데이터가 유효한 UTF-8이 아니거나 스트림 읽기 자체에 문제가 있으면 Result 는 에러가 될 수 있습니다. 실제 프로덕션 프로그램이라면 이런 경우도 더 우아하게 처리해야 하겠지만, 여기서는 단순화를 위해 오류 시 프로그램을 멈추기로 합니다.

브라우저는 HTTP 요청의 끝을 “연속된 두 줄바꿈”으로 나타냅니다. 따라서 스트림으로부터 하나의 요청만 얻으려면, 빈 문자열이 나오는 줄까지 읽어들이면 됩니다. 이렇게 줄들을 벡터로 모은 뒤에는, 그 요청을 들여다볼 수 있도록 pretty debug 형식으로 출력합니다.

이제 이 코드를 실행해 봅시다! 프로그램을 시작하고 다시 브라우저에서 요청을 보내 보세요. 브라우저에는 여전히 에러 페이지가 뜨겠지만, 터미널 출력은 이제 다음과 비슷할 것입니다.

$ 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 요청 자세히 보기

HTTP 는 텍스트 기반 프로토콜이고, 하나의 요청은 다음과 같은 형식을 가집니다.

Method Request-URI HTTP-Version CRLF
headers CRLF
message-body

첫 줄은 요청 줄(request line) 이고, 클라이언트가 무엇을 요청하고 있는지에 대한 정보를 담습니다. 요청 줄 첫 부분은 메서드이며, GET 이나 POST 같은 값이 올 수 있습니다. 이는 클라이언트가 어떤 방식으로 요청하고 있는지를 설명합니다. 우리 클라이언트는 GET 요청을 사용했는데, 이는 “정보를 달라”는 의미입니다.

요청 줄의 다음 부분은 / 인데, 이것은 클라이언트가 요청하는 URI(Uniform Resource Identifier) 입니다. URI 는 URL 과 아주 비슷하지만 완전히 같은 개념은 아닙니다. 하지만 이 장의 목적에서는 그 차이를 엄밀하게 알 필요가 없고, HTTP 명세는 URI 라는 말을 쓰므로 여기서는 그냥 URI 를 URL 이라고 생각해도 충분합니다.

마지막 부분은 클라이언트가 사용하는 HTTP 버전입니다. 그 뒤 요청 줄은 CRLF 시퀀스로 끝납니다. (CRLFcarriage returnline feed 를 뜻하는데, 타자기 시절의 용어입니다.) CRLF 시퀀스는 \r\n 이라고 쓸 수도 있습니다. 여기서 \r 은 carriage return, \n 은 line feed 입니다. 이 CRLF 시퀀스 가 요청 줄과 나머지 요청 데이터를 구분합니다. 출력할 때는 \r\n 이 문자 그대로 보이는 대신 실제 줄바꿈 으로 나타납니다.

지금까지 실행한 프로그램에서 받은 요청 줄 데이터를 보면, GET 이 메서드이고, / 가 요청 URI이며, HTTP/1.1 이 버전이라는 것을 알 수 있습니다.

요청 줄 뒤에 Host: 부터 시작하는 나머지 줄들은 헤더입니다. GET 요청에는 본문이 없습니다.

다른 브라우저를 사용해 보거나, 127.0.0.1:7878/test 같은 다른 주소를 요청해 보면서 요청 데이터가 어떻게 바뀌는지도 확인해 보세요.

이제 브라우저가 무엇을 요청하는지 알게 되었으니, 그에 응답하는 데이터를 보내 봅시다!