Ayden's journal

RAD 아키텍처의 decorator

RAD 아키텍처는 데코레이터를 적극적으로 사용해 여러 클래스 훅에서 동일하게 처리되어야 할 관심사를 횡적으로 분리한다. 나는 주로 세 가지 데코레이터를 사용하는데, thisBind∙transformResult∙transformArgs가 그것이다. 이 포스트에서는 각각의 데코레이터가 어떤 관심사를 처리하는지 알아보고자 한다.

 

thisBind 클래스 데코레이터

[ the Rules of Hooks in a class-based architecture ] 에서 나는 this binding의 문제로 인해 함수 선언문보다는 함수 표현식을 권장한다고 말했다. 그러나 이후에 살펴볼 transformResult나 transformArgs 데코레이터를 '하나의 메서드'에 동시에 적용하기 위해서는 함수 선언문을 사용해야 한다. 함수 표현식은 메서드 데코레이터가 아니라 프로퍼티 데코레이터를 사용하게 되는데, 프로퍼티 데코레이터는 PropertyDescriptor를 받지 못해 데코레이터 체이닝이 몹시 불편하기 때문이다.

따라서 나는 함수 선언문을 사용하면서도 클래스 훅을 사용하면서 발생할 수 있는 this binding 문제에서 벗어나기 위해 thisBind라는 클래스 데코레이터를 만들어 사용하고 있다. thisBind 데코레이터는 클래스의 모든 메서드가 항상 올바른 인스턴스 컨텍스트를 유지하도록 보장한다.

데코레이터는 두 가지 핵심 전략을 사용하는데, 프로토타입 메서드에는 인스턴스에 바인딩된 함수를 반환하는 getter를 생성하고, 인스턴스 속성인 일반 함수에는 명시적으로 bind()를 적용한다. 화살표 함수는 이미 렉시컬 스코프의 this를 캡처하므로 자동으로 제외된다. 이 접근 방식을 사용하면 메서드가 어떻게 호출되든 항상 올바른 this 참조를 유지하므로 "Cannot read properties of undefined" 유형의 런타임 오류를 방지할 수 있다.

function thisBind<T extends { new (...args: any[]): any }>(constructor: T) {
  return class extends constructor {
    constructor(...args: any[]) {
      super(...args);
      
      // 인스턴스 생성 후 처리
      const instance = this;
      
      // 프로토타입 메서드 바인딩
      const methodNames = Object.getOwnPropertyNames(constructor.prototype)
        .filter(name => 
          name !== 'constructor' && 
          typeof constructor.prototype[name] === 'function'
        );
      
      for (const methodName of methodNames) {
        // 중요: 속성 getter를 만들어 항상 바인딩된 함수 반환
        Object.defineProperty(instance, methodName, {
          get: function() {
            // 항상 현재 인스턴스에 바인딩된 함수를 반환
            return constructor.prototype[methodName].bind(instance);
          },
          configurable: true,
          enumerable: true
        });
      }
      
      // 인스턴스 속성 처리 (화살표 함수 + 일반 함수 속성)
      const propertyNames = Object.getOwnPropertyNames(instance);
      
      for (const key of propertyNames) {
        const descriptor = Object.getOwnPropertyDescriptor(instance, key);
        const isMethod = descriptor && typeof descriptor.value === 'function';
        
        if (isMethod) {
          // 화살표 함수 속성은 이미 this가 바인딩되어 있으므로 추가 작업 불필요
          // prototype이 없는 함수는 화살표 함수로 간주
          if (!descriptor!.value.prototype) {
            continue;
          }
          
          // 일반 함수 속성에 this 바인딩
          Object.defineProperty(instance, key, {
            value: descriptor!.value.bind(instance),
            enumerable: descriptor!.enumerable,
            configurable: descriptor!.configurable,
            writable: descriptor!.writable
          });
        }
      }
    }
  };
}

 

아래의 사용 예시를 살펴보자. 하나의 클래스 안에 서로 다른 방식으로 메서드가 구현되어있다. 보통이라면 각각의 this가 참조하는 대상이 상황에 따라 달라지겠지만, thisBind 데코레이터 덕분에 언제나 인스턴스를 가리키게 된다.

@thisBind
class Example {  
  name = "Example";

  // 일반 메서드 (프로토타입)
  doSomething() {
    console.log("일반 메서드:", this.name);
  }
  
