본문으로 건너뛰기
jsonbeautifiers
한국어

JSON에 주석이 없는 건 일부러 그렇게 만든 것입니다

주석은 상호운용성을 지키려고 JSON에서 빠졌고, 그 대가는 그 이후의 모든 설정 파일이 치르고 있습니다.

이 페이지의 모든 주장은 직접 측정했거나 출처가 있습니다. 둘 다 아닌 경우에는 그렇다고 명시합니다.

타임아웃이 30초가 아니라 45초인 이유를 설정 파일에 한 줄 적어 넣었더니 배포가 깨지고, 메시지는 아무 도움도 되지 않습니다.

JSON.parse('{\n  // 45s: upstream p99 is 38s\n  "timeout": 45\n}')
// Expected property name or '}' in JSON at position 4 (line 2 column 3)

파서는 키가 와야 할 자리에서 슬래시를 보고 포기했습니다. 파이썬도 원인에 대해 더 친절하지는 않습니다.

json.loads('{\n  // 45s: upstream p99 is 38s\n  "timeout": 45\n}')
# JSONDecodeError: Expecting property name enclosed in double quotes: line 2 column 3 (char 4)

두 메시지 어느 쪽도 주석을 언급하지 않습니다. 문법의 관점에서는 언급할 것이 아예 없기 때문입니다. RFC 8259가 토큰 사이에 올 수 있다고 정한 문자는 정확히 넷, 공백, 탭, 캐리지 리턴, 라인 피드뿐입니다. 나머지는 전부 값의 일부이거나 문법 오류입니다.

왜 제거되었나

주석은 초기 JSON에 들어 있었고, Douglas Crockford가 그것을 뺐습니다. 그가 밝힌 이유가 흥미로운 대목입니다. 사람들은 주석을 산문이 아니라 파싱 지시문을 실어 나르는 데 쓰고 있었습니다. 인코딩 힌트나 스키마를 가리키는 포인터를 주석에 적어 두고, 특정 소비자가 그걸 읽어 실행하는 식이었죠. 그 순간부터 주석은 더 이상 주석이 아닙니다. 문서화되지 않은 두 번째 데이터 채널이고, 그것이 실려 다니는 형식은 하필이면 “어디의 어떤 파서든 같은 바이트에서 같은 값을 읽는다”는 것을 유일한 강점으로 내세운 형식이었습니다.

Crockford 본인이 제시한 우회책은, 주석이 붙은 파일을 파서에 넘기기 전에 미니파이어에 통과시키라는 것이었습니다. 지금도 이것이 답의 올바른 형태이고, 이 글의 나머지는 대체로 그걸 제대로 해내는 방법에 관한 이야기입니다.

초기의 JSON이 무엇이었는지를 생각하면 그 결정은 방어할 만합니다. 의미에 대해 이미 합의한 두 프로그램 사이에서 값을 옮기는 전송 형식이었으니까요. 네트워크 패킷에 주석을 다는 사람은 없습니다.

그래도 아픈 이유

JSON은 전송 형식으로 남지 않았습니다. 툴체인 전체의 기본 설정 언어가 되었고, 설정이야말로 값 자체보다 값 뒤의 판단 근거가 더 중요한 영역입니다. 설명 없는 retries: 0은 다음 사람이 선의로 “고쳐” 놓습니다. 위에 // 의도한 것. 이 엔드포인트는 멱등하지 않음이 붙은 retries: 0은 그렇지 않고요.

그래서 설정에 JSON을 채택한 생태계마다 각자의 덧붙임을 만들어 냈고, 그것들은 서로 호환되지 않습니다.

다섯 가지 선택지

_comment

{
  "_comment": "상류 p99가 38초라서 45초",
  "timeout": 45
}

엄격한 JSON이고, 어디서나 파싱되며, 도구가 전혀 필요 없습니다. 다만 문제는 실재합니다. 이제 스키마가 이걸 허용해야 하고, 아니면 검증기가 거부합니다. 이건 데이터라서 클라이언트로 나가고, 로그에 남고, diff에서는 주석 변경이 아니라 값 변경으로 보입니다. 게다가 객체마다 사실상 하나만 둘 수 있습니다. RFC 8259는 키가 고유해야 한다(SHOULD)고만 말하고 중복은 정의하지 않았으며, JavaScript와 파이썬 모두 마지막 것을 취하므로 같은 층위에 둔 두 번째 _comment가 첫 번째를 조용히 삼킵니다. 사람들은 _comment1, _comment2로 우회하는데, 거기까지 가면 이 방식은 더 이상 남는 장사가 아닙니다.

파일 맨 위의 머리말 성격의 메모에 쓰세요. 줄 단위 주석에는 쓰지 마세요.

JSONC

JSONC는 JSON에 두 가지를 더한 것입니다. ///* */ 주석, 그리고 끝 쉼표. 그게 전부입니다. VS Code가 자신의 settings.jsonkeybindings.json에 쓰는 것이 이것이고, TypeScript가 tsconfig.json에서 받아 주는 것도 이것입니다.

{
  // 상류 p99는 38초
  "timeout": 45,
  "retries": 0, // 이 엔드포인트는 멱등하지 않음
}

위상에 대해서는 솔직할 필요가 있습니다. JSONC에는 독립된 명세가 없습니다. RFC도, 버전 번호도, 적합성 테스트 모음도 없습니다. 에디터라는 형태의 구현이 뒤에 있는 관행이고, 가장자리에서는 방언이 갈립니다(배열 마지막 요소 뒤의 끝 쉼표를 허용하는지, 주석이 왕복에서 살아남는지). 소비하는 쪽이 이미 이를 지원하는 도구라면 가장 안전한 선택지이고, 제3자에게 넘기는 것에는 나쁜 선택지입니다.

