0%

단일 스레드 웹서버 구현 (1)

단일 스레드 웹서버를 러스트로 구현하는 과정을 정리합니다.

시작하기 전에

본격적으로 시작하기 전에 알아야 할 개념들을 간략히 정리해보자.

단어 설명
스레드 프로세스가 할당받은 자원을 이용하는 실행의 단위.
웹서버 웹 브라우저와 같은 클라이언트로부터 HTTP 요청을 받아들이고, HTML 문서와 같은 웹 페이지를 반환하는 컴퓨터 프로그램.
HTTP HyperText Transfer Protocol의 약자로 클라이언트와 서버 사이에 이루어지는 요청/응답(request/response) 프로토콜. HTTP를 통해 전달되는 자료는 http:로 시작하는 URL로 조회할 수 있다.
TCP Transmission Control Protocol의 약자로 네트워크의 정보 전달을 통제하여 인터넷을 이루는 핵심 프로토콜. 웹 브라우저들이 서버에 연결할 때 사용되거나 이메일 및 파일 전송에도 사용된다.
소켓 네트워크로 데이터를 내보내거나 받을 때 사용되는 창구. 소켓은 크게 프로토콜, IP, 포트로 구성되며 프로세스가 데이터를 주고받기 위해서는 반드시 소켓을 열어야 한다.

웹서버 작동원리

웹서버를 구현하기 위해서는 웹서버가 어떤 순서로 일을 진행하는지를 알아야 한다. 웹서버는 개념에서 간단하게 살펴봤듯이 클라이언트와 연결되어 요청을 받고 이에 맞는 응답을 제공해주는 프로그램이다. 웹서버가 작동하는 순서는 아래와 같다.

  1. 소켓으로 TCP 연결 대기하기
  2. HTTP 요청 읽기
  3. HTTP 요청 분석 및 검증하기
  4. 요청 분석에 따른 응답 작성하기
  5. 응답 보내기

이제 위 순서대로 하나씩 러스트로 구현해보자.

시작 전 준비

먼저 이번에 사용할 새로운 프로젝트를 생성하자. 원하는 경로에서 아래의 코드를 입력하여 새로운 프로젝트를 만든다.

1
cargo new <project name>

그러면 아래와 같은 디렉토리 구조를 가진 프로젝트 폴더가 생성된다.

1
2
3
4
5
6
7
8
Cargo.lock
Cargo.toml
src
ㄴmain.rs
target
ㄴ.rustc_info.json
ㄴCACHEDIR.TAG
ㄴdebug

웹서버 개발을 위한 모든 준비가 완료되었다.

소켓으로 TCP 연결 대기하기

웹서버는 클라이언트에서 TCP 연결을 시도할때까지 대기해야 한다. 해당 코드는 모듈 std::net을 사용하면 쉽게 구현할 수 있다. 아래의 코드를 src/main.rs에 작성해보자.

1
2
3
4
5
6
7
8
9
10
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!("connected");
}
}

위 코드는 “127.0.0.1:7878” 주소로 Tcp 연결을 받을 수 있도록 작성한 것이다. 먼저 TcpListenerBind()를 사용하여 연결 요청을 기다린다. 네트워크에서 바인드(Bind)란 특정 포트에서 대기하다가 들어오는 요청을 서버 프로그램에 연결해주는 것을 의미하며, 여기서도 동일한 기능을 하는 함수이다. listener의 값을 출력해보면 아래와 같은 TcpListener 인스턴스를 생성한 것을 알 수 있다.

1
TcpListener { addr: 127.0.0.1:7878, fd: 3 }

문제없이 포트에 바인딩에 성공했다면 위와 같이 TcpListener 인스턴스가 생성되어 클라이언트의 연결을 대기한다.

이제 클라이언트에서 연결을 시도해보자. 웹 브라우저에 접속해서 URL 창에 “127.0.0.1:7878”을 입력한다. 그러면 브라우저에서는 “사이트에 연결할 수 없음”이라고 나오지만 우리가 만든 프로그램에서는 “connected”가 계속 출력될 것이다.

1
2
3
connected
connected
connected

클라이언트에 연결이 시도되면 TcpListener 인스턴스는 incoming이라는 메서드를 통해 스트림을 읽어온다. 여기서 스트림은 클라이언트가 요청하고 이를 서버가 읽어 적합한 응답을 생성한 뒤 보내는 일련의 과정을 통칭한다. 즉, 위 코드에서 보자면 for loop 한번이 스트림 하나라고 보면 된다. 따라서 현재 우리가 만든 프로그램은 연결 요청은 받았지만 응답을 보내지 않기 때문에 웹 브라우저에서는 연결 이슈로 나오는 것이다. incoming 메서드로 읽어온 스트림은 아래와 같다.

1
TcpStream { addr: 127.0.0.1:7878, peer: 127.0.0.1:53614, fd: 4 }

웹 브라우저에서 접속을 한번 시도했음에도 계속 연결을 시도하는 이유는 여러가지가 있을 수 있다. 클라이언트가 서버에게 여러 자원에 대한 요청을 해야하는 경우도 있고, 또 연결에 실패했을 경우 재시도하도록 설계된 경우도 있다.

HTTP 요청 읽기

이제 스트림의 요청을 읽어보는 코드를 추가해보자. handle_connection()이라는 함수를 작성하고 이를 main()에 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::{net::{TcpListener, TcpStream}, io::Read};

fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

for stream in listener.incoming() {
let stream = stream.unwrap();
println!("connected");

handle_connection(stream);
}
}

fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 512];
stream.read(&mut buffer).unwrap();
println!("request: {}", String::from_utf8_lossy(&buffer[..]));
}

handle_connection()은 TcpStream 데이터를 출력하는 함수이다. 첫번째 줄에서는 buffer라는 변수를 만들고, 두번째 줄에서는 해당 변수에 스트림 데이터를 입력한다. 여기서 스트림 데이터는 모두 바이트 타입의 데이터이다. 마지막으로 String::ffrom_utf8_lossy()을 사용하여 스트림 데이터를 문자열로 변환하여 출력한다. 출력한 결과는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
connected
request: GET / HTTP/1.1
Host: localhost:7878
Connection: keep-alive
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-

위 요청이 정확히 무엇인지는 다음 글에서 더 자세히 살펴보자.