PUBLISHED

descriptor

작성일: 2025.12.26

descriptor

새로운 기능 개발을 마친 뒤, 여기저기 흩어져 있던 데이터와 유틸리티 로직을 하나의 클래스로 정리하는 리팩터링을 진행했다. 로직을 쪼개거나 책임을 재설계하는 작업은 아니었고, 단순히 구조를 정돈하는 수준이었기 때문에 큰 문제가 생길 것이라 예상하지 않았다. 그러나 dev 환경에서 확인한 결과, 전혀 예상하지 못한 오류가 발생했다.

untitled
TS
@thisBind
export class AssetPricePrediction {
  public actual: PredictionDTO['res']['get']['pastPriceByAssetId']['actual'];
  public median: PredictionDTO['res']['get']['futurePriceByAssetId']['median'];
  public interval50: PredictionDTO['res']['get']['futurePriceByAssetId']['interval50'];
  public interval90: PredictionDTO['res']['get']['futurePriceByAssetId']['interval90'];

  constructor(
    data: PredictionDTO['res']['get']['pastPriceByAssetId'] &
      PredictionDTO['res']['get']['futurePriceByAssetId'],
  ) {
    this.actual = data.actual;
    this.median = data.median;
    this.interval50 = data.interval50;
    this.interval90 = data.interval90;
  }

  get currentPrice(): number {
    return this.actual.at(-1)?.at(-1) as number;
  }

  //...중략
}

클래스 생성자에서는 분명히 값이 정상적으로 주입되었음에도 불구하고, 동일한 인스턴스의 getter에서는 해당 값이 undefined로 평가되고 있었다. 객체의 상태가 생성자 실행 이후에 바뀐 것도 아니었고, 참조가 끊어진 것처럼 보이지도 않았다. 그저 같은 객체 안에서 시점에 따라 상태가 다르게 보이는, 설명하기 어려운 상황이었다.

이미지에 alt 속성이 없음

초반에는 React Query의 SuspenseQuery 동작을 의심했다. 비동기 경계에서 인스턴스가 재생성되거나 참조가 꼬였을 가능성을 먼저 떠올렸기 때문이다. 하지만 데이터 패칭 결과는 일관됐고, Suspense 자체의 동작에도 특별한 문제는 없었다. 오히려 이상한 점은 생성자와 getter 사이에서 객체의 상태가 일관되지 않게 보존되고 있다는 사실이었다. 이 시점부터 문제의 원인을 React나 Query 레이어가 아니라, 클래스 인스턴스가 생성되고 사용되는 방식 자체에서 찾기 시작했다.

문제의 실마리는 클래스에 적용된 @thisBind 데코레이터였다. 이 데코레이터는 클래스의 prototype을 순회하며 모든 메서드를 인스턴스에 바인딩해, this 컨텍스트가 항상 유지되도록 만드는 역할을 한다. 구현 자체는 흔히 볼 수 있는 패턴이었고, 그동안 여러 곳에서 문제 없이 사용해 왔던 코드였다.

하지만 prototype을 순회하며 (proto as any)[name] 방식으로 프로퍼티를 가져오는 이 구현은, 해당 프로퍼티가 일반 메서드인지, 접근자인지에 대한 구분을 전혀 하지 않고 있었다. 여기서 문제가 발생했다. getter는 메서드처럼 “참조 가능한 함수”가 아니라, 접근하는 순간 즉시 실행되는 접근자다. 즉 prototype에서 해당 프로퍼티에 접근하는 행위 자체가 이미 getter 호출이 되어 버린다.

더 큰 문제는 이 getter 호출 시점이었다. 데코레이터가 동작하는 시점은 인스턴스가 완전히 초기화되기 전이다. 생성자는 아직 실행되지 않았거나, 모든 필드 초기화가 끝나지 않은 상태다. 그 상태에서 getter가 실행되면, 내부에서 참조하는 this.actual 같은 값은 당연히 존재하지 않는다. 이로 인해 생성자에서는 정상적으로 보이던 값이, 이후 getter 접근 시 undefined로 평가되는 상황이 만들어졌다.

