Object.freeze를 했는데 설정이 바뀔 때: 중첩 객체와 공유 참조 구분하기

AI 도구의 공통 설정에 Object.freeze()를 적용했는데, 한 요청이 검색 개수를 바꾸자 다음 요청에도 바뀐 값이 들어온다. 객체를 얼렸다는 확인까지 통과했는데 왜 그럴까? freeze는 전달한 객체 한 겹의 속성을 고정한다. 그 속성이 가리키는 중첩 객체나 배열까지 자동으로 얼리지 않는다.

JavaScript 객체와 배열을 다뤄 본 독자라면, 설정 전체를 갈아 끼우는 일과 설정 안의 값을 바꾸는 일을 나누어 보면 된다. 공유 설정을 안정적으로 유지하려면 필요한 필드로 새 객체를 만들고, 변경을 막을 각 객체와 배열을 따로 고정한다. 아래에서는 같은 설정으로 두 방식을 실행해 차이를 확인한다.

같은 객체를 돌려주는 freeze, 그대로 남는 중첩 참조

이 글의 도구 설정은 설명을 위한 가상 사례다. maxResults는 검색 결과 개수, tools는 도구 이름 목록으로 가정한다. 실제 MCP SDK의 설정 규격이나 장애 사례를 나타내지는 않는다.

const input = { limits: { maxResults: 5 }, tools: ['search'] };
const config = Object.freeze(input);

config.limits.maxResults = 20;
config.tools.push('summarize');
console.log(config === input);          // true
console.log(Object.isFrozen(config));   // true
console.log(config.limits.maxResults);  // 20

바깥 객체의 limits 속성에는 숫자가 아니라 객체를 가리키는 참조가 들어 있다. freeze 이후 그 참조를 다른 객체로 바꿀 수는 없다. 그러나 기존 참조를 따라 들어가 maxResults를 쓰는 것은 다른 객체에 대한 조작이다. tools.push()도 바깥 설정이 아니라 배열을 변경한다.

또 하나의 차이는 복사가 없다는 점이다. config === input은 참이다. 원본을 따로 남겨 둔다고 생각하고 freeze를 호출해도, 실제로는 같은 객체를 고정한다. 반대로 중첩 참조를 공유하는 코드가 있다면 그 코드는 여전히 중첩 값을 바꿀 수 있다.

다시 넣기와 안쪽 값 쓰기는 실패 지점이 다르다

아래 표는 일반 데이터 속성으로 만든 위 설정을 기준으로 한다. 첫 번째 열의 조작을 구별하면 바깥 객체의 isFrozen 검사만으로 안심하는 일을 피할 수 있다.

조작바깥만 freeze한 결과
config.limits = 다른객체기존 속성 값 교체가 막힌다.
config.limits.maxResults = 20안쪽 객체가 고정되지 않아 바뀐다.
config.tools.push("summarize")배열이 고정되지 않아 항목이 추가된다.
config.extra = true새 자체 속성 추가가 막힌다.

이 글의 실행 파일은 .mjs 모듈이므로 엄격 모드로 실행된다. 고정된 데이터 속성에 다른 값을 대입하면 TypeError가 발생한다. 비엄격 모드의 단순 대입은 오류 없이 무시될 수 있다. 따라서 “예외가 없었다”와 “값이 바뀌었다”를 같은 뜻으로 읽으면 안 된다. 변경 전후의 값도 확인해야 한다.

ECMAScript의 Object.freeze 절은 전달받은 객체에 고정 절차를 적용하고 같은 객체를 반환한다. 이어지는 SetIntegrityLevel 절은 확장을 막고 자체 속성의 설정을 바꾼다. 데이터 속성의 쓰기·재설정을 막지만 속성 값에 든 객체를 재귀적으로 순회하는 절차는 없다.

공유할 필드만 새로 만들고 각 층을 고정하기

