Go에서 JSONL: bufio, json.Decoder & 동시성

Go에서 JSONL(JSON Lines) 파일 작업을 위한 완전 가이드입니다. 표준 라이브러리의 bufio, encoding/json, goroutine을 사용하여 JSONL 데이터를 읽고, 쓰고, 스트리밍하고, 동시에 처리하는 방법을 배우세요.

최종 업데이트: 2026년 2월

왜 Go로 JSONL을?

Go는 성능과 동시성이 중요한 JSONL 파일 처리에 탁월한 선택입니다. 표준 라이브러리가 필요한 모든 것을 제공합니다: 효율적인 줄 단위 I/O를 위한 bufio, 파싱 및 직렬화를 위한 encoding/json, 병렬 처리를 위한 goroutine. 서드파티 의존성이 필요 없습니다. Go의 컴파일 특성은 JSONL 파이프라인이 동등한 Python이나 Node.js 스크립트보다 훨씬 빠르게 실행되며, 보통의 하드웨어에서도 초당 수백만 줄을 처리할 수 있음을 의미합니다.

JSONL(JSON Lines)은 줄당 하나의 JSON 객체를 저장하므로 Go의 스트리밍 I/O 모델에 자연스럽게 적합합니다. 전체 파일을 메모리에 로드하는 대신 스캐너나 디코더를 사용하여 레코드를 하나씩 읽고 처리할 수 있습니다. Go의 경량 goroutine과 채널을 결합하면 사용 가능한 모든 CPU 코어를 활용하는 동시 JSONL 파이프라인을 구축할 수 있습니다. 이 가이드에서는 bufio.Scanner로 JSONL 읽기, json.Decoder로 스트리밍, 효율적인 JSONL 쓰기, 레코드 동시 처리, 강력한 오류 처리 방법을 배우게 됩니다.

bufio.Scanner로 JSONL 파일 읽기

Go에서 JSONL을 읽는 가장 간단한 방법은 bufio.Scanner를 사용하는 것입니다. 파일을 줄 단위로 읽고, 각 줄을 json.Unmarshal로 파싱합니다. 이 접근 방식은 버퍼 크기와 오류 처리에 대한 완전한 제어를 제공합니다.

파일을 열고, 스캐너를 생성하고, 줄 단위로 순회합니다. 각 줄은 json.Unmarshal을 사용하여 map 또는 struct로 파싱됩니다. 이 방식은 메모리 사용량을 전체 파일이 아닌 단일 레코드에 비례하게 유지합니다.

bufio.Scanner로 기본 읽기
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
)
func main() {
file, err := os.Open("data.jsonl")
if err != nil {
log.Fatal(err)
}
defer file.Close()
var records []map[string]any
scanner := bufio.NewScanner(file)
// Increase buffer for lines longer than 64KB
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
for scanner.Scan() {
var record map[string]any
if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
log.Printf("skipping invalid JSON: %v", err)
continue
}
records = append(records, record)
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
fmt.Printf("Loaded %d records\n", len(records))
}

프로덕션 코드에서는 제네릭 map 대신 타입이 지정된 Go 구조체로 JSONL 레코드를 파싱하세요. 이를 통해 컴파일 타임 타입 안전성, 더 나은 성능, 더 명확한 코드를 얻을 수 있습니다. 필드 매핑을 제어하기 위해 json 태그가 있는 구조체를 정의하세요.

타입 구조체로 파싱하기
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age,omitempty"`
}
func readUsers(path string) ([]User, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open file: %w", err)
}
defer file.Close()
var users []User
scanner := bufio.NewScanner(file)
lineNum := 0
for scanner.Scan() {
lineNum++
var u User
if err := json.Unmarshal(scanner.Bytes(), &u); err != nil {
return nil, fmt.Errorf("line %d: %w", lineNum, err)
}
users = append(users, u)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanner error: %w", err)
}
return users, nil
}
func main() {
users, err := readUsers("users.jsonl")
if err != nil {
log.Fatal(err)
}
for _, u := range users {
fmt.Printf("%s (%s)\n", u.Name, u.Email)
}
}

json.Decoder로 스트리밍

고성능 스트리밍을 위해 json.Decoder는 중간 줄 분할 없이 io.Reader에서 직접 읽습니다. 내부적으로 버퍼링을 처리하며, 최소한의 할당으로 대용량 JSONL 파일이나 네트워크 스트림에 권장되는 접근 방식입니다.

json.NewDecoder는 모든 io.Reader를 래핑하고 JSON 값을 순차적으로 디코딩합니다. Decode를 호출할 때마다 정확히 하나의 JSON 객체를 읽습니다. 스트림이 끝나면 io.EOF를 반환합니다. bufio.Scanner + json.Unmarshal보다 효율적인데, 각 줄을 별도의 버퍼에 복사하는 과정을 생략하기 때문입니다.

