본문으로 건너뛰기
개발 머꼬
개발 노트JavaScript
hohyeon.dev15

Object.freeze로 상수를 지켰다고 생각했는데 중첩된 값이 바뀐 이유

  • #Engineering Note
  • #JavaScript

문제 발생

설정 객체를 상수로 잠갔다고 생각했는데 런타임에 값이 바뀌어 있었습니다.

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 모듈과 클래스 본문은 항상 엄격 모드라 예외가 나지만, 그렇지 않은 스크립트에서는 아무 일도 일어나지 않은 것처럼 보입니다.

해결 방안

  1. 중첩까지 얼려야 하면 재귀적으로 처리합니다. 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 같은 전역 객체는 얼리지 않습니다.

  1. TypeScript를 쓴다면 as const가 더 실용적입니다. 런타임 비용 없이 컴파일 타임에 변경을 막습니다. 실행해 봐야 아는 대신 편집기에서 바로 걸립니다.
const config = {
  api: { baseUrl: "https://api.example.com", timeout: 3000 },
} as const;

config.api.timeout = 100;   // 컴파일 에러
  1. MapSet은 얼려도 내용이 잠기지 않습니다. 이들의 데이터는 일반 속성이 아니라 내부 슬롯에 있어 freeze의 대상이 아닙니다. 불변이 필요하면 애초에 읽기 전용 래퍼를 만들어야 합니다.
  2. 얼리는 것보다 복사하는 편이 나은 경우가 많습니다. 공유 객체를 잠그기보다, 넘길 때마다 필요한 만큼 새로 만드는 쪽이 추적하기 쉽습니다.
  3. 정말 얼렸는지 확인은 Object.isFrozen으로 합니다. 다만 이것도 얕은 판정이라 중첩 객체까지 보장하지 않습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.