RUST · 심층 가이드
Rust 파일/IO 완전 정리
텍스트 읽기·쓰기와 JSON 저장, CSV 파싱, 환경 변수, 경로 합치기, 표준 입력까지 Rust 파일 입출력 15가지를 실행 예제와 함께 정리했습니다.
파일 입출력은 Rust에서 오류 처리와 소유권이 동시에 드러나는 영역입니다. 거의 모든 함수가 io::Result를 돌려주므로 실패 경로를 건너뛸 수 없고, 경로는 String이 아니라 Path와 PathBuf라는 별도 타입으로 다룹니다. 문자열을 그대로 이어 붙이던 습관이 여기서 한 번 교정됩니다.
텍스트 파일 읽기와 텍스트 파일 쓰기로 시작해 텍스트 파일 추가 쓰기까지 보면 열기 옵션의 차이가 정리됩니다. 그다음 경로 합치기와 디렉터리 목록, 존재 여부 확인을 묶어 읽으면 경로를 안전하게 다루는 감각이 생기고, 환경 변수 읽기와 명령행 인자, 설정 로드는 그대로 작은 CLI 도구의 뼈대가 됩니다.
버퍼링에는 조용한 함정이 있습니다. BufWriter는 스코프를 벗어날 때 자동으로 비워지지만, 그 과정에서 발생한 오류는 보고되지 않고 사라집니다. 디스크가 가득 찼거나 쓰기가 실패해도 프로그램은 성공한 것처럼 끝날 수 있습니다. 쓰기를 마쳤으면 스코프에 맡기지 말고 flush()를 명시적으로 호출해 그 결과를 확인하십시오.
01텍스트 파일 읽기
제목은 읽기지만 실제 코드는 fs::write로 sample-1.txt에 hello를 저장한 뒤 written을 출력합니다. 열기·쓰기·닫기가 한 호출에 묶여 있어 작은 파일을 통째로 내보낼 때 적합합니다. 이 함수는 기존 내용을 잘라내고 덮어쓰므로, 뒤에 이어 붙이려면 OpenOptions의 append 모드를 따로 열어야 합니다.
Rust code
// 텍스트 파일 읽기
use std::fs;
fn main() {
fs::write("sample-1.txt", "hello").unwrap();
println!("written");
}fs::write는 열기와 쓰기와 닫기를 한 호출로 묶어 주고, 두 번째 인자가 AsRef<[u8]>라 문자열과 바이트 배열을 모두 그대로 받습니다.
이 함수는 기존 파일을 잘라 내고 덮어쓰므로, 이어 붙일 생각으로 반복 호출하면 마지막 내용만 남고 앞서 쓴 것이 전부 사라집니다.
02텍스트 파일 쓰기
PathBuf::from("logs")에 join을 두 번 이어 붙여 logs/2026/app-2.log 경로를 조립하고 화면에 찍습니다. 로그나 캐시처럼 디렉터리 구조가 규칙적인 경로를 코드로 만들 때 이 형태를 씁니다. 구분자를 문자열로 직접 붙이면 윈도우에서 어긋나지만, join은 플랫폼에 맞는 구분자를 알아서 넣습니다.
Rust code
// 텍스트 파일 쓰기
use std::path::PathBuf;
fn main() {
let path = PathBuf::from("logs").join("2026").join("app-2.log");
println!("{}", path.display());
}join은 플랫폼에 맞는 구분자를 알아서 넣어 주므로, 같은 코드가 윈도우와 유닉스 양쪽에서 올바른 경로를 만들어 냅니다.
구분자를 format!으로 직접 붙이면 윈도우에서 어긋나거나 구분자가 겹쳐, 실제로 존재하는 파일을 찾지 못하는 오류로 이어집니다.
03텍스트 파일 추가 쓰기
여기서 하는 일은 sample-3.txt를 만들어 hello를 채우고 성공 여부를 그대로 흘려보내는 것입니다. 임시 산출물이나 테스트 픽스처를 만들 때 흔히 쓰는 최단 경로입니다. 다만 unwrap은 권한 오류나 경로 오류에서 곧바로 패닉하므로, 실제 코드에서는 main을 io::Result<()>로 두고 ?를 쓰는 편이 안전합니다.
Rust code
// 텍스트 파일 추가 쓰기
use std::fs;
fn main() {
fs::write("sample-3.txt", "hello").unwrap();
println!("written");
}정말로 이어 붙이려면 OpenOptions::new().append(true).create(true)로 연 핸들에 써야 하며, 이때는 기존 내용이 그대로 유지됩니다.
unwrap()은 권한 부족이나 경로 오류에서 곧바로 패닉하므로, main을 io::Result<()>로 두고 ?를 쓰면 원인이 메시지로 남습니다.
04JSON 저장
JSON이라는 제목과 달리 코드가 다루는 것은 직렬화가 아니라 logs/2026/app-4.log라는 경로 값 하나입니다. 저장 위치를 먼저 계산해 두고 나중에 쓰기 함수에 넘기는 구조에서 이런 분리가 유용합니다. display()를 거치는 이유는 경로가 UTF-8이라는 보장이 없어 Path에 Display가 구현되어 있지 않기 때문입니다.
Rust code
// JSON 저장
use std::path::PathBuf;
fn main() {
let path = PathBuf::from("logs").join("2026").join("app-4.log");
println!("{}", path.display());
}Path에는 Display가 없어 display()를 거치는데, 경로가 UTF-8이라는 보장이 없기 때문에 일부러 그렇게 나뉘어 있습니다.
저장 경로를 문자열로 들고 다니면 확장자 교체나 부모 계산을 직접 해야 하므로, Path로 유지해 set_extension 같은 메서드를 쓰는 편이 안전합니다.
05CSV 파싱
CSV 파싱 대신 sample-5.txt에 hello 다섯 글자를 기록하는 코드가 실려 있습니다. 파싱 실험 전에 입력 파일을 먼저 만들어 두는 준비 단계로 보면 자연스럽습니다. 여기 쓰인 상대 경로는 소스 파일 위치가 아니라 프로그램을 실행한 작업 디렉터리를 기준으로 풀리므로, cargo run 위치에 따라 파일이 다른 곳에 생깁니다.
Rust code
// CSV 파싱
use std::fs;
fn main() {
fs::write("sample-5.txt", "hello").unwrap();
println!("written");
}여기 쓰인 상대 경로는 소스 위치가 아니라 프로세스의 작업 디렉터리를 기준으로 풀리므로, env::current_dir()로 실제 기준을 확인할 수 있습니다.
작업 디렉터리는 실행 방법에 따라 달라져서, 같은 코드가 cargo run과 배포 실행에서 서로 다른 위치에 파일을 만들어 놓습니다.
06환경 변수 읽기
환경 변수와는 무관하게, 이 예제는 세 조각을 이어 하나의 로그 파일 경로를 만들어 출력합니다. 날짜별 디렉터리를 코드로 계산해야 하는 배치 작업에서 이런 조립이 자주 나옵니다. 주의할 점은 join에 절대 경로를 넘기면 앞서 쌓은 부분이 이어지지 않고 통째로 교체된다는 것입니다.
Rust code
// 환경 변수 읽기
use std::path::PathBuf;
fn main() {
let path = PathBuf::from("logs").join("2026").join("app-6.log");
println!("{}", path.display());
}경로 조각을 변수로 받아 join으로 이어 붙이면 날짜별 디렉터리처럼 규칙적인 구조를 코드로 계산해 낼 수 있습니다.
join에 절대 경로를 넘기면 앞서 쌓은 부분이 이어지지 않고 통째로 교체되므로, 사용자 입력을 그대로 넘기면 엉뚱한 위치에 쓰게 됩니다.
07명령행 인자
명령행 인자를 받는 대신, 코드는 sample-7.txt를 생성하고 문자열 hello를 그 안에 넣습니다. 설정 파일 초기값을 프로그램이 직접 깔아 두는 상황에 그대로 쓸 수 있는 형태입니다. 두 번째 인자 타입이 AsRef<[u8]>라 문자열도 바이트 배열도 받지만, 줄바꿈은 자동으로 붙지 않아 필요하면 직접 넣어야 합니다.
Rust code
// 명령행 인자
use std::fs;
fn main() {
fs::write("sample-7.txt", "hello").unwrap();
println!("written");
}쓰는 내용에 줄바꿈이 자동으로 붙지 않으므로, 여러 줄을 남기려면 \n을 직접 넣거나 미리 이어 붙인 문자열을 넘겨야 합니다.
사용자에게 받은 이름을 그대로 경로에 쓰면 상위 디렉터리를 뜻하는 조각이 섞였을 때 의도한 폴더 밖에 파일이 생기므로, 파일명만 뽑아 써야 합니다.
08경로 합치기
제목 그대로 경로 합치기를 보여 주는 예제이며, PathBuf가 소유권을 가진 경로 버퍼 역할을 합니다. 문자열을 조합해 파일 위치를 결정하는 모든 자리에서 이 타입이 기준이 됩니다. String과 &str의 관계가 그대로 PathBuf와 &Path에 적용되므로, 함수 인자는 소유 타입 대신 &Path로 받는 편이 유연합니다.
Rust code
// 경로 합치기
use std::path::PathBuf;
fn main() {
let path = PathBuf::from("logs").join("2026").join("app-8.log");
println!("{}", path.display());
}함수 인자는 PathBuf 대신 &Path나 impl AsRef<Path>로 받으면 문자열 리터럴까지 그대로 넘길 수 있어 호출부가 편해집니다.
읽기만 하는 함수가 PathBuf를 요구하면 호출할 때마다 경로를 복제해 넘기게 되어, 의미 없는 할당이 호출 수만큼 쌓입니다.
09디렉터리 목록
디렉터리를 훑는 코드가 아니라 sample-9.txt 하나를 쓰고 끝나는 코드입니다. 결과물을 한 파일에 모아 내보내는 단순한 출력 단계에 어울립니다. fs::write는 없는 상위 디렉터리를 만들어 주지 않으므로, 중첩 경로에 쓸 생각이라면 fs::create_dir_all을 먼저 불러야 NotFound 오류를 피합니다.
Rust code
// 디렉터리 목록
use std::fs;
fn main() {
fs::write("sample-9.txt", "hello").unwrap();
println!("written");
}중첩된 경로에 쓰기 전에 fs::create_dir_all을 부르면 이미 있는 디렉터리에도 오류 없이 넘어가므로 존재 확인을 따로 하지 않아도 됩니다.
상위 디렉터리를 만들지 않은 채 fs::write를 부르면 디렉터리를 자동으로 만들어 주지 않아 NotFound 오류로 실패합니다.
10임시 파일
임시 파일을 만들지는 않고, logs/2026/app-10.log라는 이름을 계산해 보여 주기만 합니다. 실제 입출력 전에 경로 규칙이 맞는지 눈으로 확인하는 용도로 이런 출력이 쓰입니다. 경로 값을 만드는 동안 파일 시스템은 전혀 건드리지 않으므로, 존재 여부는 Path::exists 같은 별도 호출로 물어봐야 합니다.
Rust code
// 임시 파일
use std::path::PathBuf;
fn main() {
let path = PathBuf::from("logs").join("2026").join("app-10.log");
println!("{}", path.display());
}경로 값을 만드는 동안에는 파일 시스템을 전혀 건드리지 않으므로, 존재 여부가 필요하면 Path::exists 같은 호출을 따로 해야 합니다.
exists()로 확인한 뒤 파일을 여는 순서는 그 사이에 상태가 바뀔 수 있어, 임시 파일처럼 경쟁이 생기는 자리에서는 열기 결과로 직접 판단해야 합니다.
11존재 여부 확인
존재 여부 확인 코드는 보이지 않고, sample-11.txt에 내용을 채워 넣는 쓰기만 남아 있습니다. 파일 하나를 한 번에 갈아 끼우는 갱신 패턴으로 이해하면 됩니다. 이렇게 단발성 쓰기에는 BufWriter가 필요 없습니다. 버퍼링이 이득을 주는 쪽은 작은 조각을 반복해서 밀어 넣는 경우입니다.
Rust code
// 존재 여부 확인
use std::fs;
fn main() {
fs::write("sample-11.txt", "hello").unwrap();
println!("written");
}이렇게 한 번에 전부 쓰는 경우에는 BufWriter가 이득을 주지 않고, 버퍼링은 작은 조각을 반복해서 밀어 넣을 때 의미가 있습니다.
BufWriter를 쓰면서 flush를 부르지 않고 스코프를 벗어나면, Drop이 대신 비우면서 그때 난 쓰기 오류를 아무도 보지 못한 채 버립니다.
12표준 입력 읽기
표준 입력 대신 등장하는 것은 세 조각을 이은 경로와 그 문자열 표현입니다. 사용자 입력으로 받은 파일명을 안전하게 붙일 때도 같은 방식을 씁니다. display()는 UTF-8이 아닌 바이트를 대체 문자로 바꿔 보여 줄 뿐이라, 진짜 문자열이 필요하면 Option을 돌려주는 to_str()로 실패 가능성을 다뤄야 합니다.
Rust code
// 표준 입력 읽기
use std::path::PathBuf;
fn main() {
let path = PathBuf::from("logs").join("2026").join("app-12.log");
println!("{}", path.display());
}to_str()은 경로가 올바른 UTF-8일 때만 Some을 돌려주므로, 문자열이 꼭 필요한 자리에서는 이 실패 가능성을 다뤄야 합니다.
display()는 표현할 수 없는 바이트를 대체 문자로 바꿔 보여 줄 뿐이라, 그 결과를 다시 경로로 되돌려 쓰면 원래 파일을 가리키지 못합니다.
13설정 로드
설정을 읽는 대신 sample-13.txt라는 파일을 만들어 값을 기록하는 흐름입니다. 기본 설정을 파일로 떨어뜨려 두었다가 다음 실행에서 읽는 구성에 맞습니다. File::create로 열고 write_all을 부르고 닫는 세 단계를 한 줄로 줄인 것이 fs::write이며, 닫기 시점의 오류까지 반환값에 담깁니다.
Rust code
// 설정 로드
use std::fs;
fn main() {
fs::write("sample-13.txt", "hello").unwrap();
println!("written");
}fs::write는 닫는 시점에 생긴 오류까지 반환값에 담아 주므로, File과 write_all을 직접 다룰 때보다 놓치는 실패가 적습니다.
설정 파일을 저장할 때마다 전체를 덮어쓰면 두 프로세스가 동시에 쓸 경우 서로의 변경을 지우게 되므로, 잠금이나 단일 기록자 구조가 필요합니다.
14직렬화
직렬화 대신 경로 조립이 나오지만, 저장 대상 위치를 정하는 단계라는 점에서는 이어집니다. 데이터를 파일로 떨구기 직전에 목적지를 확정하는 자리에 넣기 좋습니다. join은 호출할 때마다 새 PathBuf를 할당하므로, 반복문 안에서 경로를 계속 늘려 갈 때는 push로 같은 버퍼를 재사용하는 편이 낫습니다.
Rust code
// 직렬화
use std::path::PathBuf;
fn main() {
let path = PathBuf::from("logs").join("2026").join("app-14.log");
println!("{}", path.display());
}경로를 반복문에서 계속 늘려 갈 때는 join으로 새 값을 만들기보다 push와 pop으로 같은 버퍼를 재사용하는 편이 할당이 적습니다.
join은 호출할 때마다 새 PathBuf를 할당하므로, 수만 개 경로를 만드는 루프 안에 그대로 두면 할당 비용이 눈에 띄게 쌓입니다.
15로그 파일 회전 흉내
회전을 흉내 내는 로직은 없고 sample-15.txt 한 개를 덮어쓰는 동작만 있습니다. 최신 상태 파일 하나만 유지하면 되는 단순한 기록에 알맞습니다. 덮어쓰기 도중 프로그램이 죽으면 파일이 반쯤 잘린 채 남을 수 있어, 원자적 교체가 필요하면 임시 파일에 쓴 뒤 fs::rename으로 바꿔치기합니다.
Rust code
// 로그 파일 회전 흉내
use std::fs;
fn main() {
fs::write("sample-15.txt", "hello").unwrap();
println!("written");
}원자적으로 갈아 끼우려면 임시 파일에 먼저 쓰고 fs::rename으로 바꿔치기하면 되며, 같은 파일 시스템 안에서는 이 교체가 한 번에 일어납니다.
덮어쓰는 도중에 프로세스가 죽으면 파일이 반쯤 잘린 채 남아, 다음 실행이 그 깨진 내용을 정상 데이터로 읽어 들일 수 있습니다.
정리하며
- 경로는 문자열이 아니라 Path와 PathBuf 타입으로 다룹니다
- 대부분의 IO 함수가 Result를 돌려주므로 실패 경로를 반드시 처리합니다
- BufWriter는 자동 flush의 오류를 삼키므로 직접 flush하고 결과를 확인합니다
- 환경 변수와 명령행 인자를 합치면 작은 CLI 도구의 뼈대가 완성됩니다
더 깊이 들어가고 싶다면 Rust 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.