Object.freeze로 상수를 지켰다고 생각했는데 중첩된 값이 바뀐 이유
문제 발생
설정 객체를 상수로 잠갔다고 생각했는데 런타임에 값이 바뀌어 있었습니다.
const config = Object.freeze({
api: { baseUrl: "https://api.example.com", timeout: 3000 },
});
config.api.timeout = 100; // 막히지 않는다
config.api.timeout; // 100게다가 최상위 속성에 대입했을 때도 오류가 나지 않아, 코드를 읽으면 성공한 것처럼 보였습니다.
config.api = {}; // 조용히 무시된다 (비엄격 모드)원인 분석
Object.freeze()가 막는 것은 그 객체 자신의 속성 추가·삭제·값 변경·속성 서술자 변경, 그리고 프로토타입 재지정입니다. 얕습니다 — 속성이 가리키는 중첩 객체는 얼지 않습니다. config.api라는 참조는 잠기지만 그 참조가 가리키는 객체 내부는 그대로 열려 있습니다.
두 번째 문제는 실패 방식입니다.
| 모드 | 얼린 속성에 대입할 때 |
|---|---|
| 비엄격(sloppy) | 조용히 실패 |
| 엄격(strict) | TypeError |
ES 모듈과 클래스 본문은 항상 엄격 모드라 예외가 나지만, 그렇지 않은 스크립트에서는 아무 일도 일어나지 않은 것처럼 보입니다.
해결 방안
- 중첩까지 얼려야 하면 재귀적으로 처리합니다. MDN이 제시하는 형태입니다.
function deepFreeze(object) {
for (const name of Reflect.ownKeys(object)) {
const value = object[name];
if ((value && typeof value === "object") || typeof value === "function") {
deepFreeze(value);
}
}
return Object.freeze(object);
}순환 참조가 있으면 무한 재귀가 되므로 WeakSet으로 방문한 객체를 기록해야 하고, window 같은 전역 객체는 얼리지 않습니다.
- TypeScript를 쓴다면
as const가 더 실용적입니다. 런타임 비용 없이 컴파일 타임에 변경을 막습니다. 실행해 봐야 아는 대신 편집기에서 바로 걸립니다.
const config = {
api: { baseUrl: "https://api.example.com", timeout: 3000 },
} as const;
config.api.timeout = 100; // 컴파일 에러Map과Set은 얼려도 내용이 잠기지 않습니다. 이들의 데이터는 일반 속성이 아니라 내부 슬롯에 있어freeze의 대상이 아닙니다. 불변이 필요하면 애초에 읽기 전용 래퍼를 만들어야 합니다.- 얼리는 것보다 복사하는 편이 나은 경우가 많습니다. 공유 객체를 잠그기보다, 넘길 때마다 필요한 만큼 새로 만드는 쪽이 추적하기 쉽습니다.
- 정말 얼렸는지 확인은
Object.isFrozen으로 합니다. 다만 이것도 얕은 판정이라 중첩 객체까지 보장하지 않습니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.