untitled
TS
export function thisBind<T extends new (...args: any[]) => any>(
  value: T,
  context: ClassDecoratorContext,
): T | void {
  if (context.kind !== 'class') throw new Error('@thisBind can only be applied to classes');

  class cls extends value {
    constructor(...args: any[]) {
      super(...args);

      const isMethod = (v: PropertyDescriptor['value']): v is Function => typeof v === 'function';
      const isArrowFunction = (fn: Function) => !fn.prototype;
      const bound = new Set<string>();

      let proto = value.prototype;
      while (proto && proto !== Object.prototype) {
        for (const name of Object.getOwnPropertyNames(proto)) {
          if (name === 'constructor' || bound.has(name)) continue;
          const descriptor = Object.getOwnPropertyDescriptor(proto, name);
          if (!descriptor || !isMethod(descriptor.value)) continue;

          Object.defineProperty(this, name, {
            value: descriptor.value.bind(this),
            writable: true,
            configurable: true,
            enumerable: false,
          });
          bound.add(name);
        }
        proto = Object.getPrototypeOf(proto);
      }

      for (const key of Object.getOwnPropertyNames(this)) {
        if (bound.has(key)) continue;
        const descriptor = Object.getOwnPropertyDescriptor(this, key);
        if (!descriptor?.value || !isMethod(descriptor.value)) continue;
        if (isArrowFunction(descriptor.value)) continue;

        Object.defineProperty(this, key, {
          value: descriptor.value.bind(this),
          writable: descriptor.writable,
          configurable: descriptor.configurable,
          enumerable: descriptor.enumerable,
        });
      }
    }
  }

  Object.defineProperty(cls, 'name', { value: 'thisBound_' + value.name });

  return cls;
}

이 코드는 메서드를 가져온다고 생각하기 쉽지만, accessor 프로퍼티에 대해서는 “가져오는 것”이 아니라 “실행하는 것”이 된다. getter 내부에서 this를 사용하고 있었다면, 이 과정에서 this 참조가 잘못된 상태로 평가될 가능성이 매우 높다. 특히 데코레이터처럼 인스턴스 초기화 이전에 실행되는 코드에서는, 이런 접근 하나만으로도 객체 상태가 쉽게 깨진다.

결국 이 문제의 본질은 this 바인딩이 아니었다. getter를 일반 메서드처럼 취급한 잘못된 리플렉션이 문제였다. 언어 차원에서 서로 다른 개념인 메서드와 접근자를 구분하지 않고 동일하게 다룬 결과, 객체의 상태 일관성이 깨진 것이다.

이 문제는 Object.getOwnPropertyDescriptor를 사용하면서 깔끔하게 해결할 수 있었다. 프로퍼티에 직접 접근하지 않고도, 해당 멤버가 데이터 프로퍼티인지, 접근자인지에 대한 메타 정보를 안전하게 조회할 수 있기 때문이다. descriptor를 기준으로 일반 메서드에만 바인딩을 적용하도록 수정하자, 의도치 않은 getter 호출이 완전히 사라졌고 객체의 상태도 안정적으로 유지되었다.

untitled
TS
export function thisBind<T extends new (...args: any[]) => any>(
  value: T,
  context: ClassDecoratorContext,
): T | void {
  if (context.kind !== 'class') throw new Error('@thisBind can only be applied to classes');

  class cls extends value {
    constructor(...args: any[]) {
      super(...args);

      const isMethod = (v: PropertyDescriptor['value']): v is Function => typeof v === 'function';
      const isArrowFunction = (fn: Function) => !fn.prototype;
      const bound = new Set<string>();

      let proto = value.prototype;
      while (proto && proto !== Object.prototype) {
        for (const name of Object.getOwnPropertyNames(proto)) {
          if (name === 'constructor' || bound.has(name)) continue;
          const descriptor = Object.getOwnPropertyDescriptor(proto, name);
          if (!descriptor || !isMethod(descriptor.value)) continue;

          Object.defineProperty(this, name, {
            value: descriptor.value.bind(this),
            writable: true,
            configurable: true,
            enumerable: false,
          });
          bound.add(name);
        }
        proto = Object.getPrototypeOf(proto);
      }

      for (const key of Object.getOwnPropertyNames(this)) {
        if (bound.has(key)) continue;
        const descriptor = Object.getOwnPropertyDescriptor(this, key);
        if (!descriptor?.value || !isMethod(descriptor.value)) continue;
        if (isArrowFunction(descriptor.value)) continue;

        Object.defineProperty(this, key, {
          value: descriptor.value.bind(this),
          writable: descriptor.writable,
          configurable: descriptor.configurable,
          enumerable: descriptor.enumerable,
        });
      }
    }
  }

  Object.defineProperty(cls, 'name', { value: 'thisBound_' + value.name });

  return cls;
}

