Rust 후기 - httprs

2026/03/29
rust

Java 애플리케이션을 개발하면서 항상 느꼈던 점은 개발은 편하지만 뭔가 무겁다는 느낌이 들었습니다.
한번 작성하면 어떤 환경에서든 바로 실행 할 수 있고, 괜찮은 패키지 매니저와 메모리를 직접 관리하지 않아도 된다는 점은 좋았지만 실행 후 최대 성능까지 도달하는 시간이 오래걸리고 실행할 때 메모리 크기를 미리 설정해야 한다는 점은 다소 아쉬웠습니다.

그런 저에게 Rust는 Java 개발을 하면서 참 흥미로웠던 언어 였습니다. 별다른 런타임 없이 컴파일 타임에 메모리 안정성을 보장하고 성능은 C/C++ 급의 성능을 낼 수 있다니 어떻게 보면 제가 원하던 부분을 살살 긁어주는 느낌이었습니다. 게다가 최근 직무전환으로 C언어를 개발하는 저에게 내장 패키지 매니저와 내장 formatter 등등 현대적인 개발환경은 매우 부럽게 느껴졌습니다.

게다가 최근에는 Linux kernel에도 Rust 통합 작업이 정식 구성요소로 편입되었다고 하니 언어의 안정성도 이제는 완전히 검증되었다고 봐도 무방할 정도로 생각 됩니다.(원래도 firefox js 엔진 개발에도 활용되었던 만큼 안정적이라고 봐도 되겠지만요)

Rust를 사용한 HTTP 서버 만들기

그러한 Rust를 이용해 뭘 해볼까하다 HTTP 서버를 만들어봐야겠다고 생각했습니다.
먼저 HTTP는 TCP를 사용한 응용 중에 가장 많이 사용되는 프로토콜이면서 가장 간단한 프로토콜입니다. text 기반의 데이터로 통신을 하기 때문에 구현하기도 쉽고 에러를 파악하는데에도 용이합니다.

어떤 식으로 HTTP 동작을 구현해볼까 하다 멀티프로세스 기반으로 진행하게 되었습니다.
일단 현재 업무로 Apache httpd 기반의 서버를 개발 중이기도 하고 언젠가 직접 HTTP 서버를 만들어보고 싶기도 해서 진행하게 되었습니다.

HTTP 스펙은 기본적으로 RFC 문서로 확인이 가능합니다. 처음부터 1.1 버전을 구현하면 좋았겠지만 먼저 PoC의 느낌으로 1.0 버전의 구현을 목표로 진행했습니다.

멀티프로세스 기반 HTTP 서버 구현

기본적으로 Rust의 표준 라이브러리에는 TCP 소켓, thread 에 대한 기능은 구현되어 있지만 프로세스(정확히는 fork)에 대해서는 크게 지원하지 않고 있습니다. 정확히는 지원은 하지만 윈도우에서도 호환될 시나리오(std::process::Command)에 대해서만 지원하고 있습니다. 따라서 리눅스에서 프로세스를 생성하기 위해서는 nix 크레이트를 사용해야 합니다.

1
2
3
4
5
6
7
8
9
10
use nix::unistd::fork;

fn main() {
let pid = fork().unwrap();
if pid == 0 {
println!("Child process");
} else {
println!("Parent process");
}
}

nix 크레이트는 리눅스 시스템 콜을 Rust에서 사용하기 좋게 wrapping 한 라이브러리 입니다. 덕분에 fork, epoll 등등의 시스템 콜을 쉽게 사용할 수 있습니다. 다만 앞서 리눅스 시스템 콜을 wrapping 한 라이브러리인 만큼 윈도우나 macos 를 타겟으로 생각하고 있다면 도입을 고려할 필요는 있습니다.

TCP 소켓 핸들링

먼저 TCP 소켓 자체는 standard libarary에 있는 std::net::TcpListener, std::net::TcpStream를 사용하면 됩니다.
하지만 socket에 대해 다양한 옵션을 지정하기 위해서는 socket2 크레이트를 사용해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use socket2::{Domain, Protocol, Socket, Type};
use std::net::SocketAddr;

fn main() -> std::io::Result<()> {
let addr: SocketAddr = "0.0.0.0:8080".parse().unwrap();
let socket = Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP))?;

// SO_REUSEADDR (reusehost) 및 SO_REUSEPORT 설정
socket.set_reuse_address(true)?;
socket.set_reuse_port(true)?;

socket.bind(&addr.into())?;
socket.listen(128)?;

let listener: std::net::TcpListener = socket.into();
Ok(())
}

