ToolActToolAct
JSON이란 무엇인가

JSON이란 무엇인가

생활/실용2026년 8월 3일15분 읽기

한 번의 네트워크 요청에서 시작된다

브라우저를 열고 개발자 도구의 Network 탭을 켠 다음, 아무 페이지에서나 목록 하나를 불러오는 요청을 잡아보자. 응답 본문은 대개 이런 생김새다.

{
  "id": 1024,
  "name": "한강 작가",
  "email": "author@example.com",
  "books": [
    { "title": "채식주의자", "year": 2007 },
    { "title": "소년이 온다", "year": 2014 }
  ]
}

처음에는 그냥 글자 덩어리처럼 보인다. 중괄호에 콜론에 쉼표, 들여쓰기까지 되어 있어서 뭔가 정돈된 느낌은 나지만, 이게 대체 무엇이기에 브라우저와 서버가 하루에도 수백만 번씩 주고받는 건지 감이 안 온다. 어느 날 아무 생각 없이 이 화면을 들여다보면서 문득 궁금해졌다. 이 괴상한 중괄호 뭉치가 도대체 왜 여기서 매일 만나고 있을까.

그리고 이 별것 아닌 포맷 하나가 없었다면 오늘날의 웹은 지금의 모습이 아니었을 것이다. 그렇다고 과장할 것도 없다. 웹의 작동 방식 자체가 이 포맷의 단순함 위에 얹혀 있기 때문이다.

나는 이 글에서 JSON이 무엇인지, 왜 존재하게 됐는지, 어디서 어떻게 만나는지, 그리고 어디서 한계에 부딪히는지를 차근차근 풀어보려고 한다. 25년이 지난 지금도 JSON이 데이터 포맷의 자리에서 내려오지 못하는 이유를, 그 지루함이 어떻게 강점이 되는지를 함께 살펴보자.

JSON이 도대체 뭔가

JSON은 JavaScript Object Notation의 줄임말이다. 2001년 무렵, Douglas Crockford가 JavaScript의 객체 리터럴(object literal) 문법을 빌려 데이터 교환 포맷으로 떼어낸 것이 그 시작이다.

원래 JSON은 JavaScript라는 특정 언어 안에 갇혀 있을 이유가 전혀 없는, 언어 중립적인 포맷으로 설계되었다. 사람이 읽을 수 있고, 기계가 파싱하기 쉬우며, 대부분의 언어가 이미 가지고 있는 자료구조와 1:1로 대응한다. XML이 당시 표준 자리를 꿰차고 있던 상황에서, "이 정도는 되어야지" 하는 가벼운 대안으로 등장한 것이다.

그렇게 태어난 JSON이 퍼져나간 경로도 특이하다. 표준이 만들어진 뒤가 아니라, 관행이 먼저 시장을 만들었다. 2006년 야후(Yahoo!)가 웹 서비스에서 JSON을 공식 지원하기 시작했고, 같은 해에 Crockford가 'JSON: The Fat-Free Alternative to XML'이라는 제목의 발표로 이 포맷을 널리 각인시켰다. 재미있는 사실 하나, json.org에는 한때 'JSON'이라는 이름과 로고가 Douglas Crockford의 상표(trademark)라는 고지가 붙어 있었다. 표준 문서의 힘보다 실전에서의 입소문이 더 컸던, 드문 사례다.

표준화 과정은 상대적으로 늦게 진행되었다. 2013년에 ECMA-404가, 이듬해 2014년에 RFC 7159가, 그리고 2017년에 RFC 8259가 나오면서 차례로 표준 자리에 올랐다. 그 전까지는 json.org에 명세 하나가 덩그러니 걸려 있었는데, 사실 그 문서만으로도 충분했다. 표준화가 늦어진 것은 JSON이 부족해서가 아니라, 너무 단순해서 "이걸 굳이 표준이라고 해야 하나" 싶을 정도였기 때문이다. HTML이나 HTTP 같은 것들과는 결이 다르게, 표준보다 먼저 실전이 있었던 셈이다.

이름은 좀 불운하다. JavaScript Object Notation이라는 이름 때문에 JavaScript와 얽힌 것으로 오해받지만, 문법의 조상이 JavaScript라는 점을 제외하면 JavaScript와 아무 관련이 없다. C++이 C에서 태어났듯, JSON은 JavaScript라는 언어가 낳은 자식이다. 하지만 자식이라고 해서 부모를 따라다닐 의무는 없다. Python도, Java도, Go도 각자 자기 언어로 JSON을 파싱한다.

왜 XML을 이겼나