이전까지 Object.getOwnPropertyDescriptor는 실제 애플리케이션 코드에서 거의 쓸 일이 없는 API처럼 느껴졌다. 누가 이런 메서드를 직접 써야 할까 싶기도 했다. 하지만 이번 일을 겪으면서, 왜 이런 API가 언어 차원에서 제공되는지 명확히 이해하게 됐다. 이 시점부터 자연스럽게 descriptor라는 개념 자체에 관심이 생기기 시작했다.

JavaScript에서 객체의 메서드와 프로퍼티는 우리가 작성한 형태 그대로 저장되지 않는다. 참조형 객체의 모든 프로퍼티는 내부적으로 descriptor라는 메타 정보 구조로 해석된다. 즉 객체는 단순히 “키와 값의 집합”이 아니라, 각 프로퍼티마다 해당 값이 어떻게 동작해야 하는지를 설명하는 규칙을 함께 가지고 있다.

일반적인 프로퍼티는 data descriptor로 표현된다. 이 경우 프로퍼티는 실제 값을 가지며, 해당 값이 변경 가능한지, 열거 가능한지, 재정의 가능한지에 대한 플래그를 함께 포함한다. 우리가 흔히 사용하는 필드나 메서드 대부분이 이 형태에 해당한다. 메서드 역시 함수 값을 가지는 data descriptor일 뿐이다.

untitled
TS
{
  value: any,
  writable: boolean,
  enumerable: boolean,
  configurable: boolean
}

반면 getter나 setter가 정의된 프로퍼티는 accessor descriptor로 해석된다. 이 경우 프로퍼티는 값을 직접 가지지 않는다. 대신 값을 만들어내는 규칙, 즉 getset 함수만을 가진다. 이 차이는 단순한 구현 디테일이 아니라, 객체를 다루는 방식 전반에 영향을 미친다.

untitled
TS
{
  get?: () => any,
  set?: (v: any) => void,
  enumerable: boolean,
  configurable: boolean
}

이 차이를 한 문장으로 요약하면 이렇다.

data descriptor는 값을 가진다. 반면 accessor descriptor는 값을 만드는 규칙을 가진다.

이로 인해 두 descriptor는 전혀 다른 성질을 가진다. data descriptor는 값이 이미 존재하기 때문에 “복사”가 가능하다. 반면 accessor descriptor는 접근 시점마다 평가되기 때문에, 복사되는 순간 이미 값으로 변환된다. 이 때문에 spread 연산자나 구조 분해 할당은 accessor를 그대로 옮기지 못하고, 즉시 평가된 결과 값만을 가져오게 된다.

이 차이는 JavaScript에서 객체를 “다룬다”는 행위가 단순히 값을 옮기거나 읽는 것 이상이라는 사실을 드러낸다. 우리가 흔히 사용하는 spread 연산자나 구조 분해 할당은 객체의 프로퍼티를 있는 그대로 복제하는 도구가 아니다. 이 연산들은 어디까지나 값을 읽는 문법이며, 그 과정에서 accessor는 규칙이 아닌 결과로 치환된다. 즉, 이 문법들은 descriptor의 구조를 보존하지 않고, 평가된 값만을 취급한다.

바로 이 지점에서 descriptor를 직접 다루는 API들의 역할이 분명해진다. JavaScript는 값 중심의 문법과는 별도로, 프로퍼티의 구조 자체를 안전하게 조회하고 복사할 수 있는 수단을 제공한다. Object.getOwnPropertyDescriptorObject.defineProperty 같은 메서드들은 값을 건드리지 않고, 프로퍼티가 어떤 규칙으로 정의되어 있는지를 기준으로 객체를 다루도록 설계되어 있다. 객체를 구조적으로 조작해야 하는 순간, 이런 API들은 선택지가 아니라 필수가 된다.