Rust의 기본 TcpListener로도 bind일반적인 소켓 생성은 가능하지만 linux 기준 socket 부터 bind 사이에 설정하는 socket 옵션들(SO_REUSEADDR, SO_REUSEPORT 등)을 설정할 수 없기 때문에 socket2 크레이트를 사용해야 합니다. 다행히 socket2 크레이트는 std::net::TcpListener로 변환하는 기능도 제공하기 때문에 중간에 순수 TcpListener에서 socket2 로 변환하는 과정에서 큰 불편함 없이 사용할 수 있습니다.

HTTP Request 파싱

http1.0의 요청은 1.1과는 크게 다르지 않습니다. 다만 Tcp를 좀 더 효율적으로 사용하는 keep-alive 나 chunked encoding 등은 지원하지 않습니다. 그래서 그런 부분들을 제외한 기본적인 요청 [파싱 로직](https://github.com/ksmail13/httprs/blob/master/httprs/src/http/mod.rs#L155을 구현했습니다.

다만 이제부터 Rust의 특징을 살린 코드를 작성할 필요가 있었습니다.
아래는 요청받은 Http request 를 구조화 한 구조체 입니다.

1
2
3
4
5
6
7
8
9
10
11
#[allow(dead_code)]
pub struct HttpRequest<'a> {
remote_addr: &'a SocketAddr,
method: HttpMethod,
http_version: HttpVersion,
path: String,
header: HashMap<&'a str, Vec<&'a str>>,
param: HashMap<&'a str, Vec<&'a str>>,
reader: Box<dyn Read + 'a>,
// TODO : 필요한건 나중에 추가
}

이 중에 눈에 띄는 타입이 있습니다. 바로 String, &'a str 입니다. 두 타입은 모두 문자열을 표시하기 위해 사용하는 타입입니다.
다만 가장 큰 차이점은 String은 실제 힙에 할당되어 수정이 가능한 문자열이고 &’a str은 문자열을 가리키는 포인터(참조) 라는 점입니다.

note &'a str 에서 'a라이프타임(lifetime) 이라고 부릅니다. 쉽게 말해 참조하는 데이터의 유효기간에 대한 alias 입니다. 자세한 내용은 rust book 을 참고하시면 좋습니다.

Rust의 특징을 살린 코드

그렇다면 Rust의 특징을 살린 코드는 어떤 것일까요?

일반적인 Java로 파일 내의 http header 같은 특정 포멧(key: value)의 문자열을 읽고 파싱하여 map(혹은 연관 배열)에 저장하는 로직은 다음과 같이 작성할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
public class Main {
public static void main(String[] args) {
String str = "key1: value1\nkey2: value2\nkey3: value3";
String[] pairs = str.split("\n");
Map<String, String> map = new HashMap<>();
for (String pair : pairs) {
String[] keyValue = pair.split(":");
map.put(keyValue[0], keyValue[1]);
}
System.out.println(map);
}
}

위 코드의 문제점은 무엇일까요?

바로 split을 호출할 때마다 새로운 메모리를 할당한다는 점입니다. Java의 경우 String에 대해서 어느정도 최적화를 지원하지만 Rust의 경우 이렇게 되면 별다른 최적화 없이 무조건 메모리를 할당하게 되고 이는 성능 저하를 야기합니다.

그렇다면 Rust에서는 split은 어떻게 사용할 수 있을까요?

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::collections::HashMap;

fn main() {
let str = "key1: value1\nkey2: value2\nkey3: value3";
let mut map: HashMap<&str, &str> = HashMap::new();
for pair in str.split("\n") {
let mut iter = pair.split(":");
let key = iter.next().unwrap();
let value = iter.next().unwrap();
map.insert(key, value);
}
println!("{:?}", map);
}

코드상으로는 동일합니다. 하지만 메소드가 리턴하는 타입이 다릅니다. Java의 경우 split 메소드는 String[]를 리턴하지만 Rust의 경우 Split을 리턴합니다. 이는 lazy evaluation 을 가능하게 합니다. 즉, split을 호출할 때마다 새로운 문자열을 생성하지 않고, 필요할 때만 clone 을 통해 생성한다는 것입니다. 이렇게 되면 불필요한 메모리 할당이 발생하지 않고 성능이 향상될 수 있습니다.

또한 HashMap이 key, value 모두 &’str 을 사용하고 있는데, 이는 HashMap이 가지는 값은 실제 할당된 메모리가 아닌 원본 문자열의 특정 부분의 참조를 저장한다는 뜻 입니다. 그리고 생성된 HashMap과 원본 문자열은 동일한 라이프타임을 가지고 있어 현재 함수 내에서만 두 정보가 유효합니다. 즉, 원본 문자열이 drop 될 때 HashMap도 같이 drop이 됩니다.

