본문 바로가기

싱글스레드 웹 서버

TcpListener로 연결을 받고 HTTP 요청을 읽어 상태에 맞는 응답과 HTML 파일을 반환하는 단일 스레드 서버를 구현합니다.

긴 여정이었지만, 이 교재의 끝에 이르렀습니다. 이번 장에서는 함께 프로젝트를 하나 더 만들며, 마지막 몇 장에서 다룬 개념 몇 가지를 보여 주고 앞에서 배운 것도 다시 짚어 봅니다. 마지막 프로젝트로 "hello"라고 말하는 웹 서버를 만들며, 웹 브라우저에서는 다음 그림처럼 보일 것입니다.

hello from rust

웹 서버를 만드는 계획은 다음과 같습니다.

단계할 일
1TCP와 HTTP에 대해 조금 배웁니다.
2소켓에서 TCP 연결을 기다립니다.
3적은 수의 HTTP 요청을 파싱합니다.
4알맞은 HTTP 응답을 만듭니다.
5스레드 풀로 서버의 처리량을 높입니다.

시작하기 전에 두 가지를 알아 두어야 합니다. 첫째, 여기서 쓸 방법은 러스트로 웹 서버를 만드는 가장 좋은 방법이 아닙니다. 커뮤니티 회원들이 crates.io에 배포한, 프로덕션에 쓸 수 있는 여러 크레이트가 여기서 만들 것보다 더 완전한 웹 서버와 스레드 풀 구현을 제공합니다. 하지만 이번 장의 목적은 쉬운 길을 가는 것이 아니라 배우는 것입니다. 러스트는 시스템 프로그래밍 언어이므로, 작업할 추상화 수준을 고를 수 있고, 다른 언어에서 할 수 있거나 해야 하는 것보다 더 낮은 수준까지 내려갈 수 있습니다.

둘째, 여기서는 async와 await를 쓰지 않습니다. 스레드 풀을 만드는 것만으로도 충분히 큰 도전이며, 비동기 런타임을 만드는 일까지 더하지 않습니다. 다만 이번 장에서 볼 문제 가운데 일부에 async와 await를 어떻게 적용할 수 있는지는 우아한 종료와 정리에서 짚어 봅니다. 결국 많은 비동기 런타임도 앞에서 말한 것처럼 일을 처리하는 데 스레드 풀을 씁니다.

그러므로 기본 HTTP 서버와 스레드 풀을 직접 써서, 앞으로 쓸 수 있는 크레이트 뒤에 있는 일반적인 생각과 기법을 배울 수 있게 합니다.

싱글스레드 웹 서버 구축하기

먼저 동작하는 싱글스레드 웹 서버를 만듭니다. 시작하기 전에, 웹 서버를 만드는 데 쓰이는 프로토콜을 간단히 봅니다. 이 프로토콜들의 세부 사항은 이 교재의 범위를 벗어나지만, 간단한 개요만으로도 필요한 정보를 얻을 수 있습니다.

웹 서버에 쓰이는 두 주요 프로토콜은 하이퍼텍스트 전송 프로토콜(Hypertext Transfer Protocol, HTTP)과 전송 제어 프로토콜(Transmission Control Protocol, TCP)입니다. 두 프로토콜 모두 요청-응답(request-response) 프로토콜입니다. 곧 클라이언트가 요청을 시작하고, 서버가 그 요청을 듣고 클라이언트에게 응답합니다. 그 요청과 응답의 내용은 프로토콜이 정의합니다.

TCP는 한 서버에서 다른 서버로 정보가 어떻게 가는지에 관한 세부 사항을 설명하는 더 낮은 수준의 프로토콜이지만, 그 정보가 무엇인지는 정하지 않습니다. HTTP는 요청과 응답의 내용을 정의해 TCP 위에 만들어집니다. 기술적으로는 HTTP를 다른 프로토콜과 함께 쓸 수도 있지만, 대부분의 경우 HTTP는 TCP 위에서 데이터를 보냅니다. 여기서는 TCP와 HTTP 요청과 응답의 날 바이트를 다룹니다. 아래 그림은 이 글에서 만들 서버가 요청 하나를 받아 응답하기까지를 단계별로 보여 줍니다.

싱글스레드 요청·응답 순서

TcpStream의 첫 줄을 읽어 정확한 GET / HTTP/1.1 여부로 파일·상태를 하나 선택하고 바이트 길이 헤더·CRLF·본문을 write_all 하는 실제 handler 실행이다. 200·404는 별개 입력 사례로 유지한다.

RUST · 요청과 응답

하나의 요청이 200 또는 404 응답으로 돌아오는 과정

연결을 받아 요청 첫 줄을 읽고, 본문 파일과 상태를 고른 뒤 응답 바이트를 씁니다.