1990년대 말에서 2000년대 초, 데이터를 주고받는 일은 전부 XML이었다. SOAP 기반의 웹 서비스가 유행이었고, 설정 파일은 전화번호부만큼이나 길고 지루했다.

<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <GetUser xmlns="http://example.com/user">
      <userId>1024</userId>
    </GetUser>
  </soap:Body>
</soap:Envelope>

그 시대의 개발자들은 속성(attribute)과 요소(element) 중 무엇을 쓸지 논쟁하느라 시간을 보냈고, 네임스페이스(namespace)의 미로에서 헤맸으며, 데이터를 제대로 읽기 위해 XSLT라는 또 하나의 언어를 통째로 배워야 했다. 데이터 한 덩어리를 주고받기 위해 지불해야 하는 학습 비용이 지나치게 컸다. 게다가 같은 의미를 표현하는 방법이 너무 많아서, 두 팀이 만든 XML 문서가 같은 구조를 전혀 다른 모양으로 기술하는 일이 다반사였다.

JSON은 이 모든 것의 정반대였다. 네임스페이스도, 스키마 문서도, 변환 규칙도 필요 없다. 그냥 중괄호를 열고 닫으면 된다.

  • 객체(object)는 대부분의 언어가 가진 해시맵, 딕셔너리, 구조체와 1:1로 대응한다.
  • 배열(array)은 리스트, 배열과 그대로 맞아떨어진다.
  • 문자열, 숫자, 불리언, null은 모든 언어에 이미 존재하는 타입이다.

스키마 없이도 그냥 열어보면 읽힌다. 어떤 도구도 필요 없다. 학습 곡선은 거의 0에 수렴한다. "이건 어떻게 파싱하지?" 하는 고민 자체가 사라지는 것이다.

JSON이 아름다워서 이긴 게 아니다. 그 시절의 다른 선택지들이 전부 더 귀찮았기 때문이다. 경쟁자가 너무 무거웠고, JSON은 그에 비해 훨씬 가벼웠다. 실용주의의 승리라고 해도 과언이 아니다. 화려함이 아니라 단순함이 이겼다.

여섯 가지 데이터 타입

JSON은 딱 여섯 가지 데이터 타입만을 가진다. 더도 말고 덜도 말고. 이 여섯 가지로 현실의 거의 모든 데이터를 표현한다.

객체(Object) — 키와 값의 쌍을 중괄호로 감싼다. 키는 반드시 큰따옴표로 묶인 문자열이어야 한다. 순서는 개념상 보장되지 않는다.

{ "name": "김철수", "age": 30 }

배열(Array) — 값의 순서 있는 목록을 대괄호로 감싼다. 요소는 무엇이든 될 수 있고, 타입이 섞여 있어도 문법상 문제는 없다.

["apple", "banana", "cherry"]

문자열(String) — 큰따옴표로 묶인 유니코드 문자열. 작은따옴표는 안 된다. 이스케이프 시퀀스를 지원한다.

"안녕하세요, 세계!"

숫자(Number) — 정수, 소수, 지수 표기까지. 앞에 0을 붙일 수 없고, NaN이나 Infinity 같은 것은 존재하지 않는다.

42
-3.14
1.5e10

불리언(Boolean) — 소문자 truefalse 둘뿐이다. TrueTRUE는 문법 자체가 허용하지 않는다.

null — 값이 없음을 나타내는 독립된 타입. 다른 언어의 nil, None, NULL에 해당한다. 0이나 빈 문자열과는 전혀 다르다.

{ "nickname": null }

이 여섯 가지가 모이면 현실의 데이터가 그려진다. 사용자 레코드 하나를 표현하면 이렇게 된다.

{
  "id": 1,
  "username": "hwan",
  "isActive": true,
  "profile": {
    "displayName": "환",
    "age": null,
    "tags": ["developer", "gamer"],
    "balance": 1530000.75
  }
}

중첩은 자유롭다. 객체 안에 배열, 배열 안에 객체, 얼마든지 깊이 들어갈 수 있다. 이것이 JSON이 한 줄짜리 로그 메시지에서 거대한 상품 카탈로그까지 폭넓게 쓰일 수 있는 이유다.

문법은 토큰 사이의 공백을 자유롭게 허용한다. 그래서 위 예시처럼 예쁘게 들여쓰기를 하든, 한 줄에 우겨 넣든 문법적으로는 완전히 같은 문서다. 사람이 읽을 수 있는 포맷이라는 JSON의 정체성은 바로 이 공백의 자유로움 위에 서 있다.

쉽게 넘어지는 문법의 함정들

