Rust 파일 I/O 기초: std::fs로 읽고 쓰기

Rust 파일 I/O 기초: std::fs로 읽고 쓰기

Rust로 코드를 짜다 보면 파일 한두 개는 꼭 만지게 되는데요. 설정 파일을 읽거나 로그를 쓰거나, 임시 파일을 만들거나 하는 일이죠. Rust 표준 라이브러리의 std::fs 모듈이 이런 작업을 위한 도구를 한가득 제공합니다.

이번 글에서는 std::fs의 가장 기본적인 사용법을 살펴보겠습니다. 경로 다루기는 Path와 PathBuf에서, 효율적인 처리는 BufReader/BufWriter에서, 비동기 처리는 tokio::fs에서 별도로 다룹니다.

한 줄 함수로 빠르게 시작하기

std::fs에는 “파일 한 번에 읽고 끝”, “파일 한 번에 쓰고 끝” 같은 시나리오를 위한 헬퍼 함수가 준비되어 있는데요. 가장 자주 쓰는 두 함수가 read_to_stringwrite입니다.

use std::fs;

fn main() -> std::io::Result<()> {
    // 파일 전체를 String으로 읽기
    let content = fs::read_to_string("config.toml")?;
    println!("{content}");

    // 문자열을 파일에 쓰기 (없으면 생성, 있으면 덮어쓰기)
    fs::write("output.txt", "Hello, Rust!")?;

    Ok(())
}

read_to_string은 이름 그대로 파일을 통째로 읽어 String으로 돌려주고, write는 받은 데이터를 파일에 그대로 씁니다. 둘 다 내부적으로 File::openFile::create를 호출하지만, 사용자 입장에서는 임시 변수 없이 한 줄로 끝낼 수 있죠.

바이트 슬라이스가 필요하면 read_to_string 대신 read를 쓰면 됩니다.

let bytes: Vec<u8> = fs::read("image.png")?;

이런 한 방 함수는 작은 파일에는 편리하지만, 대용량 파일을 메모리에 통째로 올리는 건 비효율적입니다. 줄 단위로 처리해야 한다면 BufReader를 쓰는 편이 낫고요.

File 핸들로 세밀하게 다루기

fs::read_to_string/fs::write 같은 헬퍼는 내부적으로 File이라는 핸들을 만들어 쓰는데요. 더 세밀한 제어가 필요하면 이 핸들을 직접 다룹니다.

use std::fs::File;
use std::io::{Read, Write};

fn main() -> std::io::Result<()> {
    let mut file = File::open("config.toml")?;
    let mut buf = String::new();
    file.read_to_string(&mut buf)?;
    println!("{buf}");

    let mut out = File::create("output.txt")?;
    out.write_all(b"Hello, Rust!")?;

    Ok(())
}

File::open은 읽기 전용으로, File::create는 쓰기 전용으로 파일을 엽니다. 이렇게 만든 핸들은 Read/Write 트레이트를 통해 다양한 메서드를 제공하죠. read_to_string, write_all 외에도 read_exact, write_fmt 같은 메서드를 쓸 수 있습니다.

ReadWrite 트레이트를 import하지 않으면 read_to_string 같은 메서드가 보이지 않으니 주의하세요. 보통 use std::io::prelude::*;로 한 번에 가져옵니다.

OpenOptions로 정밀하게 열기

File::open은 읽기, File::create는 쓰기인데요. “기존 파일에 이어 쓰기”나 “있을 때만 만들기” 같은 시나리오는 OpenOptions 빌더로 표현합니다.

use std::fs::OpenOptions;
use std::io::Write;

fn main() -> std::io::Result<()> {
    // 기존 로그 파일에 이어 쓰기
    let mut log = OpenOptions::new()
        .append(true)
        .create(true)
        .open("app.log")?;

    writeln!(log, "이벤트 발생: {}", chrono::Utc::now())?;

    Ok(())
}