첫 요청 줄의 정확한 비교로 200 또는 404 중 하나를 선택한 뒤 바이트 응답을 쓰고 다음 연결로 돌아간다.read→match: 첫 줄로 분기。match→ok: 루트 GET인 경우。match→bad: 다른 메서드·경로·버전도 404。ok→bytes: 선택한 상태와 파일만 조립。bad→bytes: 서로 배타적인 파일 선택。bytes→write: Content-Length는 UTF-8 바이트 길이일치그 밖TcpStream 첫 줄TcpStream 첫 줄BufReader로 읽기GET / HTTP/1.1?GET / HTTP/1.1?전체 첫 줄 정확 비교일치: 200일치: 200hello.html그 밖: 404그 밖: 404404.html하나의 응답 조립하나의 응답 조립상태 · 헤더 · CRLF · 본문write_all(bytes)write_all(bytes)성공 → handler 끝다음 연결
첫 요청 줄의 정확한 비교로 200 또는 404 중 하나를 선택한 뒤 바이트 응답을 쓰고 다음 연결로 돌아간다.read→match: 첫 줄로 분기。match→ok: 루트 GET인 경우。match→bad: 다른 메서드·경로·버전도 404。ok→bytes: 선택한 상태와 파일만 조립。bad→bytes: 서로 배타적인 파일 선택。bytes→write: Content-Length는 UTF-8 바이트 길이일치그 밖TcpStream 첫 줄TcpStream 첫 줄BufReader로 읽기GET / HTTP/1.1?GET / HTTP/1.1?전체 첫 줄 정확 비교일치: 200일치: 200hello.html그 밖: 404그 밖:404404.html하나의 응답 조립하나의 응답 조립상태 · 헤더 · CRLF · 본문write_all(bytes)write_all(bytes)성공 → handler 끝다음 연결

Content-Length는 문자 수가 아닌 본문 바이트 수이다. 예제의 read/write unwrap 오류는 panic 경로이다.

코드·조건·비교표와 보충 설명

1 · 연결을 받고 요청 첫 줄 읽기

listener.incoming()에서 받은 TcpStream을 handler에 넘깁니다. BufReader::lines().next()로 요청 첫 줄을 읽습니다.

2A · 정확히 GET / HTTP/1.1

hello.html을 읽습니다. 응답 상태는 HTTP/1.1 200 OK입니다.

2B · 그 밖의 요청 첫 줄

404.html을 읽습니다. 응답 상태는 HTTP/1.1 404 NOT FOUND입니다. 경로뿐 아니라 메서드·버전까지 첫 줄 전체를 비교합니다.

3 · 상태 → 헤더 → 빈 줄 → 본문

contents.len()은 UTF-8 본문의 바이트 수입니다. CRLF 두 번으로 헤더와 본문을 구분합니다.

HTTP/1.1 200 OK    [CRLF]
Content-Length: 25 [CRLF]
                  [CRLF]
본문 (예: UTF-8 25바이트)

4 · TcpStream으로 응답 쓰기

stream.write_all(
    response.as_bytes()
).unwrap();

응답 바이트 전체를 쓰는 데 성공하면 handler가 끝나고 루프가 다음 연결을 받습니다. 쓰기 오류는 이 코드의 unwrap()에서 패닉으로 이어집니다.

두 파일을 모두 보내는 것이 아니라, 요청 첫 줄에 맞는 파일 하나와 상태 하나를 골라 응답합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 줄 읽기

TcpStream의 요청 첫 줄을 확인한다.

  • 입력: GET / HTTP/1.1
  • 읽기: lines().next()
  • 메서드·경로·버전 전체를 정확히 비교하는 예제다.
응답 선택

이 입력에서 hello 파일과 200 상태를 고른다.

  • 본문: hello.html
  • 상태: 200 OK
  • 404 파일은 다른 첫 줄의 대안으로 둔다.
바이트 쓰기

상태·길이·빈 줄·본문을 write_all한다.

  • 길이: UTF-8 바이트 수
  • 헤더 경계: CRLF 두 번
  • 완전한 바이트 쓰기 성공 뒤 handler가 끝나며 오류는 unwrap 패닉이다.

싱글스레드 요청·응답 순서

TcpStream의 첫 줄을 읽어 정확한 GET / HTTP/1.1 여부로 파일·상태를 하나 선택하고 바이트 길이 헤더·CRLF·본문을 write_all 하는 실제 handler 실행이다. 200·404는 별개 입력 사례로 유지한다.

RUST · 요청과 응답

하나의 요청이 200 또는 404 응답으로 돌아오는 과정

연결을 받아 요청 첫 줄을 읽고, 본문 파일과 상태를 고른 뒤 응답 바이트를 씁니다.

첫 요청 줄의 정확한 비교로 200 또는 404 중 하나를 선택한 뒤 바이트 응답을 쓰고 다음 연결로 돌아간다.read→match: 첫 줄로 분기。match→ok: 루트 GET인 경우。match→bad: 다른 메서드·경로·버전도 404。ok→bytes: 선택한 상태와 파일만 조립。bad→bytes: 서로 배타적인 파일 선택。bytes→write: Content-Length는 UTF-8 바이트 길이일치그 밖TcpStream 첫 줄TcpStream 첫 줄BufReader로 읽기GET / HTTP/1.1?GET / HTTP/1.1?전체 첫 줄 정확 비교일치: 200일치: 200hello.html그 밖: 404그 밖: 404404.html하나의 응답 조립하나의 응답 조립상태 · 헤더 · CRLF · 본문write_all(bytes)write_all(bytes)성공 → handler 끝다음 연결
첫 요청 줄의 정확한 비교로 200 또는 404 중 하나를 선택한 뒤 바이트 응답을 쓰고 다음 연결로 돌아간다.read→match: 첫 줄로 분기。match→ok: 루트 GET인 경우。match→bad: 다른 메서드·경로·버전도 404。ok→bytes: 선택한 상태와 파일만 조립。bad→bytes: 서로 배타적인 파일 선택。bytes→write: Content-Length는 UTF-8 바이트 길이일치그 밖TcpStream 첫 줄TcpStream 첫 줄BufReader로 읽기GET / HTTP/1.1?GET / HTTP/1.1?전체 첫 줄 정확 비교일치: 200일치: 200hello.html그 밖: 404그 밖:404404.html하나의 응답 조립하나의 응답 조립상태 · 헤더 · CRLF · 본문write_all(bytes)write_all(bytes)성공 → handler 끝다음 연결

