어제 오늘 내일

JavaScript Object.freeze 총정리: 얕은 동결의 함정과 deepFreeze 구현법 본문

IT/Javascript

JavaScript Object.freeze 총정리: 얕은 동결의 함정과 deepFreeze 구현법

hi.anna 2026. 8. 8. 22:14

자바스크립트에서 객체를 "바꾸지 못하게" 만드는 가장 익숙한 방법은 Object.freeze입니다. 이름만 보면 모든 게 해결될 것 같지만, 실제로는 딱 한 겹만 얼립니다. 이 사실을 모르고 쓰면 오히려 안전하다고 착각한 채 버그를 안고 가게 됩니다.
이 글에서는 Object.freeze가 정확히 무엇을 막고 무엇을 못 막는지, 그리고 실무에서 어떻게 쓰면 좋을지 정리합니다.

기본 동작

const config = { host: "localhost", port: 3000 };
Object.freeze(config);

config.port = 8080;        // 무시됨
delete config.host;        // 무시됨
config.protocol = "https"; // 무시됨

console.log(config);  // { host: "localhost", port: 3000 }

Object.freeze는 객체에 세 가지를 한꺼번에 겁니다.

  1. 기존 속성을 쓰기 불가(non-writable)로 만든다
  2. 기존 속성을 재정의 불가(non-configurable)로 만든다 → 삭제도 불가
  3. 객체를 확장 불가(non-extensible)로 만든다 → 새 속성 추가 불가

여기에 더해 프로토타입도 바꿀 수 없게 됩니다.

Object.setPrototypeOf(config, Array.prototype);  // TypeError

반환값은 새 객체가 아니라 원본 그 자체입니다. 복사본을 만드는 게 아니라 원본을 제자리에서 변경합니다.

const frozen = Object.freeze(config);
console.log(frozen === config);  // true

조용한 실패라는 함정

위 예제에서 대입이 "무시됐다"고 했는데, 이게 문제입니다. 느슨한 모드에서는 에러가 나지 않습니다.

// 느슨한 모드
const obj = Object.freeze({ n: 1 });
obj.n = 999;
console.log(obj.n);  // 1 — 에러 없이 조용히 실패

코드는 멀쩡히 돌아가는데 값만 안 바뀝니다. 원인을 찾기 매우 어려운 종류의 버그죠.
엄격 모드에서는 에러가 발생합니다.

"use strict";
const obj = Object.freeze({ n: 1 });
obj.n = 999;  // TypeError: Cannot assign to read only property 'n'

ES 모듈과 클래스 본문은 자동으로 엄격 모드이므로, 모던 환경에서는 대부분 예외가 발생합니다. 하지만 <script> 태그나 CommonJS에서 작업 중이라면 지시어를 명시하는 편이 안전합니다.

얕은 동결(shallow freeze)

가장 중요한 부분입니다. Object.freeze는 한 겹만 얼립니다.

const state = Object.freeze({
  user: { name: "김철수", age: 30 },
  tags: ["a", "b"],
});

state.user = {};          // ❌ 막힘
state.user.name = "이영희"; // ✅ 통과! 중첩 객체는 그대로
state.tags.push("c");      // ✅ 통과!

console.log(state.user.name);  // "이영희"
console.log(state.tags);       // ["a", "b", "c"]

state.user라는 참조는 고정되지만, 그 참조가 가리키는 객체 내부는 전혀 보호되지 않습니다. 중첩 구조를 다룬다면 이 동작을 반드시 기억해야 합니다.

깊은 동결 직접 구현하기

재귀로 순회하면 됩니다. 다만 순환 참조를 조심해야 합니다.

function deepFreeze(obj) {
  // 이미 얼어 있으면 하위도 처리됐다고 보고 중단 → 순환 참조 방어
  if (obj === null || typeof obj !== "object" || Object.isFrozen(obj)) {
    return obj;
  }

  Object.freeze(obj);  // 먼저 얼려야 순환 참조에서 무한 루프를 막을 수 있음

  for (const key of Reflect.ownKeys(obj)) {
    deepFreeze(obj[key]);
  }

  return obj;
}

두 가지 포인트가 있습니다.

  • Object.freeze를 재귀 호출보다 먼저 실행합니다. 순환 참조가 있을 때 되돌아온 객체가 이미 isFrozen이면 즉시 빠져나올 수 있습니다.
  • Object.keys 대신 Reflect.ownKeys를 쓰면 심볼 키와 열거 불가능한 속성까지 포함됩니다.

getter가 있는 객체라면 obj[key] 접근이 부수 효과를 일으킬 수 있으니, 엄밀하게 하려면 Object.getOwnPropertyDescriptor로 value만 꺼내 검사하는 방식이 더 안전합니다.

얼려도 막지 못하는 것들

1. getter는 여전히 살아 있다

접근자 속성은 함수 자체가 교체 불가능해질 뿐, 호출은 정상적으로 됩니다.

const timer = Object.freeze({
  get now() { return Date.now(); }
});

console.log(timer.now);  // 호출할 때마다 다른 값

getter가 클로저 변수를 읽는다면, 그 변수가 바뀌는 순간 "얼린 객체"의 값도 바뀝니다.

2. Map과 Set은 얼지 않는다

Map, Set, Date 같은 내장 객체는 데이터를 일반 속성이 아니라 내부 슬롯에 저장합니다. Object.freeze는 속성만 건드리므로 아무 효과가 없습니다.

const m = Object.freeze(new Map());
m.set("key", "value");   // 정상 동작
console.log(m.size);     // 1

