본문 바로가기
Converter

JSON 포맷터 · 표 변환

JSON을 정렬하고, 배열은 표로 확인

입력 0 B

포맷터가 “성공”이라고 말해도 데이터는 이미 망가졌을 수 있다

JSON 도구를 쓸 때 우리가 확인하는 건 대개 하나입니다. 빨간 오류가 뜨는가, 안 뜨는가. 오류가 없으면 통과라고 생각합니다.

그런데 이 입력을 보세요.

JSON.parse('{"id": 9007199254740993}')

// → { id: 9007199254740992 }
//                        ↑ 3이 2가 됐다

예외도, 경고도, 콘솔 메시지도 없습니다. 파싱은 성공했습니다. 그저 값이 다를 뿐입니다.

이유는 JSON 명세에 있습니다. JSON에는 정수 타입이 따로 없습니다. 숫자는 하나뿐이고, JavaScript는 그걸 IEEE-754 배정밀도 부동소수점(double)으로 읽습니다. double이 정확하게 표현할 수 있는 정수의 상한은 253-1, 즉 9007199254740991입니다. 약 9007조입니다. 이 값을 넘어가면 표현 가능한 정수가 띄엄띄엄해지기 시작해서, 홀수를 담을 수 없게 됩니다. 그래서 ...993은 가장 가까운 짝수인 ...992로 반올림됩니다.

9007조는 생각보다 자주 넘는다

“9000조를 넘을 일이 있나?” 싶지만, 이건 금액이 아니라 ID에서 터집니다.

  • 스노플레이크 ID: 트위터가 만들고 디스코드 등이 채택한 64비트 ID입니다. 타임스탬프 41비트 + 워커 10비트 + 시퀀스 12비트 구조라 19자리 숫자가 나옵니다. 2^53은 16자리입니다. 처음부터 안전 범위 밖입니다.
  • DB의 BIGINT 기본키: 64비트입니다. 값이 작을 땐 멀쩡히 돌다가, 시퀀스가 커지거나 눈속임으로 큰 값에서 시작하도록 설정한 순간 조용히 깨집니다. 스테이징에서는 안 터지고 운영에서만 터지는 전형적인 형태입니다.
  • 외부 플랫폼의 사용자 식별자: 카카오·네이버 같은 로그인 연동에서 받는 숫자 ID도 64비트 범위를 씁니다.

이 손실의 최악인 점은 실패가 아니라는 것입니다. 프론트에서 받은 ID로 다시 조회를 걸면 404가 나옵니다. 로그를 봐도 요청은 정상이고, 눈으로 보면 숫자도 그럴듯합니다. 마지막 한 자리가 다르다는 걸 알아채기 전까지 원인을 못 찾습니다.

그래서 성숙한 API는 ID를 문자열로 줍니다. 트위터 API는 같은 값을 id(숫자)와 id_str(문자열)로 함께 내려주고, 문서에서 숫자 쪽을 쓰지 말라고 명시합니다. 스트라이프는 애초에 ID를 cus_로 시작하는 문자열로 발급합니다. ID는 연산할 값이 아니라 식별자이므로 문자열이어도 잃는 게 없습니다.

받는 쪽에서 이미 숫자로 오는 걸 막을 수 없다면, 방법은 JSON.parse 이전에 텍스트 단계에서 처리하는 것뿐입니다. json-bigint 같은 라이브러리가 이 일을 합니다. 위 변환기가 원문을 따로 훑어 경고를 띄우는 이유도 같습니다 — 파싱된 객체를 아무리 들여다봐도 그 정보는 이미 사라진 뒤입니다.

JSON은 JavaScript가 아니다

이름 때문에 JavaScript의 부분집합처럼 느껴지지만, 실무에서는 “JS에서 되는 게 JSON에서 안 되는” 쪽으로 부딪힙니다.

{
  name: '홍길동',      // ✗ 키에 따옴표 없음, 값에 홑따옴표
  age: undefined,      // ✗ undefined 라는 리터럴이 없음
  score: NaN,          // ✗ NaN·Infinity 없음
  tags: ["a", "b",],   // ✗ 후행 쉼표
  // 메모                ✗ 주석 없음
}

전부 JavaScript에서는 유효하고 JSON에서는 오류입니다. 키는 반드시 큰따옴표로 감싼 문자열, 문자열은 큰따옴표만, 후행 쉼표 금지, 주석 금지, undefined·NaN·Infinity 없음입니다.

여기서 사람들이 가장 많이 걸려 넘어지는 지점이 설정 파일입니다. tsconfig.json에는 주석이 들어갑니다. VS Code의 settings.json도 마찬가지입니다. 확장자가 .json인데 주석이 되니까 “JSON에 주석 써도 되는구나”라고 결론 내리기 쉽습니다.

