Object.freeze의 마법: 불변성의 진짜 이야기

“왜 내 객체가 멋대로 바뀌는 거야?” - 저도 이 문제로 며칠을 고생했습니다.


여러분도 이런 경험 있으신가요?

시나리오: 설정 관리의 악몽

저는 최근에 웹 애플리케이션의 설정 시스템을 만들고 있었습니다. 간단해 보였죠:

// 전역 설정 객체
const config = {
  theme: 'light',
  language: 'ko',
  notifications: true
};

// UI 초기화
function initUI() {
  const currentTheme = config.theme;
  applyTheme(currentTheme);
}

// 설정 변경
function changeTheme(newTheme) {
  config.theme = newTheme;
  applyTheme(newTheme);
}

“완벽한데? 뭐가 문제야?”

문제는 다음날 아침에 터졌습니다.

// utils.js (어느 파일인가에서...)
function someUtilityFunction() {
  // 실수로 config를 수정!
  config.theme = 'debug-mode';
  config.apiKey = 'exposed-key';  // 새로운 속성까지 추가!

  // ... 다른 로직 ...
}

// ui.js
function renderHeader() {
  console.log(config.theme);  // 'debug-mode' (???? 언제 바뀌었지?)
}

문제점들:

  • 어디서든 config를 변경할 수 있음
  • 누가 언제 바꿨는지 추적 불가능
  • 예상치 못한 값으로 인한 버그
  • 디버깅 지옥…

더 끔찍한 경험: 공유 참조의 함정

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

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

// B 모듈에서 사용
const settingsB = originalSettings;
console.log(settingsB.volume);  // 100 (???)
// "어? 나는 50이어야 하는데..."

시각화:

메모리 상황:

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

settingsA.volume = 100 실행
    ↓
모든 참조가 같은 객체를 보기 때문에
originalSettings.volume도 100!
settingsB.volume도 100!

“이게 JavaScript의 ‘공유 참조’ 문제입니다.”


Object.freeze: 마법의 얼음 주문

“얼음”을 떠올려보세요

물을 생각해봅시다.

물 (일반 객체):
┌──────────────┐
│   ~~~~~~~    │  ← 자유롭게 흐름, 모양이 계속 바뀜
│  ~~~~~~~     │
└──────────────┘
어디서든 모양을 바꿀 수 있음

얼음 (Object.freeze):
┌──────────────┐
│   ▓▓▓▓▓▓     │  ← 단단히 얼어붙음, 모양 고정
│   ▓▓▓▓▓▓     │
└──────────────┘
아무도 모양을 바꿀 수 없음!

Object.freeze의 진짜 모습

// 일반 객체 (물)
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에선 에러)
frozenObj.age = 25;            // ✗ 무시됨!
delete frozenObj.name;         // ✗ 무시됨!
console.log(frozenObj);        // { name: 'Alice' } (변하지 않음!)

마법 같은 일이 벌어집니다:

const config = Object.freeze({
  theme: 'light',
  language: 'ko'
});

// 악의적인 코드가 수정을 시도해도...
config.theme = 'dark';         // 조용히 무시됨
config.apiKey = 'secret';      // 조용히 무시됨
delete config.language;        // 조용히 무시됨

console.log(config);
// { theme: 'light', language: 'ko' }
// 완벽하게 보호됨! ✨

실전 예제: Before/After

Before: 혼돈의 설정 관리

// ❌ 문제투성이 코드
let appSettings = {
  theme: 'light',
  volume: 50,
  language: 'ko'
};

// 여러 곳에서 마음대로 수정
function featureA() {
  appSettings.theme = 'dark';
  appSettings.newFeature = true;  // 새 속성까지 추가!
}

function featureB() {
  appSettings.volume = 100;
}

function featureC() {
  delete appSettings.language;  // 삭제까지!
}

// 나중에 다른 곳에서...
function renderUI() {
  console.log(appSettings);
  // { theme: 'dark', volume: 100, newFeature: true }
  // "어? language는 어디 갔어? 누가 theme 바꿨어?" 😱
}

문제점:

  • 예측 불가능한 상태
  • 디버깅 불가능 (누가 언제 바꿨는지 모름)
  • 사이드 이펙트 폭발
  • 팀 협업 시 충돌

After: Object.freeze로 안전하게

// ✅ 안전한 코드
let appSettings = Object.freeze({
  theme: 'light',
  volume: 50,
  language: 'ko'
});

// 설정을 바꾸려면 새 객체를 만들어야 함
function changeTheme(newTheme) {
  // 기존 객체는 불변, 새 객체 생성!
  appSettings = Object.freeze({
    ...appSettings,      // 기존 속성 복사
    theme: newTheme      // theme만 변경
  });

  console.log('테마 변경:', appSettings);
  // 명시적인 변경, 추적 가능!
}

