Object.freeze는 정말 불변성을 보장할까?

Object.freeze를 사용하면 객체가 완벽하게 보호된다고 생각하기 쉽습니다. 하지만 정말 그럴까요?


먼저, 왜 불변성이 필요한가?

불변성(Immutability)을 이해하기 전에, 가변 객체가 일으키는 문제부터 살펴봅시다. JavaScript에서 객체는 참조로 전달되기 때문에, 예상치 못한 곳에서 데이터가 변경될 수 있습니다.

공유 참조의 함정

const originalSettings = {
  volume: 50,
  quality: 'high'
};

// A 모듈에서 사용
const settingsA = originalSettings;
settingsA.volume = 100;

// B 모듈에서 사용
const settingsB = originalSettings;
console.log(settingsB.volume);  // 100 - 예상과 다름!

시각화:

메모리 상황:

originalSettings ──┐
                   ├──> { volume: 100, quality: 'high' }
settingsA ─────────┤    (모두 같은 객체를 가리킴)
                   │
settingsB ─────────┘

settingsA.volume = 100 실행 시
모든 참조가 영향을 받음

이런 문제는 실제 프로덕션 환경에서 디버깅하기 매우 어렵습니다. Redux 공식 문서에서도 불변성을 강조하는 이유가 바로 이것입니다.


Object.freeze: 기본 동작 이해하기

Object.freeze가 어떻게 작동하는지 이해하려면, JavaScript 객체의 속성 설명자(Property Descriptor)를 알아야 합니다.

내부 메커니즘

const obj = { name: 'Alice' };

// freeze 실행 전
Object.getOwnPropertyDescriptor(obj, 'name');
// {
//   value: 'Alice',
//   writable: true,      ← 변경 가능
//   enumerable: true,
//   configurable: true   ← 삭제/재정의 가능
// }

// freeze 실행 후
Object.freeze(obj);
Object.getOwnPropertyDescriptor(obj, 'name');
// {
//   value: 'Alice',
//   writable: false,     ← 변경 불가능
//   enumerable: true,
//   configurable: false  ← 삭제/재정의 불가능
// }

Object.freeze는 객체의 모든 속성을 writable: false, configurable: false로 설정하고, 객체 자체를 확장 불가능(non-extensible)하게 만듭니다.

실제 동작 확인

// 일반 객체
const normalObj = { name: 'Alice' };
normalObj.name = 'Bob';        // ✓ 변경됨
normalObj.age = 25;            // ✓ 추가됨
delete normalObj.name;         // ✓ 삭제됨
console.log(normalObj);        // { age: 25 }

// Object.freeze 적용
const frozenObj = Object.freeze({ name: 'Alice' });
frozenObj.name = 'Bob';        // ✗ 무시됨 (strict mode에서는 TypeError)
frozenObj.age = 25;            // ✗ 무시됨
delete frozenObj.name;         // ✗ 무시됨
console.log(frozenObj);        // { name: 'Alice' }

얕은 동결(Shallow Freeze)의 한계

여기서 중요한 질문이 나옵니다. Object.freeze는 정말로 완전한 불변성을 보장할까요? 결론부터 말하면, 아닙니다.

중첩 객체는 보호되지 않는다

const user = Object.freeze({
  name: 'Alice',
  address: {
    city: 'Seoul',
    country: 'Korea'
  }
});

// 최상위 속성은 보호됨
user.name = 'Bob';
console.log(user.name);         // 'Alice' ✓

// 하지만 중첩된 객체는?
user.address.city = 'Busan';
console.log(user.address.city); // 'Busan' - 변경됨!

시각화:

Object.freeze가 적용되는 범위:

user (frozen) ─────────────────────────────────┐
│                                              │
├── name: 'Alice'           [보호됨]           │
│                                              │
└── address ──────> { city: 'Seoul', ... }     │
                    [참조만 보호됨, 내용은 X]   │
                                               │
───────────────────────────────────────────────┘

ECMAScript 명세에 따르면, Object.freeze는 객체의 직접 속성(own properties)만 동결합니다. 중첩된 객체는 별도의 객체이므로 동결되지 않습니다.


Deep Freeze: 진짜 불변성 구현하기

중첩된 모든 객체까지 동결하려면 재귀적으로 처리해야 합니다.

구현 방법

function deepFreeze(obj) {
  // null, undefined, 원시값은 그대로 반환
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  // 이미 동결된 객체는 스킵
  if (Object.isFrozen(obj)) {
    return obj;
  }

  // 현재 객체 동결
  Object.freeze(obj);

  // 모든 속성을 순회하며 재귀 적용
  const propNames = Object.getOwnPropertyNames(obj);
  for (let i = 0; i < propNames.length; i++) {
    const value = obj[propNames[i]];
    if (value && typeof value === 'object') {
      deepFreeze(value);
    }
  }

  return obj;
}

사용 예시

const user = deepFreeze({
  name: 'Alice',
  address: {
    city: 'Seoul',
    country: 'Korea'
  }
});