자주 쓰는 옵션은 다음과 같습니다.

  • read(true) — 읽기 권한
  • write(true) — 쓰기 권한 (기존 내용은 그대로)
  • append(true) — 끝에 이어 쓰기
  • truncate(true) — 열면서 내용 비우기 (write와 함께)
  • create(true) — 없으면 생성
  • create_new(true) — 없을 때만 생성, 이미 있으면 에러

create_new는 TOCTOU(check-then-use) 경쟁 조건을 막아주는 원자적 연산이라, “이미 있으면 만들지 말고 실패해라”는 락 파일 패턴에 자주 등장합니다.

let lock = OpenOptions::new()
    .write(true)
    .create_new(true)
    .open("/tmp/app.lock")?;

디렉터리 다루기

파일 자체뿐 아니라 디렉터리를 만들고 지우고 순회하는 함수도 std::fs에 들어 있습니다.

use std::fs;

fn main() -> std::io::Result<()> {
    // 디렉터리 만들기 (중간 경로까지 한 번에)
    fs::create_dir_all("data/cache/today")?;

    // 디렉터리 안 항목 순회
    for entry in fs::read_dir("data")? {
        let entry = entry?;
        println!("{:?}", entry.path());
    }

    // 파일 또는 디렉터리 삭제
    fs::remove_file("output.txt")?;
    fs::remove_dir_all("data/cache")?;

    Ok(())
}

create_dir은 한 단계 디렉터리만 만들고 부모가 없으면 실패하지만, create_dir_all은 필요한 부모를 모두 만들어줍니다. 마찬가지로 remove_dir은 빈 디렉터리만 지우고, remove_dir_all은 안에 든 내용까지 재귀적으로 지우죠.

read_dir은 디렉터리 항목을 이터레이터로 돌려주는데, 각 항목이 또 Result라 두 번 ?를 써야 한다는 점이 살짝 헷갈릴 수 있습니다.

메타데이터 확인하기

파일 크기, 수정 시각, 종류(파일인지 디렉터리인지) 같은 메타데이터는 metadata 함수로 가져옵니다.

use std::fs;

fn main() -> std::io::Result<()> {
    let meta = fs::metadata("config.toml")?;

    println!("크기: {} bytes", meta.len());
    println!("파일인가: {}", meta.is_file());
    println!("디렉터리인가: {}", meta.is_dir());
    println!("수정 시각: {:?}", meta.modified()?);

    Ok(())
}

심볼릭 링크의 메타데이터를 따라가지 않고 링크 자체의 정보를 보고 싶을 땐 symlink_metadata를 씁니다.

io::Error 다루기

std::fs 함수는 모두 io::Result<T>(즉 Result<T, std::io::Error>)를 돌려주는데요. 이 에러는 ErrorKind로 종류를 구분할 수 있어서, match로 변형별 처리하기에 좋습니다.

use std::fs;
use std::io::ErrorKind;

fn load_config() -> String {
    match fs::read_to_string("config.toml") {
        Ok(content) => content,
        Err(e) if e.kind() == ErrorKind::NotFound => {
            // 파일이 없으면 기본값
            String::from("default = true")
        }
        Err(e) => panic!("설정 파일 읽기 실패: {e}"),
    }
}

NotFound, PermissionDenied, AlreadyExists, Interrupted 같은 변형이 자주 쓰입니다. 모든 에러를 한 바구니로 묶지 말고, 회복 가능한 에러는 별도로 처리하는 게 안전합니다.

마치며

std::fs는 파일 처리에 필요한 거의 모든 일을 표준 라이브러리만으로 끝낼 수 있게 해줍니다. 한 방 함수(read_to_string/write)로 빠르게, FileOpenOptions로 세밀하게, metadata로 정보 조회까지 한 모듈에 모여 있죠.

다음 단계로는 Path와 PathBuf에서 경로를 안전하게 다루는 방법을 보시면 좋습니다. 대용량 파일을 효율적으로 다루고 싶으시다면 BufReader/BufWriter를, 비동기 컨텍스트에서 파일을 다뤄야 한다면 tokio::fs를 이어서 보시기 바랍니다.

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

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

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

Discord