Rust BufReader와 BufWriter: 버퍼링된 파일 I/O로 효율 챙기기

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로 줄 단위 읽기

BufReaderFile을 감싸면 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_lineString 버퍼를 재사용할 수 있어서, 매 줄마다 새 할당을 피하고 싶을 때 유리합니다.

적절한 버퍼 크기

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를 끼우면 됩니다.

마치며

BufReaderBufWriter는 단순한 래퍼지만, 큰 파일을 다룰 때 메모리 사용량과 성능에 결정적인 차이를 만듭니다. lines() 이터레이터로 메모리를 일정하게 유지하면서 처리하고, 쓰기 후에는 flush()로 에러를 명시적으로 확인하는 두 패턴만 익혀두면 일상 작업의 대부분을 커버할 수 있습니다.

비동기 컨텍스트에서 파일을 다뤄야 한다면 tokio::fs에서 이어지는 이야기를 확인해보세요. 이전 단계가 궁금하시면 std::fs 기초Path와 PathBuf를 참고하시기 바랍니다.

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

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

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

Discord