user.name = 'Bob';              // ✗ 무시됨
user.address.city = 'Busan';    // ✗ 이제 무시됨!
console.log(user.address.city); // 'Seoul'

배열도 객체다

JavaScript에서 배열은 객체의 특수한 형태입니다. Object.freeze를 배열에 적용하면 어떻게 될까요?

배열 동결의 특성

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

arr[0] = 99;           // ✗ 무시됨
arr.push(4);           // TypeError: Cannot add property 3
arr.pop();             // TypeError: Cannot delete property '2'

console.log(arr);      // [1, 2, 3]

배열 내부 객체의 함정

const users = Object.freeze([
  { name: 'Alice' },
  { name: 'Bob' }
]);

users[0] = { name: 'Charlie' };  // ✗ 무시됨
users[0].name = 'Charlie';       // ✓ 변경됨!

console.log(users[0].name);      // 'Charlie'

배열 요소가 객체라면, 그 객체의 내용은 여전히 변경 가능합니다. deepFreeze를 사용해야 합니다.

const users = deepFreeze([
  { name: 'Alice' },
  { name: 'Bob' }
]);

users[0].name = 'Charlie';       // ✗ 이제 무시됨
console.log(users[0].name);      // 'Alice'

Strict Mode에서의 차이

Object.freeze의 동작은 Strict Mode 여부에 따라 다릅니다.

// Non-strict mode
const obj = Object.freeze({ x: 1 });
obj.x = 2;              // 조용히 실패 (에러 없음)
console.log(obj.x);     // 1

// Strict mode
'use strict';
const obj2 = Object.freeze({ x: 1 });
obj2.x = 2;             // TypeError: Cannot assign to read only property

프로덕션 코드에서는 Strict Mode를 사용하는 것이 권장됩니다. ES6 모듈은 자동으로 Strict Mode가 적용됩니다.


Object.freeze vs 다른 방법들

불변성을 다루는 여러 방법을 비교해봅시다.

비교표

방법 값 변경 속성 추가 속성 삭제 중첩 객체
const ✓ 가능 ✓ 가능 ✓ 가능 ✓ 가능
Object.preventExtensions ✓ 가능 ✗ 불가 ✓ 가능 ✓ 가능
Object.seal ✓ 가능 ✗ 불가 ✗ 불가 ✓ 가능
Object.freeze ✗ 불가 ✗ 불가 ✗ 불가 ✓ 가능
deepFreeze ✗ 불가 ✗ 불가 ✗ 불가 ✗ 불가

코드로 비교

// 1. const: 재할당만 방지
const obj1 = { x: 1 };
obj1 = { x: 2 };       // ❌ TypeError
obj1.x = 2;            // ✓ 가능
obj1.y = 3;            // ✓ 가능

// 2. Object.preventExtensions: 확장 방지
const obj2 = Object.preventExtensions({ x: 1 });
obj2.x = 2;            // ✓ 가능
obj2.y = 3;            // ✗ 무시됨
delete obj2.x;         // ✓ 가능

// 3. Object.seal: 구조 고정
const obj3 = Object.seal({ x: 1 });
obj3.x = 2;            // ✓ 가능
obj3.y = 3;            // ✗ 무시됨
delete obj3.x;         // ✗ 무시됨

// 4. Object.freeze: 완전 동결 (얕은)
const obj4 = Object.freeze({ x: 1 });
obj4.x = 2;            // ✗ 무시됨
obj4.y = 3;            // ✗ 무시됨
delete obj4.x;         // ✗ 무시됨

실전 패턴

실제 애플리케이션에서 Object.freeze를 효과적으로 활용하는 패턴을 살펴봅시다.

패턴 1: 설정 객체 보호

const Config = Object.freeze({
  API_URL: 'https://api.example.com',
  TIMEOUT: 5000,
  MAX_RETRIES: 3
});

// 어디서든 안전하게 사용
function fetchData() {
  return fetch(Config.API_URL, {
    timeout: Config.TIMEOUT
  });
}

// 실수로 수정해도 무시됨
Config.API_URL = 'http://malicious.com';  // ✗ 무시됨

패턴 2: 불변 상태 업데이트 (Redux 스타일)

let state = Object.freeze({
  user: { name: 'Alice', age: 25 },
  theme: 'light'
});

function updateState(updates) {
  // 새 객체를 생성하고 동결
  state = Object.freeze({
    ...state,
    ...updates
  });
  return state;
}

// 사용
updateState({ theme: 'dark' });
console.log(state.theme);  // 'dark'

// 직접 수정 시도
state.theme = 'light';     // ✗ 무시됨

패턴 3: 이벤트 데이터 보호

class EventEmitter {
  constructor() {
    this.handlers = new Map();
  }

  emit(event, data) {
    // 데이터를 동결하여 핸들러가 수정하지 못하게 함
    const frozenData = Object.freeze({ ...data });

    const handlers = this.handlers.get(event) || [];
    for (let i = 0; i < handlers.length; i++) {
      handlers[i](frozenData);
    }
  }