Content-Length는 문자 수가 아닌 본문 바이트 수이다. 예제의 read/write unwrap 오류는 panic 경로이다.

코드·조건·비교표와 보충 설명

1 · 연결을 받고 요청 첫 줄 읽기

listener.incoming()에서 받은 TcpStream을 handler에 넘깁니다. BufReader::lines().next()로 요청 첫 줄을 읽습니다.

2A · 정확히 GET / HTTP/1.1

hello.html을 읽습니다. 응답 상태는 HTTP/1.1 200 OK입니다.

2B · 그 밖의 요청 첫 줄

404.html을 읽습니다. 응답 상태는 HTTP/1.1 404 NOT FOUND입니다. 경로뿐 아니라 메서드·버전까지 첫 줄 전체를 비교합니다.

3 · 상태 → 헤더 → 빈 줄 → 본문

contents.len()은 UTF-8 본문의 바이트 수입니다. CRLF 두 번으로 헤더와 본문을 구분합니다.

HTTP/1.1 200 OK    [CRLF]
Content-Length: 25 [CRLF]
                  [CRLF]
본문 (예: UTF-8 25바이트)

4 · TcpStream으로 응답 쓰기

stream.write_all(
    response.as_bytes()
).unwrap();

응답 바이트 전체를 쓰는 데 성공하면 handler가 끝나고 루프가 다음 연결을 받습니다. 쓰기 오류는 이 코드의 unwrap()에서 패닉으로 이어집니다.

두 파일을 모두 보내는 것이 아니라, 요청 첫 줄에 맞는 파일 하나와 상태 하나를 골라 응답합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 줄 읽기

TcpStream의 요청 첫 줄을 확인한다.

  • 입력: GET / HTTP/1.1
  • 읽기: lines().next()
  • 메서드·경로·버전 전체를 정확히 비교하는 예제다.
응답 선택

이 입력에서 hello 파일과 200 상태를 고른다.

  • 본문: hello.html
  • 상태: 200 OK
  • 404 파일은 다른 첫 줄의 대안으로 둔다.
바이트 쓰기

상태·길이·빈 줄·본문을 write_all한다.

  • 길이: UTF-8 바이트 수
  • 헤더 경계: CRLF 두 번
  • 완전한 바이트 쓰기 성공 뒤 handler가 끝나며 오류는 unwrap 패닉이다.

listener.incoming()에서 받은 TcpStream을 처리 함수에 넘기면, 함수는 BufReader의 lines().next()로 요청의 첫 줄을 읽습니다. 첫 줄이 정확히 GET / HTTP/1.1이면 hello.html을 읽고 상태는 HTTP/1.1 200 OK로, 그 밖이면 404.html을 읽고 상태는 HTTP/1.1 404 NOT FOUND로 정합니다. 경로만이 아니라 메서드와 버전까지 첫 줄 전체를 비교하므로, 다른 메서드나 버전도 404가 됩니다. 그다음 상태 줄, Content-Length 헤더, 빈 줄, 본문의 순서로 응답 하나를 조립하며, 헤더와 본문은 CRLF 두 번으로 나뉩니다. contents.len()은 글자 수가 아니라 UTF-8 본문의 바이트 수입니다. 마지막으로 stream.write_all(response.as_bytes())로 응답 바이트 전체를 쓰고, 성공하면 처리 함수가 끝나 루프가 다음 연결을 받습니다. 이 글의 코드에서 읽기나 쓰기 오류는 unwrap()에서 패닉으로 이어집니다. 두 파일을 모두 보내는 것이 아니라, 요청 첫 줄에 맞는 하나만 보냅니다. 이 과정을 차례로 만들어 갑니다.

TCP 연결 수신 대기하기

웹 서버는 TCP 연결을 기다려야 하므로, 그것이 처음 할 일입니다. 표준 라이브러리는 이것을 할 수 있게 해 주는 std::net 모듈을 제공합니다. 평소처럼 새 프로젝트를 만듭니다.

cargo new hello
cd hello

이제 시작으로 src/main.rs에 다음 코드를 넣습니다. 이 코드는 로컬 주소 127.0.0.1:7878에서 들어오는 TCP 스트림을 기다립니다. 들어오는 스트림을 받으면 Connection established!를 출력합니다.

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!");
    }
}

TcpListener로 주소 127.0.0.1:7878에서 TCP 연결을 기다릴 수 있습니다. 주소에서 콜론 앞부분은 우리 컴퓨터를 나타내는 IP 주소이며(모든 컴퓨터에서 같고, 저자들의 컴퓨터만을 뜻하지 않습니다), 7878은 포트입니다. 이 포트를 고른 까닭은 두 가지입니다. HTTP는 보통 이 포트에서 받아들여지지 않으므로, 우리 서버가 우리 컴퓨터에서 실행 중일지 모르는 다른 웹 서버와 충돌할 가능성이 낮습니다. 또 7878은 전화기 자판으로 rust를 입력한 숫자입니다.

