본문으로 건너뛰기
기술스택 · stack/javascript/getter · setter와 Object.freeze: 속성의 동작 정하기

JavaScript · 7장 프로퍼티 설정

getter · setter와 Object.freeze: 속성의 동작 정하기

객체의 속성은 보통 값을 담아 두는 칸이에요. 그런데 읽을 때마다 계산하고 싶거나, 이상한 값이 들어오지 못하게 막고 싶거나, 아예 바꾸지 못하게 잠그고 싶을 때가 있어요. 속성이 어떻게 동작할지 정하는 세 가지 도구를 짧게 정리해요.

#7.1

01. getter · setter: 속성처럼 보이는 함수

메서드 이름 앞에 get을 붙이면, 읽는 쪽에서는 괄호 없이 속성처럼 읽는데 실제로는 함수가 실행돼요. 다른 값으로 계산되는 값(합계, 전체 이름)을 따로 저장하지 않아도 늘 최신 값을 돌려줘요. set을 붙이면 값을 넣을 때 함수가 실행돼서, 넣는 값을 나눠 담거나 검사할 수 있어요.

const cart = {
  items: [{ price: 500, qty: 3 }, { price: 2000, qty: 1 }],
  get total() {                                      // 읽을 때마다 계산해요
    return this.items.reduce((sum, i) => sum + i.price * i.qty, 0);
  },
};
console.log(cart.total);                             // 3500 — 괄호 없이 읽어요
cart.items.push({ price: 1200, qty: 2 });
console.log(cart.total);                             // 5900 — 따로 고치지 않아도 최신 값

const user = {
  last: '김', first: '하늘',
  get fullName() { return this.last + this.first; },
  set fullName(value) {                              // 넣을 때 나눠 담아요
    this.last = value[0];
    this.first = value.slice(1);
  },
};
user.fullName = '박가람';
console.log(user.last, user.first, user.fullName);   // 박 가람 박가람

setter로 잘못된 값을 막을 수도 있어요. 실제 값은 클로저(비공개 상태)에 숨겨 두고 getter · setter로만 닿게 하면, 어디서 넣든 같은 검사를 거쳐요. 클래스에서는 같은 일을 #비공개 필드와 함께 해요(9장).

function createProfile() {
  let age = 0;                                  // 밖에서 직접 못 건드려요
  return {
    get age() { return age; },
    set age(value) {
      if (!Number.isInteger(value) || value < 0) throw new RangeError(`나이가 이상해요: ${value}`);
      age = value;
    },
  };
}

const profile = createProfile();
profile.age = 27;
console.log(profile.age);   // 27
profile.age = -3;           // 검사에 걸리면?

getter는 읽는 쪽에서 함수인지 모르니까, 가볍고 부작용이 없게 만들어요. 읽을 때마다 서버에 요청하거나 값을 바꾸는 getter는 버그를 찾기 어렵게 해요.

02. 속성마다 숨어 있는 설정, 플래그

모든 속성은 값 말고도 세 가지 설정(플래그)을 가지고 있어요. 보통은 모두 true라서 의식하지 않을 뿐이에요.

  • writable — 값을 바꿀 수 있는지
  • enumerable — Object.keys · for...in · JSON에 나타나는지
  • configurable — 지우거나 플래그를 다시 바꿀 수 있는지
const item = { name: '연필' };
console.log(Object.getOwnPropertyDescriptor(item, 'name'));
// { value: '연필', writable: true, enumerable: true, configurable: true }

Object.defineProperty(item, 'id', { value: 7 });     // 따로 정하지 않은 플래그는 false
console.log(item.id, Object.keys(item), JSON.stringify(item)); // 7 ['name'] {"name":"연필"}

item.id = 8;                                         // writable이 false라서
console.log(item.id);                                // 7 — 조용히 무시돼요

직접 플래그를 다룰 일은 드물어요. 라이브러리가 "보이지 않는 속성"이나 "읽기 전용 속성"을 만들 때 쓰고, 우리는 그 결과를 만나요. Object.keys에 안 나오는 속성이 있다면 enumerable: false일 가능성이 커요. 심볼 키(심볼)와 함께, 숨은 속성이 생기는 두 가지 이유예요.

03. Object.freeze: 바꾸지 못하게 잠그기, 하지만 얕게

Object.freeze(obj)는 객체의 모든 속성을 writable: false · configurable: false로 바꾸고 새 속성도 못 넣게 해요. 설정값 · 상수 목록처럼 절대 바뀌면 안 되는 값을 지킬 때 써요. 잠긴 객체를 고치려 하면 엄격 모드(모듈 · 클래스 안)에서는 TypeError, 아니면 조용히 무시돼요.

