JSON Toolkit

JSON 텍스트 시퀀스(RFC 7464): RS로 구분하는 스트리밍 JSON

핵심 요약: RFC 7464는 0x1E 구분자로 JSON 텍스트를 스트리밍하며, 읽기와 쓰기를 동시에 할 수 있습니다. 규칙, NDJSON과의 차이, 분할 후 파싱하는 구현 방법을 다룹니다.

중급약 9분 소요2026-10-31 발행2026-10-31 업데이트
JSON 텍스트 시퀀스와 RFC 7464 레코드 구분자 표지

핵심부터 말하면, 양쪽 끝을 모두 제어할 수 있고, 등록된 미디어 타입이 필요하며, 잘림 이후 판독기가 재동기화하기를 원한다면 JSON-seq를 쓰세요. 데이터가 로그 시스템으로 흘러가거나, 사람이 읽을 수 있어야 하거나, 기존 도구와 호환되어야 한다면 NDJSON을 쓰세요. 줄 구분 입력만 이해하는 상대에게 JSON-seq를 강요하지 마세요.

NDJSON은 줄 바꿈으로 레코드를 나누지만, 줄 바꿈은 인간의 관례일 뿐입니다. RFC 7464는 경계를 바이트 스트림 자체에 새깁니다. 레코드마다 보이지 않는 0x1E를 앞에 둡니다.

1. 한 바이트가 해결하는 문제

RFC 7464는 2015년 2월 "JSON Text Sequences"라는 제목으로 발표되었고, application/json-seq 미디어 타입을 등록합니다. 새로운 데이터 타입을 추가하지 않습니다. 한 바이트 스트림 안에 JSON 텍스트 시퀀스를 어떻게 배열할지만 정의합니다. 규칙 전체는 한 문장으로 요약됩니다. 각 JSON 텍스트 앞에 RS 하나, 뒤에 LF 하나를 둔다.

  • RS는 ASCII 레코드 구분자로, 코드 포인트 0x1E이며, Unicode에서는 INFORMATION SEPARATOR TWO(U+001E)로 알려져 있습니다.
  • LF는 줄 바꿈 0x0A입니다.

두 레코드는 바이트 수준에서 이렇게 보입니다:

1E 7B 22 69 64 22 3A 31 7D 0A      ← 0x1E {"id":1} + LF
1E 7B 22 69 64 22 3A 32 7D 0A      ← 0x1E {"id":2} + LF

RS는 레코드의 앞에 위치하며, NDJSON의 "내용을 쓴 다음 구분자를 쓴다"는 배치와 반대입니다. 이 방향 차이가 복구 동작을 바꿉니다. 0x1E를 본 판독기는 이전 레코드가 끝났음을 알므로, 쓰는 도중에 잘려도 다음 레코드의 경계를 흐릴 수 없습니다.

이것은 이론적인 설계가 아닙니다. RFC는 크래시나 관리자에 의한 종료 후 파일 시스템이 잘라낸 로그 파일에 덧붙여진 레코드를 명시적으로 언급하며, 파서가 그 때문에 전체 읽기를 중단해서는 안 된다고 말합니다.

2. 두 가지 규칙 집합: 인코더와 파서

RFC 7464는 흔치 않게도 두 개의 ABNF 문법을 제공합니다. 하나는 작성자를 제약하고, 다른 하나는 판독기를 완화합니다.

역할 문법 의미
인코더 *(RS JSON-text LF) 각 JSON 텍스트 앞에 RS 하나, 뒤에 LF 하나
파서 *(1*RS possible-JSON) 연속된 RS 바이트를 허용하며, 레코드 사이의 LF는 선택 사항

엔지니어링 관점에서 다섯 가지 엄격한 규칙이 있습니다:

  1. UTF-8은 필수이며, UTF-16과 UTF-32는 사용해서는 안 됩니다.
  2. 파서는 "RS가 아닌 모든 바이트"를 가능한 JSON 텍스트로 간주해 파싱을 시도하며, 실패하면 다음 RS로 건너뛰고 계속 진행합니다.
  3. 연속된 RS 바이트는 빈 요소를 의미하지 않으며 무시됩니다.
  4. 시퀀스 끝 표시가 없으므로 스트림은 그냥 끝납니다.
  5. 최상위 값이 숫자, true, false, null일 때 레코드는 공백 바이트로 끝나야 하며, 그렇지 않으면 잘린 것으로 간주해 버립니다.

다섯 번째 규칙을 사람들이 자주 잊습니다. 객체, 배열, 문자열은 JSON에서 스스로 경계를 구분하지만, 숫자는 그렇지 않습니다. <RS>123<RS>는 완전한 123인지 잘린 1234인지 알려 줄 수 없습니다. 그래서 RFC는 작성자에게 LF를 덧붙여 잘림 감지를 위한 카나리 바이트로 쓰도록 요구합니다. 마찬가지로 <RS>true<RS>는 원래 trueish였을 수도 있습니다.