이 경우 bind 함수는 new 함수처럼 동작해, 새 TcpListener 인스턴스를 돌려줍니다. 이 함수를 bind라고 부르는 까닭은, 네트워킹에서 포트에 연결해 기다리는 것을 "포트에 바인딩한다"라고 하기 때문입니다.

bind 함수는 바인딩이 실패할 수 있음을 나타내는 Result<T, E>를 돌려줍니다. 예를 들어 서버 프로그램 두 개를 실행해 두 프로그램이 같은 포트를 기다리게 하면 바인딩이 실패합니다. 우리는 배우려고 기본 서버를 쓰는 것이므로, 이런 종류의 오류를 처리하는 일은 신경 쓰지 않습니다. 대신 unwrap으로 오류가 나면 프로그램을 멈춥니다.

TcpListener의 incoming 메서드는 일련의 스트림(더 구체적으로는 TcpStream 타입의 스트림)을 주는 반복자를 돌려줍니다. 하나의 스트림(stream)은 클라이언트와 서버 사이의 열린 연결을 나타냅니다. 연결(connection)은 클라이언트가 서버에 연결하고, 서버가 응답을 만들고, 서버가 연결을 닫는 요청과 응답의 전체 과정을 가리킵니다. 따라서 클라이언트가 보낸 것을 보려면 TcpStream에서 읽고, 응답을 클라이언트에게 돌려보내려면 스트림에 데이터를 씁니다. 전체적으로 이 for 루프는 각 연결을 차례로 처리하며, 다룰 일련의 스트림을 만들어 냅니다.

지금은 스트림을 처리하는 일이, 스트림에 오류가 있으면 unwrap을 불러 프로그램을 끝내는 것뿐입니다. 오류가 없으면 프로그램은 메시지를 출력합니다. 다음 코드에서 성공했을 때의 기능을 더 더합니다. 클라이언트가 서버에 연결할 때 incoming 메서드에서 오류를 받을 수 있는 까닭은, 실제로 연결을 반복하는 것이 아니기 때문입니다. 대신 연결 시도(connection attempt)를 반복합니다. 연결은 여러 까닭으로 성공하지 못할 수 있으며, 그 가운데 많은 것은 운영체제에 따라 다릅니다. 예를 들어 많은 운영체제에는 동시에 열 수 있는 연결의 수에 제한이 있습니다. 그 수를 넘는 새 연결 시도는, 열린 연결 일부가 닫힐 때까지 오류를 냅니다.

이 코드를 실행해 봅니다. 터미널에서 cargo run을 실행한 다음, 웹 브라우저에서 127.0.0.1:7878을 엽니다. 서버가 지금은 아무 데이터도 돌려보내지 않으므로, 브라우저는 "Connection reset" 같은 오류 메시지를 보여 줄 것입니다. 하지만 터미널을 보면, 브라우저가 서버에 연결할 때 출력된 메시지 몇 개를 볼 수 있습니다.

     Running `target/debug/hello`
Connection established!
Connection established!
Connection established!

브라우저 요청 하나에 메시지가 여러 개 출력될 때가 있습니다. 브라우저가 페이지를 요청하면서, 브라우저 탭에 나타나는 favicon.ico 아이콘 같은 다른 자원도 요청할 수 있기 때문입니다.

또 서버가 아무 데이터도 돌려주지 않으므로, 브라우저가 서버에 여러 번 연결하려 할 수도 있습니다. 루프의 끝에서 stream이 스코프를 벗어나 버려지면, drop 구현의 일부로 연결이 닫힙니다. 브라우저는 문제가 일시적일 수 있으므로, 닫힌 연결을 다시 시도해 처리하기도 합니다.

브라우저도 서버에 연결을 여러 개 열고 요청을 보내지 않을 때가 있어서, 나중에 요청을 보낼 때 더 빨리 처리할 수 있게 합니다. 그때 서버는 그 연결에 요청이 있든 없든 각 연결을 보게 됩니다. 예를 들어 크롬 계열의 많은 브라우저가 그렇게 합니다. 시크릿 모드를 쓰거나 다른 브라우저를 쓰면 이 최적화를 끌 수 있습니다.

중요한 것은 TCP 연결에 대한 핸들을 성공적으로 얻었다는 것입니다.

특정 버전의 코드를 다 실행했으면 Ctrl+C로 프로그램을 멈춰야 한다는 것을 기억하세요. 그다음 코드를 고칠 때마다 cargo run 명령을 실행해 프로그램을 다시 시작해, 가장 최신 코드를 실행하고 있는지 확인합니다.

요청 읽기

브라우저의 요청을 읽는 기능을 구현합니다. 먼저 연결을 얻는 것과 연결로 어떤 동작을 하는 것의 관심사를 나누기 위해, 연결을 처리할 새 함수를 시작합니다. 이 새 handle_connection 함수에서는 TCP 스트림에서 데이터를 읽고 출력해, 브라우저가 보낸 데이터를 볼 수 있게 합니다. 코드를 다음처럼 바꿉니다.

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:#?}");
}

스트림에서 읽고 쓸 수 있게 해 주는 트레이트와 타입을 쓰려고 std::io::prelude와 std::io::BufReader를 스코프로 가져옵니다. main 함수의 for 루프에서는 연결을 만들었다는 메시지를 출력하는 대신, 이제 새 handle_connection 함수를 부르고 stream을 넘깁니다.