JSON은 단순하다. 하지만 그 단순함 때문에 사람들이 실수하는 지점도 아주 명확하다. 몇 년간 코드 리뷰를 하면서 반복적으로 목격한 것들만 골라봤다.

키는 반드시 큰따옴표. 작은따옴표나 따옴표 없는 키는 허용되지 않는다. JavaScript의 객체 리터럴에 익숙한 상태에서 그대로 JSON을 쓰다가 Unexpected token 'u' 같은 에러를 마주한 적이 한두 번이 아니다.

{ "name": "철수" }

문자열도 큰따옴표만. "hello"는 되지만 'hello'는 안 된다. HTML 속성이나 셸 스크립트 안에서 JSON을 만들 때 특히 헷갈리기 쉽다.

주석이 없다. JSON에는 주석이라는 개념 자체가 없다. 설정 파일을 만지다 보면 주석의 필요성을 곧 느끼게 되는데, 이 이야기는 뒤의 '한계와 불만들' 절에서 다시 이어진다.

후행 쉼표(trailing comma) 금지. 배열이나 객체의 마지막 요소 뒤에 쉼표를 붙이면 그대로 파싱 에러다. 요소 하나를 지우고 나서 이전 요소 뒤에 쉼표가 남는 실수가 잦다.

[1, 2, 3,]
{ "a": 1, }

숫자 앞에 0 금지. 01은 허용되지 않는다. 0.5는 되지만 00.5는 안 된다. 패딩된 ID를 숫자로 저장하려다 마주치는 함정이다. 반면 -0은 유효하고, 1.5e10 같은 지수 표기도 유효하다. 소수점 앞이 비어 있는 .5나, 뒤가 비어 있는 5.는 안 된다.

중복 키는 문법상 허용된다. RFC가 "이름은 유일해야 한다"고 권장할 뿐 강제하지는 않으며, 실제로 대부분의 파서는 마지막 값을 취한다. 하지만 어떤 파서는 첫 값을 취할 수도 있고, 어떤 파서는 에러를 던질 수도 있다. 동작이 정의되어 있지 않다는 뜻이다. 이중 파서에서 중복 키가 서로 다른 결과를 낳는 버그를 실제로 본 적이 있다. 중복 키에 기대지 말자.

유니코드는 \uXXXX. 기본 다국어 평면(BMP) 밖의 문자, 이를테면 이모지 같은 것은 서로게이트 쌍(surrogate pair)으로 표현한다. 다행히 대부분의 파서가 그대로 쓴 이모지도 처리해 주지만, 이스케이프 형태도 알아두면 유용하다.

"😀"
"\uD83D\uDE00"

전체 이스케이프 목록은 딱 아홉 개. \", \\, \/, \b, \f, \n, \r, \t, \uXXXX. \' 같은 것은 없다. 그 밖의 문자는 그대로 써야 한다.

문자열 안의 제어 문자는 반드시 이스케이프. U+0000부터 U+001F까지의 제어 문자는 큰따옴표 안에 그대로 쓸 수 없다. 예컨대 탭은 실제 탭 문자가 아니라 \t로, 개행은 \n으로 넣어야 한다. 프로그램에서 JSON을 만들 때 그냥 문자열을 이어 붙이면 개행 문자가 그대로 들어가 파싱이 깨지는 경우가 흔하다.

이 규칙들은 다소 보수적으로 보인다. 하지만 이렇게 단순하고 명확한 문법이었기에, 파서를 온전히 구현하는 것이 그렇게 오래 걸리지 않았다. 사양이 복잡할수록 구현이 저마다 다르게 갈라지는 법인데, JSON은 그런 일이 벌어질 여지가 아주 적었다.

JSON은 JavaScript의 부분집합인가

흔한 오해다. JSON을 JavaScript 객체 리터럴과 동일시하는 것. 2006년의 RFC 4627은 JSON을 "JavaScript의 부분집합"이라고 불렀는데, 그 표현이 이후 오랫동안 골칫거리가 되었다.

실제로는 이렇다. JSON 문법에 부합하지 않는 JavaScript 객체 리터럴은 수없이 많다.

// JavaScript에서는 되지만 JSON에는 없는 것들
{
  name: '철수',           // 따옴표 없는 키, 작은따옴표
  age: 30,
  sayHi() { /* ... */ },  // 함수
  tags: ['a', 'b',],      // 후행 쉼표
  updatedAt: new Date(),  // Date 객체
  budget: NaN,            // NaN
  score: Infinity,        // Infinity
  // 주석도 쓸 수 없다
}