  // 인스턴스 메서드 (일반 함수)
  instanceMethod = function(this: any) {
    console.log("인스턴스 메서드:", this.name);
  };
  
  // 화살표 함수 (자체 바인딩)
  arrowMethod = () => {
    console.log("화살표 함수:", this.name);
  };
}


가령 이런 경우를 생각해보자. 구조 분해 할당으로 this 연결을 끊고, 심지어 새로운 객체에 넣어서 this가 a를 바라보게 만들었다.

 

const { doSomething, instanceMethod, arrowMethod } = new Example();

const a = {
  doSomething,
  instanceMethod,
  arrowMethod
}

a.doSomething();
a.instanceMethod();
a.arrowMethod();

 

처음 세 로그는 thisBind 없이 메서드가 호출된 경우를 나타낸다. 당연하게도 this 연결이 끊어지고 a 객체에는 name도 없기 때문에 화살표 함수를 제외한 나머지 메서드들은 undefined를 출력한다. 그러나 thisBind를 사용하여 클래스의 모든 메서드에 this를 바인딩한 뒤의 세 로그는 데코레이터를 통해 this가 유지되는 덕분에 모두 "Example"을 출력하는 것을 확인할 수 있다.

 

 

transformArgs & transformResult 메서드 데코레이터

transformArgs는 메서드가 실행되기 전에 전달된 인자들을 가공하여 새로운 값으로 바꿔주며, transformResult는 메서드 실행 이후 반환된 결과를 가공하는 역할을 한다. 이 두 데코레이터를 함께 사용하면 입력과 출력을 모두 제어할 수 있어, 로깅, 유효성 검사, 포맷팅, 또는 비즈니스 로직에 맞춘 전처리/후처리를 적용하는 데 유용하다. 예를 들어, API 호출 전 인자를 정제하거나, 반환된 데이터를 특정 형식으로 가공해야 하는 경우에 매우 효과적이다.

function transformResult<T>(transformer: (result: T) => any) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    
    descriptor.value = function(...args: any[]) {
      // 원본 메서드 호출
      const result = originalMethod.apply(this, args);
      
      // 응답이 없거나 data가 객체가 아닌 경우
      if (!result.data || typeof result.data !== 'object') return result;

      // 결과값 변환
      return {
        ...result,
        data: transformer(result.data),
      }
    };
    
    return descriptor;
  };
}
function transformArgs<T extends any[]>(transformer: (args: any[]) => T) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    
    descriptor.value = function(...args: any[]) {
      // 인자 변환
      const transformedArgs = transformer(args);
      
      // 변환된 인자로 원본 메서드 호출
      return originalMethod.apply(this, transformedArgs);
    };
    
    return descriptor;
  };
}

 

transformArgs와 transformResult 데코레이터를 함께 사용하면, 메서드의 인자와 반환값을 각각 원하는 형태로 유연하게 변환할 수 있어 코드의 재사용성과 가독성을 높일 수 있다.

예를 들어, transformArgs를 사용해 메서드에 전달되는 ID 값을 대문자로 변환하고, transformResult를 통해 반환되는 결과에서 필요한 정보만 추출한 뒤 추가 속성을 덧붙이면, 호출자는 보다 직관적이고 간결한 데이터를 얻을 수 있다. 이처럼 데코레이터를 적절히 조합하면 메서드 로직은 그대로 유지하면서도 입력과 출력을 상황에 맞게 제어할 수 있어, 유지보수성과 확장성이 크게 향상된다.

// 사용 예시
@thisBind
class Example {  
  @transformArgs((args) => [args[0].toUpperCase()])
  @transformResult((data: {user: { id: string, name: string }}) => ({ ...data.user, verified: true }))
  getUserById(id: string) {
    return { data: { user: { id, name: `사용자-${id}` }}};
  }
}

const { getUserById } = new Example();
getUserById("ayden")

// 둘 다 썼을 때
{data: {id: "AYDEN", name: "사용자-AYDEN", verified: true}}

// transformArgs만 썼을 때
{data: {user: {id: "AYDEN", name: "사용자-AYDEN"}}}

// transformResult만 썼을 때
{data: {id: "ayden", name: "사용자-ayden", verified: true}}

// 둘 다 안 썼을 때
{data: {user: {id: "ayden", name: "사용자-ayden"}}}

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기