인프라스트럭처에서의 Rust: 왜 많은 도구들이 Rust로 다시 작성되는가
1. 개요
최근 몇 년간 많은 인프라 도구들이 Rust로 다시 작성되고 있습니다. 파일 검색 도구, linter, formatter, JavaScript bundler, Python 패키지 매니저, proxy, 로그 수집 agent 등이 그 예입니다. 이 글은 Rust가 인프라 분야에 적합한 이유를 설명합니다. 그 후 Rust로 작은 CLI를 직접 작성하고, 이를 컨테이너에 패키징한 후 Java 애플리케이션에서 호출할 것입니다. 마지막으로 Rust를 사용하지 말아야 할 경우들을 다룹니다.
2. 인프라가 Rust 같은 언어를 필요로 하는 이유
인프라 도구들은 일반적으로 몇 가지 공통점을 가집니다. 매우 자주 실행됩니다. 예를 들어 파일을 저장할 때마다, 또는 CI 빌드할 때마다 실행됩니다. 대량의 데이터를 처리하고 수일간 안정적이어야 합니다. 또한 다양한 머신에 쉽게 배포할 수 있어야 합니다.
과거에는 보통 두 그룹 사이에서 선택했습니다:
- C/C++: 빠르고 무거운 runtime이 없습니다. 대신 use-after-free, buffer overflow, data race 같은 메모리 오류가 쉽게 발생하고 찾기 어렵습니다.
- GC나 interpreter가 있는 언어 (Java, Python, Node.js): 안전하고 생산성이 높습니다. 대신 시작 시간이 더 길고 메모리를 더 많이 소비합니다. 배포할 때 runtime도 함께 제공해야 합니다.
Rust는 이 두 그룹 사이의 틈을 목표로 합니다: C 수준의 성능이지만 메모리 안전성은 compiler가 보장합니다.
2.1. GC 없는 메모리 안전성
Rust는 ownership을 통해 메모리를 관리합니다. 각 값은 정확히 하나의 owner를 가집니다. owner가 scope를 벗어나면 메모리가 해제됩니다. Compiler는 compile time에 이러한 규칙들을 검사합니다:
fn main() {
let order_id = String::from("ORD-001");
let moved = order_id; // ownership이 `moved`로 이동
println!("{moved}");
println!("{order_id}"); // 이동된 변수를 다시 사용
}
3번 줄은 문자열의 ownership을 moved로 이동시킵니다. 그 이후 order_id는 더 이상 유효하지 않습니다. 5번 줄은 이를 다시 사용하려고 하므로 프로그램이 컴파일되지 않습니다:
error[E0382]: borrow of moved value: `order_id`
C에서는 유사한 오류가 정상적으로 컴파일되다가 런타임에 crash를 유발하거나, 더 나쁘게는 보안 취약점이 될 수 있습니다. proxy나 parser처럼 높은 권한에서 실행되거나 외부 데이터를 다루는 인프라 도구에서는 이러한 차이가 매우 중요합니다.
2.2. Concurrency의 안전성
인프라 도구는 종종 여러 core를 활용해야 합니다. Rust는 Send와 Sync trait을 사용하여 compiler가 data race가 있는 프로그램을 거부하게 합니다:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let processed = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for _ in 0..4 {
let processed = Arc::clone(&processed);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*processed.lock().unwrap() += 1;
}
}));
}
for h in handles {
h.join().unwrap();
}
println!("processed = {}", *processed.lock().unwrap());
}
각 부분의 의미:
Arc는 thread 간에 안전한 reference counting pointer입니다.Mutex는 한 번에 오직 하나의 thread만 count 변수를 수정할 수 있도록 보장합니다.move는 각 thread의 closure로Arc의 복사본을 이동시킵니다.
결과는 항상:
processed = 4000
Arc를 thread 간에 안전하지 않은 Rc로 바꾸면 compiler는 `Rc<Mutex<u64>>` cannot be sent between threads safely라고 알려줄 것입니다. production까지 기다릴 필요 없이 race condition을 발견할 수 있습니다.
2.3. 하나의 바이너리 파일, 즉시 시작
cargo build --release는 하나의 native 실행 파일을 만듭니다. 이 파일은 JVM, interpreter, 또는 node_modules를 필요로 하지 않습니다. 이는 여러 이점을 제공합니다:
- 간단한 설치: 파일 하나만 다운로드해서 실행하면 됩니다.
- 빠른 시작: editor나 pre-commit hook에서 분당 수백 번 호출되는 linter나 formatter에 적합합니다.
- 작은 container image: sidecar와 모든 노드에서 실행되는 agent에 적합합니다.
3. 현재 다시 작성되고 있는 도구들의 종류
구체적인 통계가 없어도 다음과 같은 도구 그룹들을 통해 이 추세를 분명히 볼 수 있습니다:
- CLI 도구:
grep,find,cat,ls를 대체합니다. 잘 알려진 예는 ripgrep과 fd입니다. - 다른 언어를 위한 도구: JavaScript/TypeScript, Python을 위한 linter와 formatter. 예: Ruff, Biome, SWC. 최근에는 uv 같은 Python 패키지 매니저도 있습니다.
- Proxy, load balancer, service mesh data plane: 성능과 메모리 안전성이 모두 중요한 곳입니다.
- Observability agent: 로그와 metric을 수집하며, 모든 머신에서 실행되기 때문에 가벼워야 합니다.
- OS와 runtime 컴포넌트: driver, container runtime, WebAssembly runtime.
주목할 만한 공통점이 하나 있습니다. 이러한 도구 대부분은 사용자가 Rust를 알 필요가 없습니다. Rust는 구현 세부사항일 뿐입니다. 사용자는 빠르고 설치하기 쉬운 도구만 봅니다.
4. 실습: Rust로 logstat CLI 작성하기
OrderService의 access log에서 각 HTTP status code의 출현 횟수를 세어야 한다고 가정합시다. 각 로그 줄은 METHOD PATH STATUS DURATION_MS 형식입니다.
4.1. 프로젝트 선언
cargo new logstat으로 프로젝트를 만든 후, command line 파라미터를 파싱하기 위해 clap 라이브러리를 추가합니다:
[package]
name = "logstat"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
4.2. 소스 코드
use clap::Parser;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
#[derive(Parser)]
#[command(name = "logstat", about = "access log에서 HTTP status code 카운트")]
struct Args {
/// log 파일의 경로
path: String,
}
fn parse_status(line: &str) -> Option<u16> {
line.split_whitespace().nth(2)?.parse().ok()
}
fn count_statuses<R: BufRead>(reader: R) -> io::Result<BTreeMap<u16, u64>> {
let mut counts = BTreeMap::new();
for line in reader.lines() {
let line = line?;
if let Some(status) = parse_status(&line) {
*counts.entry(status).or_insert(0) += 1;
}
}
Ok(counts)
}
fn main() -> io::Result<()> {
let args = Args::parse();
let file = File::open(&args.path)?;
let counts = count_statuses(BufReader::new(file))?;
for (status, n) in counts {
println!("{status} {n}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn counts_statuses_and_skips_bad_lines() {
let log = "GET /orders 200 12\nPOST /orders 201 30\nGET /orders 200 9\nbad\n";
let counts = count_statuses(log.as_bytes()).unwrap();
assert_eq!(counts.get(&200), Some(&2));
assert_eq!(counts.get(&201), Some(&1));
assert_eq!(counts.len(), 2);
}
}
주요 포인트:
#[derive(Parser)]는 structArgs에서 파라미터 파싱 코드와--help내용을 자동으로 생성합니다.parse_status는Option<u16>을 반환합니다.Option의?연산자가 열 없는 줄을 건너뛰도록 도와줍니다..parse().ok()는 parse 오류를None으로 변환하므로 잘못된 줄이 프로그램을 crash 시키지 않습니다.count_statuses는 어떤BufRead든 받습니다. 코드가 한 줄씩 읽기 때문에 log가 몇 GB여도 전체를 메모리에 로드할 필요가 없습니다.BTreeMap은 key를 오름차순으로 유지하므로 output이 일관성 있습니다.- test 함수는 문자열을
&[u8]로 전달하는데, 이것도BufRead를 구현하므로 실제 파일 없이 로직을 테스트할 수 있습니다.
테스트 실행과 빌드:
cargo test
cargo build --release
./target/release/logstat access.log
샘플 log 파일로 실행하면 output은 다음과 같은 형태일 것입니다:
200 2
201 1
500 1
4.3. 컨테이너에 패키징하기
Rust 바이너리는 multi-stage 빌드에 적합합니다:
FROM rust:slim AS build
WORKDIR /app
COPY . .
RUN cargo build --release
FROM gcr.io/distroless/cc-debian12
COPY --from=build /app/target/release/logstat /usr/local/bin/logstat
ENTRYPOINT ["logstat"]
첫 stage는 컴파일을 위한 전체 toolchain을 포함합니다. 두 번째 stage는 바이너리와 기본 C 라이브러리만 포함합니다. 최종 image는 JDK나 Node.js를 포함하는 image보다 훨씬 작습니다.
주의:
scratchimage를 사용하려면x86_64-unknown-linux-musltarget으로 static 빌드해야 합니다. 이 경우 OpenSSL처럼 C 라이브러리에 의존하는 crate가 없는지 주의 깊게 확인하세요.
5. Rust 도구를 Java 시스템에 통합하기
전체 Spring Boot 애플리케이션을 다시 작성할 필요가 없습니다. 가장 현실적인 접근법은 Rust로 특화된 도구 하나만 사용하는 것이고, Java는 이를 외부 프로세스처럼 호출합니다:
import java.io.IOException;
import java.nio.file.Path;
import java.util.Map;
import java.util.TreeMap;
public class LogStatClient {
private final Path binary;
public LogStatClient(Path binary) {
this.binary = binary;
}
public Map<Integer, Long> count(Path logFile) throws IOException, InterruptedException {
Process process = new ProcessBuilder(binary.toString(), logFile.toString())
.redirectError(ProcessBuilder.Redirect.INHERIT)
.start();
Map<Integer, Long> result = new TreeMap<>();
try (var reader = process.inputReader()) {
reader.lines().forEach(line -> {
String[] parts = line.split(" ");
result.put(Integer.parseInt(parts[0]), Long.parseLong(parts[1]));
});
}
int exitCode = process.waitFor();
if (exitCode != 0) {
throw new IllegalStateException("logstat이 코드 " + exitCode + "로 종료됨");
}
return result;
}
}
설명:
ProcessBuilder는 log 파일 경로를 파라미터로 바이너리를 실행합니다.redirectError(INHERIT)은 도구의 stderr을 Java 애플리케이션의 log로 보냅니다. 그래서 오류 메시지가 stdout과 섞이지 않으며, Java는 결과를 파싱할 수 있습니다.process.inputReader()는 stdout을 한 줄씩 읽습니다. 각 줄은 status와 출현 횟수로 분할됩니다.- 0이 아닌 exit code는 오류로 간주됩니다. 예를 들어, 파일이 없으면
main은Err을 반환하고 Rust 프로그램은 코드 1로 종료됩니다.
JUnit 5와 AssertJ로 테스트합니다:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.entry;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class LogStatClientTest {
@TempDir
Path tempDir;
@Test
void countsStatusCodesUsingRustBinary() throws Exception {
Path binary = Path.of("tools/logstat/target/release/logstat");
assumeTrue(Files.isExecutable(binary), "logstat을 먼저 빌드해야 합니다");
Path log = tempDir.resolve("access.log");
Files.writeString(log, """
GET /orders 200 12
GET /orders 500 40
GET /customers 200 8
""");
var client = new LogStatClient(binary);
assertThat(client.count(log))
.containsExactly(entry(200, 2L), entry(500, 1L));
}
}
assumeTrue는 바이너리를 빌드하지 않은 머신에서 test를 건너뛸 수 있게 해줍니다. 빌드가 실패하는 대신입니다. CI에서는 mvn test 전에 cargo build --release를 실행해야 합니다.
프로세스 호출 외에도 두 가지 다른 선택지가 있습니다:
- FFI를 통해 직접 호출: Java 21의 Foreign Function & Memory API를 사용하여 C ABI에 따라 export된 Rust 함수를 호출합니다 (
extern "C"). 이는 프로세스 생성 오버헤드를 피합니다. 대신 두 언어의 경계에서 메모리 관리와 타입 변환이 훨씬 복잡합니다. - 별도의 service로 분리: Rust 도구는 독립적으로 실행되고 HTTP/gRPC를 통해 통신합니다. 이는 proxy나 agent처럼 도구가 지속적으로 실행되어야 할 때 적합합니다.
대부분의 경우, 프로세스를 통해 호출하는 것이 충분히 단순하고 디버깅하기 가장 쉽습니다.
6. Rust를 사용하면 안 되는 경우
Rust는 모든 상황의 기본 선택이 아닙니다. 다음 경우들에서는 신중하게 생각해보세요.
- 일반적인 업무 로직 CRUD 애플리케이션:
Order와Customer를 관리하는 Spring Boot service는 보통 CPU가 아닌 database와 network에 제한됩니다. Rust로 다시 작성하는 것이 사용자 경험을 거의 개선하지 못하지만 개발 속도는 확실히 느려집니다. - 팀이 경험이 없는 경우: borrow checker는 가파른 학습 곡선을 가지고 있습니다. 초기에 compiler와 “싸우느라” 많은 시간을 보낼 것입니다. 한 사람만 Rust를 아는 내부 도구는 실제 운영 위험입니다.
- 생태계가 부족한 경우: Java는 ORM, message broker 통합, 보안 표준 같은 매우 성숙한 엔터프라이즈 라이브러리를 가지고 있습니다. 일부 분야에서는 Rust가 여전히 더 적은 옵션을 제공합니다.
- 지속적으로 변하는 prototype: 요구사항이 불안정할 때 Python이나 Java 스크립트가 더 빠르게 실험할 수 있게 해줍니다.
- 유일한 이유가 “Rust는 빠르다”: 먼저 측정하세요. 기존 언어에서 profiling과 알고리즘 최적화로 충분한 경우가 많습니다.
시작할 때 자주 하는 실수:
주의: debug 빌드(
--release없이cargo build)로 성능을 측정하면 실제보다 훨씬 느린 결과를 얻을 것입니다. 항상 release 빌드로 벤치마크하세요.
주의:
.unwrap()을 남용하면 나쁜 input을 만났을 때 도구가 panic합니다. 인프라 도구의 경우Result를 반환하고 명확한 오류 메시지를 stderr에 출력하세요.
주의:
unsafe는 compiler의 일부 검사를 비활성화합니다. 작은 module 내에만 제한하고 신중하게 review하세요. borrow checker를 “속이기” 위해unsafe를 사용하지 마세요.
또한 큰 project에서 Rust의 컴파일 시간은 상당합니다. CI에서 target 디렉터리와 Cargo registry에 대한 캐싱을 활성화하세요. 그렇지 않으면 pipeline이 상당히 느려질 것입니다.
7. 결론
Rust는 인프라에서 선택되는데, 이는 세 가지를 이전에는 동시에 가지기 어려웠던 것들을 결합하기 때문입니다: native 성능, compiler가 보장하는 메모리 안전성, 단일 바이너리 파일로 배포할 수 있는 능력. 이것이 많은 CLI, linter, bundler, proxy와 agent가 Rust로 다시 작성되는 이유입니다.
이 글에서 test를 포함한 logstat CLI를 작성하고, multi-stage Docker 빌드로 패키징하고, ProcessBuilder를 통해 Java에서 호출했습니다. 가장 현명한 접근법은 자주 실행되거나 리소스에 민감한 특화된 컴포넌트에 Rust를 사용하는 것입니다. 업무 로직은 팀이 숙련된 언어로 유지하세요. 무엇을 다시 작성하기 전에 측정하고 자신에게 물어보세요: 학습과 유지보수 비용을 충당할 만큼 이득이 충분한가?