  on(event, handler) {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, []);
    }
    this.handlers.get(event).push(handler);
  }
}

// 사용
const emitter = new EventEmitter();

emitter.on('login', (data) => {
  console.log('User:', data.username);
  data.username = 'hacker';  // ✗ 무시됨
});

emitter.emit('login', { username: 'Alice', token: 'secret' });

성능 고려사항

Object.freeze는 성능에 어떤 영향을 미칠까요?

Object.freeze 자체의 비용

V8 엔진 기준으로, Object.freeze 호출은 객체의 내부 플래그를 변경하는 작업입니다. 단순 객체에서는 무시할 수 있는 수준입니다.

// 성능 테스트
const iterations = 1000000;

console.time('without freeze');
for (let i = 0; i < iterations; i++) {
  const obj = { x: 1, y: 2 };
}
console.timeEnd('without freeze');

console.time('with freeze');
for (let i = 0; i < iterations; i++) {
  const obj = Object.freeze({ x: 1, y: 2 });
}
console.timeEnd('with freeze');

deepFreeze의 비용

deepFreeze는 재귀적으로 모든 중첩 객체를 순회하므로, 대용량 객체에서는 주의가 필요합니다.

// ❌ 대용량 객체에서 비용이 큼
const hugeObject = generateLargeNestedObject();
deepFreeze(hugeObject);  // 느릴 수 있음

// ✅ 필요한 부분만 선택적으로 동결
const config = Object.freeze({
  constants: deepFreeze({
    API_URL: 'https://api.example.com'
  }),
  cache: {}  // 자주 변경되는 부분은 동결 안 함
});

대안: Immutable.js와 Immer

런타임에서 완전한 불변성이 필요하다면, 전용 라이브러리를 고려해볼 수 있습니다.

Immutable.js

Facebook에서 개발한 불변 데이터 구조 라이브러리입니다.

import { Map, List } from 'immutable';

const user = Map({
  name: 'Alice',
  address: Map({
    city: 'Seoul'
  })
});

// 변경 시 새 객체 반환
const updated = user.setIn(['address', 'city'], 'Busan');

console.log(user.getIn(['address', 'city']));    // 'Seoul' (원본 유지)
console.log(updated.getIn(['address', 'city'])); // 'Busan' (새 객체)

Immer

더 직관적인 API로 불변 업데이트를 지원합니다.

import produce from 'immer';

const user = {
  name: 'Alice',
  address: { city: 'Seoul' }
};

const updated = produce(user, draft => {
  draft.address.city = 'Busan';  // 마치 직접 수정하는 것처럼
});

console.log(user.address.city);    // 'Seoul' (원본 유지)
console.log(updated.address.city); // 'Busan' (새 객체)

디버깅 팁

Object.freeze 관련 문제를 디버깅하는 방법입니다.

객체 상태 확인

const obj = Object.freeze({ x: 1 });

// 동결 여부 확인
console.log(Object.isFrozen(obj));  // true

// 봉인 여부 확인
console.log(Object.isSealed(obj));  // true

// 확장 가능 여부 확인
console.log(Object.isExtensible(obj));  // false

// 속성 설명자 확인
console.log(Object.getOwnPropertyDescriptor(obj, 'x'));
// { value: 1, writable: false, enumerable: true, configurable: false }

중첩 객체 동결 상태 확인

function checkFreezeDepth(obj, path = '') {
  if (obj === null || typeof obj !== 'object') return;

  const frozen = Object.isFrozen(obj);
  console.log(`${path || 'root'}: ${frozen ? 'frozen' : 'NOT frozen'}`);

  const props = Object.getOwnPropertyNames(obj);
  for (let i = 0; i < props.length; i++) {
    const value = obj[props[i]];
    if (value && typeof value === 'object') {
      checkFreezeDepth(value, `${path}.${props[i]}`);
    }
  }
}

const user = Object.freeze({
  name: 'Alice',
  address: { city: 'Seoul' }
});

checkFreezeDepth(user);
// root: frozen
// .address: NOT frozen

결론

핵심 정리

  1. Object.freeze는 얕은 동결만 수행합니다
    • 최상위 속성만 보호
    • 중첩 객체는 여전히 변경 가능
  2. 완전한 불변성을 원한다면 deepFreeze 사용
    • 재귀적으로 모든 중첩 객체 동결
    • 성능 비용 고려 필요
  3. Strict Mode 사용 권장
    • 동결된 객체 수정 시 명시적 에러 발생
  4. 대안도 고려
    • Immutable.js: 불변 데이터 구조
    • Immer: 직관적인 불변 업데이트

사용 시기

상황 권장 방법
설정 객체 보호 Object.freeze
중첩된 설정 deepFreeze
대용량 상태 관리 Immer
복잡한 데이터 구조 Immutable.js

참고 자료

댓글