이 중 어느 것도 JSON이 아니다. JSON은 값으로 함수, undefined, NaN, Infinity, Date를 가질 수 없고, JSON.parse를 통과할 수 없는 문법이다.

그럼에도 RFC 4627이 "부분집합"이라고 한 것은, JSON이 JavaScript의 객체 리터럴 문법에서 태어났기 때문에 대부분의 경우 JSON 문서가 곧 유효한 JavaScript 코드이기도 하다는 사실 때문이었다. eval()로 바로 파싱이 가능했다는 뜻이다. 물론 이것이 보안상의 참사로 이어지는데, 이 이야기는 뒤의 '보안' 절에서 다룬다.

부분집합이라는 표현이 정말로 어색해진 지점은 U+2028/U+2029 줄 구분 문자(line separator)의 등장이었다. 유니코드에서 문자열의 줄바꿈으로 취급되는 이 문자들이 JSON 문자열 안에는 그대로 들어갈 수 있는데, 당시 JavaScript는 이 문자들이 문자열 안에서 줄바꿈을 일으킨다고 판단해 문법 에러를 만들었다. 즉 유효한 JSON이 유효한 JavaScript가 아닌 첫 사례가 등장한 것이다. 이후 ECMAScript가 이 문자를 문자열 안에서 허용하게 바뀌었지만, "부분집합"이라는 말이 처음부터 정확했던 것은 아니었다.

왜 이 구분이 중요한가. JSON.parse는 엄격한 문법을 그대로 강제한다. JSON.parse('{"a": undefined}')는 예외를 던진다. 객체 리터럴 문법에 익숙한 채로 JSON을 만지면 이런 예외가 "왜 나지?" 하는 지점이 정확히 여기다. JSON은 JavaScript 코드가 아니라, JavaScript의 문법을 닮은 독립된 데이터 포맷이다. 이 사실을 머릿속에 넣어두면 앞으로 수많은 헷갈림이 사라진다.

일상에서 만나는 곳

JSON은 이제 사실상 인터넷의 공용어다. 눈을 돌리면 어디에나 있다. 각각이 저마다 다른 이름을 쓰지만, 그 안을 들여다보면 전부 JSON이다.

REST API. 요청 본문과 응답 본문의 기본 포맷. 이 글의 첫 장면에서 본 그 Network 요청이 바로 그것이다. 서버가 JSON을 반환하고, 브라우저가 파싱하고, 화면에 그린다. 이 요청-응답-렌더링의 순환이 오늘날 웹이 돌아가는 기본 방식이라 해도 과언이 아니다.

설정 파일. package.json, tsconfig.json, .babelrc... JavaScript 생태계의 설정 파일은 거의 전부 JSON이다. Rust가 Cargo.toml에 TOML을 쓰고 Python이 pyproject.toml을 쓰는 것과 달리, JS 진영은 그냥 JSON이다.

{
  "name": "my-app",
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build"
  },
  "dependencies": {
    "vue": "^3.4.0"
  }
}

브라우저 저장소. localStorage에는 문자열만 담을 수 있다. 그래서 구조화된 데이터는 JSON.stringify로 직렬화해 넣고, JSON.parse로 꺼낸다.

localStorage.setItem('prefs', JSON.stringify({ theme: 'dark', lang: 'ko' }));
const prefs = JSON.parse(localStorage.getItem('prefs') || '{}');

IndexedDB는 객체를 통째로 저장할 수 있지만, 그 내부 직렬화는 JSON이 아니라 structured clone이라는 별도 알고리즘을 쓴다. 다만 그 객체를 서버로 보내거나 로그로 남길 때는 어차피 JSON을 거치게 된다.

데이터베이스. PostgreSQL의 jsonb 타입은 JSON을 쿼리 가능한 이진 형식으로 저장한다. MongoDB는 애초에 문서를 저장하는 포맷이 BSON, 즉 이진 JSON 변형이다. 관계형 데이터베이스조차 통계나 설정 같은 비정형 데이터를 JSON 컬럼에 넣는 것이 일상이 되었다.

JSON Lines(NDJSON). 한 줄에 JSON 객체 하나씩. 줄 단위로 처리할 수 있어서 로그 파일과 스트리밍에 강하다. 백만 줄짜리 로그에서 한 줄만 읽어도 된다.

{"ts": "2026-08-12T09:00:01Z", "level": "info", "msg": "server started"}
{"ts": "2026-08-12T09:00:02Z", "level": "warn", "msg": "slow query", "ms": 480}