json.Decoder로 스트리밍
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
)
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Service string `json:"service"`
}
func processJSONLStream(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer file.Close()
decoder := json.NewDecoder(file)
var count int
for {
var entry LogEntry
err := decoder.Decode(&entry)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("decode record %d: %w", count+1, err)
}
count++
// Process each record as it's decoded
if entry.Level == "ERROR" {
fmt.Printf("[%s] %s: %s\n",
entry.Timestamp, entry.Service, entry.Message)
}
}
fmt.Printf("Processed %d log entries\n", count)
return nil
}
func main() {
if err := processJSONLStream("logs.jsonl"); err != nil {
log.Fatal(err)
}
}

Go에서 JSONL 파일 쓰기

Go에서 JSONL 쓰기는 간단합니다: 각 레코드를 json.Marshal로 직렬화하고, 개행 바이트를 추가하고, 파일에 씁니다. 최고의 성능을 위해 파일 writer를 bufio.Writer로 래핑하여 시스템 호출을 줄이세요.

json.Marshal을 사용하여 각 레코드를 JSON 바이트로 직렬화한 다음 개행 문자와 함께 씁니다. 이 접근 방식은 간단하며 소규모에서 중간 크기의 파일에 잘 작동합니다.

json.Marshal로 기본 쓰기
package main
import (
"encoding/json"
"fmt"
"log"
"os"
)
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
}
func main() {
products := []Product{
{ID: 1, Name: "Laptop", Price: 999.99},
{ID: 2, Name: "Mouse", Price: 29.99},
{ID: 3, Name: "Keyboard", Price: 79.99},
}
file, err := os.Create("products.jsonl")
if err != nil {
log.Fatal(err)
}
defer file.Close()
for _, p := range products {
data, err := json.Marshal(p)
if err != nil {
log.Printf("skip marshal error: %v", err)
continue
}
data = append(data, '\n')
if _, err := file.Write(data); err != nil {
log.Fatal(err)
}
}
fmt.Printf("Wrote %d records to products.jsonl\n", len(products))
}

고처리량 쓰기를 위해 json.NewEncoder와 bufio.Writer를 함께 사용하세요. 인코더는 각 Encode 호출 후 자동으로 개행을 추가하고, 버퍼링된 writer는 작은 쓰기를 큰 시스템 호출로 일괄 처리합니다. 파일을 닫기 전에 항상 Flush를 호출하세요.

json.Encoder로 버퍼링된 쓰기
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
)
type Event struct {
Type string `json:"type"`
Timestamp int64 `json:"timestamp"`
Payload map[string]any `json:"payload"`
}
func writeEvents(path string, events []Event) error {
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("create file: %w", err)
}
defer file.Close()
writer := bufio.NewWriter(file)
encoder := json.NewEncoder(writer)
// Disable HTML escaping for cleaner output
encoder.SetEscapeHTML(false)
for i, event := range events {
if err := encoder.Encode(event); err != nil {
return fmt.Errorf("encode event %d: %w", i, err)
}
}
// Flush buffered data to file
if err := writer.Flush(); err != nil {
return fmt.Errorf("flush: %w", err)
}
fmt.Printf("Wrote %d events to %s\n", len(events), path)
return nil
}
func main() {
events := []Event{
{Type: "click", Timestamp: 1700000001, Payload: map[string]any{"x": 120, "y": 450}},
{Type: "view", Timestamp: 1700000002, Payload: map[string]any{"page": "/home"}},
}
if err := writeEvents("events.jsonl", events); err != nil {
log.Fatal(err)
}
}

Goroutine을 이용한 동시 JSONL 처리

Go의 goroutine과 채널을 사용하면 동시 JSONL 파이프라인을 쉽게 구축할 수 있습니다. fan-out 패턴은 파일에서 레코드를 읽고, 여러 워커 goroutine에 분배하고, 채널을 통해 결과를 수집합니다. 이 방식은 대용량 JSONL 파일에 대한 CPU 집약적 변환에 이상적입니다.

Goroutine을 이용한 동시 JSONL 처리
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
"runtime"
"sync"
)
type Record struct {
ID int `json:"id"`
Data map[string]any `json:"data"`
}
type Result struct {
ID int `json:"id"`
Processed bool `json:"processed"`
Score int `json:"score"`
}
func process(r Record) Result {
// Simulate CPU-bound work
score := len(r.Data) * r.ID
return Result{ID: r.ID, Processed: true, Score: score}
}
func main() {
file, err := os.Open("records.jsonl")
if err != nil {
log.Fatal(err)
}
defer file.Close()
numWorkers := runtime.NumCPU()
jobs := make(chan Record, numWorkers*2)
results := make(chan Result, numWorkers*2)
// Start workers
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for record := range jobs {
results <- process(record)
}
}()
}
// Close results channel when all workers finish
go func() {
wg.Wait()
close(results)
}()
// Read file and send records to workers
go func() {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
var r Record
if err := json.Unmarshal(scanner.Bytes(), &r); err != nil {
log.Printf("skip invalid line: %v", err)
continue
}
jobs <- r
}
close(jobs)
}()
// Collect and write results
outFile, err := os.Create("results.jsonl")
if err != nil {
log.Fatal(err)
}
defer outFile.Close()
writer := bufio.NewWriter(outFile)
encoder := json.NewEncoder(writer)
var count int
for result := range results {
if err := encoder.Encode(result); err != nil {
log.Printf("encode error: %v", err)
}
count++
}
writer.Flush()
fmt.Printf("Processed %d records with %d workers\n", count, numWorkers)
}