handle_connection 함수에서는 stream의 참조를 감싸는 새 BufReader 인스턴스를 만듭니다. BufReader는 std::io::Read 트레이트의 메서드 호출을 관리해 버퍼링을 더해 줍니다.

브라우저가 서버에 보내는 요청의 줄을 모으려고 http_request라는 변수를 만듭니다. Vec<_> 타입 표시를 더해 이 줄들을 벡터에 모으고 싶다고 나타냅니다.

BufReader는 lines 메서드를 제공하는 std::io::BufRead 트레이트를 구현합니다. lines 메서드는 새 줄 바이트를 볼 때마다 데이터의 스트림을 나눠, Result<String, std::io::Error>의 반복자를 돌려줍니다. 각 String을 얻으려고 각 Result를 map하고 unwrap합니다. 데이터가 올바른 UTF-8이 아니거나 스트림에서 읽는 데 문제가 있으면 Result는 오류일 수 있습니다. 다시 말하지만 프로덕션 프로그램은 이런 오류를 더 우아하게 처리해야 하지만, 여기서는 단순하게 하려고 오류일 때 프로그램을 멈추게 했습니다.

브라우저는 줄바꿈 문자 두 개를 잇달아 보내 HTTP 요청의 끝을 알리므로, 스트림에서 요청 하나를 얻으려면 빈 문자열인 줄을 얻을 때까지 줄을 가져옵니다. 줄을 벡터에 모았으면, 웹 브라우저가 서버에 보내는 지시를 볼 수 있도록 보기 좋은 디버그 형식으로 출력합니다.

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

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는 텍스트 기반 프로토콜이며, 요청은 다음 형식을 가집니다. 줄 끝의 CRLF는 캐리지 리턴과 줄 바꿈 두 글자(\r\n)를 뜻하며, HTTP는 이 두 글자로 요청의 줄을 나눕니다.

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

첫 줄은 클라이언트가 무엇을 요청하는지에 관한 정보를 담은 요청 줄(request line)입니다. 요청 줄의 첫 부분은 GET이나 POST 같은 메서드로, 클라이언트가 이 요청을 어떻게 하는지를 나타냅니다. 우리 클라이언트는 GET 요청을 썼으며, 이것은 정보를 요청한다는 뜻입니다.

요청 줄의 다음 부분은 /로, 클라이언트가 요청하는 통합 자원 식별자(Uniform Resource Identifier, URI)를 나타냅니다. URI는 통합 자원 위치(Uniform Resource Locator, URL)와 거의 같지만 아주 같지는 않습니다. 이번 장의 목적에서 URI와 URL의 차이는 중요하지 않지만, HTTP 명세는 URI라는 용어를 쓰므로 여기서는 URI를 URL로 바꿔 생각하면 됩니다.

마지막 부분은 클라이언트가 쓰는 HTTP 버전이며, 요청 줄은 CRLF 시퀀스로 끝납니다. CRLF는 캐리지 리턴(carriage return)과 라인 피드(line feed)를 뜻하며, 타자기 시절에서 온 용어입니다. CRLF 시퀀스는 \r\n이라고도 쓰며, \r은 캐리지 리턴이고 \n은 라인 피드입니다. CRLF 시퀀스는 요청 줄과 요청의 나머지 데이터를 나눕니다. CRLF가 출력될 때는 \r\n이 보이는 대신 새 줄이 시작된다는 점을 알아 두세요.

지금까지 프로그램을 실행해 받은 요청 줄 데이터를 보면, GET이 메서드이고, /가 요청 URI이며, HTTP/1.1이 버전입니다.

요청 줄 다음에, Host:부터 시작하는 남은 줄들은 헤더입니다. GET 요청에는 본문이 없습니다.

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

브라우저가 무엇을 요청하는지 알았으니, 데이터를 돌려보내 봅니다.

응답 작성하기

클라이언트 요청에 대한 응답으로 데이터를 보내는 것을 구현합니다. 응답은 다음 형식을 가집니다.

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body

첫 줄은 상태 줄(status line)로, 응답에 쓰인 HTTP 버전, 요청의 결과를 요약하는 숫자 상태 코드, 그리고 상태 코드를 글로 설명하는 사유 구절(reason phrase)을 담습니다. CRLF 시퀀스 다음에는 헤더, 또 하나의 CRLF 시퀀스, 그리고 응답의 본문이 옵니다.

다음은 HTTP 버전 1.1을 쓰고, 상태 코드가 200이며, 사유 구절이 OK이고, 헤더와 본문이 없는 응답의 예입니다.

HTTP/1.1 200 OK\r\n\r\n

상태 코드 200은 표준 성공 응답입니다. 이 텍스트는 작은 성공 HTTP 응답입니다. 성공한 요청에 대한 응답으로 이것을 스트림에 써 봅니다. handle_connection 함수에서 요청 데이터를 출력하던 println!을 지우고, 다음 코드로 바꿉니다.

src/main.rs
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 반환하기

빈 페이지 이상을 돌려주는 기능을 구현합니다. 프로젝트 폴더의 맨 위, src 폴더가 아닌 곳에 새 파일 hello.html을 만듭니다. 원하는 HTML을 넣어도 되며, 다음은 한 가지 예입니다.

hello.html
<!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 문서입니다. 요청을 받았을 때 서버에서 이것을 돌려주려면, 다음처럼 handle_connection을 고쳐 HTML 파일을 읽고, 응답에 본문으로 더해 보냅니다.