WebSocket 메시지. 양방향 통신에서도 형식은 관례적으로 JSON이다. 채팅 메시지 하나하나가 작은 JSON 객체다. 몇 바이트짜리 커서 동기화 메시지도, 킬로바이트짜리 화면 상태 스냅샷도 전부 JSON으로 주고받는다.

fetch. 브라우저에서 서버로 뭔가를 보낼 때도 기본 포맷은 JSON이다.

const res = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: '철수', age: 30 })
});
const user = await res.json();

이렇게 익숙한 장면들이 전부 JSON 위에서 돌고 있다. 기술 이름은 저마다 다르지만, 정작 눈앞에서 오가는 데이터는 언제나 저 중괄호 뭉치다.

각 언어에서 어떻게 파싱하나

모든 주류 언어가 JSON 파서를 기본으로 내장하고 있다는 것은 대단한 일이다. 예전의 다른 포맷들은 그렇지 않았다. 각 언어의 맛을 아주 짧게만 보여주겠다. 튜토리얼이 아니라, "아 이렇게 되나 보다" 하는 정도의 감각을 위한 것이다.

JavaScript. 내장 메서드 두 개가 전부다.

const obj = JSON.parse(rawText);
const text = JSON.stringify(obj);

JSON.stringify의 두 번째 인자로 replacer 함수를, 세 번째 인자로 들여쓰기 폭을 줄 수 있다는 것이 유일하게 알아둘 만한 비밀이다. 이 두 메서드가 자바스크립트 세상의 모든 직렬화를 책임진다.

Python. json 모듈. json.loads로 문자열을, json.dumps로 문자열을 만든다. 파일과 직접 오가는 json.loadjson.dump도 있다.

import json
data = json.loads(raw_text)   # dict
raw = json.dumps(data, ensure_ascii=False, indent=2)

ensure_ascii=False를 빼먹으면 한글이 전부 \uXXXX로 인코딩되는 것을 자주 봤다. 동작은 정확하지만, 사람이 읽을 수 없다. 한글이 섞인 데이터를 다룰 때면 이 인자를 빼먹지 않도록 조심하게 된다.

Java. 표준 라이브러리에는 JSON이 없다. Jackson과 Gson이 사실상 표준 역할을 한다. Jackson은 성능이 뛰어나고, Gson은 사용법이 단순하다.

// Jackson
ObjectMapper mapper = new ObjectMapper();
User user = mapper.readValue(rawText, User.class);
String json = mapper.writeValueAsString(user);

Go. encoding/json 패키지. 구조체에 태그를 붙여 필드 이름을 매핑한다. JSON 필드 이름이 카멜케이스이고 Go 필드가 파스칼케이스인 경우가 많아서, 이 태그가 실무의 절반을 차지한다고 해도 과언이 아니다.

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}
var u User
json.Unmarshal(body, &u)

Rust. serde가 지배적이다. 구조체에 #[derive(Serialize, Deserialize)]를 붙이면 serde_json이 알아서 처리한다. 타입 안전성이 가장 강해서, 런타임에야 알게 되는 필드 불일치를 컴파일 타임에 잡아준다.

#[derive(Serialize, Deserialize)]
struct User { name: String, age: u32 }

let u: User = serde_json::from_str(&raw)?;

C#. .NET에는 System.Text.Json이 내장되어 있고, 그 이전부터는 Newtonsoft.Json(Json.NET)이 사실상 표준으로 쓰였다.

var user = JsonSerializer.Deserialize<User>(rawText);
var json  = JsonSerializer.Serialize(user);

PHP. json_decodejson_encode가 내장되어 있다. json_decode는 기본적으로 객체를 돌려주고, 두 번째 인자에 true를 주면 연관 배열로 받는다.

$user = json_decode($raw, true);   // 연관 배열
$json = json_encode($user);

이 일곱 가지 예시에서도 공통점이 보일 것이다. 파싱이라는 행위의 난이도가 언어마다 크게 다르지 않다는 것, "문자열을 넣으면 구조가 나온다"는 단 하나의 직관으로 통일된다는 것. 이 통일감이야말로 JSON이 언어를 넘어서는 이유다.

보안: 피를 치르고 배운 교훈

JSON의 역사에는 값비싼 교훈이 깔려 있다. 첫 번째가 eval 사건이다.

2000년대 초, 서버에서 받아온 JSON을 파싱하는 가장 쉬운 방법은 JavaScript의 eval()에 통째로 넣는 것이었다. 문법이 맞으니까, 정말로 잘 동작했다. 문제는 그만큼 누구나 그 eval을 실행할 수 있다는 것이었다.

// 절대 이렇게 하지 말 것
const data = eval('(' + serverResponse + ')');

