Rust의 Path와 PathBuf: 안전하게 경로 다루기

Rust의 Path와 PathBuf: 안전하게 경로 다루기

std::fs로 파일 다루기에서 봤듯이 Rust 파일 함수는 모두 경로를 받습니다. 그런데 경로를 그냥 &str이나 String으로 다루면 미묘한 함정이 많은데요. /\가 섞인 윈도우 경로 처리가 빠진다거나, 확장자만 바꾸려는데 문자열 슬라이싱이 필요해진다거나 하는 식이죠.

Rust는 경로 전용 타입 두 개를 제공합니다. &PathPathBuf인데요. String과 &str이 그렇듯, 이 둘도 짝꿍처럼 한 묶음으로 이해하면 좋습니다.

&Path와 PathBuf의 관계

핵심은 한 줄로 정리됩니다. &Path는 빌린 경로 슬라이스, PathBuf는 소유한 경로 버퍼입니다.

use std::path::{Path, PathBuf};

let borrowed: &Path = Path::new("config.toml");
let owned: PathBuf = PathBuf::from("config.toml");

String&str이 가졌던 그 관계가 그대로 옮겨온 셈인데요. 함수에 경로를 넘길 때는 &Path를 받고, 새로 만들거나 변형할 때는 PathBuf를 씁니다.

PathBuf&Path로 자연스럽게 deref되기 때문에, PathBuf를 가지고 있어도 &Path를 요구하는 함수에 그대로 넘길 수 있습니다.

AsRef로 받으면 더 유연하다

표준 라이브러리의 파일 함수 시그니처를 보면 대부분 P: AsRef<Path>로 되어 있습니다.

pub fn read_to_string<P: AsRef<Path>>(path: P) -> io::Result<String>

덕분에 호출 측은 &str, String, &Path, PathBuf 등 어느 타입이든 그대로 넘길 수 있죠.

fs::read_to_string("config.toml")?;                       // &str
fs::read_to_string(String::from("config.toml"))?;         // String
fs::read_to_string(Path::new("config.toml"))?;            // &Path
fs::read_to_string(PathBuf::from("config.toml"))?;        // PathBuf

내가 만드는 함수에서도 같은 패턴을 따르면 호출자가 편해집니다. 자세한 AsRef 활용은 별도 글에서 다뤘는데, 경로에서 가장 자주 보는 사용 사례라 짚어둡니다.

fn load_config(path: impl AsRef<Path>) -> std::io::Result<String> {
    std::fs::read_to_string(path.as_ref())
}

경로 합치기: join과 push

문자열 덧셈으로 경로를 만들면 구분자(/ vs \) 처리에서 쉽게 사고가 납니다. Path::join이나 PathBuf::push를 쓰면 플랫폼에 맞는 구분자를 알아서 끼워 넣어줍니다.

use std::path::{Path, PathBuf};

let base = Path::new("/home/dale");
let full = base.join("projects").join("blog");
// /home/dale/projects/blog (또는 윈도우면 \\)

let mut buf = PathBuf::from("/home/dale");
buf.push("projects");
buf.push("blog");
// 같은 결과

join은 새 PathBuf를 돌려주는 불변 연산이고, push는 기존 PathBuf를 수정하는 가변 연산이라는 차이가 있습니다.

한 가지 주의할 점은 절대 경로를 join하면 기존 경로가 무시된다는 건데요. POSIX 동작을 따르기 때문입니다.

let p = Path::new("/home/dale").join("/etc/passwd");
// 결과: /etc/passwd  (예상한 /home/dale/etc/passwd가 아님)

사용자 입력으로 받은 경로를 join할 때는 절대 경로 검사를 해야 사고를 막을 수 있습니다.

분해하기: parent, file_name, extension

경로에서 부분만 꺼내는 메서드도 풍부한데요. 자주 쓰는 셋만 보면 다음과 같습니다.

use std::path::Path;

let p = Path::new("/home/dale/notes.md");

p.parent();         // Some("/home/dale")
p.file_name();      // Some("notes.md")
p.file_stem();      // Some("notes")
p.extension();      // Some("md")

모두 Option을 돌려줍니다. 루트 디렉터리에서 parent()를 호출하거나 디렉터리 경로에서 file_name()을 부르면 None이 나오기 때문이죠.

확장자나 파일명만 바꾸고 싶을 때는 with_extension, with_file_name을 씁니다. 둘 다 새 PathBuf를 돌려주는 불변 연산입니다.

let src = Path::new("notes.md");
let dst = src.with_extension("html");      // notes.html
let backup = src.with_file_name("backup.md"); // backup.md

문자열 슬라이싱으로 직접 처리하면 점이 여러 개 들어간 파일명에서 헷갈리기 쉬운데, with_extension은 마지막 점 이후만 정확히 바꿔줍니다.

출력할 때는 display()

Path는 내부적으로 OS 네이티브 표현을 쓰기 때문에 항상 유효한 UTF-8이라는 보장이 없습니다. 그래서 PathPathBuf를 그대로 println!에 넘기면 컴파일이 안 되는데요. display()로 한 번 감싸주면 됩니다.

let p = Path::new("/home/dale/notes.md");
println!("경로: {}", p.display());

display()는 유효하지 않은 UTF-8을 만나면 ( 같은) 대체 문자로 바꿔서 출력합니다. 디버그 표현으로 보고 싶으면 {:?}로 출력하면 되고요.

절대 경로와 상대 경로

경로가 절대인지 상대인지 확인할 때는 is_absolute/is_relative를 씁니다.

Path::new("/etc/passwd").is_absolute();  // true
Path::new("./config.toml").is_relative(); // true

상대 경로를 절대 경로로 바꾸려면 std::env::current_dir과 조합하거나, 더 간편하게 dunce 같은 외부 크레이트를 쓰면 윈도우의 UNC 경로 같은 함정도 피할 수 있습니다.

use std::env;

let absolute = env::current_dir()?.join("config.toml");

심볼릭 링크를 따라가는 정규화된 절대 경로가 필요하면 fs::canonicalize를 쓰는데, 이건 실제 파일 시스템에 접근하기 때문에 비용이 들고 파일이 존재해야 합니다.

플랫폼 차이 처리

윈도우와 유닉스의 가장 큰 차이는 경로 구분자(\ vs /)인데요. Path/PathBuf는 이 차이를 거의 다 흡수해줍니다. join이나 push는 플랫폼 네이티브 구분자를 사용하고, 비교할 때도 양쪽 구분자를 호환되게 처리합니다.

// 두 플랫폼 모두에서 정상 동작
let p = Path::new("data").join("file.txt");

다만 사용자 입력으로 받은 경로 문자열을 Path::new로 감쌀 때는 들어온 모양 그대로 유지되니, 입력의 구분자를 정규화할 필요가 있다면 명시적으로 처리해야 합니다. 일반적으로는 Path 메서드만 거치면 자연스럽게 정규화되니 큰 문제는 없고요.

마치며

Rust의 경로 처리는 처음에는 타입이 두 개라 번거롭게 느껴질 수 있습니다. 하지만 &str 대신 &Path를, String 대신 PathBuf를 쓰는 것만으로 플랫폼 차이, 확장자 변경, 부분 추출 같은 작업이 안전하고 명시적으로 표현됩니다. AsRef<Path>로 함수 시그니처를 열어두면 호출자도 편하고요.

파일 처리 흐름으로 돌아가서 BufReader/BufWriter로 효율적으로 읽고 쓰는 법을 이어서 보시거나, std::fs 기초를 다시 짚어보시면 좋습니다.

더 자세한 내용은 std::path 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord