Rust 실전 강좌 #3 파일 IO와 파싱: BufReader로 읽고, 로그 한 줄을 구조체로
뼈대(1편)와 에러 경로(2편)가 준비됐으니, 이제 도구의 심장인 “읽고 해석하기"를 만듭니다. 소재는 가장 널리 쓰이는 nginx·Apache 계열의 combined 로그 형식입니다.
203.0.113.9 - - [23/Aug/2026:10:12:01 +0900] "GET /api/users HTTP/1.1" 200 512읽기 전략: 통째로 올릴까, 흘려보낼까 #
파일을 읽는 방법은 크게 둘입니다.
// 전략 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: 한 줄의 목적지 #
#[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로 #
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_or는 Option을 Result로 바꾸는 다리(기초 5·6편), status.parse()?의 ?는 #[from] 덕에 ParseIntError를 ParseError::BadStatus로 자동 변환합니다(2편). 따옴표를 기준으로 먼저 3분할하는 이유는 요청 라인("GET /api ...") 안에 공백이 있어서 통짜 split_whitespace로는 안전하게 자를 수 없기 때문입니다. bytes 필드가 -인 라인(본문 없는 응답)은 0으로 관대하게 받습니다. 어디에 엄격하고 어디에 관대할지를 파서가 결정하고, 그 결정이 에러 타입에 문서화되는 것이 이 설계의 핵심입니다.
연결: errors 커맨드 완성 #
파서가 생겼으니 1편의 todo!() 하나를 실제 구현으로 바꿉니다.
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#[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 지원은
Deserializederive 한 줄입니다. 형식이 달라도 목적지(LogEntry)가 같으면 이후 코드는 그대로입니다. - 다음 편은 이 파서 위에 집계를 올립니다. 이터레이터 체인으로 stats와 top을 완성하고, release 빌드가 성능을 얼마나 바꾸는지 측정하겠습니다.