서버 응답이 순수한 데이터라면 문제없다. 하지만 응답이 가로채이거나, 서버가 오염되었거나, 응답에 의도된 코드가 실려 있다면? eval은 그 코드를 그대로 실행한다. 원격 코드 실행(remote code execution)의 지름길이었다. 악의적인 JSON은 데이터가 아니라 프로그램이 되는 것이다. 한 줄로 보안 사고가 나는 순간이었다.

그다음은 JSONP(JSON with Padding)다. CORS가 없던 시절, 다른 도메인의 데이터를 가져오는 유일한 방법은 <script> 태그를 동적으로 붙이는 것이었다. 서버는 JSON을 콜백 함수 호출로 감싸서 돌려줬다.

callback({ "name": "철수" });

스크립트 태그라서 동일 출처 정책(SOP)의 제약을 피할 수 있었지만, 결과적으로 또 하나의 eval이었다. 콜백 이름을 URL 파라미터로 받는 구조라서, 그 콜백을 조작해 임의 코드를 실행할 여지가 컸다.

CSRF 구멍은 특히 악명 높았다. JSON 응답이 최상위 배열(top-level array)이라면, 그 배열 리터럴이 공격자 페이지의 <script> 태그로 그대로 실행되고, Array.prototype의 세터를 덮어쓴 스크립트가 원소들을 가로채 데이터를 빼돌릴 수 있었다. 그래서 응답을 항상 최상위 객체로 감싸라는 보안 관례가 널리 퍼졌다. 다만 이것은 표준이 강제한 것이 아니었다. RFC 4627은 JSON 텍스트를 '객체 또는 배열'로 정의했고, 이후의 RFC 7159·8259는 오히려 최상위에 어떤 값이든 둘 수 있게 규칙을 더 풀었다. 다시 말해 최상위 배열을 피하는 관례는 표준이 아니라, 그 시절의 수많은 사고로 다져진 실무 지식인 셈이다.

// 위험했던 형태: 최상위 배열
[{"name": "철수"}, {"name": "영희"}]

// 안전해진 형태: 최상위 객체
{"data": [{"name": "철수"}, {"name": "영희"}]}

그리고 프로토타입 오염(prototype pollution). 파서가 JSON의 __proto__ 키를 그대로 객체에 복사하는 경우, 해당 객체의 프로토타입이 오염되어 이후 생성되는 모든 객체가 영향을 받는다. 특히 라이브러리들이 파싱된 객체를 다른 객체와 머지(merge)할 때 터지는 유명한 취약점이다. 요청 본문에 다음 한 줄만 넣으면 끝이다.

{ "__proto__": { "isAdmin": true } }

마지막으로 JSON 폭탄. 수 메가바이트짜리 JSON을 파싱하면 그보다 몇 배는 큰 객체 그래프가 메모리에 만들어진다. 배열이 배열을 품고, 또 품고, 수백만 개의 요소가 중첩되면 스택이나 힙이 넘쳐 서비스가 다운된다. 파싱 전에 콘텐츠 길이를 제한하고, 중첩 깊이를 제한하는 것이 관례가 되었다.

이 모든 교훈이 오늘날의 단순한 규칙 하나로 응축되었다. 절대 eval로 파싱하지 말고, 진짜 파서를 쓰고, 입력 크기와 깊이를 제한하고, 프로토타입에 머지하는 라이브러리를 피하라. 어렵지 않은 규칙이다. 그런데 이 규칙들은 전부 누군가의 피로 배운 것이다.

검증과 확장: Schema, JSON5

JSON은 그 자체로 "구조가 맞는지"만 보장하지, "값이 올바른지"는 보장하지 않는다. {"age": "30"}은 문법상 완벽한 JSON이다. 나이가 숫자여야 한다는 사실은 그 어디에도 없다. 이 텍스트가 어떤 API의 응답이라면, 클라이언트는 숫자를 기대하다가 문자열을 받고 버그를 맞이한다.

이런 검증의 필요성 때문에 JSON Schema가 태어났다. JSON으로 JSON의 구조를 정의하는 메타 언어다. 타입, 필수 필드, 범위, 정규식까지 표현할 수 있다.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["name", "age"],
  "properties": {
    "name": { "type": "string", "minLength": 1 },
    "age": { "type": "integer", "minimum": 0 }
  }
}

이 스키마가 있으면 검증 라이브러리(Ajv 같은)가 문서를 검사하고, API 계약을 문서화하고, 심지어 TypeScript 타입을 생성하는 데까지 쓸 수 있다. 다만 JSON Schema는 그 자체로 상당히 복잡한 세계라서, 대부분의 서비스는 "그때그때 수동 검증"으로 만족한다. 스키마는 선택지일 뿐 필수는 아니다.

