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으로 파싱합니다. 파일 크기에 관계없이 한 번에 하나의 줄만 메모리에 유지합니다.

BufReader로 기본 JSONL 읽기
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 매크로를 활성화합니다.

Cargo.toml 의존성
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Optional: for parallel processing
rayon = "1.10"
# Optional: for async I/O
tokio = { 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() 메서드를 사용할 수 있습니다.

serde_json::Value로 동적 파싱
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 dynamically
if 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 exists
if 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으로의 변환을 자동으로 처리합니다.

기본 JSONL 쓰기
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 변환에 특히 효과적입니다.

rayon으로 병렬 처리
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 memory
let file = File::open(input_path)?;
let reader = BufReader::new(file);
let lines: Vec<String> = reader.lines().collect::<Result<_, _>>()?;
// Step 2: Parse and transform in parallel
let 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 results
let 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() 메서드를 제공합니다.

tokio를 사용한 비동기 I/O
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를 반환합니다. 이를 통해 오류 경로가 명시적이며 실수로 무시할 수 없게 됩니다.

오류 처리: 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 errors
let 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 + serde

serde 호환 API를 갖춘 SIMD 가속 JSON 라이브러리입니다. simd-json의 속도와 serde_json의 사용 편의성을 결합하는 것을 목표로 합니다. 지연 파싱과 즉시 파싱 모드를 모두 지원하여 코드를 다시 작성하지 않고 성능이 필요할 때 좋은 선택입니다.

무료 JSONL 도구 사용해 보기

코드를 작성하고 싶지 않으신가요? 무료 온라인 도구로 브라우저에서 바로 JSONL 파일을 보고, 검증하고, 변환하세요.

온라인으로 JSONL 파일 작업하기

브라우저에서 최대 1GB의 JSONL 파일을 보고, 검증하고, 변환하세요. 업로드 불필요, 100% 프라이빗.

자주 묻는 질문

Rust에서 JSONL — serde_json, BufReader & 스트리밍 패턴 | jsonl.co