Rust에서 JSONL: serde_json, BufReader & 제로 코스트 추상화
Rust에서 JSONL(JSON Lines) 파일 작업을 위한 완전 가이드입니다. serde_json, BufReader, 병렬 처리를 위한 rayon, 비동기 I/O를 위한 tokio를 사용하여 JSONL 데이터를 읽고, 쓰고, 파싱하고, 스트리밍하는 방법을 배우세요.
최종 업데이트: 2026년 2월
왜 Rust로 JSONL을?
Rust는 성능, 메모리 안전성, 정확성이 중요한 JSONL 처리에 탁월한 선택입니다. 소유권 모델은 컴파일 타임에 데이터 경쟁을 제거하고, 제로 코스트 추상화는 고수준 이터레이터 체인을 최적화된 루프로 컴파일하며, serde_json은 모든 언어에서 가장 빠른 JSON 파서 중 하나입니다. 초당 수백만 개의 JSONL 레코드를 처리해야 하는 데이터 파이프라인을 구축한다면, Rust는 안전성을 희생하지 않고 이를 달성할 수 있는 제어를 제공합니다.
JSONL(JSON Lines)은 줄당 하나의 JSON 객체를 저장하므로 스트리밍, 추가 전용 로깅, 대규모 데이터셋 처리에 이상적입니다. Rust의 BufReader는 전체 파일을 메모리에 로드하지 않고 줄 단위로 파일을 읽으며, 이터레이터 모델은 JSONL의 줄 지향 구조에 완벽하게 적합합니다. 이 가이드에서는 BufReader로 JSONL 읽기와 쓰기, serde로 강타입 구조체 파싱, rayon으로 병렬 처리, tokio로 비동기 I/O 처리, Result와 ? 연산자로 강력한 오류 처리를 구축하는 방법을 배우게 됩니다.
BufReader로 JSONL 파일 읽기
Rust의 표준 라이브러리는 효율적인 버퍼링 파일 읽기를 위해 BufReader를 제공합니다. lines() 이터레이터와 결합하면 최소한의 메모리 오버헤드로 JSONL 파일을 한 줄씩 읽습니다. 각 줄은 독립적으로 파싱되므로 이 접근 방식은 모든 크기의 파일에 적합합니다.
가장 간단한 접근 방식은 File을 BufReader로 래핑하고, lines()를 순회하며, 각 줄을 serde_json으로 파싱합니다. 파일 크기에 관계없이 한 번에 하나의 줄만 메모리에 유지합니다.
use std::fs::File;use std::io::{BufRead, BufReader};use serde_json::Value;fn read_jsonl(path: &str) -> std::io::Result<Vec<Value>> {let file = File::open(path)?;let reader = BufReader::new(file);let mut records = Vec::new();for (line_num, line) in reader.lines().enumerate() {let line = line?;let trimmed = line.trim();if trimmed.is_empty() {continue; // Skip empty lines}match serde_json::from_str::<Value>(trimmed) {Ok(value) => records.push(value),Err(e) => {eprintln!("Skipping invalid JSON at line {}: {}", line_num + 1, e);}}}Ok(records)}fn main() -> std::io::Result<()> {let records = read_jsonl("data.jsonl")?;println!("Loaded {} records", records.len());if let Some(first) = records.first() {println!("First record: {}", first);}Ok(())}
Cargo.toml에 serde와 serde_json을 추가하세요. derive 기능은 타입 구조체를 정의하기 위한 Serialize 및 Deserialize derive 매크로를 활성화합니다.
[dependencies]serde = { version = "1", features = ["derive"] }serde_json = "1"# Optional: for parallel processingrayon = "1.10"# Optional: for async I/Otokio = { version = "1", features = ["full"] }
serde_json으로 타입 안전 파싱
JSONL 처리에서 Rust의 가장 큰 강점 중 하나는 serde의 derive 매크로입니다. 각 JSON 줄을 강타입 구조체로 직접 역직렬화할 수 있습니다. 컴파일러가 코드가 데이터를 올바르게 처리하는지 확인하여 타입 불일치, 누락 필드, 구조적 오류를 런타임이 아닌 컴파일 타임에 잡아냅니다.
Deserialize를 가진 구조체를 정의하고 serde_json::from_str을 사용하여 각 JSONL 줄을 파싱합니다. 누락되거나 잘못된 형식의 필드는 프로그램의 나중 부분에서 패닉을 일으키는 대신 파싱 시점에서 명확한 오류 메시지를 생성합니다.
use serde::Deserialize;use std::fs::File;use std::io::{BufRead, BufReader};#[derive(Debug, Deserialize)]struct LogEntry {timestamp: String,level: String,message: String,#[serde(default)]metadata: Option<serde_json::Value>,}fn read_typed_jsonl(path: &str) -> anyhow::Result<Vec<LogEntry>> {let file = File::open(path)?;let reader = BufReader::new(file);let mut entries = Vec::new();for line in reader.lines() {let line = line?;let trimmed = line.trim();if trimmed.is_empty() {continue;}let entry: LogEntry = serde_json::from_str(trimmed)?;entries.push(entry);}Ok(entries)}fn main() -> anyhow::Result<()> {let entries = read_typed_jsonl("logs.jsonl")?;for entry in &entries {println!("[{}] {}: {}", entry.level, entry.timestamp, entry.message);}Ok(())}
JSONL 레코드의 스키마가 다양한 경우, 유연한 접근을 위해 serde_json::Value를 사용하세요. 대괄호 표기법으로 값에 접근하거나 안전한 타입 변환을 위해 as_str(), as_i64() 메서드를 사용할 수 있습니다.
use serde_json::Value;use std::fs::File;use std::io::{BufRead, BufReader};fn process_dynamic_jsonl(path: &str) -> anyhow::Result<()> {let file = File::open(path)?;let reader = BufReader::new(file);for line in reader.lines() {let line = line?;let trimmed = line.trim();if trimmed.is_empty() {continue;}let value: Value = serde_json::from_str(trimmed)?;// Access fields dynamicallyif let Some(name) = value.get("name").and_then(Value::as_str) {println!("Name: {}", name);}if let Some(age) = value.get("age").and_then(Value::as_i64) {println!("Age: {}", age);}// Check if a field existsif value.get("email").is_some() {println!("Has email field");}}Ok(())}
Rust에서 JSONL 파일 쓰기
JSONL 쓰기는 읽기의 역순입니다: 각 레코드를 JSON 문자열로 직렬화하고, 개행을 추가하고, 파일에 씁니다. BufWriter와 serde_json::to_string을 사용하면 정확성과 성능을 모두 보장합니다.
serde_json::to_string()으로 각 구조체를 직렬화하고 개행과 함께 씁니다. Serialize derive 매크로가 Rust 타입에서 JSON으로의 변환을 자동으로 처리합니다.
use serde::Serialize;use std::fs::File;use std::io::Write;#[derive(Serialize)]struct Record {id: u64,name: String,score: f64,}fn write_jsonl(path: &str, records: &[Record]) -> std::io::Result<()> {let mut file = File::create(path)?;for record in records {let json = serde_json::to_string(record).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;writeln!(file, "{}", json)?;}Ok(())}fn main() -> std::io::Result<()> {let records = vec![Record { id: 1, name: "Alice".into(), score: 95.5 },Record { id: 2, name: "Bob".into(), score: 87.3 },Record { id: 3, name: "Charlie".into(), score: 92.1 },];write_jsonl("output.jsonl", &records)?;println!("Wrote {} records", records.len());Ok(())}
대용량 JSONL 파일을 쓸 때는 시스템 호출 수를 줄이기 위해 File을 BufWriter로 래핑하세요. 이를 통해 작은 쓰기를 큰 버퍼 플러시로 일괄 처리하여 처리량을 크게 향상시킵니다.
use serde::Serialize;use std::fs::File;use std::io::{BufWriter, Write};#[derive(Serialize)]struct Event {timestamp: u64,event_type: String,payload: serde_json::Value,}fn write_buffered_jsonl(path: &str, events: &[Event]) -> std::io::Result<()> {let file = File::create(path)?;let mut writer = BufWriter::new(file);for event in events {serde_json::to_writer(&mut writer, event).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;writer.write_all(b"\n")?;}writer.flush()?;Ok(())}
rayon으로 병렬 처리
Rust의 rayon 크레이트는 최소한의 코드 변경으로 데이터 병렬 처리를 제공합니다. iter() 대신 par_iter()를 호출하면 rayon이 작업 훔치기 스레드 풀을 사용하여 모든 CPU 코어에 자동으로 작업을 분배합니다. 파싱과 처리가 I/O 시간보다 지배적인 CPU 집약적 JSONL 변환에 특히 효과적입니다.
use rayon::prelude::*;use serde::{Deserialize, Serialize};use std::fs::File;use std::io::{BufRead, BufReader, BufWriter, Write};#[derive(Debug, Deserialize, Serialize)]struct Record {id: u64,text: String,#[serde(default)]word_count: Option<usize>,}fn parallel_process_jsonl(input_path: &str,output_path: &str,) -> anyhow::Result<usize> {// Step 1: Read all lines into memorylet file = File::open(input_path)?;let reader = BufReader::new(file);let lines: Vec<String> = reader.lines().collect::<Result<_, _>>()?;// Step 2: Parse and transform in parallellet processed: Vec<Record> = lines.par_iter().filter(|line| !line.trim().is_empty()).filter_map(|line| {serde_json::from_str::<Record>(line.trim()).ok()}).map(|mut record| {record.word_count = Some(record.text.split_whitespace().count());record}).collect();// Step 3: Write resultslet file = File::create(output_path)?;let mut writer = BufWriter::new(file);let count = processed.len();for record in &processed {serde_json::to_writer(&mut writer, record)?;writer.write_all(b"\n")?;}writer.flush()?;Ok(count)}fn main() -> anyhow::Result<()> {let count = parallel_process_jsonl("input.jsonl", "output.jsonl")?;println!("Processed {} records in parallel", count);Ok(())}
이 패턴은 모든 줄을 읽고, rayon의 par_iter()로 병렬 처리한 후, 결과를 순차적으로 씁니다. 병렬 단계는 사용 가능한 모든 CPU 코어에서 파싱과 변환을 처리합니다. 메모리에 들어가는 파일의 경우 코어 수에 거의 비례하는 속도 향상을 달성할 수 있습니다. 매우 큰 파일의 경우, 메모리 사용량과 병렬 처리의 균형을 맞추기 위해 입력을 청크로 나누는 것을 고려하세요.
tokio를 사용한 비동기 I/O
JSONL 처리가 HTTP 엔드포인트에서 읽기나 원격 스토리지에 쓰기와 같은 네트워크 I/O를 포함하는 경우, tokio의 비동기 런타임을 사용하면 I/O 대기와 계산을 겹칠 수 있습니다. tokio::io::AsyncBufReadExt 트레이트는 동기식 BufReader API를 미러링하는 비동기 lines() 메서드를 제공합니다.
use tokio::fs::File;use tokio::io::{AsyncBufReadExt, BufReader};use serde::Deserialize;#[derive(Debug, Deserialize)]struct ApiRecord {id: u64,url: String,status: String,}async fn read_jsonl_async(path: &str) -> anyhow::Result<Vec<ApiRecord>> {let file = File::open(path).await?;let reader = BufReader::new(file);let mut lines = reader.lines();let mut records = Vec::new();while let Some(line) = lines.next_line().await? {let trimmed = line.trim().to_string();if trimmed.is_empty() {continue;}let record: ApiRecord = serde_json::from_str(&trimmed)?;records.push(record);}Ok(records)}async fn write_jsonl_async(path: &str,records: &[ApiRecord],) -> anyhow::Result<()> {use tokio::io::AsyncWriteExt;let mut file = File::create(path).await?;for record in records {let json = serde_json::to_string(record)?;file.write_all(json.as_bytes()).await?;file.write_all(b"\n").await?;}file.flush().await?;Ok(())}#[tokio::main]async fn main() -> anyhow::Result<()> {let records = read_jsonl_async("api_logs.jsonl").await?;println!("Read {} async records", records.len());write_jsonl_async("output.jsonl", &records).await?;Ok(())}
비동기 접근 방식은 네트워크 작업과 결합할 때 가장 유용합니다. 로컬 디스크에서의 순수 파일 I/O의 경우, 비동기가 작업 스케줄링에 오버헤드를 추가하기 때문에 동기식 BufReader가 종종 더 빠릅니다. I/O 지연 시간이 지배적인 HTTP 스트림, S3 버킷 또는 기타 네트워크 소스에서 JSONL을 읽어야 할 때 tokio를 사용하세요.
오류 처리: Result & ? 연산자
Rust의 Result 타입과 ? 연산자는 JSONL 처리를 위한 깔끔하고 조합 가능한 오류 처리 패턴을 제공합니다. try-catch 블록 대신, 각 실패 가능한 작업은 ?로 전파하거나 match로 로컬에서 처리할 수 있는 Result를 반환합니다. 이를 통해 오류 경로가 명시적이며 실수로 무시할 수 없게 됩니다.
use serde::Deserialize;use std::fs::File;use std::io::{BufRead, BufReader};use thiserror::Error;#[derive(Error, Debug)]enum JsonlError {#[error("I/O error: {0}")]Io(#[from] std::io::Error),#[error("JSON parse error at line {line}: {source}")]Parse {line: usize,source: serde_json::Error,},#[error("Validation error at line {line}: {message}")]Validation {line: usize,message: String,},}#[derive(Debug, Deserialize)]struct Record {id: u64,name: String,value: f64,}fn validate_record(record: &Record, line: usize) -> Result<(), JsonlError> {if record.name.is_empty() {return Err(JsonlError::Validation {line,message: "name cannot be empty".into(),});}if record.value < 0.0 {return Err(JsonlError::Validation {line,message: format!("value must be non-negative, got {}", record.value),});}Ok(())}fn process_jsonl_with_errors(path: &str,) -> Result<Vec<Record>, JsonlError> {let file = File::open(path)?; // Io error auto-converted via #[from]let reader = BufReader::new(file);let mut records = Vec::new();for (idx, line) in reader.lines().enumerate() {let line_num = idx + 1;let line = line?; // Propagate I/O errorslet trimmed = line.trim();if trimmed.is_empty() {continue;}let record: Record = serde_json::from_str(trimmed).map_err(|e| JsonlError::Parse { line: line_num, source: e })?;validate_record(&record, line_num)?;records.push(record);}Ok(records)}fn main() {match process_jsonl_with_errors("data.jsonl") {Ok(records) => println!("Successfully processed {} records", records.len()),Err(JsonlError::Io(e)) => eprintln!("File error: {}", e),Err(JsonlError::Parse { line, source }) => {eprintln!("JSON error at line {}: {}", line, source);}Err(JsonlError::Validation { line, message }) => {eprintln!("Validation error at line {}: {}", line, message);}}}
이 패턴은 모든 실패 모드를 다루는 사용자 정의 오류 열거형을 thiserror로 정의합니다: I/O 오류, 줄 번호가 포함된 JSON 파싱 오류, 도메인별 검증 오류. #[from] 속성은 std::io::Error에서의 자동 변환을 가능하게 하고, ? 연산자는 오류를 호출 스택 위로 전파합니다. 각 오류 변형은 명확한 진단 메시지를 생성하기에 충분한 컨텍스트를 전달하여 정확히 어떤 줄이 문제를 일으켰는지 쉽게 파악할 수 있습니다.
JSON 처리를 위한 Rust 크레이트
Rust 생태계는 다양한 성능 특성을 가진 여러 JSON 파싱 크레이트를 제공합니다. serde_json이 표준 선택이며, simd-json과 sonic-rs는 SIMD 명령어를 사용하여 성능의 한계를 확장합니다.
serde_json
표준Rust에서 JSON의 사실상 표준입니다. serde의 derive 매크로와 원활하게 통합되어 보일러플레이트 없는 직렬화를 제공합니다. 강력한 타입 안전성, 포괄적인 오류 메시지, 폭넓은 생태계 지원으로 대부분의 JSONL 작업에 우수합니다.
simd-json
최고 속도SIMD 명령어를 사용하여 초당 수 기가바이트의 속도로 JSON을 파싱하는 simdjson의 Rust 포트입니다. 변경 가능한 입력 버퍼가 필요하고 serde_json과 다른 API를 제공하지만, 파싱 집약적 작업에서 2-4배 더 빠를 수 있습니다. 고처리량 파이프라인에 가장 적합합니다.
sonic-rs
SIMD + serdeserde 호환 API를 갖춘 SIMD 가속 JSON 라이브러리입니다. simd-json의 속도와 serde_json의 사용 편의성을 결합하는 것을 목표로 합니다. 지연 파싱과 즉시 파싱 모드를 모두 지원하여 코드를 다시 작성하지 않고 성능이 필요할 때 좋은 선택입니다.
무료 JSONL 도구 사용해 보기
코드를 작성하고 싶지 않으신가요? 무료 온라인 도구로 브라우저에서 바로 JSONL 파일을 보고, 검증하고, 변환하세요.