주의할 점은 잠금이 얕다는 거예요. 바깥 객체의 칸만 잠그고, 칸에 든 안쪽 객체는 그대로 바꿀 수 있어요. 얕은 복사와 같은 이유예요(얕은 복사).

'use strict';

const config = Object.freeze({ theme: 'dark', api: { url: '/api' } });

try { config.theme = 'light'; } catch (e) { console.log(e.message); } // 막혀요
config.api.url = '/v2';                    // 안쪽 객체는 잠기지 않았어요
console.log(config.api.url);               // /v2
console.log(Object.isFrozen(config), Object.isFrozen(config.api)); // true false
①Object.freeze(config)는 바깥 상자의 칸(theme, api)만 잠가요. 그래서 theme을 바꾸려 하면 막히지만, api 칸이 가리키는 안쪽 객체의 url은 그대로 바뀌어요. 안쪽까지 잠그려면 안쪽 객체도 하나씩 freeze해야 해요.

안쪽까지 잠그려면 값을 돌면서 객체마다 freeze를 불러요(아래 과제). 덜 엄격한 Object.seal(값은 바꿀 수 있고, 넣기 · 지우기만 막음)도 있지만 실무에서 거의 쓰지 않아요. 화면 상태처럼 "바꾸지 말고 새로 만드는" 규칙은 잠그기보다 불변 업데이트(불변 업데이트)와 TypeScript의 readonly로 지키는 경우가 많아요.

설정 객체를 안쪽까지 잠그는 deepFreeze를 완성해 보세요. 지금은 바깥만 잠가서 timeout이 0으로 바뀌어요. 고치면 막혔어요와 3000 true가 나와야 해요.

'use strict';

function deepFreeze(obj) {
  return Object.freeze(obj);   // 바깥만 잠가요
}

const config = deepFreeze({ api: { url: '/api', timeout: 3000 }, retries: 3 });

try {
  config.api.timeout = 0;
} catch (e) {
  console.log('막혔어요');
}
console.log(config.api.timeout, Object.isFrozen(config.api));
해답 보기

자기 자신을 잠그기 전에, 값 중에서 객체인 것을 먼저 재귀로 잠가요(트리를 도는 재귀). 객체가 아닌 값에서 멈춰요.

function deepFreeze(obj) {
  for (const value of Object.values(obj)) {
    if (value && typeof value === 'object') deepFreeze(value);
  }
  return Object.freeze(obj);
}
// 막혔어요
// 3000 true

04. 실전 포인트

  • 언제 쓰나요? 다른 값으로 계산되는 값은 getter, 넣을 때 검사가 필요한 값은 setter, 앱 전체가 함께 쓰는 설정 · 상수 목록은 Object.freeze(필요하면 깊게)를 써요.
  • 대신 쓰는 것: 계산이 무겁거나 인자가 필요하면 getter보다 이름이 드러나는 메서드(getTotal())가 나아요. 상태를 지키는 건 잠그기보다 불변 업데이트와 TypeScript의 readonly · as const가 흔해요.
  • 흔한 실수: freeze가 안쪽까지 잠근다고 생각하는 것, 엄격 모드가 아니라서 막힌 대입이 조용히 무시되는 걸 모르는 것, getter 안에서 같은 이름의 속성을 읽어 끝없이 자기를 부르는 것(get name() { return this.name; })이에요.

정리

  • getter는 속성처럼 읽히는 함수, setter는 넣을 때 실행되는 함수예요. 검사 · 계산에 써요.
  • 속성마다 writable · enumerable · configurable 플래그가 있어요. defineProperty로 만든 속성은 기본이 false예요.
  • Object.freeze는 객체를 잠그지만 얕아요. 안쪽 객체는 따로 잠가야 해요.
cart.total이 getter라면, 상품을 추가한 뒤 값을 다시 계산해 넣어야 할까요?

아니요. getter는 읽을 때마다 함수가 실행돼서 늘 지금의 items로 계산한 값을 돌려줘요.

Object.freeze(config) 뒤에 config.api.url = '/v2'는 막힐까요?

막히지 않아요. freeze는 바깥 객체의 칸만 잠가요. config.api가 가리키는 안쪽 객체는 잠기지 않아서 그대로 바뀌어요.

Object.keys에 나오지 않는데 읽으면 값이 있는 속성은 어떤 경우일까요?

enumerable 플래그가 false인 속성이거나, 키가 심볼인 속성이에요. Object.getOwnPropertyDescriptor나 Reflect.ownKeys로 확인할 수 있어요.

SIMPLE · 기술스택 / JavaScript — 7장 프로퍼티 설정