Rust 실전 강좌 #3 파일 IO와 파싱: BufReader로 읽고, 로그 한 줄을 구조체로

4 분 소요

뼈대(1편)와 에러 경로(2편)가 준비됐으니, 이제 도구의 심장인 “읽고 해석하기"를 만듭니다. 소재는 가장 널리 쓰이는 nginx·Apache 계열의 combined 로그 형식입니다.

access.log
203.0.113.9 - - [23/Aug/2026:10:12:01 +0900] "GET /api/users HTTP/1.1" 200 512

읽기 전략: 통째로 올릴까, 흘려보낼까 #

파일을 읽는 방법은 크게 둘입니다.

src/main.rs
// 전략 1: 전체를 메모리로
let text = std::fs::read_to_string(path)?;

// 전략 2: 줄 단위 스트리밍
use std::io::{BufRead, BufReader};
let reader = BufReader::new(File::open(path)?);
for line in reader.lines() {
    let line = line?;
    // 한 줄씩 처리
}

read_to_string은 단순하지만 파일 크기만큼 메모리를 씁니다. 10GB 로그면 10GB입니다. BufReader는 일정 크기의 버퍼만 쓰면서 줄 단위로 흘려보내므로, 파일 크기와 무관하게 메모리가 일정합니다. loglens의 기본은 스트리밍입니다(5편의 병렬화에서 전략 1로 돌아올 이유가 생기는데, 그때 다시 비교하겠습니다).

BufReader를 끼우는 이유는 시스템 콜 비용입니다. File을 직접 읽으면 읽기 요청마다 OS 호출이 발생하는데, 이 비용은 줄 단위 처리처럼 잘게 읽는 패턴에서 치명적입니다. BufReader는 한 번에 수 KB를 미리 읽어 버퍼에 두고, 줄 요청은 버퍼에서 꺼내 줍니다. 파일을 줄 단위로 읽을 때 BufReader::new로 감싸는 것은 고민할 필요가 없는 기본기입니다.

LogEntry: 한 줄의 목적지 #

src/parser.rs
#[derive(Debug, PartialEq)]
pub struct LogEntry {
    pub ip: String,
    pub method: String,
    pub path: String,
    pub status: u16,
    pub bytes: u64,
}

파서가 만들 구조체입니다. #[derive(Debug, PartialEq)]는 기초 8편의 상비약 그대로이고, PartialEq는 6편의 테스트에서 assert_eq!를 쓰기 위한 준비입니다. 시각 필드는 이번 시리즈의 분석에 쓰지 않으므로 일단 생략합니다. 필요해질 때 추가하면 그때 5편의 match들이 컴파일 에러로 수정 지점을 알려 줄 것입니다.

파서: &str에서 LogEntry로 #

src/parser.rs
pub fn parse_line(line: &str) -> Result<LogEntry, ParseError> {
    if line.trim().is_empty() {
        return Err(ParseError::Empty);
    }

    // "GET /api/users HTTP/1.1" 부분을 따옴표 기준으로 분리
    let mut quoted = line.splitn(3, '"');
    let before = quoted.next().unwrap_or("");
    let request = quoted.next().ok_or(ParseError::TooFewFields { found: 0 })?;
    let after = quoted.next().ok_or(ParseError::TooFewFields { found: 0 })?;

    let ip = before
        .split_whitespace()
        .next()
        .ok_or(ParseError::TooFewFields { found: 0 })?;

    let mut req = request.split_whitespace();
    let method = req.next().ok_or(ParseError::TooFewFields { found: 1 })?;
    let path = req.next().ok_or(ParseError::TooFewFields { found: 2 })?;

    let mut tail = after.split_whitespace();
    let status: u16 = tail
        .next()
        .ok_or(ParseError::TooFewFields { found: 3 })?
        .parse()?; // ParseIntError → ParseError::BadStatus (#[from])
    let bytes: u64 = tail.next().and_then(|s| s.parse().ok()).unwrap_or(0);

    Ok(LogEntry {
        ip: ip.to_string(),
        method: method.to_string(),
        path: path.to_string(),
        status,
        bytes,
    })
}