src/main.rs
use std::{
    fs,
    io::{BufReader, prelude::*},
    net::{TcpListener, TcpStream},
};
// --생략--

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를 더했습니다. 파일의 내용을 문자열로 읽는 코드는 익숙해 보일 것입니다. 파일 읽기에서 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을 고쳐야 합니다. 이 새 코드는 받은 요청의 내용을 /에 대한 요청이 어떻게 생겼는지 아는 것과 비교하고, 요청을 다르게 다루려고 if와 else 블록을 더합니다.

src/main.rs
// --생략--

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 {
        // 다른 요청
    }
}

HTTP 요청의 첫 줄만 볼 것이므로, 요청 전체를 벡터로 읽는 대신 next를 불러 반복자의 첫 항목을 얻습니다. 첫 unwrap은 Option을 다루며, 반복자에 항목이 없으면 프로그램을 멈춥니다. 둘째 unwrap은 Result를 다루며, 앞의 코드에서 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 같은 다른 요청을 보내면, 앞에서 본 것과 같은 연결 오류를 받을 것입니다.

이제 다음 코드를 else 블록에 더해, 요청한 내용을 찾을 수 없다는 신호인 상태 코드 404의 응답을 돌려줍니다. 최종 사용자에게 응답을 보여 줄 수 있도록, 브라우저에 그려질 페이지의 HTML도 돌려줍니다.

src/main.rs
    // --생략--
    } 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을 넣어도 되며, 다음은 한 가지 예입니다.

404.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 줄로 뽑아내, 상태 줄과 파일 이름의 값을 변수에 넣어 코드를 더 간결하게 만듭니다. 그러면 그 변수들을 코드에서 조건 없이 써서 파일을 읽고 응답을 쓸 수 있습니다. 다음 코드는 큰 if와 else 블록을 바꾼 결과입니다.

src/main.rs
// --생략--