const d = Object.freeze(new Date());
d.setFullYear(2000);     // 정상 동작

3. 클래스 인스턴스의 메서드 호출

class Counter {
  #count = 0;
  increment() { this.#count++; }
  get value() { return this.#count; }
}

const c = Object.freeze(new Counter());
c.increment();
console.log(c.value);  // 1 — private 필드는 막히지 않음

프라이빗 필드(#)도 내부 슬롯과 유사하게 동작해 동결의 영향을 받지 않습니다.

배열 동결

배열도 객체이므로 얼릴 수 있습니다.

"use strict";
const arr = Object.freeze([1, 2, 3]);

arr.push(4);     // TypeError: Cannot add property 3
arr[0] = 99;     // TypeError
arr.length = 0;  // TypeError
arr.sort();      // TypeError (제자리 정렬이라 실패)

const doubled = arr.map(x => x * 2);  // ✅ 새 배열 반환이라 문제없음

원본을 바꾸는 메서드(push, pop, splice, sort, reverse)는 모두 막히고, 새 배열을 반환하는 메서드(map, filter, concat, slice, toSorted)는 자유롭게 쓸 수 있습니다.

freeze / seal / preventExtensions 비교

동결 관련 API는 세 단계로 나뉩니다.
속성 추가 속성 삭제 값 변경 확인 함수

preventExtensionsisExtensible
sealisSealed
freezeisFrozen
const sealed = Object.seal({ a: 1 });
sealed.a = 2;     // ✅ 값 변경은 가능
sealed.b = 3;     // ❌ 추가 불가
delete sealed.a;  // ❌ 삭제 불가

객체의 모양(shape)은 고정하되 값은 바꾸고 싶다면 seal이 적절합니다.

const와는 무엇이 다른가

자주 혼동되는 지점입니다. 둘은 완전히 다른 층위를 다룹니다.

const obj = { n: 1 };
obj.n = 2;        // ✅ 가능 — const는 재할당만 막음
obj = { n: 3 };   // ❌ TypeError — 바인딩 재할당

let frozen = Object.freeze({ n: 1 });
frozen.n = 2;     // ❌ 내용 변경 불가
frozen = { n: 3 }; // ✅ 가능 — let이므로 재할당은 자유
  • const: 변수 바인딩을 고정 (변수가 다른 값을 가리키지 못함)
  • Object.freeze: 객체 내용을 고정

둘을 함께 쓰면 양쪽 모두 막을 수 있습니다.

const CONFIG = Object.freeze({ apiUrl: "https://api.example.com" });

실전에서 쓸 만한 곳

상수 객체와 열거형

const Status = Object.freeze({
  PENDING: "pending",
  ACTIVE: "active",
  DONE: "done",
});

실수로 값을 덮어쓰는 사고를 방지합니다.
기본 설정값 보호

const DEFAULTS = Object.freeze({ retries: 3, timeout: 5000 });

function createClient(options) {
  return { ...DEFAULTS, ...options };  // 스프레드로 새 객체 생성
}

DEFAULTS를 여러 곳에서 공유해도 누군가 실수로 오염시킬 수 없습니다.
개발 모드에서만 동결하기
동결에는 약간의 비용이 있고, 얕은 동결의 한계도 있습니다. 그래서 실수를 잡아내는 용도로만 개발 환경에서 적용하는 패턴이 흔합니다.

const state = process.env.NODE_ENV === "development"
  ? deepFreeze(rawState)
  : rawState;

Redux 생태계에서 오랫동안 쓰인 방식입니다. 프로덕션에서는 오버헤드 없이, 개발 중에는 의도치 않은 변경을 즉시 잡아냅니다.

성능에 대한 오해

"동결하면 엔진이 최적화해서 더 빨라진다"는 이야기를 종종 듣습니다. 하지만 이는 엔진과 버전, 사용 패턴에 따라 크게 달라지며 일반적으로 보장되는 사실이 아닙니다.
확실한 것은 이렇습니다.

  • Object.freeze 호출 자체는 속성 순회 비용이 들며, 깊은 동결은 객체 크기에 비례해 비용이 커집니다
  • 얼린 객체의 속성 읽기는 대체로 일반 객체와 비슷합니다
  • 성능을 이유로 동결하는 것은 권장되지 않습니다

Object.freeze는 성능 최적화 도구가 아니라 정확성 도구로 생각하는 편이 맞습니다. 뜨거운 경로에서 큰 객체를 반복적으로 깊게 동결하고 있다면, 그 자체가 병목이 될 수 있으니 측정해 보세요.

정리

  • Object.freeze는 원본을 제자리에서 얼리고, 같은 객체를 반환한다
  • 얕게 동작한다 — 중첩 객체는 별도로 처리해야 한다
  • 느슨한 모드에서는 조용히 실패한다. 엄격 모드에서 써야 의미가 있다
  • Map, Set, Date, 프라이빗 필드, getter는 막지 못한다
  • const는 변수를, freeze는 내용을 고정한다. 서로 대체 관계가 아니다
  • 성능이 아니라 실수 방지를 위해 쓴다

진짜 불변성이 필요하다면 동결에만 의존하기보다, 애초에 값을 바꾸지 않는 코딩 스타일(스프레드, map/filter, toSorted 같은 비파괴 메서드)을 기본으로 삼고 Object.freeze는 그 규칙을 강제하는 안전장치로 쓰는 것이 좋습니다. 타입스크립트를 쓴다면 readonly와 as const로 컴파일 타임에 먼저 막는 것이 런타임 동결보다 훨씬 저렴합니다.

반응형
Comments