JSON.stringify에 필드 목록을 넣었더니 중첩 데이터가 사라질 때

AI 검색 도구가 만든 객체에는 문서 제목과 점수가 있는데, JSON으로 바꾸고 나면 results: [{}]만 남는다면 JSON.stringify의 두 번째 인수를 보자. 여기에 ["requestId", "results"]처럼 필드 목록을 넣으면 최상위 필드만 고르는 데서 끝나지 않는다. 중첩 객체에도 같은 이름 목록이 적용되어 title과 score가 빠질 수 있다.

이 글은 JavaScript 객체·배열과 JSON 변환을 사용하는 개발자를 위한 설명이다. 아래는 AI 검색 응답을 줄여 보내려는 가상 사례다. 필드가 사라지는 이유를 재현하고, 이름 목록을 계속 늘리는 대신 응답의 각 위치에 필요한 값을 직접 담는 방법을 살펴본다. 실제 서비스의 장애 사례는 아니다.

두 번째 인수는 최상위 필드 선택기가 아니다

replacer는 JSON으로 바꿀 값을 선택하거나 변환하는 두 번째 인수다. 함수 또는 배열을 사용할 수 있는데, 여기서는 배열 형태를 다룬다. ["requestId", "results"]를 주면 최상위 객체의 두 속성은 남는다. 그러나 results 안의 객체도 같은 목록으로 검사하므로 title과 score는 남지 않는다. 배열 안에 있던 객체 자체는 남아서 [{}]가 된다.

ECMAScript의 JSON.stringify 알고리즘은 배열 replacer에서 속성 이름 목록을 만들고, SerializeJSONObject는 각 객체를 처리할 때 그 목록을 사용한다. 여기에 results[0].score 같은 경로 조건은 없다. 이름이 score인지와 results 안에 있는 score인지는 다른 선택 기준이다.

빠진 이름을 추가하면 다른 위치의 같은 이름도 들어온다

예제에는 최상위 score와 검색 결과의 score가 함께 있다. 숫자 999는 차이를 눈으로 구별하기 위한 가상 값이며 성능·품질 수치가 아니다. title과 score를 목록에 추가하면 중첩 값은 돌아오지만, 보내려 하지 않았던 최상위 score도 포함된다.

const sample = {
  requestId: 'sample-1',
  results: [{ title: '샘플 문서', score: 0.8, debugNote: 'example only' }],
  score: 999
};

JSON.stringify(sample, ['requestId', 'results']);
// {"requestId":"sample-1","results":[{}]}

JSON.stringify(sample, ['requestId', 'results', 'title', 'score']);
// 중첩 title·score뿐 아니라 최상위 score도 포함된다.

모든 깊이에서 같은 이름의 속성을 남기려는 목적이라면 배열 replacer를 쓸 수 있다. 반대로 최상위에는 requestId와 results만, 결과 항목에는 title과 score만 보내야 한다면 경로마다 선택을 표현하는 편이 명확하다. 이 차이는 목록의 길이로 해결할 문제가 아니다.

보낼 객체를 먼저 만들고 그다음 JSON으로 바꾼다

아래 함수는 입력에서 허용한 값만 꺼내 새 응답 객체를 만든다. requestId와 title은 문자열, score는 유한한 숫자여야 한다는 예제 조건을 함께 검사한다. 원본 전체를 펼쳐 넣지 않으므로 나중에 원본에 다른 속성이 추가되어도 응답에 자동으로 섞이지 않는다.

import assert from 'node:assert/strict';

// 가상 검색 결과. 실제 요청이나 고객 데이터가 아니다.
const sample = {
  requestId: 'sample-1',
  results: [{ title: '샘플 문서', score: 0.8, debugNote: 'example only' }],
  score: 999
};
const narrowed = JSON.parse(JSON.stringify(sample, ['requestId', 'results']));
assert.deepEqual(narrowed, { requestId: 'sample-1', results: [{}] });
console.log('narrowed:', JSON.stringify(narrowed));

const widened = JSON.parse(JSON.stringify(sample,
  ['requestId', 'results', 'title', 'score']));
assert.equal(widened.results[0].score, 0.8);
assert.equal(widened.score, 999);
console.log('widened:', JSON.stringify(widened));

function toSearchResponse(input) {
  if (!input || typeof input.requestId !== 'string' ||
      !Array.isArray(input.results)) {
    throw new TypeError('Invalid search response');
  }
  return {
    requestId: input.requestId,
    results: Array.from(input.results, item => {
      if (!item || typeof item.title !== 'string' ||
          typeof item.score !== 'number' || !Number.isFinite(item.score)) {
        throw new TypeError('Invalid result item');
      }
      return { title: item.title, score: item.score };
    })
  };
}
const wire = JSON.stringify(toSearchResponse(sample));
assert.deepEqual(JSON.parse(wire), {
  requestId: 'sample-1', results: [{ title: '샘플 문서', score: 0.8 }]
});
assert.deepEqual(toSearchResponse({ requestId: 'empty', results: [] }), {
  requestId: 'empty', results: []
});
assert.throws(() => toSearchResponse({
  requestId: 'bad', results: [{ title: 'sample', score: NaN }]
}), TypeError);
assert.throws(() => toSearchResponse({
  requestId: 'hole', results: Array(1)
}), TypeError);
console.log('projected:', wire);
console.log('empty / invalid number / sparse slot: checked');
console.log('PASS:', process.version, process.platform);