구조가 작은 설정에는 범용 deepFreeze 함수보다 필드를 명시한 생성 함수가 읽기 쉽다. 아래 makeConfig는 숫자 하나와 문자열 배열만 받는다. 숫자는 값으로 옮기고, 배열은 새로 만들어 원본과 분리한다. 새 바깥 객체, limits 객체, tools 배열을 각각 고정한다.

예제의 검색 개수 1~100과 도구 이름 최대 20개는 이 가상 서비스에서 정한 조건이다. 제품의 공식 한도가 아니다. 실제 서비스에서는 필요한 범위와 등록된 도구 이름을 검사하는 규칙으로 바꿔야 한다. 입력은 JSON을 파싱한 일반 데이터처럼 getter나 Proxy가 없는 자료를 전제로 한다.

전체 코드로 실패 사례와 수정 결과 함께 확인하기

다음 코드를 freeze-check.mjs로 저장하고 node freeze-check.mjs로 실행한다. 외부 패키지와 네트워크 요청은 사용하지 않는다. 앞부분은 얕은 고정의 한계, 가운데는 원본과 분리한 설정, 마지막은 getter·setter가 남는 경우다.

import assert from 'node:assert/strict';

const input = { limits: { maxResults: 5 }, tools: ['search'] };
const config = Object.freeze(input);
assert.equal(config, input);
assert.equal(Object.isFrozen(config), true);
assert.equal(Object.isFrozen(config.limits), false);
config.limits.maxResults = 20;
config.tools.push('summarize');
assert.equal(config.limits.maxResults, 20);
assert.deepEqual(config.tools, ['search', 'summarize']);
assert.throws(() => { config.limits = { maxResults: 1 }; }, TypeError);
console.log('shallow: nested value=20; array length=2; replacement rejected');

function makeConfig(source) {
  const maxResults = source?.limits?.maxResults;
  const tools = Array.isArray(source?.tools) ? Array.from(source.tools) : null;
  if (!Number.isSafeInteger(maxResults) || maxResults < 1 || maxResults > 100
      || tools === null || tools.length > 20
      || !tools.every(name => typeof name === 'string' && name.length > 0)) {
    throw new TypeError('Invalid tool config');
  }
  return Object.freeze({
    limits: Object.freeze({ maxResults }),
    tools: Object.freeze(tools)
  });
}
const source = { limits: { maxResults: 5 }, tools: ['search'] };
const fixed = makeConfig(source);
source.limits.maxResults = 99;
source.tools.push('summarize');
assert.equal(fixed.limits.maxResults, 5);
assert.deepEqual(fixed.tools, ['search']);
assert.equal(Object.isFrozen(fixed.limits), true);
assert.equal(Object.isFrozen(fixed.tools), true);
assert.throws(() => { fixed.limits.maxResults = 8; }, TypeError);
assert.throws(() => fixed.tools.push('summarize'), TypeError);
assert.throws(() => makeConfig({ limits: { maxResults: NaN }, tools: ['search'] }), TypeError);
assert.throws(() => makeConfig({ limits: { maxResults: 5 }, tools: [,] }), TypeError);
console.log('fixed: value=5; tools=search; source changes isolated');

let current = 5;
const accessor = Object.freeze({
  get maxResults() { return current; },
  set maxResults(value) { current = value; }
});
accessor.maxResults = 12;
assert.equal(Object.isFrozen(accessor), true);
assert.equal(accessor.maxResults, 12);
console.log('accessor: frozen=true; getter value=12');
console.log('PASS:', process.version);

2026년 11월 8일 KST에 Node.js v24.22.1에서 위 전체 코드를 실행한 출력이다.

shallow: nested value=20; array length=2; replacement rejected
fixed: value=5; tools=search; source changes isolated
accessor: frozen=true; getter value=12
PASS: v24.22.1

수정본에서는 원본 설정의 값을 99로 바꾸고 배열에 항목을 추가해도 fixed는 5와 search를 유지했다. 수정본 자체에 중첩 대입이나 배열 추가를 시도하면 TypeError로 막혔다. 잘못된 숫자 NaN과 빈 슬롯이 있는 배열도 거부했다. Array.from으로 슬롯을 undefined 값으로 드러내므로 배열의 every가 빈 슬롯을 건너뛰는 문제를 피한다.

