Rust BufReader와 BufWriter: 버퍼링된 파일 I/O로 효율 챙기기
std::fs 기초에서 본 read_to_string은 편합니다. 한 줄로 파일을 통째로 읽어 String으로 돌려주죠. 하지만 5GB짜리 로그 파일에 같은 함수를 쓴다면? 메모리에 5GB가 그대로 올라갑니다.
이번 글에서는 대용량 파일을 효율적으로 다루는 도구인 BufReader/BufWriter를 정리하고, 줄 단위 처리, 적절한 버퍼링, flush 잊지 않기 같은 실무 패턴을 살펴보겠습니다.
왜 버퍼링이 필요한가
파일 시스템 호출(syscall)은 비싼 연산입니다. 한 바이트씩 읽고 쓰면 매번 syscall이 발생해서 성능이 크게 떨어지죠. 운영체제는 보통 4KB 같은 블록 단위로 읽어 들이는 게 효율적인데, 사용자 코드가 한 바이트씩 요청하면 그 효율을 살릴 수 없습니다.
버퍼링은 이 갭을 메우는 패턴입니다. 메모리에 적당한 크기의 버퍼를 두고, 한 번에 큰 덩어리를 읽어와 그 안에서 작은 단위로 꺼내 쓰는 거죠. 결과적으로 syscall 횟수가 줄고 처리량이 올라갑니다.
File에 직접 read_to_string을 호출해도 잘 작동하긴 합니다. 다만 한 줄씩 처리하거나 부분적으로 읽고 쓰는 패턴에서는 명시적으로 BufReader/BufWriter로 감싸주는 게 표준적입니다.
BufReader로 줄 단위 읽기
BufReader로 File을 감싸면 BufRead 트레이트의 메서드를 쓸 수 있는데요. 가장 유용한 건 lines() 이터레이터입니다.
use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> {
let file = File::open("big.log")?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
if line.contains("ERROR") {
println!("{line}");
}
}
Ok(())
}
lines()는 줄 단위로 Result<String, io::Error>를 돌려주는 이터레이터를 만듭니다. 5GB짜리 로그 파일이라도 한 번에 한 줄만 메모리에 올라가기 때문에, 메모리 사용량이 거의 일정하게 유지되죠.
for 루프 안에서 let line = line?;로 이중으로 풀어주는 부분이 처음 보면 어색할 수 있는데요. 이터레이터 항목 자체가 Result라서 그렇습니다. ? 연산자를 써서 에러는 위로 전파하고 성공값만 받아 쓰는 패턴입니다.
lines()는 줄 끝 개행 문자(\n이나 \r\n)를 알아서 떼고 돌려줍니다. 개행을 살리고 싶다면 read_line을 직접 호출하면 되고요.
let mut line = String::new();
loop {
line.clear();
let bytes_read = reader.read_line(&mut line)?;
if bytes_read == 0 {
break; // EOF
}
// line에 개행 포함됨
}
read_line은 String 버퍼를 재사용할 수 있어서, 매 줄마다 새 할당을 피하고 싶을 때 유리합니다.
적절한 버퍼 크기
BufReader::new는 기본 8KB 버퍼를 만드는데요. 대용량 파일이나 특수한 워크로드에서는 더 큰 버퍼가 도움이 될 수 있습니다.
use std::io::BufReader;
let reader = BufReader::with_capacity(64 * 1024, file); // 64KB
다만 무작정 키운다고 빨라지진 않습니다. 디스크 블록 크기와 OS 페이지 캐시를 고려하면 64KB 정도가 합리적인 상한이고, 그 이상은 보통 효과가 미미합니다. 작은 파일을 많이 처리하는 워크로드라면 오히려 기본값이 더 빠를 수도 있고요.
벤치마크 없이 버퍼 크기를 바꾸는 건 추천하지 않습니다. 기본값으로 시작해서 측정 결과가 명확할 때만 조정하세요.
BufWriter로 쓰기 효율 챙기기
읽기와 마찬가지로 쓰기도 버퍼링이 효과적입니다. File에 한 줄씩 쓰면 매번 syscall이 발생하는데, BufWriter로 감싸면 버퍼가 찼을 때만 디스크로 내보냅니다.
use std::fs::File;
use std::io::{BufWriter, Write};
fn main() -> std::io::Result<()> {
let file = File::create("output.log")?;
let mut writer = BufWriter::new(file);
for i in 0..10_000 {
writeln!(writer, "이벤트 {i} 발생")?;
}
writer.flush()?; // 명시적으로 flush
Ok(())
}
writeln!은 매크로라서 매번 syscall을 부르지 않고, BufWriter 내부 버퍼에만 쓰고 끝납니다. 버퍼가 가득 차거나 명시적으로 flush()를 호출했을 때만 실제 디스크에 내려가죠.
여기서 가장 흔한 함정이 flush를 안 부르는 건데요. BufWriter가 drop될 때 자동으로 flush를 시도하긴 하지만, 그때 발생하는 에러는 무시되어 버립니다. 중요한 데이터를 쓸 때는 반드시 명시적으로 flush()?를 호출해서 에러를 확인하세요.
// 안 좋은 패턴
let writer = BufWriter::new(file);
writeln!(writer, "{data}")?;
// drop 시 자동 flush, 하지만 에러를 알 길이 없음
// 좋은 패턴
let mut writer = BufWriter::new(file);
writeln!(writer, "{data}")?;
writer.flush()?; // 명시적으로 확인
한 번에 다 합치기: 읽고 변형해서 쓰기
실무에서 자주 등장하는 “파일 읽어서 변형해 다른 파일로 쓰기” 패턴을 합쳐 보면 이렇게 됩니다.
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};
fn convert_log(input: &str, output: &str) -> std::io::Result<()> {
let in_file = File::open(input)?;
let out_file = File::create(output)?;
let reader = BufReader::new(in_file);
let mut writer = BufWriter::new(out_file);
for line in reader.lines() {
let line = line?;
if line.contains("ERROR") {
writeln!(writer, "[ALERT] {line}")?;
}
}
writer.flush()?;
Ok(())
}
입력 파일을 줄 단위로 읽으면서 메모리 사용량은 일정하게 유지하고, 출력 파일은 버퍼링해서 syscall을 줄입니다. 5GB 로그 파일이든 5KB 로그 파일이든 같은 코드가 잘 작동하죠.
언제 버퍼링이 필요 없는가
작은 파일을 한 번에 다 읽어 쓰는 시나리오에서는 버퍼링이 오히려 군더더기입니다. read_to_string이나 fs::write 같은 한 방 함수가 내부적으로 적절한 처리를 해주거든요.
// 버퍼링 불필요
let config = std::fs::read_to_string("config.toml")?;
std::fs::write("output.json", json_string)?;
또한 다른 Read/Write 구현체(예: TcpStream, Stdin)는 이미 자체 버퍼를 갖고 있는 경우가 있어서 또 한 번 감싸면 중복이 됩니다. 일반적으로는 File을 직접 다룰 때만 명시적으로 BufReader/BufWriter를 끼우면 됩니다.
마치며
BufReader와 BufWriter는 단순한 래퍼지만, 큰 파일을 다룰 때 메모리 사용량과 성능에 결정적인 차이를 만듭니다. lines() 이터레이터로 메모리를 일정하게 유지하면서 처리하고, 쓰기 후에는 flush()로 에러를 명시적으로 확인하는 두 패턴만 익혀두면 일상 작업의 대부분을 커버할 수 있습니다.
비동기 컨텍스트에서 파일을 다뤄야 한다면 tokio::fs에서 이어지는 이야기를 확인해보세요. 이전 단계가 궁금하시면 std::fs 기초나 Path와 PathBuf를 참고하시기 바랍니다.
더 자세한 내용은 std::io::BufReader 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0