3. JSON-seq와 NDJSON 중 선택하기

두 형식 모두 스트리밍이지만, 서로 다른 문제를 겨냥합니다:

차원 JSON 텍스트 시퀀스 NDJSON / JSON Lines
구분자 위치 레코드 앞의 RS(0x1E) 레코드 뒤의 LF(0x0A)
미디어 타입 application/json-seq, IANA 등록 공식 등록 없음, 커뮤니티 관례
바이트 가독성 보이지 않는 제어 문자라 cat으로는 아무것도 보이지 않음 일반 텍스트, 바로 읽을 수 있음
동시 append 판독기가 재동기화하고 잘린 꼬리를 버릴 수 있음 append도 동작하며 불량 줄을 건너뜀
최상위 숫자 신뢰하려면 뒤에 오는 공백 바이트가 필요 줄 바꿈이 경계
문자열 내부의 구분자 RS는 \u001e로만 나타날 수 있음 LF는 \n으로 써야 함
도구 jq --seq와 소수의 라이브러리 거의 모든 로그 및 빅데이터 도구
파일 확장자 RFC에서 정의하지 않음 .jsonl / .ndjson

결정은 짧습니다. 양쪽 끝을 모두 제어할 수 있고, 공식 미디어 타입을 원하며, 잘림 이후 판독기가 재동기화하기를 원한다면 JSON-seq를 쓰세요. 데이터가 로그 시스템으로 흘러가거나, 사람이 읽을 수 있어야 하거나, 기존 도구와 호환되어야 한다면 JSON Lines와 NDJSON을 쓰세요.

4. 파서: 0x1E로 분할한 뒤 JSON.parse

RFC 7464의 가장 실질적인 이점은 스트리밍 JSON 파서가 필요 없다는 것입니다. 바이트 단위로 나눈 뒤 각 조각에 JSON.parse를 호출하면 됩니다:

/** ASCII 레코드 구분자, RFC 7464에서 0x1E로 정의 */
const RECORD_SEPARATOR = 0x1e;

/**
 * JSON 텍스트 시퀀스 바이트 청크를 파싱합니다.
 * @param buffer 지금까지 읽은 바이트
 * @returns 성공적으로 파싱된 레코드와 실패한 레코드 수
 */
function parseJsonSequence(buffer: Buffer): { records: unknown[]; badCount: number } {
  const records: unknown[] = [];
  let badCount = 0;
  let recordStart = buffer.indexOf(RECORD_SEPARATOR);

  while (recordStart !== -1) {
    const nextRecordStart = buffer.indexOf(RECORD_SEPARATOR, recordStart + 1);
    const recordEnd = nextRecordStart === -1 ? buffer.length : nextRecordStart;
    const rawText = buffer.subarray(recordStart + 1, recordEnd).toString('utf8').trim();

    if (rawText !== '') {
      try {
        records.push(JSON.parse(rawText));
      } catch {
        badCount += 1;
      }
    }

    recordStart = nextRecordStart;
  }

  return { records, badCount };
}

구현 시 세 가지 유의점:

  • 바이트 수준 분할은 안전합니다. UTF-8 연속 바이트는 0x80–0xBF 범위에 있으므로 0x1E와 같아질 수 없습니다. 0x1E로 나누면 멀티바이트 문자를 반으로 자를 수 없습니다.
  • 스트리밍 시 꼬리를 버퍼링하세요. 청크 단위로 읽을 때 마지막 조각은 아직 다음 RS에 도달하지 않았을 수 있습니다. 다음 청크가 올 때까지 버퍼에 유지하지 않으면 같은 반쪽 레코드를 두 번 파싱하게 됩니다.
  • 중단하지 말고 실패를 세세요. RFC는 파서에게 다음 RS로 건너뛰고 계속하라고 말하므로, badCount는 경고 지표이지 예외가 아닙니다. 잘린 마지막 레코드도 이 버킷에 속합니다.

메모리 사용량은 전체 파일 크기가 아니라 가장 큰 단일 레코드와 버퍼 하나에 달려 있습니다. 대용량 JSON 파싱과 스트리밍에서 다룬 것과 같은 개념입니다.

5. 문자열 내부의 제어 문자

JSON은 문자열 안에서 U+0000부터 U+001F까지의 모든 제어 문자를 이스케이프해야 하며, RS도 이 범위에 속합니다. 따라서 유효한 JSON 문자열은 \u001e 다섯 문자로만 RS를 담을 수 있고, 원시 0x1E 바이트는 절대 담을 수 없습니다. 그래서 유효한 데이터에서는 0x1E 분할이 항상 안전합니다.