enum 과 pattern matching 을 활용한 null / error check

Rust의 마음에 드는 기능이고 언어 구조상 꼭 필요한 기능 중에 하나인 pattern matching 입니다. 최근에 많은 언어들이 지원을 하고 있지만 Rust의 경우 enum과 함께 사용하면 매우 강력한 기능을 제공합니다.

Rust의 enum은 C/C++, Java의 enum과는 다릅니다. C/C++의 enum은 정수형 상수를 정의하는 용도로 사용되고 Java에서는 하나의 Singleton instance로 정의하여 사용하지만 Rust의 enum은 타입을 정의하는 용도로 사용됩니다. kotlin 의 sealed interface 정도로 보셔도 좋을 것 같네요.

예를 들어 Rust의 표준 라이브러리에는 Option<T> 라는 enum이 있습니다. 이 enum은 Some(T) 또는 None 값을 가질 수 있습니다. Option는 다른언어에서는 null 과 비슷하지만 null과는 매우 다릅니다. null은 값이 없음을 나타내지만 Option는 값이 있거나 없을 수도 있음을 나타냅니다. 따라서 기존 언어에서는 null 체크를 하지 않을 수 있고 이로 인해 런타임 에러가 발생할 수 있지만 Rust에서는 Option 내 값을 사용하기 위해서는 어쩔 수 없이 검증을 강제하게 됩니다.

마찬가지로 Result<T, E> 라는 enum도 있습니다. 이 enum은 Ok(T) 또는 Err(E) 값을 가질 수 있습니다. Result<T, E>는 다른언어에서는 try-catch 처리를 대체 하는 enum 타입입니다. try-catch 는 try 블록 내의 에러를 잡아서 catch 핸들러에서 처리하도록 구분하는 것 처럼 Result<T, E>는 성공 / 실패 상황에 대한 케이스를 pattern matching 문법을 사용해 표현합니다.

1
2
3
4
5
6
7
8
9
10
11
12

let result = socket.accept();

match result {
Ok(stream) => {
println!("Accepted connection from {}", stream.peer_addr().unwrap());
}
Err(e) => {
println!("Error: {}", e);
}
}

또한 실패 케이스의 경우 ? 연산자를 사용하면 에러를 호출한 함수로 전파할 수 있습니다. 이 경우 호출한 함수의 에러 리턴 타입은 함수의 리턴타입에 선언된 에러타입의 것과 동일해야 합니다. 만약 다른 경우 map_err 함수를 통해 변환 후 처리 할 수도 있습니다.

1
2
3
4
5
6
7
8
9

fn main() -> Result<(), Box<dyn std::error::Error>> {
let socket = Socket::new(Domain::IPV4, Type::STREAM, Some(Protocol::TCP))?; // err 상황일 때는 바로 main 함수 리턴
socket.bind(&"0.0.0.0:8080".parse()?);
socket.listen(128)?;
let (stream, addr) = socket.accept()?;
// do something...
}

그렇게 작성된 코드는 여기에서 확인하실 수 있습니다.

현재는 위에서 언급한대로 HTTP 1.0 만 지원하고 있고 로깅이나 필터 같은 기능은 아직 지원하지 않고 있습니다. 추후에 HTTP 1.1 을 지원하고 로깅이나 필터 같은 기능을 추가해보고자 합니다.
성능은 로깅을 끈 상태에서 keep-alive 를 끈 Apache httpd 와 동일하게 나오는 것을 확인했고, 로깅을 켠 상태에서는 20% 정도 느리게 나옵니다.

전반적인 후기

개발을 하면서 가장 인상 깊었던 점은 메모리를 관리하지 않아도 되지만 결국 메모리를 관리한다는 점이었습니다. 약간 말장난 같지만 실제로 개발을 하는데에 있어 직접 new / malloc, delete / free 를 호출하지 않았을 뿐, 실제 메모리 할당과 해제를 고려하지 않는다면 일단 먼저 컴파일이 안될 것이고 그 이후에는 성능에 많은 영향을 주게 됩니다.

따라서 Rust 코드를 작성하기에 앞서 신중한 고민이 필요하고 지속적인 프로파일링을 통해 병목지점을 찾아 최적화 할 방법을 고민해야할 필요가 있습니다.

물론 요즘은 AI 딸깍으로 해결 가능한 부분이기는 합니다.

꽤나 재미있는 경험이었고 계속 진행하여 추후에는 async/await 런타임을 개발해보는 것도 좋은 경험이 될 것 같습니다.