짧은 함수지만 기초 강좌의 회수 지점이 촘촘합니다. 입력이 &str(기초 4편의 관례), 반환이 Result<LogEntry, ParseError>(2편의 설계), ok_orOptionResult로 바꾸는 다리(기초 5·6편), status.parse()??#[from] 덕에 ParseIntErrorParseError::BadStatus로 자동 변환합니다(2편). 따옴표를 기준으로 먼저 3분할하는 이유는 요청 라인("GET /api ...") 안에 공백이 있어서 통짜 split_whitespace로는 안전하게 자를 수 없기 때문입니다. bytes 필드가 -인 라인(본문 없는 응답)은 0으로 관대하게 받습니다. 어디에 엄격하고 어디에 관대할지를 파서가 결정하고, 그 결정이 에러 타입에 문서화되는 것이 이 설계의 핵심입니다.

연결: errors 커맨드 완성 #

파서가 생겼으니 1편의 todo!() 하나를 실제 구현으로 바꿉니다.

src/main.rs
fn cmd_errors(path: &Path) -> anyhow::Result<()> {
    let reader = BufReader::new(open_log(path)?); // 2편의 context 포함 열기
    for line in reader.lines() {
        let line = line?;
        if let Ok(entry) = parse_line(&line) {
            if entry.status >= 500 {
                println!("{line}");
            }
        }
    }
    Ok(())
}

파싱 실패 라인은 조용히 지나갑니다(if let Ok). 5xx 추출이 목적일 때 깨진 라인은 관심 밖이기 때문입니다. 반면 다음 편의 stats는 “몇 줄이 안 읽혔는지"도 보고해야 하므로 실패를 세게 됩니다. 같은 파서를 쓰되 실패의 취급이 커맨드마다 다른 것, 이것이 Result를 반환하는 파서의 유연함입니다.

확장: JSON Lines 로그도 받기 #

요즘은 로그를 JSON Lines(한 줄에 JSON 객체 하나)로 남기는 서버도 많습니다. serde를 붙이면 이 지원이 놀랄 만큼 짧습니다.

설치
cargo add serde --features derive
cargo add serde_json
src/parser.rs
#[derive(Debug, PartialEq, serde::Deserialize)]
pub struct LogEntry { /* 필드 동일 */ }

pub fn parse_json_line(line: &str) -> Result<LogEntry, serde_json::Error> {
    serde_json::from_str(line)
}

Deserialize를 derive 목록에 추가하는 것이 사실상 전부입니다. 필드 이름과 타입 검증까지 serde가 수행합니다. 손으로 쓴 파서와 자동 파생 파서가 같은 LogEntry를 만들어 내므로, 이후의 집계 코드는 로그 형식을 몰라도 됩니다.

정리 #

  • 대용량 파일의 기본은 BufReader 스트리밍입니다. 메모리가 파일 크기와 무관해지고, 버퍼링이 시스템 콜 비용을 흡수합니다.
  • 파서는 &str를 받아 Result<LogEntry, ParseError>를 반환합니다. 요청 라인의 공백 때문에 따옴표 기준 3분할이 먼저입니다.
  • 어디에 엄격하고(상태 코드) 어디에 관대할지(bytes의 -)는 파서의 설계 결정이고, 에러 타입이 그 문서가 됩니다.
  • 같은 파서라도 실패의 취급은 커맨드마다 다릅니다. errors는 스킵, stats는 카운트. Result 반환이 그 유연함의 원천입니다.
  • JSON Lines 지원은 Deserialize derive 한 줄입니다. 형식이 달라도 목적지(LogEntry)가 같으면 이후 코드는 그대로입니다.
  • 다음 편은 이 파서 위에 집계를 올립니다. 이터레이터 체인으로 stats와 top을 완성하고, release 빌드가 성능을 얼마나 바꾸는지 측정하겠습니다.
X