← 전체 글

JSON 문자열 리터럴의 잘못된 제어 문자: 수정 방법

JSON 문자열 내부의 원시 탭, 개행, null 바이트, ANSI 이스케이프가 이 오류를 일으킨다. JSON 명세가 금지하는 이유, 어떻게 섞여 들어오는지, 제거/이스케이프 방법을 배운다.

SyntaxError: Bad control character in string literal in JSON at position N 은 JSON 문자열 안에 이스케이프되지 않은 원시 제어 문자 —— 탭, 줄바꿈, 캐리지 리턴, 또는 U+0000–U+001F 범위의 어떤 문자 —— 가 있다는 뜻입니다. JSON 명세는 이를 금지합니다. 이 글은 왜 그렇고, 어디서 오며, 어떻게 없애는지 설명합니다.

어떤 문자열 오류를 보고 있나요?

제어 문자란?

Unicode 의 처음 32 개 코드 포인트(U+0000~U+001F)는 제어 문자 —— 텔레타이프 시대에서 물려받은 보이지 않는 포매팅 코드입니다. 익숙한 것들:

코드 포인트이름JSON 이스케이프
U+0000Null
U+0008Backspace\b
U+0009수평 탭\t
U+000ALine feed(줄바꿈)\n
U+000CForm feed\f
U+000DCarriage return\r
U+001BEscape(ANSI 시퀀스의 시작)

JSON 명세(RFC 8259 §7)는 명시적입니다: 이 범위의 모든 문자가 JSON 문자열 안에 나타나면 반드시 이스케이프되어야 합니다. 따옴표 안의 원시 줄바꿈은 값이 아니라 구문 오류입니다.

왜 원시 줄바꿈이 JSON 을 깨뜨리나

실제 줄바꿈 문자가 들어 있는 문자열 값을 생각해 보세요:

// 바이트가 어떻게 보이는지 (줄바꿈은 리터럴, \n 이 아님):
{"message":"line one
line two"}

JSON.parse('{"message":"line one\nline two"}')
// SyntaxError: Bad control character in string literal in JSON at position 20

파서는 여는 큰따옴표를 보고, 문자를 읽다가, 원시 0x0A 바이트를 만납니다. JSON 문자열은 같은 논리 줄 위에서 두 큰따옴표 사이에 끊김 없는 문자 시퀀스여야 하므로, 맨몸의 줄바꿈이 문자열을 예기치 않게 종료시킵니다.

올바른 표현은 두 문자 이스케이프 시퀀스 \n 을 쓰는 것:

// 올바름 —— \n 은 이스케이프이지 리터럴 줄바꿈이 아님
{"message":"line one\nline two"}

JSON.parse('{"message":"line one\nline two"}') // ✓ 동작

제어 문자가 JSON 에 끼어드는 경로

1. 템플릿 리터럴이나 결합으로 JSON 문자열 만들기

const note = `first line
second line`;                   // 템플릿 리터럴의 실제 줄바꿈

const json = `{"note":"${note}"}`; // 원시 줄바꿈이 문자열에 임베드됨
JSON.parse(json);               // SyntaxError: Bad control character…

// ✓ 해결: 값에는 항상 JSON.stringify, 수동 보간 절대 금지
const json = JSON.stringify({ note });  // 줄바꿈을 자동 이스케이프

2. 파일을 읽어 내용을 그대로 임베드

import fs from 'fs';
const content = fs.readFileSync('notes.txt', 'utf8');
// content 에 \n, \r\n, \t 등이 있을 수 있음

// ❌ 수동 문자열 빌딩 —— 원시 제어 문자가 임베드됨
const json = '{"content":"' + content + '"}';

// ✓ JSON.stringify 가 모든 이스케이프를 처리
const json = JSON.stringify({ content });

3. 출력을 이스케이프하지 않는 시스템의 API 응답

일부 백엔드 시스템(레거시 PHP 스크립트, 커스텀 직렬화기, JSON 을 수동으로 만드는 DB 트리거)은 이스케이프되지 않은 줄바꿈이나 탭이 들어간 필드 값을 내놓습니다. 받는 건 구문상 잘못된 JSON 이고 response.json() 가 던집니다.

진단은 원시 body 길이와 앞 200 자를 로그. 문자열 값이어야 할 곳에 보이는 줄바꿈이 있는지 확인하세요.

4. 터미널에서 복사-붙여넣기(ANSI 이스케이프 시퀀스)

터미널 출력에는 ANSI 색상 코드가 들어 있는데, Escape 문자(0x1B)로 시작해 [ 가 뒤따릅니다. 터미널 출력을 JSON 문자열에 붙여 넣으면 색상 리셋(\x1B[0m)마다 제어 문자가 됩니다.

// JSON 문자열에 붙여 넣은 터미널 출력:
{"log":"\u001B[32mOK\u001B[0m request processed"}
//      ^^^ 원시 ESC 바이트 —— 6 문자짜리 \u001B 이스케이프 시퀀스여야 함

5. 바이너리 데이터에서 온 null 바이트

바이너리 파일 일부, DB BLOB 컬럼, C 스타일 문자열을 JSON 필드에 읽어 들이면 null 바이트(U+0000)가 임베드될 수 있습니다 —— DB 에서 JSON 으로 가는 파이프라인에서 이 오류의 가장 흔한 원천.

고치는 법

예방: 항상 JSON.stringify 사용

결정적인 해결책입니다. JSON.stringify() 는 모든 제어 문자를 정확히 이스케이프합니다 —— 수동으로 이스케이프할 필요가 전혀 없습니다.

// ✓ userInput 에 뭐가 있든 안전
const json = JSON.stringify({ message: userInput });

수리: 사후에 제거 또는 이스케이프

외부 출처에서 깨진 JSON 을 받고 프로듀서를 고칠 수 없다면, 파싱 전에 원시 문자열을 살균할 수 있습니다. 가장 안전한 방법은 맨몸의 제어 문자를 이스케이프하는 것:

function escapeControlChars(raw) {
  return raw.replace(
    /[\u0000-\u001F]/g,
    (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0')
  );
}

const fixed = escapeControlChars(rawFromApi);
const data  = JSON.parse(fixed);

제어 문자가 의미 있는 데이터가 아니라 노이즈(ANSI 코드, null 바이트)라면 그냥 제거하는 게 더 간단:

// \t, \n, \r(텍스트에 흔함)만 남기고 모든 제어 문자 제거
const cleaned = raw.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '');
const data = JSON.parse(cleaned);

현실의 또 다른 두 가지 원천

  • 위치 0 의 UTF-8 BOM. "UTF-8 with BOM" 으로 저장된 파일은 0xEF 0xBB 0xBF 로 시작합니다. RFC 8259 는 JSON 에서 BOM 을 금지하고 JSON.parse 가 position 0 오류로 거부하는데, 이걸 제어 문자 오류로 잘못 읽기 쉽습니다. 파싱 전에 text.replace(/^/, '') 로 제거하거나, 에디터에서 파일을 "UTF-8"(BOM 없음)으로 저장하세요.
  • VS Code: "Remove Control Characters". 한 파일을 수동으로 정리한다면 VS Code 의 Selection → Remove All Occurrences of Find Match 과 정규식 검색 [\x00-\x08\x0b\x0c\x0e-\x1f](진짜 \t\n\r 은 보존)을 결합하면 보이지 않는 제어 바이트를 한 번에 지웁니다. 자주 한다면 일부 확장에서 제공하는 "Remove Control Characters" 명령도 있습니다.

문제 위치 탐지

오류 메시지에 바이트 위치가 들어 있습니다. 그것으로 무엇이 거기 있는지 확인:

try {
  JSON.parse(raw);
} catch (e) {
  const pos = Number(e.message.match(/position (\d+)/)?.[1]);
  if (!isNaN(pos)) {
    const context = raw.slice(Math.max(0, pos - 20), pos + 20);
    console.log('Context around error:', JSON.stringify(context));
    // JSON.stringify 가 제어 문자를 다시 이스케이프해 보여줌
    // 예: "...line one\nline two..." —— 그 \n 은 원래 원시 줄바꿈
  }
}

자주 묻는 질문

JSON 의 bad control character 가 뭔가요?

JSON 문자열 안에 있는 U+0000~U+001F 범위의 이스케이프되지 않은 원시 문자 —— 탭, 줄바꿈, 캐리지 리턴, null 바이트, 또는 ANSI escape. RFC 8259 §7 은 이런 문자들 모두 이스케이프되어야 한다고 요구합니다 (예: 줄바꿈은 \n).

bad control character 오류를 어떻게 고치나요?

결정적인 해결책은 JSON.stringify() 로 JSON 을 만드는 것 —— 자동으로 제어 문자를 이스케이프합니다. 소스에서 못 고치는 깨진 JSON 을 받는다면 파싱 전에 정규식으로 제어 문자를 이스케이프하거나 제거하세요(위 스니펫 참조).

왜 원시 줄바꿈이 JSON 을 깨뜨리나요?

JSON 문자열은 두 큰따옴표 사이의 끊김 없는 시퀀스여야 합니다. 리터럴 줄바꿈 바이트(0x0A)는 문자열을 일찍 끝내므로 파서가 제어 문자 오류를 보고합니다. 유효한 표현은 두 문자 이스케이프 \n.

JSON 에서 ANSI 색상 코드를 어떻게 제거하나요?

ANSI 시퀀스는 Escape 바이트(U+001B)로 시작합니다. 파싱 전에 raw.replace(/\[[0-9;]*m/g, '') 로 제거하거나, 애초에 원시 터미널 출력을 JSON 문자열 값에 붙여 넣지 마세요.

브라우저에서 깨진 JSON 수리

깨진 JSON 을 fixjson.org 의 JSON Fix 에 붙여 넣으세요. 관대한 파서가 제어 문자 위반을 식별하고 정확한 위치를 보고합니다. 간단한 경우 문자열을 자동으로 수리할 수 있습니다.