fn handle_connection(mut stream: TcpStream) {
    // --생략--
    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 블록은 튜플로 상태 줄과 파일 이름에 알맞은 값만 돌려줍니다. 그다음 패턴 문법에서 이야기했듯, let 구문의 패턴으로 이 두 값을 status_line과 filename에 대입하는 해체를 합니다.

앞에서 중복되던 코드는 이제 if와 else 블록 바깥에 있으며, status_line과 filename 변수를 씁니다. 그러면 두 경우의 차이를 보기 쉬워지고, 파일을 읽고 응답을 쓰는 방식을 바꾸고 싶을 때 고칠 곳이 한 곳뿐입니다. 이 코드의 동작은 앞의 코드와 같습니다.

멋집니다. 약 40줄의 러스트 코드로, 한 요청에는 내용 페이지로 응답하고 다른 모든 요청에는 404 응답으로 답하는 간단한 웹 서버가 생겼습니다.

지금 서버는 단일 스레드에서 실행되므로, 한 번에 요청 하나만 처리할 수 있습니다. 아래 그림은 이것이 왜 문제가 될 수 있는지 보여 줍니다.

싱글스레드 선두 지연

A handler가 단일 application loop를 점유한 동안 B 연결이 대기하고 A 종료 뒤 B accept·처리를 시작하는 명확한 시간 순서다. 연결 대기와 application 처리 대기를 나누면 선두 지연 원인이 드러난다.

RUST · SEQUENCE

느린 A가 끝나야 B 처리가 시작된다

단일 루프는 연결 하나의 handler를 끝낸 뒤 다음 연결을 받습니다. A가 느리면 B의 처리도 뒤로 밀립니다.

싱글스레드 선두 지연 클라이언트 A, 연결 대기, 단일 application loop, 클라이언트 B의 네 lifeline에서 A의 느린 처리 때문에 B handling이 뒤로 밀리는 여덟 메시지를 보여줍니다. A가 loop 점유 A 연결 A accept 느린 handler A B 연결 · app 대기 A 응답 완료 A 뒤 B accept handler B B 응답 완료 요청자 A Listener연결 대기 단일 처리 루프한 handler씩 실행 요청자 B
단일 처리 루프가 handler A를 실행하는 동안 B 연결은 application에서 처리되지 못하며 A 완료 뒤 B를 받는다.a→busy: A가 단일 루프 점유。b→wait: 연결 대기와 handler 실행을 구분。busy→done: A 처리 종료。done→next: A 뒤에 B accept。wait→next: B handler는 A가 끝난 뒤 시작이후요청 A요청 A연결 ·accept요청 B요청 B연결대기단일 루프단일루프느린handler AB는 대기B는 대기아직handler B없음A 응답 완료A 응답완료루프가비워짐다음 accept: B다음 accept: Bhandler B → B 응답
단계별 조건과 보충 설명
  1. A 연결

    Listener가 A를 accept해 단일 application loop에 전달합니다.

  2. 느린 handler A

    A를 처리하는 동안에는 단일 루프가 다음 연결을 받으러 돌아가지 않습니다.

  3. B 연결 · 애플리케이션 대기

    B 연결이 들어와도 A handler가 끝나기 전에는 B의 application handling이 시작되지 않습니다.

  4. A 응답 완료

    A가 끝나야 loop가 다음 연결을 받을 수 있습니다.

  5. A 뒤 B accept

    Listener가 B를 loop에 전달하고 handler B가 실행됩니다.

  6. B 응답 완료

    A 처리가 끝난 뒤 B를 받아 처리하고 응답합니다. 실제 대기 시간은 A의 처리 시간과 연결 시점에 따라 달라집니다.

  • 굵은 화살표: 느린 A 처리
  • 실선: 연결 전달과 handler 호출
  • 점선은 A·B 응답 반환

느린 요청 A 처리 중 들어온 연결 B가 단일 application loop에서 기다렸다가 A 종료 뒤 처리되는 순서입니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A accept·처리 시작

A를 accept한 단일 loop가 느린 A handler를 시작하며 B는 아직 도착하지 않았다.

  • loop: A handler 실행
  • B: 아직 미도착
  • 한 handler를 끝내기 전에는 단일 application loop가 다음 연결을 받으러 돌아가지 않는다.
B 도착·대기

A 처리 중 B 연결이 도착해도 B의 application 처리는 시작되지 않는다.

  • A: 아직 처리 중
  • B 연결: 도착 · application 처리 대기
  • B handler: 미시작
  • 연결 요청의 도착과 accept·handler 실행은 다르다. loop가 A를 처리하는 동안 B 처리가 밀린다.
A 뒤 B

A 응답을 완료한 뒤 B를 accept해 handler와 응답을 수행한다.

  • 순서: A 완료 → B accept → B handler
  • 응답: B 완료
  • A handler를 끝내야 loop가 다음 연결을 받아 처리할 수 있으므로 느린 A가 B의 대기를 만든다.

싱글스레드 선두 지연

A handler가 단일 application loop를 점유한 동안 B 연결이 대기하고 A 종료 뒤 B accept·처리를 시작하는 명확한 시간 순서다. 연결 대기와 application 처리 대기를 나누면 선두 지연 원인이 드러난다.

RUST · SEQUENCE

느린 A가 끝나야 B 처리가 시작된다

단일 루프는 연결 하나의 handler를 끝낸 뒤 다음 연결을 받습니다. A가 느리면 B의 처리도 뒤로 밀립니다.

싱글스레드 선두 지연 클라이언트 A, 연결 대기, 단일 application loop, 클라이언트 B의 네 lifeline에서 A의 느린 처리 때문에 B handling이 뒤로 밀리는 여덟 메시지를 보여줍니다. A가 loop 점유 A 연결 A accept 느린 handler A B 연결 · app 대기 A 응답 완료 A 뒤 B accept handler B B 응답 완료 요청자 A Listener연결 대기 단일 처리 루프한 handler씩 실행 요청자 B
단일 처리 루프가 handler A를 실행하는 동안 B 연결은 application에서 처리되지 못하며 A 완료 뒤 B를 받는다.a→busy: A가 단일 루프 점유。b→wait: 연결 대기와 handler 실행을 구분。busy→done: A 처리 종료。done→next: A 뒤에 B accept。wait→next: B handler는 A가 끝난 뒤 시작이후요청 A요청 A연결 ·accept요청 B요청 B연결대기단일 루프단일루프느린handler AB는 대기B는 대기아직handler B없음A 응답 완료A 응답완료루프가비워짐다음 accept: B다음 accept: Bhandler B → B 응답
단계별 조건과 보충 설명
  1. A 연결

    Listener가 A를 accept해 단일 application loop에 전달합니다.

  2. 느린 handler A

    A를 처리하는 동안에는 단일 루프가 다음 연결을 받으러 돌아가지 않습니다.

  3. B 연결 · 애플리케이션 대기

    B 연결이 들어와도 A handler가 끝나기 전에는 B의 application handling이 시작되지 않습니다.

  4. A 응답 완료

    A가 끝나야 loop가 다음 연결을 받을 수 있습니다.

  5. A 뒤 B accept

    Listener가 B를 loop에 전달하고 handler B가 실행됩니다.

  6. B 응답 완료

    A 처리가 끝난 뒤 B를 받아 처리하고 응답합니다. 실제 대기 시간은 A의 처리 시간과 연결 시점에 따라 달라집니다.

  • 굵은 화살표: 느린 A 처리
  • 실선: 연결 전달과 handler 호출
  • 점선은 A·B 응답 반환

느린 요청 A 처리 중 들어온 연결 B가 단일 application loop에서 기다렸다가 A 종료 뒤 처리되는 순서입니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A accept·처리 시작

A를 accept한 단일 loop가 느린 A handler를 시작하며 B는 아직 도착하지 않았다.

  • loop: A handler 실행
  • B: 아직 미도착
  • 한 handler를 끝내기 전에는 단일 application loop가 다음 연결을 받으러 돌아가지 않는다.
B 도착·대기

A 처리 중 B 연결이 도착해도 B의 application 처리는 시작되지 않는다.

  • A: 아직 처리 중
  • B 연결: 도착 · application 처리 대기
  • B handler: 미시작
  • 연결 요청의 도착과 accept·handler 실행은 다르다. loop가 A를 처리하는 동안 B 처리가 밀린다.
A 뒤 B

A 응답을 완료한 뒤 B를 accept해 handler와 응답을 수행한다.

  • 순서: A 완료 → B accept → B handler
  • 응답: B 완료
  • A handler를 끝내야 loop가 다음 연결을 받아 처리할 수 있으므로 느린 A가 B의 대기를 만든다.

리스너가 요청 A의 연결을 받아 단일 처리 루프에 넘기면, 루프는 A를 처리하는 동안 다음 연결을 받으러 돌아가지 않습니다. A를 처리하는 중에 요청 B의 연결이 들어와도, A의 처리가 끝나기 전에는 B의 처리가 시작되지 않습니다. A의 응답을 마쳐야 루프가 비워지고, 그다음에야 B를 받아 처리하고 응답합니다. 실제로 B가 기다리는 시간은 A의 처리 시간과 연결 시점에 따라 다릅니다. 다음 글에서는 느린 요청을 흉내 내 이 문제를 직접 보고, 서버가 한 번에 여러 요청을 처리하도록 고칩니다.

더 깊이: 이 서버가 HTTP/1.1의 규칙을 어디까지 따를까

이 서버는 요청 줄 하나만 보고 응답하지만, HTTP/1.1의 메시지 형식을 정한 RFC 9112와 견주어 보면 몇 가지가 드러납니다. RFC는 줄 끝을 CRLF로 정하면서도, 받는 쪽은 CR 없는 LF 하나도 줄 끝으로 알아보고 그 앞의 CR은 무시해도 된다고 허용합니다. BufRead::lines는 문서대로 줄 끝의 \n과 \r\n을 모두 떼어 내므로, 이 서버는 두 형태의 요청을 똑같이 읽습니다. 다만 lines는 read_line처럼 읽은 바이트가 올바른 UTF-8이 아니면 Err를 돌려주므로, 그런 요청이 오면 지금 코드의 unwrap이 패닉을 냅니다.

응답의 Content-Length는 RFC에 따르면 본문의 길이를 옥텟(octet), 곧 바이트 단위의 10진수로 적은 값이므로, 글자 수가 아니라 바이트 수를 돌려주는 contents.len()을 쓰는 것이 맞습니다. 이 헤더도 Transfer-Encoding도 없는 응답이라면, 받는 쪽은 서버가 연결을 닫을 때까지 받은 바이트를 본문으로 봅니다. 또 HTTP/1.1은 기본으로 연결을 계속 열어 두는 지속 연결(persistent connection)을 쓰지만, 이 서버는 응답을 쓴 뒤 stream이 버려지면서 연결을 닫습니다. RFC는 지속 연결을 지원하지 않는 서버가 정보성(1xx) 응답이 아닌 모든 응답에 Connection: close를 보내야 한다고 정하므로, 이 서버처럼 응답마다 연결을 닫는 실제 서버라면 이 헤더를 함께 보내야 합니다.

TcpListener::incoming이 돌려주는 반복자도 알아 둘 점이 있습니다. 문서에 따르면 이 반복자는 결코 None을 돌려주지 않으며, 그것을 도는 것은 accept를 반복해 부르는 것과 같습니다. 그래서 main의 for 루프는 스스로 끝나지 않으며, 연결마다 실패할 수 있으므로 각 항목은 Result입니다.

정리

  • HTTP는 TCP 위에서 요청과 응답의 내용을 정하는 텍스트 기반 프로토콜이며, 요청은 요청 줄, 헤더, 본문으로, 응답은 상태 줄, 헤더, 본문으로 이루어지고 CRLF로 나뉩니다.
  • TcpListener::bind("127.0.0.1:7878")로 포트에 바인딩하고, incoming()이 주는 연결 시도를 for로 차례로 처리합니다.
  • BufReader의 lines()로 요청을 줄 단위로 읽으며, 빈 줄이 요청의 끝입니다.
  • 응답은 format!으로 상태 줄, Content-Length 헤더, 빈 줄, 본문을 이어 만들고 write_all로 바이트를 씁니다. Content-Length는 본문의 바이트 수입니다.
  • 요청 줄이 정확히 GET / HTTP/1.1이면 hello.html을, 그 밖에는 404 상태와 404.html을 돌려주며, 단일 스레드라 느린 요청이 뒤의 요청을 막습니다.

연습 문제

1. 무엇을 돌려줄까

이 글의 최종 서버에 (가) GET / HTTP/1.1, (나) GET /index.html HTTP/1.1, (다) POST / HTTP/1.1, (라) GET / HTTP/1.0이라는 요청 줄이 오면 각각 어떤 상태 줄과 파일을 돌려주나요?

해설 보기

요청 줄 전체를 "GET / HTTP/1.1"과 정확히 비교하므로, (가)만 HTTP/1.1 200 OK와 hello.html을 돌려줍니다. 경로가 다른 (나), 메서드가 다른 (다), 버전이 다른 (라)는 모두 HTTP/1.1 404 NOT FOUND와 404.html을 돌려줍니다. 경로만으로 판단하려면 요청 줄을 공백으로 나눠 두 번째 부분을 비교하도록 고쳐야 합니다.

2. Content-Length를 잘못 쓰면

hello.html에 한글 "안녕"을 더한 뒤 let length = contents.chars().count();로 길이를 정하면 어떤 문제가 생기나요?

해설 보기

Content-Length는 본문의 바이트 수여야 하는데, chars().count()는 글자 수를 셉니다. "안녕"은 글자 2개지만 UTF-8로 6바이트이므로, 헤더가 실제 본문보다 4바이트 작은 길이를 알려 주어 브라우저가 본문의 끝부분을 잘라 버릴 수 있습니다. 이 글의 코드처럼 contents.len()을 쓰면 바이트 수가 되어 올바릅니다.

다음 글 싱글스레드 서버를 멀티스레드 서버로 바꾸기에서는 느린 요청이 서버 전체를 막는 문제를 재현하고, 스레드 풀로 여러 요청을 함께 처리합니다.