얼린 객체에서도 getter 값은 달라질 수 있다

마지막 실험의 객체는 Object.isFrozen()이 참인데도 maxResults가 5에서 12로 바뀌었다. 데이터 속성이 아니라 getter와 setter로 만든 접근자 속성이기 때문이다. freeze는 이 접근자 정의를 재설정하지 못하게 하지만, setter의 실행이나 getter가 읽는 외부 변수까지 멈추지는 않는다. 예제에서는 setter가 current를 바꿨다.

이 차이는 설정을 만드는 시점에도 중요하다. 임의의 외부 객체를 읽으면 getter가 실행될 수 있다. 위 생성 함수는 허용된 형태의 일반 데이터를 옮기는 예제이며, 실행 가능한 임의 객체를 안전하게 정화하는 함수가 아니다. 속성 고정과 입력 출처 검증은 서로 다른 문제다. MDN의 Object.freeze 설명도 접근자 동작과 얕은 고정을 구별한다.

모두 재귀적으로 얼리기 전에 소유 범위를 정한다

중첩 구조가 늘어날 때마다 무조건 원본 전체를 재귀적으로 고정하면, 같은 객체를 쓰는 다른 코드의 정상 수정까지 막을 수 있다. 이 글의 생성 함수는 원본을 건드리지 않고 공유용 설정만 새로 만든다는 점에서 다르다. 현재 허용한 값이 숫자와 문자열뿐이므로 새로 만든 세 객체의 고정으로 범위를 설명할 수 있다.

이후 도구 목록을 문자열 대신 객체 배열로 바꾸면 이야기가 달라진다. 배열만 새로 만들고 얼리면 각 항목 객체는 여전히 공유될 수 있다. 새 필드를 추가할 때는 그 값이 원시값인지 객체인지, 복사해야 하는지, 변경을 막아야 하는지를 함께 정한다. 함수·클래스 인스턴스 등까지 받는 범용 도구로 이 예제를 확대하지 않는다.

단순 복사가 목적이라면 structuredClone과 클래스 메서드 복원에서 복사 후의 사용 방식을 먼저 확인할 수 있다. 복사는 참조를 분리하는 선택이고, freeze는 지정한 객체의 속성 변경을 제한하는 선택이다. 하나를 적용했다고 다른 하나까지 해결되는 것은 아니다.

다음 요청까지 설정이 새어 나가는지 검사한다

실제 코드에서는 먼저 변경되는 경로를 적는다. config.limits를 교체하는지, config.limits.maxResults를 쓰는지, 배열 메서드를 호출하는지를 나눈다. 그런 다음 바깥 설정뿐 아니라 해당 중첩 객체와 배열의 고정 상태를 확인한다. 민감한 설정값 전체를 로그에 출력할 필요는 없다.

수정 뒤에는 두 방향을 테스트한다. 원본을 바꿔도 공유용 설정이 유지되는지, 공유용 설정을 쓰려는 코드가 변경을 거부당하는지다. 요청마다 달라져야 하는 값은 공유 기본 설정을 고치는 대신 요청 전용 객체에 둔다. 공통 기본값은 그대로 남고 요청별 선택은 별도 값으로 드러나므로, 다음 요청이 이전 요청의 변경을 물려받는 경로를 줄일 수 있다.

확인 범위: 2026년 11월 8일 KST, Node.js v24.22.1의 일반 객체·배열·접근자 합성 실험. 실제 AI 서비스나 MCP SDK, 다른 런타임의 동작과 성능은 측정하지 않았다. 공식 근거는 위에 연결한 TC39 현행 초안으로, 열람 당시 ECMAScript 2027로 표시됐다. 확정판의 배포 번호나 런타임 버전과는 구별한다.


RUDA DIRECTOR · 루다(이루다)

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

다른 글 보기 · 주제 탐색