// 악의적인 수정 시도는 무시됨
function maliciousCode() {
  appSettings.theme = 'hacked';        // ✗ 무시
  appSettings.virus = true;            // ✗ 무시
  delete appSettings.language;         // ✗ 무시
}

maliciousCode();
console.log(appSettings);
// { theme: 'light', volume: 50, language: 'ko' }
// 완벽하게 보호됨! ✨

개선 효과:

  • ✅ 예측 가능한 상태
  • ✅ 변경 추적 가능
  • ✅ 사이드 이펙트 제거
  • ✅ 안전한 협업

깊이 파보기: Object.freeze는 어떻게 작동하나?

내부 메커니즘

const obj = { name: 'Alice' };

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

// Object.freeze 실행
Object.freeze(obj);

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

시각화:

Normal Object:
┌─────────────────────────┐
│ name: 'Alice'           │
│ [writable: ✓]           │  ← 자물쇠 없음
│ [configurable: ✓]       │
└─────────────────────────┘

After Object.freeze:
┌─────────────────────────┐
│ name: 'Alice'           │
│ [writable: ✗] 🔒        │  ← 자물쇠!
│ [configurable: ✗] 🔒    │
└─────────────────────────┘

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

권장: 항상 'use strict' 사용!


함정과 주의사항

함정 1: 얕은 동결 (Shallow 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({
  name: 'Alice',        🔒 ← 얼어붙음
  address: {            🔒 ← 참조는 얼어붙음
    city: 'Seoul',      ❌ ← 내부는 안 얼어붙음!
    country: 'Korea'    ❌ ← 여전히 변경 가능
  }
})

해결책: Deep Freeze (깊은 동결)

// ✅ 재귀적으로 모든 것을 동결
function deepFreeze(obj) {
  // 1. 최상위 객체 동결
  Object.freeze(obj);

  // 2. 모든 속성을 순회하며 재귀적으로 동결
  Object.getOwnPropertyNames(obj).forEach(prop => {
    const value = obj[prop];

    // 객체이고 아직 동결되지 않았으면 재귀 호출
    if (value && typeof value === 'object' && !Object.isFrozen(value)) {
      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'

함정 2: 배열도 객체!

// ❌ 배열의 함정
const arr = Object.freeze([1, 2, 3]);

arr[0] = 99;           // ✗ 무시 (요소 변경 불가)
arr.push(4);           // ❌ TypeError (배열 메서드 사용 불가)
arr.pop();             // ❌ TypeError

// 하지만 내부 객체는...
const users = Object.freeze([
  { name: 'Alice' },
  { name: 'Bob' }
]);

users[0].name = 'Charlie';  // ✓ 변경됨! (얕은 동결)
console.log(users[0].name); // 'Charlie' 😱

해결책:

// ✅ 배열과 내부 객체 모두 동결
const users = deepFreeze([
  { name: 'Alice' },
  { name: 'Bob' }
]);

users[0] = { name: 'Charlie' };  // ✗ 무시
users[0].name = 'Charlie';       // ✗ 무시
users.push({ name: 'Dave' });    // ❌ TypeError

console.log(users);
// [{ name: 'Alice' }, { name: 'Bob' }]
// 완벽하게 보호됨!

함정 3: 성능 고려사항

// ❌ 대용량 객체에 deepFreeze는 느릴 수 있음
const hugeObject = {
  level1: {
    level2: {
      level3: {
        // ... 수천 개의 중첩 객체
      }
    }
  }
};

// 재귀적으로 모든 객체 동결 → 느림!
deepFreeze(hugeObject);  // ⚠️ 성능 이슈

해결책: 선택적 동결

// ✅ 필요한 부분만 동결
const config = Object.freeze({
  // 자주 변경되지 않는 부분만 동결
  constants: deepFreeze({
    API_URL: 'https://api.example.com',
    TIMEOUT: 5000
  }),

  // 자주 변경되는 부분은 동결 안 함
  cache: {}  // 동결하지 않음
});

실전 패턴

패턴 1: 불변 상태 관리 (React/Redux 스타일)

// ✅ 상태 업데이트 패턴
let state = Object.freeze({
  user: { name: 'Alice', age: 25 },
  theme: 'light',
  notifications: true
});

// 상태 변경 함수
function updateState(updates) {
  // 기존 상태와 병합하여 새 객체 생성
  state = Object.freeze({
    ...state,
    ...updates
  });

  console.log('State updated:', state);
  return state;
}

// 사용
updateState({ theme: 'dark' });
// { user: {...}, theme: 'dark', notifications: true }

// 직접 수정은 무시됨
state.theme = 'light';         // ✗ 무시
console.log(state.theme);      // 'dark'

시각화:

Initial State:
┌─────────────────────────────┐
│ state v1 (frozen)           │
│ { theme: 'light', ... }     │
└─────────────────────────────┘
         │
         │ updateState({ theme: 'dark' })
         ↓
┌─────────────────────────────┐
│ state v2 (frozen)           │  ← 새 객체!
│ { theme: 'dark', ... }      │
└─────────────────────────────┘
         │
         │ updateState({ notifications: false })
         ↓
┌─────────────────────────────┐
│ state v3 (frozen)           │  ← 또 다른 새 객체!
│ { theme: 'dark',            │
│   notifications: false }    │
└─────────────────────────────┘

패턴 2: Config 싱글톤

// ✅ 안전한 Config 관리
const Config = (function() {
  const defaultConfig = Object.freeze({
    DEBUG: false,
    API_URL: 'https://api.example.com',
    TIMEOUT: 5000
  });

  let currentConfig = defaultConfig;

  return {
    get(key) {
      return currentConfig[key];
    },

    set(key, value) {
      // 새 객체 생성 (불변성 유지)
      currentConfig = Object.freeze({
        ...currentConfig,
        [key]: value
      });
    },

    setMany(updates) {
      currentConfig = Object.freeze({
        ...currentConfig,
        ...updates
      });
    },

    reset() {
      currentConfig = defaultConfig;
    },

    getAll() {
      // 복사본 반환 (직접 수정 방지)
      return { ...currentConfig };
    }
  };
})();

// 사용
console.log(Config.get('DEBUG'));  // false
Config.set('DEBUG', true);
console.log(Config.get('DEBUG'));  // true

// 직접 접근은 불가능
Config.currentConfig = {};  // ✗ 접근 불가 (private)

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

// ✅ 이벤트 리스너에 전달되는 데이터 보호
class EventBus {
  constructor() {
    this.handlers = {};
  }

  emit(event, data) {
    // 데이터를 동결하여 리스너가 수정 못하게
    const frozenData = Object.freeze({ ...data });

    if (this.handlers[event]) {
      this.handlers[event].forEach(handler => {
        try {
          handler(frozenData);
        } catch (error) {
          console.error('Handler error:', error);
        }
      });
    }
  }

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

// 사용
const bus = new EventBus();

bus.on('user:login', (data) => {
  console.log('User logged in:', data.username);

  // 데이터 수정 시도 (무시됨)
  data.username = 'hacker';  // ✗ 무시
  data.token = 'stolen';     // ✗ 무시
});

bus.emit('user:login', { username: 'Alice', token: 'secret123' });
// 원본 데이터 안전하게 보호됨!

Object.freeze vs 다른 방법들

비교표

방법 변경 방지 추가 방지 삭제 방지 성능 사용 시기
Object.freeze 빠름 완전한 불변성 필요
Object.seal 빠름 구조 고정, 값 변경 가능
Object.preventExtensions 빠름 추가만 방지
const 빠름 재할당만 방지

코드로 비교

// 1. const: 재할당만 방지
const obj1 = { x: 1 };
obj1 = { x: 2 };       // ❌ TypeError (재할당 불가)
obj1.x = 2;            // ✓ 속성 변경 가능
obj1.y = 3;            // ✓ 추가 가능
delete obj1.x;         // ✓ 삭제 가능

// 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;         // ✗ 삭제 불가

시각화:

const:
┌─────────────┐
│ obj ─┐      │  재할당만 막음
│      ↓      │
│   { x: 1 }  │  ← 내용 자유롭게 변경 가능
└─────────────┘

Object.freeze:
┌─────────────┐
│ obj ─┐      │  재할당 막음
│      ↓      │
│   🔒{ x: 1 }│  ← 내용도 완전히 잠김!
└─────────────┘

실전 체크리스트

Object.freeze 사용해야 할 때

  • 설정 객체를 보호하고 싶을 때
  • 상수 데이터 (API URL, 에러 메시지 등)
  • 이벤트 데이터를 리스너가 수정 못하게 할 때
  • Redux/React 상태 관리
  • 함수형 프로그래밍 패턴 적용 시

주의해야 할 상황

  • 대용량 객체 (성능 이슈)
  • 중첩된 객체 (deepFreeze 필요)
  • 자주 변경되는 데이터 (새 객체 생성 비용)
  • 서드파티 라이브러리와의 호환성

디버깅 팁

// ✅ 객체가 동결되었는지 확인
const obj = Object.freeze({ x: 1 });
console.log(Object.isFrozen(obj));  // true

// ✅ 속성별로 확인
const descriptor = Object.getOwnPropertyDescriptor(obj, 'x');
console.log(descriptor.writable);      // false
console.log(descriptor.configurable);  // false

// ✅ Strict mode에서 테스트
'use strict';
try {
  obj.x = 2;
} catch (error) {
  console.log('Caught:', error.message);
  // "Cannot assign to read only property"
}

실전 예제: 테마 시스템

// ✅ 완전한 테마 관리 시스템
const ThemeManager = (function() {
  // 테마 정의 (완전히 동결)
  const themes = deepFreeze({
    light: {
      background: '#ffffff',
      text: '#000000',
      primary: '#007bff'
    },
    dark: {
      background: '#1a1a1a',
      text: '#ffffff',
      primary: '#4dabf7'
    }
  });

  // 현재 상태
  let state = Object.freeze({
    currentTheme: 'light',
    customColors: {}
  });

  // Public API
  return {
    getTheme(name) {
      return themes[name];  // 이미 동결된 객체 반환
    },

    getCurrentTheme() {
      return state.currentTheme;
    },

    setTheme(name) {
      if (!themes[name]) {
        throw new Error(`Unknown theme: ${name}`);
      }

      // 새 상태 생성
      state = Object.freeze({
        ...state,
        currentTheme: name
      });

      console.log(`Theme changed to: ${name}`);
      return this.getTheme(name);
    },

    getColors() {
      const theme = themes[state.currentTheme];
      const custom = state.customColors;

      // 병합하여 동결된 객체 반환
      return Object.freeze({
        ...theme,
        ...custom
      });
    },

    setCustomColor(key, value) {
      state = Object.freeze({
        ...state,
        customColors: Object.freeze({
          ...state.customColors,
          [key]: value
        })
      });
    }
  };
})();

// 사용
const lightTheme = ThemeManager.getTheme('light');
console.log(lightTheme);  // { background: '#ffffff', ... }

// 테마 데이터 수정 시도 (무시됨)
lightTheme.background = '#000000';  // ✗ 무시
console.log(lightTheme.background); // '#ffffff' (변하지 않음)

// 테마 변경
ThemeManager.setTheme('dark');
const colors = ThemeManager.getColors();
console.log(colors);  // { background: '#1a1a1a', ... }

// 커스텀 색상 추가
ThemeManager.setCustomColor('accent', '#ff6b6b');
console.log(ThemeManager.getColors());
// { background: '#1a1a1a', ..., accent: '#ff6b6b' }

마무리

핵심 요약

Object.freeze는 무엇인가?

  • 객체를 “얼음”처럼 얼려서 변경 불가능하게 만드는 함수
  • 속성 변경, 추가, 삭제 모두 차단

왜 사용하나?

  • ✅ 예측 가능한 코드
  • ✅ 버그 추적 용이
  • ✅ 사이드 이펙트 제거
  • ✅ 안전한 데이터 공유

주의할 점

  • ⚠️ 얕은 동결 (중첩 객체는 별도 처리)
  • ⚠️ 성능 고려 (대용량 객체)
  • ⚠️ Strict mode 사용 권장

실제로 느낀 변화

Before (Object.freeze 없이):
  - "어? 이 값이 왜 바뀌었지?"
  - 디버깅에 3시간 소요
  - 팀원과 충돌 빈번
  - 예측 불가능한 버그

After (Object.freeze 사용):
  - "값이 안 바뀌네? 완벽!"
  - 버그 즉시 발견
  - 안전한 코드 공유
  - 자신있는 리팩토링

다음 단계

Object.freeze를 마스터했다면:

  • Immer.js: 불변성을 더 쉽게 다루는 라이브러리
  • Immutable.js: Facebook의 불변 데이터 구조
  • 함수형 프로그래밍: 불변성의 진짜 힘

기억하세요: 불변성은 복잡해 보이지만, 실제로는 코드를 더 간단하고 안전하게 만듭니다!


Happy Freezing! ❄️✨


참고 자료

관련 가이드

외부 자료

인터랙티브 예제

직접 테스트해보세요:

// 브라우저 콘솔에서 실행해보세요!

// 1. 일반 객체
const normal = { count: 0 };
normal.count++;
console.log('Normal:', normal.count);  // 1

// 2. 동결된 객체
const frozen = Object.freeze({ count: 0 });
frozen.count++;
console.log('Frozen:', frozen.count);  // 0 (변하지 않음!)

// 3. Strict mode에서
'use strict';
try {
  frozen.count = 10;
} catch (e) {
  console.log('Error caught:', e.message);
}

작성일: 2025-10-20 주제: Object.freeze & Immutability 난이도: Beginner to Intermediate

댓글