Rust로 CLI 도구 만들며 배운 소유권 시스템
배경
배포 스크립트가 Python으로 작성되어 있었는데, 의존성 관리와 배포 속도가 문제였다. 단일 바이너리로 배포 가능한 Rust로 재작성을 결정했다.
소유권과의 첫 충돌
파일 경로를 여러 함수에서 재사용하려다 컴파일 에러를 마주쳤다.
fn validate_path(path: String) -> bool {
Path::new(&path).exists()
}
fn read_config(path: String) -> Result<Config> {
let contents = fs::read_to_string(path)?;
// ...
}
let config_path = String::from("config.toml");
if validate_path(config_path) {
read_config(config_path) // error: value moved
}
validate_path에서 config_path의 소유권이 이동되어 이후 사용할 수 없었다. 해결은 참조자 사용이었다.
fn validate_path(path: &str) -> bool {
Path::new(path).exists()
}
fn read_config(path: &str) -> Result<Config> {
let contents = fs::read_to_string(path)?;
// ...
}
let config_path = String::from("config.toml");
if validate_path(&config_path) {
read_config(&config_path) // OK
}
구조체 설계
배포 설정을 담는 구조체에서도 소유권 고민이 있었다.
#[derive(Debug, Clone)]
struct DeployConfig {
env: String,
targets: Vec<String>,
hooks: Vec<Hook>,
}
초기엔 Clone이 성능 오버헤드를 걱정했지만, 배포 도구 특성상 설정 객체를 복사하는 일이 거의 없어 문제없었다. 필요한 곳에서만 참조자를 넘기고, 명확히 소유권이 필요한 경우만 clone()을 사용했다.
에러 핸들링
anyhow와 thiserror를 조합해 에러 처리를 구성했다.
use anyhow::{Context, Result};
fn execute_deploy(config: &DeployConfig) -> Result<()> {
let output = Command::new("kubectl")
.args(&config.targets)
.output()
.context("Failed to execute kubectl")?;
if !output.status.success() {
anyhow::bail!("Deploy failed: {}", String::from_utf8_lossy(&output.stderr));
}
Ok(())
}
결과
500줄 Python 스크립트가 300줄 Rust 코드로 줄었고, 실행 시간은 2.3초에서 0.4초로 개선됐다. 바이너리 크기는 5MB 정도로, 도커 이미지 없이도 배포 가능해졌다.
소유권 시스템이 처음엔 번거로웠지만, 컴파일 타임에 메모리 안전성을 보장받는 점은 확실한 장점이었다. CLI 도구처럼 명확한 입출력이 있는 프로그램은 Rust 학습용으로도 적합하다고 느꼈다.