바로 위 코드 블록 전체를 replacer-check.mjs로 저장하고 node replacer-check.mjs로 실행하면 된다. 외부 패키지 설치와 네트워크 호출은 없다. 2026년 11월 9일 KST, Linux의 Node.js v24.22.1에서 실행한 출력은 다음과 같다.

narrowed: {"requestId":"sample-1","results":[{}]}
widened: {"requestId":"sample-1","results":[{"title":"샘플 문서","score":0.8}],"score":999}
projected: {"requestId":"sample-1","results":[{"title":"샘플 문서","score":0.8}]}
empty / invalid number / sparse slot: checked
PASS: v24.22.1 linux

검사는 좁은 목록으로 생긴 빈 객체, 넓힌 목록에 섞인 최상위 score, 명시적으로 만든 응답의 정확한 구조를 비교한다. 결과 0건은 빈 배열로 보존하며, NaN과 배열의 빈 슬롯은 잘못된 입력으로 거부한다. Array.from을 쓴 이유도 빈 슬롯을 건너뛰지 않고 검사하기 위해서다. 출력이 만들어졌다는 사실만 확인하지 않고, 빠져야 할 필드와 남아야 할 필드를 함께 비교했다.

오류가 난 위치를 좁히는 세 가지 비교

먼저 변환 직전을 확인한다. 테스트 입력에서 title과 score가 실제로 있는지 살핀다. 이미 없었다면 JSON.stringify보다 앞선 조회·매핑 단계의 문제다. 고객 원문을 통째로 기록하기보다 합성 데이터로 같은 구조를 만든다.

다음으로 두 번째 인수를 뺀 결과와 비교한다. 배열 replacer를 뺐을 때만 값이 돌아오면 목록의 적용 범위를 확인한다. 그래도 없으면 undefined, toJSON, 사용자 정의 변환 함수를 따로 살핀다. undefined·NaN이 JSON에서 바뀌는 경우는 값 자체의 변환 문제라 이번 이름 선택 문제와 구분할 수 있다.

마지막으로 소비자가 받는 구조를 비교한다. stringify 결과를 테스트에서 다시 파싱해 기대 객체와 대조한다. 이 왕복 비교는 누락을 찾는 검사일 뿐, 이미 사라진 값을 복구하지는 않는다. 전송 JSON이 맞는데 화면만 비었다면 표시 코드를 확인한다.

명시적 응답 구성에도 입력 조건은 필요하다

예제는 파싱된 JSON처럼 일반적인 데이터 객체를 받는다는 전제다. 임의 클래스·getter·Proxy·사용자 정의 toJSON의 동작까지 안전하게 처리하는 보안 필터가 아니다. 실제 구현에서는 입력 스키마를 검사하고, 문자열 길이·결과 개수·점수 범위도 서비스의 규칙에 맞게 제한해야 한다. 유한한 숫자라고 해서 그 점수가 업무상 유효하다는 뜻은 아니다.

필드가 많은 서비스에서는 응답 구성 코드가 길어질 수 있다. 그 비용을 감수하면 어떤 위치의 값을 외부에 보내는지 코드에서 바로 확인할 수 있다. 이미 출력 스키마와 변환기를 관리한다면 같은 정의를 재사용하는 방법을 검토하되, 중첩 객체와 같은 이름의 속성이 다른 위치에 있는 사례를 테스트에 넣는다. 이 예제는 MCP SDK에 넘길 응답 규격을 새로 정하지 않으므로 실제 도구의 출력 형식은 그대로 맞춰야 한다.

확인 범위: 2026년 11월 9일 KST에 TC39의 JSON.stringify·SerializeJSONObject 공식 알고리즘을 직접 대조했다. 열람 문서의 표제는 갱신 중인 ECMAScript 2027 명세이며 확정판 전체 호환성을 뜻하지 않는다. 위 출력은 Node.js v24.22.1/Linux의 합성 데이터 검사 결과다. 실제 AI 서비스·MCP SDK·브라우저·대용량 응답은 시험하지 않았다.

필드 이름보다 응답의 모양을 먼저 정한다

전송 전에 적을 것은 하나의 긴 이름 목록보다 작은 기대 객체다. 최상위에 무엇이 남고, 각 결과 항목에 무엇이 남아야 하는지 그려 놓으면 [{}]도, 엉뚱한 위치에 붙은 score도 같은 검사에서 잡힌다. 이번 경우에는 빠진 이름을 추가하는 데서 멈추지 말고, 필요한 위치에 필요한 값만 담아 보내면 된다.


RUDA DIRECTOR · 루다(이루다)

작성자·공개 작업·편집 기준 · 문의·정정 요청

다른 글 보기 · 주제 탐색