콘텐츠로 이동

Rust 파일 입출력

파일을 읽고 쓰는 일은 지금까지 배운 Result와 ? 연산자가 이론이 아니라 실전이 되는 첫 무대예요. 파일 작업은 거의 다 실패할 수 있어요(파일이 없을 수도 있고, 권한이 없을 수도 있어요). 그래서 표준 라이브러리는 그 상황을 직접 처리하도록 해요.

파일 내용을 String으로 읽어 오는 가장 간단한 방법이에요.

use std::fs;
fn main() {
let contents = fs::read_to_string("hello.txt");
match contents {
Ok(text) => println!("{text}"),
Err(e) => println!("파일을 읽을 수 없어요: {e}"),
}
}

fs::read_to_string은 Result<String, std::io::Error>를 돌려줘요. 성공하면 텍스트가 담긴 Ok, 실패하면 이유(파일이 없음, 권한 없음 등)가 담긴 Err예요.

파일 작업마다 match를 쓰다 보면 금방 반복이 많아져요. 지금 있는 함수가 Result를 돌려주는 함수라면, (Result 페이지에서 본) ? 연산자로 훨씬 깔끔하게 쓸 수 있어요.

use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // 실패하면 바로 반환해요
Ok(contents)
}
fn main() {
match read_file("hello.txt") {
Ok(text) => println!("{text}"),
Err(e) => println!("에러: {e}"),
}
}
use std::fs;
fn main() {
let result = fs::write("output.txt", "안녕, 파일!");
match result {
Ok(()) => println!("잘 저장했어요"),
Err(e) => println!("쓰기에 실패했어요: {e}"),
}
}

fs::write는 파일이 없으면 새로 만들고, 이미 있으면 내용을 통째로 덮어써요. 파일을 바꾸는 대신 내용을 덧붙이고 싶을 때는 이 점을 꼭 기억하세요.

기존 파일을 덮어쓰지 않고 뒤에 덧붙이려면 OpenOptions를 쓰세요.

use std::fs::OpenOptions;
use std::io::Write;
fn main() -> std::io::Result<()> {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("log.txt")?;
writeln!(file, "새 로그 한 줄")?;
Ok(())
}

여기서는 main 자체가 Result를 돌려준다는 점에 주목하세요. 알아 두면 좋은 패턴이에요. main이 Result를 돌려주면, 모든 걸 match로 감싸는 대신 main 안에서 바로 ?를 쓸 수 있어요.

큰 파일은 한 번에 전부 메모리에 올리는 게 늘 좋은 방법은 아니에요. 대신 한 줄씩 읽어 볼게요.

use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> {
let file = File::open("hello.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
println!("{}", line?);
}
Ok(())
}

지금까지는 모든 에러를 똑같이 처리했어요. 그냥 출력만 했죠. 하지만 무엇이 잘못됐는지에 따라 다르게 대응하고 싶을 때가 많아요. 파일이 없는 건 대개 괜찮아요(처음 실행한 것일 수도 있으니, 빈 상태로 시작하면 돼요). 반면 “권한 없음”은 사용자에게 알려야 할 진짜 문제예요.

io::Error는 .kind()로 자기 종류를 알려 줘요. 이 메서드는 match로 나눌 수 있는 ErrorKind (영어) 열거형을 돌려줘요.

use std::fs;
use std::io::ErrorKind;
fn main() {
// 시스템 임시 폴더 안의, 존재하지 않는 파일
let path = std::env::temp_dir().join("rusttutorial-missing-notes.txt");
match fs::read_to_string(&path) {
Ok(text) => println!("{}줄을 불러왔어요", text.lines().count()),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("아직 메모가 없어요. 새로 시작할게요"),
ErrorKind::PermissionDenied => println!("메모 파일을 읽을 권한이 없어요"),
_ => println!("다른 문제가 생겼어요: {e}"),
},
}
}

실행 결과:

아직 메모가 없어요. 새로 시작할게요
▶ 직접 실행해 보기

std::env::temp_dir()는 시스템 임시 폴더의 경로(Linux라면 /tmp 같은 것)를 주고, .join(...)은 거기에 파일 이름을 붙여요. 파일 함수들은 &str과 마찬가지로 이런 경로도 문제없이 받아요.

