JSON 직렬화와 역직렬화 완벽 가이드 | 데이터 손실 함정부터 검증·성능까지
이 글의 핵심
JSON 직렬화·역직렬화의 개념과 필요성을 프로세스·언어 경계 관점에서 설명하고, JS/TS와 Python의 실제 API 사용법, Date·Map·BigInt·순환 참조 같은 흔한 데이터 손실 함정, 커스텀 직렬화, 외부 데이터를 신뢰하지 않고 스키마로 검증하는 방법, 대용량 페이로드에서의 성능 이슈까지 언어에 상관없이 적용 가능한 관점으로 정리합니다.
들어가며 — 직렬화와 역직렬화란 무엇인가
직렬화(serialization)는 메모리에 있는 객체나 자료구조를 저장하거나 전송할 수 있는 형태(대개 바이트 스트림이나 문자열)로 바꾸는 과정이고, 역직렬화(deserialization)는 그 반대로 저장·전송된 데이터를 다시 프로그램이 다룰 수 있는 객체로 복원하는 과정입니다. 둘은 항상 짝을 이루며, 이 글에서는 오늘날 웹과 백엔드 시스템에서 가장 널리 쓰이는 직렬화 포맷인 JSON(JavaScript Object Notation)을 중심으로 다룹니다.
직렬화가 필요한 이유는 한 문장으로 요약할 수 있습니다. 메모리 안의 객체는 그 객체가 살아있는 프로세스의 경계를 넘어갈 수 없기 때문입니다. 조금 더 구체적으로 세 가지 경계를 넘을 때 직렬화가 필요합니다.
첫째, 프로세스 경계입니다. 같은 컴퓨터 안에서도 서로 다른 프로세스는 독립된 메모리 공간을 가집니다. 프로세스 A의 메모리 주소 0x7ffee3a1은 프로세스 B에서는 아무 의미가 없거나 다른 데이터를 가리킵니다. 두 프로세스가 데이터를 주고받으려면(파이프, 소켓, 공유 파일을 통해서든) 포인터가 아니라 값 자체를 복사해서 넘겨야 하고, 이를 위해 객체를 평평한(flat) 바이트 표현으로 바꾸는 과정이 필요합니다.
둘째, 네트워크 경계입니다. 클라이언트와 서버, 또는 마이크로서비스 간의 통신은 본질적으로 바이트 스트림을 주고받는 행위입니다. HTTP 요청 본문, WebSocket 메시지, gRPC 페이로드 모두 결국은 네트워크 소켓을 통해 흐르는 바이트일 뿐이며, 자바스크립트 객체나 파이썬 딕셔너리를 그대로 “전송”할 방법은 존재하지 않습니다.
셋째, 언어 경계입니다. 프론트엔드는 TypeScript, 백엔드는 Go, 배치 작업은 Python으로 작성된 시스템에서 각 언어의 객체 모델(클래스 인스턴스, 참조, 메서드 포함)은 서로 호환되지 않습니다. 하지만 이들 모두 문자열이라는 공통분모는 다룰 수 있으므로, JSON처럼 텍스트 기반의 언어 중립적 포맷이 서로 다른 언어로 작성된 시스템 사이의 다리 역할을 합니다.
메모리 안의 객체가 직렬화 없이 이 경계를 넘을 수 없는 근본적인 이유는, 객체가 단순한 값의 나열이 아니라 참조(포인터), 타입 정보(클래스, 프로토타입), 메서드(코드)까지 포함하기 때문입니다. JSON은 이 중에서 오직 값(문자열, 숫자, 불리언, null, 배열, 객체)만을 표현할 수 있는 의도적으로 제한된 포맷이며, 이 제한이야말로 이 글에서 다룰 대부분의 “데이터 손실” 문제의 근본 원인입니다.
JavaScript/TypeScript에서의 JSON 처리
JSON.stringify — 객체를 문자열로
자바스크립트는 언어 표준에 JSON 전역 객체를 내장하고 있으며, JSON.stringify()가 직렬화를, JSON.parse()가 역직렬화를 담당합니다.
interface User {
id: number;
name: string;
isActive: boolean;
roles: string[];
}
const user: User = {
id: 1024,
name: "김개발",
isActive: true,
roles: ["admin", "editor"],
};
// 객체를 JSON 문자열로 직렬화
const json = JSON.stringify(user);
console.log(json);
// '{"id":1024,"name":"김개발","isActive":true,"roles":["admin","editor"]}'
// 가독성을 위한 들여쓰기 옵션 (세 번째 인자: space)
const prettyJson = JSON.stringify(user, null, 2);
console.log(prettyJson);
/*
{
"id": 1024,
"name": "김개발",
"isActive": true,
"roles": ["admin", "editor"]
}
*/
JSON.stringify는 최대 세 개의 인자를 받습니다. 첫 번째는 직렬화할 값, 두 번째는 뒤에서 다룰 replacer(특정 속성만 골라내거나 값을 가공할 때 사용), 세 번째는 들여쓰기 문자 수 혹은 문자열입니다. 로그를 남기거나 API 응답을 사람이 읽기 쉽게 만들 때는 space 인자를 2나 4로 주는 것이 일반적이고, 네트워크로 전송할 때는 페이로드 크기를 줄이기 위해 생략하는 것이 관례입니다.
JSON.parse — 문자열을 객체로
const raw = '{"id":1024,"name":"김개발","isActive":true,"roles":["admin","editor"]}';
const parsed = JSON.parse(raw);
console.log(parsed.name); // "김개발"
console.log(typeof parsed); // "object"
JSON.parse는 문법적으로 유효한 JSON 문자열을 받아 자바스크립트 값(대개 객체나 배열)으로 복원합니다. 여기서 주의할 점은, TypeScript를 쓰고 있더라도 JSON.parse의 반환 타입은 기본적으로 any라는 사실입니다. const parsed: User = JSON.parse(raw)처럼 타입을 명시해도 이는 컴파일 타임 타입 단언일 뿐 런타임 검증이 아닙니다. 실제 문자열에 id 필드가 없거나 타입이 다르더라도 TypeScript 컴파일러는 이를 잡아내지 못합니다. 이 문제는 뒤의 “역직렬화 데이터 신뢰하지 않기” 절에서 스키마 검증으로 해결하는 방법을 다룹니다.
Python에서의 JSON 처리
Python 표준 라이브러리의 json 모듈도 동일한 역할을 수행하며, API는 자바스크립트와 대칭적입니다.
import json
from dataclasses import dataclass, asdict
@dataclass
class User:
id: int
name: str
is_active: bool
roles: list[str]
user = User(id=1024, name="김개발", is_active=True, roles=["admin", "editor"])
# dataclass를 dict로 바꾼 뒤 직렬화
json_str = json.dumps(asdict(user), ensure_ascii=False)
print(json_str)
# '{"id": 1024, "name": "김개발", "is_active": true, "roles": ["admin", "editor"]}'
# 파일에 바로 쓰기
with open("user.json", "w", encoding="utf-8") as f:
json.dump(asdict(user), f, ensure_ascii=False, indent=2)
Python의 json.dumps는 문자열을 반환하고, json.dump는 파일 객체에 바로 씁니다(끝의 s가 있고 없고의 차이입니다). ensure_ascii=False는 한글 같은 비 ASCII 문자를 가 형태로 이스케이프하지 않고 그대로 출력하게 하는 옵션으로, 한국어 서비스라면 거의 항상 켜두는 것이 좋습니다.
raw = '{"id": 1024, "name": "김개발", "is_active": true, "roles": ["admin", "editor"]}'
parsed = json.loads(raw)
print(parsed["name"]) # 김개발
print(type(parsed)) # <class 'dict'>
json.loads(문자열에서 로드), json.load(파일 객체에서 로드)는 JSON을 파이썬의 dict, list, str, int, float, bool, None으로 변환합니다. 자바스크립트와 마찬가지로, 파싱 결과는 순수한 dict일 뿐 원래 사용했던 dataclass나 커스텀 클래스로 자동 복원되지 않는다는 점이 중요합니다. 이 역시 뒤에서 object_hook으로 해결하는 방법을 다룹니다.
흔한 데이터 손실 함정들
JSON은 값의 표현 범위가 의도적으로 제한된 포맷입니다. 자바스크립트나 파이썬의 런타임 객체 모델이 표현할 수 있는 모든 것을 JSON이 담을 수 있는 것은 아니며, 이 간극에서 실무에서 가장 자주 재현되는 버그들이 발생합니다.
Date 객체 — 문자열로 변했다가 돌아오지 않는다
const event = {
title: "배포 회의",
scheduledAt: new Date("2026-09-10T09:00:00Z"),
};
const json = JSON.stringify(event);
console.log(json);
// '{"title":"배포 회의","scheduledAt":"2026-09-10T09:00:00.000Z"}'
const restored = JSON.parse(json);
console.log(typeof restored.scheduledAt); // "string" — Date가 아니다!
console.log(restored.scheduledAt.getFullYear); // undefined, 메서드 호출 시 에러
Date 객체는 자체적으로 toJSON() 메서드를 구현하고 있어서, JSON.stringify가 이를 만나면 자동으로 호출해 ISO 8601 문자열로 바꿉니다. 문제는 JSON.parse가 이 역변환을 해주지 않는다는 점입니다. JSON 자체에는 “이 문자열은 날짜다”라는 타입 정보가 없으므로, 파서는 그냥 문자열로 남겨둘 수밖에 없습니다. 이 때문에 API 응답을 파싱한 뒤 .getFullYear() 같은 Date 메서드를 호출하면 런타임 에러가 발생하는 버그가 흔히 발생합니다. 해결 방법은 파싱 직후 명시적으로 new Date(restored.scheduledAt)으로 변환하거나, 뒤에서 다룰 reviver 함수를 사용하는 것입니다.
Map과 Set — 조용히 사라진다
const tagCounts = new Map<string, number>([
["typescript", 42],
["json", 17],
]);
console.log(JSON.stringify({ tagCounts }));
// '{"tagCounts":{}}' — 데이터가 통째로 사라졌다!
const uniqueIds = new Set([1, 2, 3]);
console.log(JSON.stringify({ uniqueIds }));
// '{"uniqueIds":{}}' — 이것도 마찬가지
Map과 Set은 열거 가능한(enumerable) 속성을 가진 일반 객체가 아니라 별도의 내장 클래스이기 때문에, JSON.stringify의 기본 직렬화 로직이 인식하지 못하고 빈 객체로 처리해버립니다. 에러가 발생하지 않고 조용히 빈 객체가 되어버리는 것이 특히 위험한 부분으로, 테스트에서 우연히 발견하지 못하면 프로덕션에서야 데이터가 사라진 것을 알아차리는 경우가 많습니다. 해결책은 직렬화 전에 명시적으로 배열로 바꾸는 것입니다.
const payload = {
tagCounts: Array.from(tagCounts.entries()), // [["typescript",42],["json",17]]
uniqueIds: Array.from(uniqueIds), // [1,2,3]
};
const json = JSON.stringify(payload);
// 복원 시에는 역방향으로 다시 Map/Set을 만들어야 한다
const parsed = JSON.parse(json);
const restoredMap = new Map<string, number>(parsed.tagCounts);
const restoredSet = new Set<number>(parsed.uniqueIds);
undefined, 함수 — 애초에 직렬화 대상이 아니다
const config = {
name: "worker-1",
onComplete: () => console.log("done"),
timeout: undefined,
retries: 3,
};
console.log(JSON.stringify(config));
// '{"name":"worker-1","retries":3}'
객체의 속성 값이 undefined이거나 함수인 경우, JSON.stringify는 이 속성을 결과에서 통째로 제외합니다(배열 안에 있을 때는 null로 바뀝니다). 이는 에러도 경고도 없이 조용히 일어나므로, “왜 서버로 보낸 필드가 응답에는 없지?” 같은 디버깅 시간을 잡아먹는 흔한 원인이 됩니다. null과 undefined는 다르게 처리된다는 점도 기억할 필요가 있습니다 — null은 그대로 null로 직렬화되지만 undefined는 사라집니다.
BigInt — 조용히 사라지지 않고 예외를 던진다
const bigNumber = {
accountId: 9007199254740993n, // BigInt 리터럴
};
JSON.stringify(bigNumber);
// TypeError: Do not know how to serialize a BigInt
앞의 사례들과 달리 BigInt는 조용히 무시되는 대신 명시적으로 예외를 던집니다. 언어 설계자 입장에서 BigInt를 숫자로 직렬화하면 정밀도 손실이 발생하고, 문자열로 직렬화하면 역직렬화 시 다시 BigInt로 복원할 방법이 없어 타입이 뒤바뀌는 문제가 생기기 때문에, 자바스크립트는 암묵적인 변환 대신 개발자가 명시적으로 처리하도록 강제하는 쪽을 택했습니다. 해결책은 replacer 함수로 문자열 변환을 명시하는 것입니다(아래 커스텀 직렬화 절 참고).
순환 참조 — 예외를 던지는 것이 오히려 안전하다
const parent: any = { name: "parent" };
const child: any = { name: "child", parent };
parent.child = child; // parent가 child를, child가 parent를 참조
JSON.stringify(parent);
// TypeError: Converting circular structure to JSON
객체 그래프가 서로를 순환 참조하는 경우, JSON.stringify는 무한 루프에 빠지는 대신 순환을 감지하고 예외를 던집니다. JSON 자체가 트리 구조만 표현할 수 있고 그래프(순환 포함)를 표현할 방법이 없기 때문입니다. 두 가지 해결 방법이 흔히 쓰입니다.
// 방법 1: structuredClone으로 순환 구조를 다룰 수는 있지만(복제 자체는 성공)
// 여전히 JSON 문자열로는 바꿀 수 없다 — 구조 복제와 JSON 직렬화는 다른 문제
const cloned = structuredClone(parent); // 성공, 순환 참조도 그대로 유지됨
// 방법 2: 순환을 끊는 커스텀 replacer (역참조 필드를 제외)
function safeStringify(obj: unknown) {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return "[Circular]";
seen.add(value);
}
return value;
});
}
console.log(safeStringify(parent));
// '{"name":"parent","child":{"name":"child","parent":"[Circular]"}}'
structuredClone은 순환 참조가 있는 객체도 복제할 수 있는 내장 함수이지만, 이는 메모리 안에서 객체를 복제하는 것이지 JSON 문자열로 변환하는 것이 아니라는 점을 구분해야 합니다. JSON 전송이 목적이라면 순환을 끊는 replacer가 필요하고, 단순히 프로세스 내에서 깊은 복사(deep copy)가 필요하다면 structuredClone이 JSON.parse(JSON.stringify(x))보다 더 안전하고 빠른 선택입니다(Date, Map, Set, RegExp 등도 올바르게 복제되기 때문입니다).
큰 정수 — 소리 없이 값이 바뀐다
const raw = '{"userId": 9007199254740993}';
const parsed = JSON.parse(raw);
console.log(parsed.userId);
// 9007199254740992 — 원래 값과 다르다! 마지막 자리가 반올림됐다
이 함정이 특히 위험한 이유는 예외도 경고도 없이 조용히 값이 바뀐다는 점입니다. 자바스크립트의 number 타입은 IEEE 754 배정밀도 부동소수점을 사용하며, 정수를 정확하게 표현할 수 있는 범위는 Number.MAX_SAFE_INTEGER(2^53 - 1 = 9007199254740991)까지입니다. 데이터베이스의 BIGINT 기본 키, 스노우플레이크(Snowflake) ID, 트위터/디스코드 스타일의 64비트 ID처럼 이 범위를 넘는 값을 JSON 숫자로 그대로 응답에 실으면, 클라이언트에서 파싱하는 순간 마지막 몇 자리가 뭉개집니다. 실무에서는 다음 두 가지 방법으로 방어합니다.
// 서버 측: 애초에 큰 정수 ID는 문자열로 직렬화
const response = {
userId: String(9007199254740993n), // "9007199254740993"
};
// 클라이언트 측: reviver로 특정 필드를 BigInt로 복원
const parsed = JSON.parse(json, (key, value) => {
if (key === "userId") return BigInt(value);
return value;
});
API 설계 단계에서 큰 정수형 ID를 다룰 가능성이 있다면, 처음부터 JSON 스키마에서 해당 필드를 문자열 타입으로 정의하는 것이 가장 확실한 예방책입니다. Twitter API, Discord API 등 대규모 서비스들이 id와 id_str 필드를 함께 내려주는 것도 이 문제를 우회하기 위한 관례입니다.
커스텀 직렬화 — replacer/reviver와 default/object_hook
JavaScript의 replacer와 reviver
JSON.stringify의 두 번째 인자(replacer)와 JSON.parse의 두 번째 인자(reviver)는 각각 직렬화·역직렬화 과정에 개입할 수 있는 훅입니다.
// replacer: 직렬화 시 각 키-값 쌍을 가공
function replacer(key: string, value: unknown) {
if (typeof value === "bigint") return value.toString();
if (value instanceof Date) return { __type: "Date", value: value.toISOString() };
if (value instanceof Map) return { __type: "Map", value: Array.from(value.entries()) };
return value;
}
// reviver: 역직렬화 시 각 키-값 쌍을 복원
function reviver(key: string, value: any) {
if (value && typeof value === "object" && value.__type === "Date") {
return new Date(value.value);
}
if (value && typeof value === "object" && value.__type === "Map") {
return new Map(value.value);
}
return value;
}
const data = { createdAt: new Date(), counts: new Map([["a", 1]]) };
const json = JSON.stringify(data, replacer);
const restored = JSON.parse(json, reviver);
console.log(restored.createdAt instanceof Date); // true
console.log(restored.counts instanceof Map); // true
이 패턴의 핵심은 타입 정보를 값 안에 마커(__type)로 함께 실어 보내는 것입니다. JSON 자체에는 타입 시스템이 없으므로, 원래 타입을 복원하려면 “이 값이 어떤 타입이었는지”를 별도로 인코딩해두고 역직렬화 시점에 그 마커를 보고 되돌리는 수밖에 없습니다. 이 원리는 뒤에 나올 Python의 object_hook에서도 동일하게 적용됩니다.
Python의 default와 object_hook
import json
from datetime import datetime
from decimal import Decimal
def custom_default(obj):
if isinstance(obj, datetime):
return {"__type": "datetime", "value": obj.isoformat()}
if isinstance(obj, Decimal):
return {"__type": "decimal", "value": str(obj)}
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
def custom_object_hook(d):
if d.get("__type") == "datetime":
return datetime.fromisoformat(d["value"])
if d.get("__type") == "decimal":
return Decimal(d["value"])
return d
payload = {"created_at": datetime.now(), "price": Decimal("19.99")}
json_str = json.dumps(payload, default=custom_default)
restored = json.loads(json_str, object_hook=custom_object_hook)
print(type(restored["created_at"])) # <class 'datetime.datetime'>
print(type(restored["price"])) # <class 'decimal.Decimal'>
json.dumps의 default 매개변수는 기본 인코더가 처리할 수 없는 타입(datetime, Decimal, 커스텀 클래스 등)을 만났을 때 호출되는 콜백이며, JSON이 이해할 수 있는 형태로 값을 변환해서 반환해야 합니다. 반대로 json.loads의 object_hook은 파싱 과정에서 만들어지는 모든 dict를 가로채, 특정 마커가 있으면 원래 타입으로 되돌리는 역할을 합니다. default와 object_hook은 자바스크립트의 replacer/reviver와 정확히 같은 문제를 정확히 같은 방식(마커 기반 왕복 변환)으로 해결합니다.
역직렬화한 데이터를 절대 그대로 신뢰하지 않기
여기까지는 “내가 만든 데이터를 직렬화했다가 다시 복원하는” 상황을 다뤘습니다. 하지만 실무에서 더 흔하고 더 위험한 상황은 외부에서 들어온 JSON을 역직렬화하는 경우입니다 — 클라이언트가 보낸 요청 본문, 서드파티 API 응답, 메시지 큐에서 꺼낸 메시지가 대표적입니다.
JSON.parse와 json.loads가 보장하는 것은 딱 하나, 문법이 유효한 JSON이라는 것뿐입니다. 필드가 존재하는지, 타입이 기대한 것과 일치하는지, 문자열 길이나 숫자 범위가 유효한지는 전혀 검증하지 않습니다. TypeScript에서 JSON.parse(body) as User처럼 타입 단언을 쓰더라도, 이는 컴파일러에게 “이 값은 User 타입이라고 믿어달라”고 요청하는 것일 뿐 런타임에 실제로 그런지 확인하지 않습니다.
// 위험한 코드 — 타입 단언은 런타임 안전을 보장하지 않는다
app.post("/users", (req, res) => {
const user = req.body as { id: number; name: string; role: "admin" | "user" };
db.grantAccess(user.id, user.role); // role이 실제로는 "superadmin" 이어도 통과된다
});
이 위험은 단순한 버그를 넘어 보안 문제로 이어집니다. 검증 없이 파싱된 필드를 그대로 SQL 쿼리에 문자열 결합하면 인젝션(injection) 공격에 노출되고, role 같은 권한 관련 필드를 그대로 신뢰하면 권한 상승(privilege escalation) 취약점이 생기며, 배열이라고 가정한 필드가 실제로는 거대한 중첩 객체일 경우 서비스 거부(DoS)로 이어질 수도 있습니다. 이런 문제를 막기 위해 파싱 직후 런타임 스키마 검증을 거치는 것이 표준적인 방어 방법입니다.
import { z } from "zod";
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1).max(100),
role: z.enum(["admin", "user"]),
});
app.post("/users", (req, res) => {
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error.flatten() });
}
const user = result.data; // 이 시점부터는 진짜로 타입이 보장된다
db.grantAccess(user.id, user.role);
});
from pydantic import BaseModel, ValidationError
from typing import Literal
class UserSchema(BaseModel):
id: int
name: str
role: Literal["admin", "user"]
@app.post("/users")
def create_user(raw_body: dict):
try:
user = UserSchema.model_validate(raw_body)
except ValidationError as e:
return {"error": e.errors()}, 400
grant_access(user.id, user.role)
Zod와 Pydantic은 각각 TypeScript와 Python 생태계에서 가장 널리 쓰이는 런타임 스키마 검증 라이브러리입니다. 둘 다 “타입을 선언하면 그 선언으로부터 실제 검증 로직이 파생된다”는 동일한 철학을 공유하며, 파싱 단계와 검증 단계를 분리하지 않고 하나의 흐름으로 묶어줍니다. 원칙은 단순합니다 — 역직렬화와 검증은 항상 한 쌍으로 취급하고, 검증을 통과하기 전의 데이터는 신뢰할 수 없는 입력으로 다뤄야 합니다.
성능 — 대용량 페이로드와 스트리밍 파서
JSON.parse와 JSON.stringify는 대부분의 상황에서 매우 빠르지만, 두 가지 특성 때문에 대용량 데이터에서는 병목이 될 수 있습니다.
첫째, 둘 다 동기(synchronous) API입니다. Node.js처럼 단일 스레드 이벤트 루프로 동작하는 런타임에서 수십~수백 MB짜리 JSON을 JSON.parse로 파싱하면, 그 시간 동안 이벤트 루프 전체가 블로킹되어 다른 요청을 전혀 처리하지 못합니다. 실제로 대형 API 응답이나 로그 파일을 다루는 서비스에서 “가끔 응답이 수 초씩 멈춘다”는 증상의 원인이 거대한 JSON을 동기적으로 파싱하는 코드였던 사례가 드물지 않습니다.
둘째, 전체 문서를 한 번에 메모리에 올려야 합니다. JSON.parse는 문자열 전체를 파싱해 완전한 객체 트리를 만든 뒤에야 결과를 반환하는 구조이기 때문에, 원본 문자열과 파싱된 객체 트리가 동시에 메모리에 존재하는 순간이 생깁니다. 기가바이트 단위의 JSON 파일을 다룰 때는 이것만으로 메모리가 부족해질 수 있습니다.
이런 상황을 위한 대안이 스트리밍 JSON 파서입니다. 전체 문서를 한 번에 메모리에 올리는 대신, 데이터를 청크(chunk) 단위로 읽으면서 토큰이나 값이 완성될 때마다 이벤트를 발생시키는 방식으로 동작합니다.
// Node.js에서 스트리밍 JSON 파싱 예시 (stream-json 라이브러리 사용)
import { chain } from "stream-chain";
import { parser } from "stream-json";
import { streamArray } from "stream-json/streamers/StreamArray.js";
import fs from "fs";
const pipeline = chain([
fs.createReadStream("huge-orders.json"),
parser(),
streamArray(), // 최상위가 배열인 JSON을 원소 단위로 스트리밍
]);
let count = 0;
pipeline.on("data", ({ value }) => {
count++;
// 배열 원소 하나가 파싱될 때마다 즉시 처리 — 전체를 메모리에 올리지 않는다
processOrder(value);
});
pipeline.on("end", () => console.log(`총 ${count}건 처리 완료`));
이 방식은 파일 전체 크기와 무관하게 메모리 사용량을 원소 하나 크기 수준으로 유지할 수 있고, 이벤트 루프를 블로킹하지 않으면서 데이터를 조금씩 처리할 수 있다는 장점이 있습니다. 다만 코드 복잡도가 늘어나고 무작위 접근(random access)이 불가능해지므로, 실무에서는 “응답 크기가 수십 MB를 넘을 가능성이 있는가”를 기준으로 일반 JSON.parse와 스트리밍 파서 중 하나를 선택하는 것이 합리적입니다. 대부분의 REST API 엔드포인트는 응답 크기가 수 KB~수백 KB 수준이므로 일반적인 방식으로 충분하고, 스트리밍은 대용량 배치 처리, 로그 분석, 대형 파일 임포트 같은 특수한 상황에서 고려하면 됩니다.
짧은 비교 — JSON, Protocol Buffers, MessagePack
JSON이 만능은 아닙니다. 상황에 따라 다른 직렬화 포맷이 더 적합할 수 있으므로 간단히 짚고 넘어갑니다.
- JSON: 사람이 읽을 수 있고(human-readable), 디버깅이 쉬우며, 모든 언어와 도구에서 지원됩니다. 대신 텍스트 기반이라 바이너리 포맷보다 크기가 크고 파싱이 느립니다. 공개 REST API, 브라우저-서버 통신, 설정 파일에 적합합니다.
- Protocol Buffers(Protobuf): 스키마(
.proto파일)를 먼저 정의하고 이로부터 각 언어별 코드를 생성하는 방식입니다. 바이너리 포맷이라 크기가 작고 파싱이 빠르며, 스키마 기반이라 필드 타입 불일치를 컴파일 타임에 잡아낼 수 있습니다. gRPC와 함께 마이크로서비스 간 내부 통신에 널리 쓰입니다. 단점은 스키마 없이는 페이로드를 읽을 수 없어 디버깅 시 별도 도구가 필요하다는 점입니다. - MessagePack: “바이너리 JSON”으로 불리는 포맷으로, 별도의 스키마 정의 없이 JSON과 거의 동일한 데이터 모델(객체, 배열, 문자열, 숫자 등)을 더 작고 빠른 바이너리로 표현합니다. 기존 JSON 기반 코드를 크게 바꾸지 않고 크기·속도만 개선하고 싶을 때, 혹은 캐시나 세션 저장소처럼 사람이 직접 읽을 필요가 없는 곳에 적합합니다.
셋 중 무엇을 선택할지는 결국 디버깅 편의성과 생태계 호환성(JSON) vs 성능과 타입 안정성(Protobuf) vs 최소한의 마이그레이션 비용으로 얻는 크기·속도 개선(MessagePack) 사이의 트레이드오프입니다. 외부에 공개되는 API라면 JSON이 여전히 기본값이고, 내부 고성능 서비스 간 통신이라면 Protobuf를, 기존 JSON 파이프라인을 유지하면서 성능만 개선하고 싶다면 MessagePack을 검토해볼 만합니다.
마무리
JSON 직렬화와 역직렬화는 겉보기엔 JSON.stringify / JSON.parse 한 줄로 끝나는 단순한 작업처럼 보이지만, 실제로는 프로세스·네트워크·언어 경계를 넘나드는 과정에서 발생하는 다양한 데이터 손실과 신뢰 문제를 안고 있습니다. 이 글에서 다룬 핵심을 정리하면 다음과 같습니다.
- JSON은 값(문자열·숫자·불리언·null·배열·객체)만 표현할 수 있으며, 참조·타입 정보·메서드는 표현하지 못합니다. 이것이 Date, Map/Set, BigInt, 순환 참조, 큰 정수와 관련된 모든 함정의 근본 원인입니다.
- Date는 문자열로 변환되고 자동으로 복원되지 않으며, Map/Set/undefined/함수는 조용히 사라지고, BigInt와 순환 참조는 예외를 던집니다. 이런 값을 다루려면 replacer/reviver(JS) 또는 default/object_hook(Python)으로 명시적인 왕복 변환 로직을 만들어야 합니다.
- 외부에서 들어온 JSON은 문법적으로 유효할 뿐 구조가 맞다는 보장이 없으므로, Zod나 Pydantic 같은 런타임 스키마 검증을 파싱 직후 항상 거쳐야 합니다.
- 대용량 페이로드에서는 동기 파싱이 이벤트 루프를 블로킹할 수 있으므로, 스트리밍 파서를 대안으로 고려해야 합니다.
어떤 언어와 프레임워크를 쓰든 이 원칙들은 동일하게 적용됩니다. JSON을 “그냥 되는 것”으로 취급하지 않고, 어떤 값이 안전하게 왕복(round-trip)되고 어떤 값이 그렇지 않은지를 명확히 인지하는 것이 백엔드 개발에서 가장 흔한 종류의 버그를 예방하는 가장 확실한 방법입니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- 백엔드 REST API 설계 심화 — 메서드·멱등성·HATEOAS·협상·버전·프로덕션
- API Rate Limiting 완벽 가이드 | 알고리즘·분산 처리·HTTP 규약 실전 정리
- JWT & OAuth 2.0 인증 실전 가이드 | 토큰 기반 인증