아닙니다. 그건 JSONC라는 다른 형식이고, TypeScript와 VS Code가 자체 파서로 읽는 것뿐입니다. 같은 파일을 CI 스크립트에서 JSON.parse로 읽으면 그 자리에서 터집니다. 주석 금지가 불편해서 JSON5·JSONC·YAML·TOML이 각각 생겨난 것이고, 이 도구를 포함한 표준 파서는 원래의 엄격한 규칙을 따릅니다.

JSON.stringify는 조용히 값을 버린다

읽는 쪽만 문제가 아닙니다. 쓰는 쪽도 소리 없이 데이터를 지웁니다.

JSON.stringify({ a: undefined, b: () => {}, c: 1 })
// → '{"c":1}'          a 와 b 는 아예 사라졌다

JSON.stringify([undefined, () => {}, 1])
// → '[null,null,1]'    배열에서는 null 이 된다 (사라지면 길이가 바뀌니까)

JSON.stringify({ d: new Date() })
// → '{"d":"2026-07-17T00:00:00.000Z"}'   문자열이다

JSON.stringify({ n: NaN, i: Infinity })
// → '{"n":null,"i":null}'

객체에서는 사라지고 배열에서는 null이 되는 이 비대칭이 핵심입니다. 배열에서 요소가 사라지면 길이와 인덱스가 밀리기 때문에 자리를 채워야 하고, 객체는 키를 그냥 빼면 되기 때문입니다.

그래서 JSON.parse(JSON.stringify(obj))깊은 복사가 아닙니다. 오랫동안 관용구처럼 쓰였지만, Date가 문자열로 바뀌고 undefined 필드가 사라지는 “복사”는 원본과 다른 객체입니다. 지금은 structuredClone(obj)이 모든 최신 브라우저와 Node 17 이상에 있습니다. Date·Map·Set·순환 참조까지 처리합니다.

키 순서에는 규칙이 하나 숨어 있다

JSON 객체는 명세상 순서 없는 집합입니다. 그런데 JSON.stringify는 실제로는 삽입 순서를 유지합니다 — 단 하나의 예외가 있습니다.

JSON.stringify({ "2": "나", "1": "가" })
// → '{"1":"가","2":"나"}'   순서가 뒤집혔다

JSON.stringify({ "b": 1, "a": 2 })
// → '{"b":1,"a":2}'         이건 그대로다

정수처럼 생긴 키는 JavaScript 객체가 항상 숫자 오름차순으로 앞에 몰아 놓기 때문입니다. JSON의 규칙이 아니라 JS 객체의 규칙이고, stringify는 그 결과를 그대로 받아 적을 뿐입니다. 연도별·ID별 데이터를 객체 키로 쓰는 코드에서 순서가 예상과 다르게 나오는 원인이 대개 이것입니다. 순서가 의미를 가져야 한다면 객체가 아니라 배열이나 Map을 써야 합니다.

정렬은 사람을 위해, 압축은 회선을 위해 — 그런데 회선은 이미 압축한다

정렬(pretty)이 필요한 이유는 명확합니다. 읽으려고, 그리고 diff를 내려고입니다. 한 줄짜리 JSON을 diff에 넣으면 “1줄이 바뀜”이라고만 나옵니다. 줄바꿈이 있어야 어디가 바뀌었는지 보입니다. 코드 리뷰에 JSON이 들어간다면 정렬은 선택이 아닙니다.

반대로 압축(minify)은 전송을 위한 것인데, 여기엔 흔한 오해가 있습니다. HTTP 응답은 이미 gzip이나 brotli로 압축되어 갑니다. 그리고 들여쓰기 공백은 같은 패턴이 계속 반복되는 데이터라, 압축 알고리즘이 세상에서 가장 잘 지우는 종류입니다. 원문 기준으로 30% 줄어드는 정렬 공백이 압축 후에는 훨씬 작은 차이로 줄어듭니다.

그러니 API 응답을 minify해서 대역폭을 아끼겠다는 계획은 대체로 기대만큼의 값을 주지 않습니다. 압축이 실제로 의미 있는 자리는 gzip이 걸리지 않는 경로입니다: localStorage에 넣는 값, 쿠키, 로그 한 줄, 압축을 끈 내부 통신, 크기 제한이 걸린 큐 메시지. 여기서는 바이트가 그대로 바이트입니다.

오류는 “몇 번째 글자”로 오지, “몇 번째 줄”로 오지 않는다

JSON.parse가 실패하면 던지는 메시지는 대략 이렇습니다.

Unexpected token } in JSON at position 42
Expected ',' or '}' after property value in JSON at position 42 (line 3 column 5)
Unexpected end of JSON input
JSON.parse: unexpected character at line 2 column 7 of the JSON data

전부 다른 형식입니다. 위에서부터 구형 V8, 신형 V8, 위치가 아예 없는 경우, Firefox입니다. 같은 크롬이어도 버전에 따라 다르고, 이 문구들은 명세로 정해진 게 아니라 엔진 구현 세부라서 언제든 또 바뀝니다.