마지막에는 항상 _ 갈래를 두세요. ErrorKind에는 변형이 아주 많고, 시간이 지나면서 새로운 것도 추가돼요. _ 갈래가 나열하지 않은 나머지를 모두 받아 줘요.

두 문제 모두 먼저 임시 폴더에 직접 파일을 만들기 때문에, Playground를 포함해 어디서든 실행할 수 있어요.

fs::write로 할 일 세 개를 한 줄에 하나씩 파일에 쓰세요. 그다음 fs::read_to_string으로 파일을 다시 읽어서, 각 줄 앞에 번호를 붙여 출력하고 마지막에 전체 개수를 출력하세요.

힌트: 항목 사이에 \n을 넣으면 줄이 나뉘어요. text.lines()는 줄을 하나씩 줘요. ?를 쓸 수 있도록 main이 std::io::Result<()>를 돌려주게 하세요.

정답 보기
use std::fs;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-todo.txt");
fs::write(&path, "소유권 배우기\n모듈 만들기\n파일 읽기\n")?;
let text = fs::read_to_string(&path)?;
let mut count = 0;
for line in text.lines() {
count += 1;
println!("{count}. {line}");
}
println!("모두 {count}개");
Ok(())
}

실행 결과:

1. 소유권 배우기
2. 모듈 만들기
3. 파일 읽기
모두 3개
▶ 직접 실행해 보기

fs::write가 파일 전체를 바꾸기 때문에, 몇 번을 실행해도 똑같은 세 줄이 나와요. 쓰기나 읽기가 실패하면 각 ?가 main에서 에러를 돌려줘요.

머리글 한 줄로 로그 파일을 새로 시작한 다음, OpenOptions와 writeln!으로 두 줄을 이어 쓰세요. 마지막으로 파일 전체를 다시 읽어서 출력하세요.

힌트: 머리글은 fs::write로 쓰고(그래야 매번 깨끗하게 시작해요), 추가할 줄은 OpenOptions::new().append(true).open(&path)?로 쓰세요. 파일이 이미 줄바꿈으로 끝나니까, println! 대신 print!를 쓰면 마지막에 빈 줄이 더 생기지 않아요.

정답 보기
use std::fs::{self, OpenOptions};
use std::io::Write;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-log.txt");
fs::write(&path, "=== 로그 ===\n")?; // 새로 시작해요
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "시작")?;
writeln!(file, "완료")?;
print!("{}", fs::read_to_string(&path)?);
Ok(())
}

실행 결과:

=== 로그 ===
시작
완료
▶ 직접 실행해 보기

use std::fs::{self, OpenOptions};는 fs 모듈 자체와 OpenOptions를 함께 가져와요. 처음의 fs::write가 없으면 실행할 때마다 예전 파일에 계속 덧붙여져서 로그가 끝없이 길어져요. 진짜 로그라면 바로 그게 원하는 동작이지만, 출력이 정해진 연습 문제에서는 곤란하죠.

  • fs::read_to_string(path)는 파일 전체를 String으로 읽고, Result를 돌려줘요.
  • fs::write(path, contents)는 파일을 써요. 파일이 이미 있으면 덮어써요.
  • OpenOptions::new().append(true)를 쓰면 파일을 바꾸는 대신 뒤에 덧붙일 수 있어요.
  • main() -> std::io::Result<()>로 쓰면 호출마다 match할 필요 없이 main 안에서 바로 ?를 쓸 수 있어요.
  • e.kind()는 ErrorKind::NotFound처럼 어떤 종류의 에러인지 알려 줘서, 종류별로 다르게 처리할 수 있어요.
  • 프로젝트 폴더의 파일을 쓰는 예제는 내 컴퓨터에서 실행하는 게 좋아요. std::env::temp_dir()는 어디서든 쓸 수 있는 폴더를 줘요.

이해도 점검

1. 이미 있는 경로에 fs::write를 호출하면 어떻게 될까요?

2. 파일을 덮어쓰지 않고 뒤에 덧붙이려면 어떻게 할까요?

3. main 자체가 std::io::Result<()>를 돌려주면 무엇을 할 수 있게 될까요?

4. fs::read_to_string(path)?가 실패하면 ? 연산자는 무엇을 할까요?

5. 큰 파일을 fs::read_to_string 대신 BufReader로 한 줄씩 읽는 이유는 무엇일까요?

점수: 0 / 5