JSON5

JSON5는 버전 이력을 가진 진짜 명세이고, JSONC보다 훨씬 멀리 나갑니다.

  • 키가 유효한 ES5 식별자면 객체 키를 따옴표 없이 쓸 수 있음
  • 작은따옴표 문자열
  • 객체와 배열의 끝 쉼표
  • 줄 주석과 블록 주석
  • 16진수
  • 앞뒤에 오는 소수점, 즉 .55.가 숫자가 됨
  • Infinity, -Infinity, NaN

곱씹어 봐야 할 건 마지막 항목입니다. NaN과 무한대는 JSON에 표현이 없어서, 그것을 쓰는 JSON5 문서는 대신 무엇을 넣을지에 관한 손실 있는 결정 없이는 JSON으로 변환할 수 없습니다. 나머지 확장은 표기상의 것이라 변환을 무난히 통과합니다. 파일의 주된 저자가 사람이고 .json5 확장자가 받아들여지는 상황이면 JSON5를 쓰고, API 형식으로는 쓰지 마세요.

JSON을 그만 쓰기

그 파일이 처음부터 끝까지 여러분이 통제하는 설정이고 외부에서 소비하지 않는다면, 형식은 자유로운 선택이며 JSON이 명백히 최선인 것도 아닙니다. YAML과 TOML은 주석을 일급 기능으로 가지고 있습니다. 둘 다 나름의 비용이 있으니 결정하기 전에 비교 글을 읽어 볼 만합니다. 특히 YAML은 이른바 노르웨이 문제를 안겨 줍니다. PyYAML과 루비의 Psych가 구현하는 YAML 1.1 의미론에서는 따옴표 없는 no가 불리언 false로 해석됩니다.

빌드 시점에 제거하기

주석이 달린 파일을 진실의 원본으로 두고, CI에서 주석을 제거하고, 엄격한 JSON을 배포하세요. Crockford가 제시한 방법이고 모든 요구를 만족시킵니다. 편집자와 리뷰어는 주석을 보고, 실행 시점의 파서는 RFC 8259를 만족하는 문서를 보며, 어떤 소비자도 두 형식 중 어느 쪽이 존재하는지 알 필요가 없습니다.

URL을 망가뜨리지 않고 주석 제거하기

가장 먼저 떠오르는 구현은 정규식이고, 가장 먼저 떠오르는 정규식은 틀렸습니다.

// 이렇게 하지 마세요.
text.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');

이걸 아래에 돌려서 값이 부서지는 장면을 보세요.

{
  "endpoint": "https://api.example.com/v2/orders", // 프로덕션
  "note": "바꾸기 전에 /* 런북 */ 을 참고"
}

첫 번째 규칙은 https:// 안의 //를 찾아 닫는 따옴표와 쉼표까지 포함해 그 줄의 나머지를 지웁니다. 두 번째 규칙은 문자열 안의 블록 주석을 찾아냅니다. 남는 건 끝나지 않은 문자열과, 전혀 상관없는 위치를 가리키는 오류입니다. 정규식은 이 일을 해낼 수 없습니다. 슬래시가 문자열 안에 있는지 알 방법이 없고, JSON에서 문자열 안팎을 가르는 것은 이스케이프를 세는 일에 달려 있기 때문입니다.

필요한 건 상태를 정확히 하나만 들고 훑는 스캐너입니다.

function stripJsonComments(text) {
  let out = '';
  let inString = false;
  let inLine = false;
  let inBlock = false;

  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    const next = text[i + 1];

    if (inLine) {
      if (c === '\n') { inLine = false; out += c; }
      continue;
    }
    if (inBlock) {
      // 줄 번호가 어긋나지 않도록 개행은 남긴다
      if (c === '*' && next === '/') { inBlock = false; i++; }
      else if (c === '\n') { out += c; }
      continue;
    }
    if (inString) {
      out += c;
      if (c === '\\') { out += next; i++; continue; }  // 이스케이프, 두 글자를 함께 소비
      if (c === '"') inString = false;
      continue;
    }
    if (c === '"') { inString = true; out += c; continue; }
    if (c === '/' && next === '/') { inLine = true; i++; continue; }
    if (c === '/' && next === '*') { inBlock = true; i++; continue; }
    out += c;
  }
  return out;
}

사람들이 빼먹는 부분이 이스케이프 분기입니다. 이게 없으면 "그는 오늘 \"https://example.com 으로 가라\"고 했다"의 이스케이프된 따옴표가 문자열의 끝으로 읽히고, 뒤따르는 //가 주석의 시작으로 간주되어 그 줄의 나머지가 사라집니다.

이것이 하지 않는 일도 함께 보세요. 주석을 걷어내도 끝 쉼표는 남고, 그쪽은 그쪽대로 자기 오류를 냅니다. V8은 {"a":1,}에 대해 Expected double-quoted property name in JSON at position 7 (line 1 column 8)을, [1,2,]에 대해서는 전혀 다른 Unexpected token ']', "[1,2,]" is not valid JSON을 돌려줍니다. JSONC에서 JSON으로의 변환은 둘 다 처리해야 합니다.

스캐너를 직접 떠안고 싶지 않다면 파일을 JSON 복구 도구에 붙여 넣으세요. 주석과 끝 쉼표를 한 번에 걷어내고 엄격한 JSON을 돌려줍니다. 그다음 결과를 검증기로 확인하세요. 둘 다 전적으로 브라우저 안에서 돌아가는데, 고치려는 파일이 자격 증명이 들어 있는 프로덕션 설정일 때 이 점이 중요해집니다. 원인이 주석이 전혀 아니었을 때 어떻게 해야 하는지는 나머지 오류 페이지가 다룹니다.