위험은 작성자 쪽에 있습니다:

  1. '\x1e'로 JSON 텍스트를 직접 만들어 문자열에 원시 제어 문자를 삽입하는 경우
  2. JSON.stringify 대신 템플릿 리터럴로 JSON을 만들어 줄 바꿈, 따옴표, 제어 문자를 이스케이프하지 않는 경우
  3. RS가 들어 있는 텍스트를 터미널이나 에디터에서 복사해 제어 문자가 삼켜지거나 대체되는 경우

올바른 패턴은 항상 텍스트를 먼저 생성하고, 그 뒤에 구분자를 붙이는 것입니다:

const recordText = JSON.stringify(record);
const line = `${String.fromCharCode(RECORD_SEPARATOR)}${recordText}\n`;

JSON.stringify는 제어 문자를 \u00XX로 내보내므로, 원시 RS가 레코드 내부에 나타날 수 없습니다. 이 단계만 제대로 하면 분할 로직은 바이트만 다루면 되고 JSON 문법을 이해할 필요가 없습니다.

이 범주의 실패는 대개 JSON.parse가 Unexpected token을 던이면서 드러나며, 특히 "첫 문자가 보이지 않는 제어 바이트"라는 혼란스러운 경우가 많습니다. JSON 오류 메시지 조회표에서 그런 오류 유형을 한 줄씩 살펴볼 수 있습니다.

6. HTTP 전송과 미디어 타입

application/json-seq는 필수 또는 선택 파라미터가 없는 등록 미디어 타입이며, 문자 집합은 UTF-8로 고정됩니다. HTTP에서는 세 가지 관행이 중요합니다:

  • Accept: application/json-seq를 보내고 Content-Type: application/json-seq로 응답합니다.
  • 실제로 스트리밍하려면 서버가 청크 전송을 사용하고, 리버스 프록시, CDN, 부하 테스트 도구 같은 중간 매개체의 응답 버퍼링을 꺼야 합니다.
  • 청크 단위로 읽는 클라이언트는 레코드 중간에서 끝나는 청크를 받을 수 있으므로, 파서는 다음 RS가 올 때까지 버퍼링한 뒤 레코드를 내보내야 합니다.

상대가 줄 구분 입력만 이해한다면 JSON-seq를 강요하지 마세요. 이는 표준이지만 생태계 침투도는 NDJSON보다 훨씬 낮으므로, 호환성은 파싱 실패 이후가 아니라 프로토콜 협상 단계에서 결정해야 합니다.

7. 명령행, 로그, 도구 지원

jq는 네이티브 지원이 있는 몇 안 되는 도구 중 하나이며, --seq 플래그는 정확히 RFC 7464를 위해 존재합니다:

printf '\x1e{"id":1}\n\x1e{"id":2}\n' | jq --seq -c '.id'

이 명령은 1과 2를 각각 다른 줄에 출력합니다. 스트리밍 엔드포인트에서는 curl의 버퍼링을 꺼야 한다는 점을 기억하세요:

curl -sN https://api.example.com/events | jq --seq -c '.type'

jq --seq는 RFC 7464 입력을 읽고 불량 레코드를 무시하며 계속 파싱합니다. 아직 쓰이고 있는 로그 파일을 tail할 때 특히 유용합니다. 마지막 레코드가 반쯤 쓰였을 수 있는데, 출력에 가짜 마지막 객체가 나타나지 않습니다.

7.1 환경별 네이티브 지원

환경 기본 지원 비고
jq --seq 가장 쉬운 명령행 옵션
Node.js 없음 Buffer.indexOf와 JSON.parse
Python 없음 b'\x1e'로 분할
Go 없음 bufio.Scanner용 사용자 정의 SplitFunc
브라우저 없음 fetch와 ReadableStream, 바이트 수동 분할

7.2 흔한 함정

실수는 주로 읽기 쪽에 몰려 있습니다:

  1. readline으로 읽어 첫 줄에 보이지 않는 RS 접두사가 남아 JSON.parse가 항상 실패하는 경우
  2. 제어 문자가 보이지 않는 에디터에서 .json-seq 파일을 열어 하나의 기괴한 JSON 문서처럼 보이는 경우
  3. split(/\n/)로 나누어 레코드 내부에 이스케이프된 줄 바꿈과 레코드 사이의 LF를 혼동하는 경우
  4. 문서에서 RS를 일반 문자로 취급해 복사 붙여넣기 후 사라지는 경우

8. 사용할 만한 경우

적합한 경우:

  • 생산자와 소비자를 모두 직접 소유하며, 중간에 서드파티 도구가 없음
  • 데이터가 계속 덧붙여지는 이벤트 스트림이고 프로세스가 크래시할 수 있어 잘림으로부터 복구해야 함
  • .jsonl 같은 관례보다 공식 미디어 타입이 어울리는 프로토콜
  • 개별 레코드가 최상위 숫자나 불리언일 수 있어 명시적인 잘림 규칙이 중요한 경우