descriptor를 직접 조작해야 하는 상황은 대체로 네 가지로 나눌 수 있다. 조회, 정의, 제한, 그리고 검사다. JavaScript의 Object 스태틱 메서드들은 이 네 가지 목적을 중심으로 구성되어 있으며, 각각은 값이 아니라 프로퍼티의 규칙을 다루는 데 초점이 맞춰져 있다.

첫 번째는 조회다. 객체의 특정 프로퍼티가 어떤 형태로 정의되어 있는지, 즉 data descriptor인지 accessor descriptor인지 확인해야 하는 경우다. 이때 사용하는 메서드가 Object.getOwnPropertyDescriptor다. 이 메서드는 프로퍼티에 접근하지 않고도 descriptor를 반환하기 때문에, getter를 의도치 않게 실행하지 않는다.

untitled
JS
const descriptor = Object.getOwnPropertyDescriptor(obj, 'value');

if (descriptor?.value) {
  // data descriptor
}

if (descriptor?.get) {
  // accessor descriptor
}

여러 프로퍼티를 한 번에 다뤄야 한다면 Object.getOwnPropertyDescriptors를 사용할 수 있다. 이 메서드는 객체가 가진 모든 own 프로퍼티의 descriptor를 한 번에 반환하며, 객체의 구조를 통째로 복제하거나 분석할 때 유용하다.

untitled
JS
const descriptors = Object.getOwnPropertyDescriptors(obj);

두 번째는 정의다. 프로퍼티를 추가하거나 재정의할 때, 단순히 값을 할당하는 것만으로는 충분하지 않은 경우가 있다. 이때 사용하는 메서드가 Object.definePropertyObject.defineProperties다. 이 메서드들은 값을 설정하는 것이 아니라, 해당 프로퍼티가 어떤 규칙으로 동작해야 하는지를 명시한다.

untitled
JS
Object.defineProperty(obj, 'x', {
  value: 10,
  writable: false,
  enumerable: false,
  configurable: false,
});

class 문법으로 정의한 메서드나 getter 역시 내부적으로는 이런 descriptor 정의 과정의 결과다. class는 descriptor를 직접 다루지 않게 해주는 문법적 추상화일 뿐, 객체 모델의 본질은 변하지 않는다.

세 번째는 제한이다. 객체나 프로퍼티가 앞으로 어떻게 변할 수 있는지를 통제해야 하는 경우다. Object.preventExtensions는 새로운 프로퍼티 추가를 막고, Object.seal은 모든 프로퍼티의 configurablefalse로 만든다. Object.freeze는 여기에 더해 writable까지 false로 설정한다.

untitled
JS
Object.freeze(obj);

이 메서드들은 값을 변경하는 것이 아니라, descriptor의 플래그를 변경함으로써 객체의 변형 가능성을 제한한다. 즉 상태를 고정하는 것이 아니라, 규칙을 고정한다.

네 번째는 검사다. 앞서 설정한 제한 상태를 확인해야 하는 경우다. Object.isExtensible, Object.isSealed, Object.isFrozen은 객체가 현재 어떤 제약 하에 있는지를 확인한다. 이들 역시 값이 아니라 descriptor 기반 상태를 검사한다.

untitled
JS
Object.isFrozen(obj); // true or false

이렇게 보면 JavaScript의 객체 조작 API들은 일관된 방향성을 가진다. 값에 접근하거나 값을 복사하는 문법과 달리, Object의 스태틱 메서드들은 프로퍼티의 구조와 규칙을 직접 다룬다. 메타 프로그래밍이나 리플렉션처럼 객체를 구조적으로 조작해야 하는 순간, 이 API들은 선택지가 아니라 전제 조건에 가깝다.

JavaScript에서 객체를 “어떻게 다룬다”는 말은, 결국 descriptor를 어떤 수준까지 의식하고 있는지의 문제다. 평소에는 문법이 이 복잡성을 가려 주지만, 그 추상화를 벗어나는 순간 descriptor는 더 이상 무시할 수 없는 실체로 드러난다.