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
결론
핵심 정리
- Object.freeze는 얕은 동결만 수행합니다
- 최상위 속성만 보호
- 중첩 객체는 여전히 변경 가능
- 완전한 불변성을 원한다면 deepFreeze 사용
- 재귀적으로 모든 중첩 객체 동결
- 성능 비용 고려 필요
- Strict Mode 사용 권장
- 동결된 객체 수정 시 명시적 에러 발생
- 대안도 고려
- Immutable.js: 불변 데이터 구조
- Immer: 직관적인 불변 업데이트
사용 시기
| 상황 | 권장 방법 |
|---|---|
| 설정 객체 보호 | Object.freeze |
| 중첩된 설정 | deepFreeze |
| 대용량 상태 관리 | Immer |
| 복잡한 데이터 구조 | Immutable.js |
댓글