부적합한 경우:

  • 로그가 줄 구분 입력만 이해하는 ELK, Loki 또는 다른 시스템으로 들어가야 함
  • 사람이 데이터를 직접 cat, grep, awk해야 함
  • 상대가 application/json만 파싱할 수 있음

9. 체크리스트

  • [ ] 작성자가 JSON.stringify로 레코드를 생성한 뒤 0x1E를 앞에, \n을 뒤에 붙입니다
  • [ ] 최상위 숫자, 불리언, null을 담은 레코드는 LF로 끝납니다
  • [ ] 판독기가 줄 단위가 아니라 바이트 단위로 분할합니다
  • [ ] 스트리밍 읽기가 다음 RS가 올 때까지 닫히지 않은 꼬리를 버퍼에 유지합니다
  • [ ] 파싱 실패는 집계하고 건너뛰며, 시퀀스에 치명적이지 않습니다
  • [ ] 공개 인터페이스는 application/json-seq를 명시하고 NDJSON 폴백을 유지합니다

FAQ

공개 API 엔드포인트에 JSON-seq를 도입해야 하나?

양쪽 끝을 모두 바꿀 수 있거나 형식을 사전에 협상할 때만입니다. JSON-seq는 application/json-seq 미디어 타입을 가진 등록 표준이지만, 생태계 침투도가 NDJSON보다 훨씬 낮아 서드파티 클라이언트에 강요하면 대개 실패합니다. 호환성은 프로토콜 협상 단계에서 결정하고 NDJSON 폴백을 유지하세요.

기존 NDJSON 파이프라인을 JSON-seq로 변환해야 하나?

대개 아닙니다. NDJSON은 이미 스트리밍하고, append하며, 불량 줄을 건너뛰고, 거의 모든 로그 및 빅데이터 도구가 받아들입니다. IANA 등록 미디어 타입이 필요하거나 선행 구분자가 주는 재동기화가 필요하고, 생산자와 소비자를 함께 바꿀 수 있을 때만 변환하세요.

JSON 텍스트 시퀀스와 NDJSON 중 어느 쪽이 더 흔한가?

NDJSON입니다. 규칙이 더 단순하고, 도구 적용 범위가 훨씬 넓으며, 로그 시스템과 빅데이터 파이프라인이 대체로 받아들입니다. JSON-seq는 IANA 등록 미디어 타입과 구분자를 앞에 둠으로써 얻는 재동기화에서 우위가 있으며, 이는 일반적인 데이터 교환보다 통제된 스트리밍 프로토콜에 어울립니다.

구분자를 레코드 뒤가 아니라 앞에 두는 이유는?

레코드가 잘릴 수 있기 때문입니다. 선행 구분자를 쓰면 RS를 본 판독기가 새 레코드가 시작되었음을 알므로, 이전 레코드가 완전하든 아니든 다음 레코드를 오염시킬 수 없습니다. 후행 구분자를 쓰면 쓰기가 중단될 때 마지막 조각에 끝 표시도 다음 시작 표시도 없어 경계를 추측할 수밖에 없습니다.

레코드 내부에 0x1E가 나타나면 어떻게 되나?

유효한 JSON 문자열은 RS를 \u001e로 이스케이프해야 하므로 원시 0x1E는 절대 나타나면 안 됩니다. 만약 나타나면 그 텍스트는 더 이상 유효한 JSON이 아니며, 0x1E로 나누면 잘못된 지점에서 끊기고 JSON.parse가 실패합니다. 해결은 작성자 쪽에 있습니다. 직접 만든 텍스트 대신 JSON.stringify를 쓰세요.

브라우저가 application/json-seq를 직접 파싱할 수 있나?

아니요. 브라우저에는 JSON-seq 파서가 없으며, fetch는 바이트만 전달합니다. response.body.getReader()로 청크를 다음 0x1E까지 누적하고 각 조각에 JSON.parse를 호출해야 합니다. 페이로드가 작다면 전체 본문을 문자열로 먼저 읽고 나중에 나누는 방법도 됩니다.

최상위 숫자에 후행 LF가 필요한 이유는?

객체, 배열, 문자열은 JSON에서 스스로 경계를 구분하지만 숫자는 그렇지 않습니다. <RS>123<RS>는 완전한 123일 수도 잘린 1234일 수도 있습니다. 그래서 RFC 7464는 각 레코드 뒤에 공백 바이트를 요구하며, LF가 잘림 감지를 위한 카나리 구실을 합니다. 최상위 true, false, null에도 같은 규칙이 적용됩니다.