그리고 사람들은 실용적인 확장을 시도했다. 표준을 어긴, 실용적인 해킹들이다.

JSON5. 주석, 후행 쉼표, 따옴표 없는 키, 작은따옴표, Infinity/NaN까지 허용한다. "좋은 점은 살리고 짜증나는 부분은 없앤 JSON"을 표방한다. 설정 파일이나 브라우저 저장소 같은 곳에 쓰이며, 과거에는 JSON.parse를 지원하지 않던 구형 브라우저를 위한 폴리필로도 쓰였다.

// JSON5는 이런 것이 된다
{
  name: '철수',
  active: true,
  balance: Infinity,     // 그리고 이렇게도 된다
  tags: ['a', 'b',],     // 후행 쉼표도
}

JSONC. "JSON with Comments"의 줄임말로, 주석만 허용하는 가장 보수적인 확장이다. VS Code의 설정 파일(settings.json)이 이 형식을 쓴다. 주석 외에는 전부 표준 JSON이라서 전환 비용이 거의 없다.

HJSON. 사람 친화적인 JSON을 지향하며 주석과 따옴표 없는 키를 허용한다.

이것들은 어느 것도 표준이 아니다. 파서 호환성 문제가 있어서, 주고받는 통신 채널에는 절대 쓸 수 없다. 하지만 설정 파일처럼 "사람이 직접 편집하고, 프로그램이 읽기만 하는" 곳에서는 지루한 제약을 풀고 싶은 욕구가 계속 나타난다. 그 욕구의 존재 자체가 JSON의 한계를 증명한다.

한계와 불만들

JSON을 오래 쓰다 보면 쌓이는 불만이 있다. 객관적으로 인정할 수밖에 없는 것들이다.

주석이 없다. 설정 파일에서 주석이 없으면, 옵션 하나를 꺼야 할 때 그 의미를 설명할 길이 없다. 그래서 JSON5, JSONC, YAML이 자꾸 등장하는 것이다. "설정 파일이 JSON이면 주석이 안 되니, 문서로 옵션을 설명해야 한다"는 개발자의 탄식은 어디서나 들린다.

날짜 타입이 없다. 날짜를 표현하는 표준적인 방법이 없다. 그래서 전부 ISO 8601 문자열로 저마다 고안해서 쓰는데, 시간대 해석과 파싱 문제가 매번 되풀이된다. date-fns나 Joda 같은 날짜 라이브러리가 필수품이 된 것도 이 빈자리 때문이다.

숫자 정밀도. 숫자는 IEEE-754 배정밀도 부동소수점(double)이다. 이 표현으로는 2^53을 넘는 정수를 정확히 담을 수 없다. 은행 계좌 잔액이나 주문 ID 같은 큰 정수를 JSON으로 주고받다가 정밀도가 깨지는 일은 실제로 자주 발생한다. 그래서 큰 정수는 문자열로 보내라는 관례가 생겼다. 그 관례가 생겼다는 것 자체가 결함의 증거다.

이진 데이터를 못 담는다. 바이너리는 문자열로 변환해야 하는데, base64로 감싸면 용량이 약 33% 늘어난다. 이미지나 파일을 JSON에 담으면 데이터가 그만큼 부풀어 오른다.

장황하다. 이름표와 구조 기호가 반복되는 텍스트라서, 같은 데이터를 담아도 이진 포맷보다 훨씬 크다. 다행히 gzip 같은 압축과 궁합이 아주 좋아서, 실제 전송 단계에서는 크기 부담이 크게 느껴지지 않는 경우가 많다.

깊은 중첩. 객체가 객체를 수백 층 감싸면 재귀 파서의 스택이 넘친다. 방어적으로 깊이 제한을 두는 파서도 있지만, 아닌 것도 있다. 입력의 깊이를 검증하지 않으면 서비스가 죽는다.

중복 키. 앞서 말했듯 동작이 정의되어 있지 않다. 파서마다 결과가 달라질 수 있고, 이를 은근히 의존하는 코드는 나중에 반드시 문제가 된다.

저마다 다시 발명하는 바퀴. 날짜 문자열, 오류 코드, 지역화된 메시지, 아이디 규칙... 모두가 각자 방식으로 고안한다. 포맷이 너무 단순해서 계약을 문서화할 기준이 없고, 그래서 모든 팀이 비슷한 바퀴를 다시 굴린다. JSON은 "어떻게 쓸지"를 거의 강제하지 않기 때문에, 쓰는 사람마다 미묘하게 다른 JSON을 만든다.