이 fan-out 패턴은 CPU 코어 수만큼의 goroutine에 JSONL 레코드를 분배합니다. 버퍼링된 채널은 goroutine이 전송이나 수신에서 블로킹되는 것을 방지합니다. sync.WaitGroup은 결과 채널이 닫히기 전에 모든 워커가 완료되도록 보장합니다. API 호출과 같은 I/O 집약적 작업의 경우 numWorkers를 CPU 수보다 높게 설정할 수 있습니다. 동시 처리에서는 출력 순서가 보장되지 않는다는 점에 유의하세요.

오류 처리 모범 사례

Go에서 강력한 JSONL 처리를 위해서는 세심한 오류 처리가 필요합니다. 실제 JSONL 파일에는 잘못된 형식의 줄, 인코딩 문제, 예상치 못하게 큰 레코드가 포함될 수 있습니다. 이 패턴은 줄 수준 오류 추적, 설정 가능한 건너뛰기 동작, 요약 보고를 제공합니다.

오류 처리 모범 사례
package main
import (
"bufio"
"encoding/json"
"fmt"
"log"
"os"
)
type ParseError struct {
Line int
Err error
Raw string
}
func (e *ParseError) Error() string {
return fmt.Sprintf("line %d: %v", e.Line, e.Err)
}
type JSONLReader struct {
scanner *bufio.Scanner
lineNum int
Errors []ParseError
Skipped int
Success int
}
func NewJSONLReader(file *os.File) *JSONLReader {
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
return &JSONLReader{scanner: scanner}
}
func (r *JSONLReader) ReadAll(target any) error {
slice, ok := target.(*[]map[string]any)
if !ok {
return fmt.Errorf("target must be *[]map[string]any")
}
for r.scanner.Scan() {
r.lineNum++
line := r.scanner.Bytes()
// Skip empty lines
if len(line) == 0 {
continue
}
var record map[string]any
if err := json.Unmarshal(line, &record); err != nil {
r.Errors = append(r.Errors, ParseError{
Line: r.lineNum,
Err: err,
Raw: string(line),
})
r.Skipped++
continue
}
*slice = append(*slice, record)
r.Success++
}
return r.scanner.Err()
}
func (r *JSONLReader) Summary() string {
return fmt.Sprintf(
"Lines: %d | Success: %d | Skipped: %d | Errors: %d",
r.lineNum, r.Success, r.Skipped, len(r.Errors),
)
}
func main() {
file, err := os.Open("data.jsonl")
if err != nil {
log.Fatal(err)
}
defer file.Close()
reader := NewJSONLReader(file)
var records []map[string]any
if err := reader.ReadAll(&records); err != nil {
log.Fatal(err)
}
fmt.Println(reader.Summary())
for _, e := range reader.Errors {
log.Printf("Parse error at %s", e.Error())
}
}

이 JSONLReader 구조체는 줄 번호를 추적하고, 원시 콘텐츠와 함께 파싱 오류를 수집하며, 성공과 실패에 대한 요약을 보고합니다. 10MB 스캐너 버퍼는 비정상적으로 큰 줄도 패닉 없이 처리합니다. 프로덕션에서는 취소를 위한 context.Context 지원, 처리를 중단하는 최대 오류 임계값, 또는 slog을 사용한 구조화된 로깅으로 확장할 수 있습니다.

JSONL 처리를 위한 Go 패키지

Go의 표준 라이브러리는 JSONL 처리에 필요한 모든 구성 요소를 제공합니다. 다음은 세 가지 핵심 접근 방식과 각각을 사용해야 할 때입니다.

bufio.Scanner

유연함

표준 줄 단위 리더입니다. 명시적 파싱을 위해 json.Unmarshal과 함께 사용합니다. 버퍼 크기를 제어하거나, 원시 줄을 건너뛰거나 검사하거나, 오류 보고를 위한 줄 번호가 필요할 때 적합합니다. 설정된 버퍼 크기까지의 줄을 처리합니다.

json.Decoder

권장

io.Reader에서 직접 읽는 스트리밍 JSON 디코더입니다. 데이터 복사를 생략하므로 Scanner + Unmarshal보다 효율적입니다. 대용량 파일과 네트워크 스트림에 이상적입니다. 명시적인 개행 분할 없이 연속된 JSON 값을 자동으로 처리합니다.

json.Encoder

쓰기

io.Writer에 쓰는 스트리밍 JSON 인코더입니다. 각 Encode 호출 후 자동으로 개행을 추가하여 JSONL 출력에 완벽합니다. 고처리량 쓰기를 위해 bufio.Writer와 결합하세요. SetEscapeHTML 및 SetIndent 설정을 지원합니다.

무료 JSONL 도구 사용해 보기

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

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

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

자주 묻는 질문

Go에서 JSONL — bufio.Scanner, json.Decoder & 동시성 | jsonl.co