그래서 위 변환기는 position(문자 오프셋)을 우선 읽어 직접 줄:칸으로 환산해 해당 줄에 캐럿을 찍습니다. 오프셋이 없으면 엔진이 준 줄:칸을 쓰고, 그것도 없으면 엔진 메시지를 원문 그대로 보여줍니다. 위치를 모르는데 아무 데나 캐럿을 찍는 것이 제일 나쁜 결과이기 때문입니다.

참고로 Unexpected end of JSON input은 위치가 없는 메시지인데, 이 오류의 원인은 거의 항상 정해져 있습니다. 괄호를 안 닫았거나, 빈 응답 본문(204 No Content 또는 빈 문자열)을 그대로 res.json()에 넘긴 경우입니다. 후자라면 JSON이 깨진 게 아니라 애초에 JSON이 오지 않은 것입니다.

자주 묻는 질문

JSON에 주석을 달 수 없나요?
표준 JSON에는 주석 문법이 없습니다. Douglas Crockford가 의도적으로 뺐는데, 주석 자리에 파싱 지시문을 넣어 쓰는 사람들이 생겨 상호운용성이 깨질 것을 우려했기 때문입니다. 그런데 tsconfig.json이나 VS Code의 settings.json에는 주석이 들어갑니다. 그건 JSON이 아니라 JSONC(JSON with Comments)라는 별개 형식이고, 해당 도구가 자체 파서로 읽기 때문입니다. 같은 파일을 JSON.parse에 넣으면 그대로 실패합니다.
숫자 ID가 마지막 자리만 다르게 나옵니다. 포맷터 버그인가요?
포맷터가 아니라 JSON의 숫자 정의 때문입니다. JSON 숫자는 IEEE-754 배정밀도 부동소수점으로 읽히고, 이 형식이 정확히 표현할 수 있는 정수의 한계는 2^53-1(9007199254740991, 약 9007조)입니다. 이 값을 넘는 정수는 가장 가까운 표현 가능한 값으로 반올림되며, JSON.parse는 이 사실을 예외로도 경고로도 알려주지 않습니다. 위 변환기는 원문 텍스트를 따로 훑어서 이런 숫자를 찾아내 경고로 표시합니다.
같은 키가 두 번 있는 JSON은 유효한가요?
문법(grammar)만 놓고 보면 유효합니다. RFC 8259는 키가 유일해야 한다고 SHOULD로만 권고하고 MUST로 금지하지 않습니다. 그래서 파서마다 처리가 갈립니다. JavaScript는 마지막 값을 남기고, Python의 json 모듈도 마지막을 남기지만, 어떤 구현은 오류를 내고 어떤 구현은 첫 값을 남깁니다. 서비스 간에 JSON이 오갈 때 한쪽에서만 값이 바뀌는 형태의 사고가 나기 쉬워서, 위 변환기는 중복 키를 따로 찾아 알려줍니다.
JSON.parse(JSON.stringify(obj))로 깊은 복사를 해도 되나요?
권하지 않습니다. stringify 과정에서 undefined 값과 함수, Symbol 키는 객체에서 아예 사라지고, 배열 안에서는 null로 바뀝니다. Date는 ISO 문자열이 되어 되돌아올 때 Date가 아니라 문자열입니다. NaN과 Infinity도 null이 됩니다. 순환 참조가 있으면 예외가 납니다. 지금은 모든 최신 브라우저와 Node 17 이상에 structuredClone이 있으므로 그쪽을 쓰는 편이 정확합니다.
응답 JSON을 압축(minify)하면 트래픽이 많이 줄어드나요?
생각보다 적게 줄어듭니다. HTTP 응답은 대부분 gzip이나 brotli로 압축되어 전송되는데, 들여쓰기 공백은 같은 패턴이 반복되는 데이터라 압축 알고리즘이 가장 잘 지우는 대상입니다. 압축 후 크기 차이는 원문 차이보다 훨씬 작아집니다. 압축이 의미 있는 곳은 gzip이 걸리지 않는 경로 — 로컬 스토리지, 쿠키, 로그 한 줄, 압축을 끈 내부 통신 — 쪽입니다.
키 정렬은 언제 쓰나요?
두 JSON을 diff로 비교할 때 씁니다. 배포 전후의 API 응답을 비교하는데 키 순서만 달라서 전부 변경된 것처럼 보이는 상황이 흔합니다. 양쪽 모두 키를 정렬해 두면 실제로 값이 바뀐 줄만 남습니다. JSON 객체는 명세상 순서가 없는 자료구조이므로 정렬해도 데이터가 훼손되지 않습니다. 다만 배열은 순서 자체가 데이터이므로 이 도구는 배열을 절대 정렬하지 않습니다.