이 한계들은 단순함이라는 강점의 반대편에 있는 것들이다. 단순하기에 빠르게 채택되었고, 널리 쓰이기에 그 한계가 더 선명하게 보이는 것이다.

대안들

JSON이 완벽하다고 생각하는 사람은 없다. 그래서 대안도 꾸준히 나온다. 솔직하게 비교해보자.

YAML. 사람 친화적으로 설계된 직렬화 포맷. 설정 파일에서는 사랑받지만, 문법이 은근히 까다롭다. 들여쓰기 하나가 전체 구조를 바꾸고, yes/no 같은 단어가 어떤 파서에서는 불리언으로, 어떤 파서에서는 문자열로 해석된다. "YAML은 JSON보다 200% 편하지만, 200% 까다롭다"는 농담이 있다. 사용자가 직접 편집하는 설정 파일이라면 고려할 만하지만, 기계 간 통신에는 부적합하다.

TOML. 설정 전용 포맷. 계층을 인라인 테이블과 배열로 표현한다. TOML이 JSON의 부분집합인 것도, 그 반대인 것도 아니지만, 기본 자료구조가 겹치기 때문에 한쪽에서 다른 쪽으로 옮겨 가는 것은 어렵지 않다. Cargo.toml, pyproject.toml, go.mod가 이 형식이다. 설정 파일을 위한 자리가 있다면 TOML이 가장 이성적인 선택이다.

[package]
name = "my-app"
version = "1.0.0"

[dependencies]
vue = "3.4.0"

Protocol Buffers(protobuf). 구글이 만든 이진 직렬화 포맷. 스키마를 먼저 정의하고 코드를 생성해 쓰며, JSON보다 훨씬 작고 빠르고 타입이 강하다. 대신 스키마 관리와 코드 생성 단계가 추가된다. 마이크로서비스 간 고성능 통신이 필요하다면 그 비용을 지불할 가치가 있다.

MessagePack / CBOR. 구조는 JSON과 비슷하지만 이진 형식이라 용량이 훨씬 작고 빠르다. 스키마 없이 이진 효율만 원한다면 이쪽이 적합하다. CBOR는 RFC 8949로 표준화되었고, 특히 IoT 영역에서 주목받는 형식이다.

BSON. MongoDB가 사용하는 이진 변형으로, JSON에 Date나 ObjectId 같은 타입을 추가했다. MongoDB 바깥에서는 거의 쓰이지 않는다.

이 선택지들은 각자 자기 목적이 있다. 하지만 대부분의 애플리케이션에서 "JSON으로 시작하고, 문제가 생기면 그때 바꾼다"가 가장 현실적인 전략이다. 전환 비용이 낮아서인지, 대부분의 경우 그 "그때"는 오지 않는다.

마무리: 지루한 포맷이 오래 산다

JSON은 25년째 살아 있다. 화려한 기능은 하나도 없다. 주석도, 날짜 타입도, 타입 시스템도 없다. 그런데도 인터넷의 모든 브라우저와 서버가 주고받는 기본 포맷은 여전히 JSON이다.

이유는 간단하다. 충분히 좋고, 어디에나 있고, 죽도록 단순하다. 배우는 데 10분이 걸리고, 어떤 언어에서도 지원되며, 디버깅할 때 그냥 눈으로 읽을 수 있다. 도구가 필요 없다. 이 특징을 동시에 갖춘 데이터 포맷은 아직 없다.

무언가 다른 걸 쓰고 싶어질 때도 있다. 이진 성능이 필요하다면 protobuf를, 설정 파일이 사람 손을 많이 타면 TOML이나 YAML을, 로그를 스트리밍한다면 JSONL을 쓰면 된다. JSON이 만능은 아니다. 그게 이 글의 결론이기도 하다.

하지만 그렇다고 해서, 이 글을 쓰는 2026년에도 대부분의 REST API와 설정 파일과 저장소가 여전히 JSON을 쓰고 있다는 것은 놀라운 일이 아니다. 새로운 포맷이 나올 때마다 "이번엔 JSON을 이긴다"는 말이 나왔지만, 지금까지 이긴 것은 없다. 자극적이지 않고, 화려하지 않고, 그저 작동하는 포맷. 지루한 포맷이 오래 산다는 교훈을 JSON이 몸소 보여주고 있다. 그리고 솔직히 말하면, 나는 다음 몇 년간도 그 자리가 바뀌지 않을 것이라고 본다.

JSON이란 무엇인가 | ToolAct Blog | ToolAct