데이터 정제소: JSON이 어떻게 AI 에이전트의 언어로 조용히 자리 잡았는가
요약
AI 에이전트와 도구 호출의 핵심 언어인 JSON의 중요성과 직렬화(serialization) 과정에서 발생하는 오류를 다룹니다. JSON은 JavaScript 객체가 아닌 전송 형식임을 명시하며, 데이터의 무결성을 유지하기 위한 엔지니어의 이해를 강조합니다.
핵심 포인트
- JSON은 메모리 상의 객체가 아닌 텍스트 기반의 전송 형식임
- AI 에이전트의 도구 호출과 구조화된 출력은 모두 JSON을 통해 이루어짐
- 직렬화 과정에서의 데이터 손실은 에러 없이 시스템 장애를 유발할 수 있음
- 현대 개발자에게 JSON 직렬화 지식은 필수적인 역량임
모든 도구 호출(tool call), 모든 구조화된 출력(structured output), 모든 에이전트의 결정은 JSON으로 전달됩니다. 이제 그 중요성이 그 어느 때보다 높아진 지금, 아마추어와 설계자를 가르는 것은 바로 이 직렬화(serialization) 지식입니다.
한 개발자가 금요일에 AI 에이전트를 출시합니다. 데모에서는 완벽합니다. 모델이 요청을 읽고, 도구를 호출하며, 앱이 완벽하게 렌더링할 수 있는 깔끔한 답변을 반환합니다.
일주일 후, 운영 대시보드는 쓰레기 데이터로 가득 찹니다. 날짜가 가공되지 않은 텍스트로 나타납니다. 분명히 존재했던 필드가 조용히 사라졌습니다. 하나의 커다란 페이로드(payload) 아래에서 서버 전체가 2초 동안 멈췄습니다. 그리고 정말 미치게 만드는 부분은 — 아무런 에러도 발생하지 않았다는 점입니다. 모델은 JSON을 반환했습니다. 코드는 그것을 파싱(parse)했습니다. 모든 것이 "작동"했습니다.
버그는 모델에 있었던 것도, 파서(parser)에 있었던 것도 아닙니다. 그것은 _텍스트(text)_와 데이터(data) 사이의 좁은 간극, 즉 모든 JSON 값이 두 번씩 통과해야 하는 지점에 존재했습니다. 그 간극이 바로 **직렬화 (serialization)**이며, 2026년 현재 이는 JavaScript 엔지니어가 실제로 이해해야 할 가장 중요한 요소 중 하나로 조용히 자리 잡았습니다.
왜 지금일까요? 현대 소프트웨어에서 가장 중요한 대화는 더 이상 인간 사이에서 이루어지지 않기 때문입니다. 그것은 모델과 기계 사이의 대화입니다. 어떤 도구를 호출할지 결정하는 LLM, 응답하는 서버, 10단계를 하나로 묶는 에이전트와 같은 대화 말입니다. 그리고 이 모든 대화는 동일한 형식으로 이루어집니다: 바로 JSON입니다.
그럼 정제소를 열어, 가공되지 않은 구조가 어떻게 소중한 것을 하나도 잃지 않고 깔끔한 바이트(bytes) 스트림으로 변하며, 다시 되돌아가는지 살펴보겠습니다.
JSON은 JavaScript 객체가 아닙니다
이것이 대부분의 JSON 버그를 만드는 오해이므로, 분명하게 말할 가치가 있습니다: JSON은 JavaScript 객체처럼 보일 뿐입니다. 그것은 객체가 아닙니다.
JSON은 _전송 형식 (transport format)_입니다. 즉, 네트워크를 통해 이동하거나 디스크에 저장되기 위해 설계된 평면적이고 비활성 상태인 텍스트입니다. 반면 JavaScript 객체는 애플리케이션이 읽고, 변경(mutate)하고, 메서드를 호출할 수 있는 메모리 상의 _활성 구조 (live structure)_입니다. 이 둘은 조립된 가구와 납작하게 포장된 판지 상자가 닮은 것과 같습니다. 본질은 같지만, 상태는 완전히 다릅니다.
const user = { name: "Joao" }; // 메모리 상의 활성 객체
typeof user; // "object"
...
V8 엔진은 이 두 세계 사이를 이동하기 위해 능동적인 작업을 수행해야 합니다. 파싱(parse)하기 전까지 {"name":"Joao"}는 _케이크 (cake)_라는 단어가 실제로 먹을 수 있는 것이 아닌 것과 마찬가지로, 결코 "객체"가 아닙니다. 이 멘탈 모델을 잘 유지하세요. 아래의 모든 내용은 이 간극을 메우는 두 가지 기계, 즉 패킹(packing)하는 기계와 언패킹(unpacking)하는 기계에 관한 이야기입니다.
컨테이너 패킹하기: JSON.stringify와 직렬화 깔때기 (serialization funnel)
JSON.stringify는 값의 열거 가능한 속성(enumerable properties)을 따라가며, 이를 네트워크 전송이나 디스크 저장을 위해 단일 JSON 문자열로 압축합니다. 하지만 이것은 중립적인 복사기가 아닙니다. 세 개의 필터가 있는 깔때기라고 생각하세요. 각 필터가 무엇을 하는지 아는 것이 새벽 2시에 당신을 구해줄 것입니다.
필터 1 — 깔끔하게 통과하는 타입: 문자열(strings), 숫자(numbers), 불리언(booleans), 배열(arrays), 그리고 일반 객체(plain objects)는 손상되지 않고 유지됩니다.
필터 2 — 조용히 변환되는 타입: Date는 ISO 8601 문자열로 변환되며, NaN과 Infinity는 null로 바뀝니다.
필터 3 — 완전히 누락되는 타입: 함수(functions), undefined, 그리고 심볼(symbols)은 출력물에서 그냥 사라져 버립니다.
const data = {
name: "Ana",
createdAt: new Date(), // ISO 문자열로 변환됨
...
출력 결과를 다시 읽어보세요. 5개의 필드 중 3개가 변경되거나 사라졌지만, 엔진은 아무 말도 하지 않았습니다. 그 침묵이 바로 모든 위험의 핵심입니다.
JSON이 결코 어기지 않는 단 하나의 규칙: 순환 참조 금지 (no cycles)
JSON 구조는 원하는 만큼 깊게 중첩될 수 있지만, 반드시 엄격하게 _비순환적(acyclic)_이어야 합니다. 엔진은 탐색 중인 객체의 스택을 추적하며, 동일한 객체를 두 번 만나는 순간 즉시 중단(abort hard)합니다.
const a = {};
a.self = a; // a가 자기 자신을 가리킴
JSON.stringify(a);
...
이는 JSON이 침묵하는 대신 명시적으로(loudly) 실패하는 드문 사례 중 하나이며, 여러분은 오히려 이에 감사해야 합니다.
필터링 에이전트: replacer
JSON.stringify의 두 번째 인자는 replacer입니다. 이는 패킹(packing) 과정 도중에 실행되는 정밀한 가로채기(surgical interception) 역할을 합니다. 이를 통해 값이 네트워크로 전송되기 전에 값을 변경하거나 민감한 데이터를 제거할 수 있습니다. 전형적인 용도는 비밀 정보를 가리는(redacting) 것입니다.
const user = { name: "Joao", password: "123", admin: true };
JSON.stringify(user, (key, value) =>
...
replacer에서 undefined를 반환하면 해당 키는 페이로드(payload)에서 삭제됩니다. 비밀번호가 외부로 유출되지 않도록 보장하는 가장 깔끔한 방법입니다.
포맷팅과 위임: space 및 toJSON
알아둘 만한 두 가지 제어 장치가 더 있습니다. 세 번째 인자인 space는 공백을 삽입합니다. 이는 디버깅 시 네트워크 효율성을 희생하는 대신 인간의 가독성(human readability)을 얻는 선택입니다. 또한, 모든 객체는 자신만의 직렬화(serialization) 방식을 규정하기 위해 toJSON() 메서드를 정의할 수 있습니다. 엔진은 해당 메서드가 존재할 경우 항상 이를 위임(delegates)받아 처리합니다.
const account = {
id: 42,
secret: "s3cr3t",
...
컨테이너 언패킹: JSON.parse와 재수화(rehydration)
다시 돌아오는 과정에서, JSON.parse는 텍스트로부터 ECMAScript 값을 재구성하며, 문자열 내의 구문(syntax)으로부터 계층 구조를 엄격하게 다시 구축합니다. 하지만 필터 2를 기억하십시오. 직렬화는 _타입을 삭제(erased types)_했습니다. 여러분이 보낸 그 Date 객체는 이제 단순한 문자열일 뿐이며, 파싱(parsing)만으로는 그것을 다시 살아나게 할 수 없습니다.
그것이 바로 parse의 두 번째 인자인 reviver가 존재하는 이유입니다. reviver는 노드별로 파싱을 가로채어, 평면적인(flat) 문자열을 다시 풍부한 인스턴스로 **재수화(rehydrate)**할 수 있게 해줍니다.
const text = '{"event":"deploy","when":"2026-06-16T10:30:00Z"}';
const obj = JSON.parse(text, (key, value) =>
...
직렬화 (Serialization)는 설계상 정보 손실이 발생하며, 리바이버 (reviver)는 반대편에서 무엇을 복구할지 선택하는 방법입니다.
두 개의 에이전트, 하나의 작업: replacer vs. reviver
이 두 훅 (hook)은 거울 쌍과 같으며, 이를 혼동하는 것은 버그의 흔한 원인이 됩니다. 명확한 비교는 다음과 같습니다:
replacer | reviver | |
|---|---|---|
| 실행 시점 | 직렬화 (Serialization, stringify) | 파싱 후 (After parsing, parse) |
| ... |
현대적인 반전: JSON을 이용한 클로닝 중단하기
거의 모든 JavaScript 개발자가 한 번쯤 사용해 본 트릭이 있습니다. 바로 JSON.parse(JSON.stringify(obj))를 사용하여 객체를 딥 클로닝 (deep-cloning) 하는 것입니다. 이는 영리하고 한 줄로 끝나지만, 데이터를 위에서 언급한 전체 깔때기(funnel)를 통과하게 만들기 때문에 '조용한 살인자'와 같습니다.
const original = {
date: new Date(),
tags: new Set(["a", "b"]),
...
날짜는 문자열이 되고, undefined는 사라지며, Map과 Set은 빈 객체로 붕괴되고, 함수는 사라지며, 순환 참조 (circular reference)가 있으면 오류가 발생합니다. 이에 대한 해결책은 2022년부터 네이티브로 제공되었습니다: 바로 structuredClone() 입니다. 이는 플랫폼이 postMessage 및 IndexedDB를 위해 내부적으로 이미 사용 중인 구조화된 복제 알고리즘 (Structured Clone Algorithm)을 기반으로 구축되었습니다.
const good = structuredClone(original);
good.date; // 실제 Date 객체
good.tags; // Set(2) { "a", "b" }
structuredClone은 순환 참조, Map, Set, 타입 배열 (typed arrays), 그리고 Date를 보존합니다. undefined를 유지하며, 약 20~30% 정도 느리지만 데이터 무결성 (data integrity)을 위해 그 대가를 치릅니다. 또한 번들 크기를 전혀 늘리지 않습니다 (Lodash의 cloneDeep은 이제 안녕입니다). 함수나 DOM 노드에 대해서는 오류를 발생시키는데, 솔직히 말해서 이것은 하나의 기능(feature)입니다. 만약 함수를 클로닝하려 한다면, 당신의 데이터 모델이 무언가 잘못되었다고 말하고 있는 것입니다.
아키텍처의 청사진으로서의 JSON
두 함수에서 한 걸음 물러나 보면 한 가지 사실을 깨닫게 됩니다. JSON은 단순히 애플리케이션을 통해 흐르는 _데이터_가 아닙니다. Node 생태계에서 JSON은 전체 아키텍처가 구축되는 선언적 청사진 (declarative blueprint) 입니다.
어떤 package.json 파일을 열더라도, 여러분은 모든 것을 제어하는 JSON 객체를 읽게 됩니다. main은 진입점(entry point)이며, scripts는 자동화 트리거(start, test, build)이고, dependencies는 npm이 조립하는 모듈 트리(module tree)를 정의하며, private: true는 실수로 배포되는 것을 방지하는 안전 잠금 장치입니다. 설정(Configuration) 또한 동일한 본능을 따릅니다. 비밀번호나 URL과 같은 중요한 값들은 소스 코드에 존재하지 않습니다. 일반적인 패턴은 process.env를 중앙 집중식 설정 객체로 통합하여 개발(development)과 운영(production) 환경 사이의 동작을 전환하는 것입니다.
그리고 바로 이 지점에서 진정으로 현대적인 업그레이드가 이루어집니다. 수년 동안 JSON 설정을 가져오는(importing) 것은 번들러(bundler)나 fetch()를 사용하는 것을 의미했습니다. ES2025(2025년 4월 이후 현대적인 런타임 전반의 기준)부터는 **임포트 속성 (import attribute)**을 사용하여 JSON을 네이티브하게 가져올 수 있습니다:
// 네이티브 JSON 임포트 — 번들러나 fetch가 필요 없음
import config from "./config.json" with { type: "json" };
...
with { type: "json" }는 단순한 장식이 아니라 **보안 계약 (security contract)**입니다. 이는 런타임이 파일을 처리하기 전에 (MIME 타입을 통해) 해당 파일이 실제로 JSON인지 확인하도록 강제하며, 이를 통해 서버가 단순히 데이터처럼 보이는 파일을 통해 실행 가능한 JavaScript를 몰래 끼워 넣는 것을 방지합니다. JSON 모듈은 코드를 실행할 수 없습니다. 그것들은 순수한 데이터이며, 오직 기본 내보내기(default export)만을 노출합니다. 플랫폼이 임시방편(workaround)을 보장(guarantee)으로 바꾼 것입니다.
HTTP 프런티어: 순진한 파싱이 이벤트 루프를 망가뜨리는 곳
이제 어려운 부분입니다. 실시간 애플리케이션은 깔끔하고 완전한 JSON 문서를 받는 것이 아니라, HTTP를 통해 스트림 (streams)으로 흐르며 조각조각 도착하는 데이터를 받습니다. 네트워크 버퍼가 절반만 도착했을 때 네이티브 JSON.parse를 순진하게 호출하면 두 가지 나쁜 결과 중 하나를 얻게 됩니다. 불완전한 데이터로 인한 구문 오류(syntax error)가 발생하거나, 더 최악의 경우 거대한 페이로드(payload)를 동기식(synchronously)으로 파싱하는 동안 단일 스레드 이벤트 루프(event loop)가 차단되어, 다른 모든 사용자를 위해 전체 서버가 얼어붙게 됩니다.
이러한 아키텍처는 특화된 중개자를 요구합니다. Express에서는 그것이 바로 express.json() 미들웨어(middleware)이며, 이는 조립 라인의 검사 컨베이어 벨트와 같습니다. 이 미들웨어는 들어오는 스트림(stream)을 안전하게 버퍼링(buffering)하고, Content-Type: application/json 헤더를 확인하며, 결과를 파싱(parsing)하여 여러분의 라우트(route)에 즉시 사용할 수 있는 req.body를 전달합니다.
const express = require("express");
const app = express();
...
네이티브 함수와 미들웨어의 차이는 스크립트와 시스템의 차이와 같습니다:
JSON.parse() | express.json() | |
|---|---|---|
| 실행 컨텍스트 (Execution context) | 동기식 메모리 (데이터가 이미 V8에 존재) | HTTP 네트워크 계층 (버퍼/스트림) |
| ... |
보상: 왜 이 모든 것이 지금 AI 시대를 구동하는가
위의 모든 사항은 과거에는 단순히 "Node.js의 좋은 위생 수칙(hygiene)"에 불과했습니다. 하지만 2026년 현재, 한 가지 구조적 사실 때문에 이는 더 큰 의미를 갖습니다. 바로 LLM(대규모 언어 모델)은 텍스트 생성기이며, 여러분의 시스템은 데이터 구조(data structures)를 필요로 한다는 점입니다. JSON은 이 둘 사이의 가교(bridge)이며, 우리가 살펴보았듯이 그 가교가 바로 버그가 발생하는 지점입니다.
이 간극은 이제 세 가지 단계의 신뢰성 수준으로 공식화되었으며, 자신이 어느 단계에 있는지 아는 것이 데모(demo)와 프로덕션(production)을 가르는 차이점이 됩니다:
- Level 1 — 프롬프트 엔지니어링 (Prompt engineering). "이 필드들을 포함한 JSON을 반환해줘."라고 요청합니다. 80~95%의 확률로 작동하지만, 엣지 케이스(edge case)에서 조용히 실패하며 타입(type)에 대한 어떠한 보장도 제공하지 않습니다.
- Level 2 — 함수 / 도구 호출 (Function / tool calling). 모델이 사용자가 정의한 스키마(schema)를 가진 함수를 "호출"합니다. 95~99%의 확률로 작동하지만, 스키마는 제약(constraint)이 아닌 하나의 "힌트(hint)"입니다. 즉, 유효한 타입이지만 유효하지 않은 값을 받을 수 있습니다.
- Level 3 — 네이티브 구조화된 출력 (Native structured output). 생성 시점에 유효하지 않은 토큰(token)을 마스킹(masking)하기 위해 유한 상태 머신(finite-state machine)을 사용하여 JSON 스키마(JSON Schema)에 따라 제약된 디코딩(constrained decoding)을 수행합니다. 텍스트가 생성되는 동안 타입과 값이 모두 강제되므로, 100% 스키마에 유효합니다.
이것은 비주류 도구가 아닙니다. 이제 OpenAI(2024년 8월부터), Google Gemini(2024년 도입, 2026년까지 확장 예정), Anthropic(2025년 말 베타, 2026년 초 GA 예정), Cohere, 그리고 xAI의 Grok에 이르기까지 네이티브 구조화된 출력(Native structured output) 기능이 탑재되어 있으며, Ollama, vLLM, SGLang과 같은 로컬 런타임(runtime)에서도 지원됩니다. 스키마는 이제 **모델과 나머지 시스템 간의 계약(contract)**이 되었으며, 이를 프로덕션 환경에서 운영하는 팀들의 조언은 단호합니다. 애플리케이션 코드를 작성하기 전에 데이터베이스 스키마를 설계하는 것과 마찬가지로, 스키마를 먼저 설계하라는 것입니다. Pydantic 및 Zod와 같은 도구들은 그 계약을 실행 가능하게 만들기 위해 존재하며, 진정한 보상은 바로 _테스트 가능성(testability)_입니다. 출력이 타입(typed)이 지정되고 스키마에 유효해지면, 이를 바탕으로 단위 테스트(unit tests)와 회귀 테스트(regression suites)를 작성할 수 있으며, 모델 업데이트로 인해 동작이 조용히 변하는 날을 포착할 수 있습니다.
한 단계 더 깊이 들어가 물리적인 연결 계층(wire)을 살펴보면, JSON은 그곳에도 존재합니다. 2024년 11월 Anthropic이 도입하여 현재 Claude, Cursor, Gemini 및 주요 클라우드에서 지원되는 **Model Context Protocol (MCP)**은 JSON-RPC 2.0 위에서 동작합니다. 에이전트가 호출하는 모든 도구와 읽어들이는 모든 리소스는 JSON-RPC 메시지입니다:
{
"jsonrpc": "2.0",
"id": 7,
...
JSON Schema는 모델이 도구를 호출하기 전에 해당 도구가 어떤 인자(arguments)를 수락하는지 알려줍니다. 단방향 알림(one-way notifications)은 진행 상황 업데이트를 전달하며, 배치(batching)를 통해 에이전트는 여러 도구 호출을 한 번에 확장(fan out)할 수 있습니다. MCP는 N개의 모델을 M개의 도구에 연결하기 위해 N×M개의 커스텀 어댑터를 작성할 필요가 없는 N×M 문제를 해결하기 위해 존재하며, 모든 에이전트와 모든 도구가 이미 사용하고 있는 공용 언어로 JSON을 만듦으로써 이 문제를 해결합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기