Decorator 파보기
TypeScript 데코레이터에 대해 깊이 알아보고 NestJS 클론 코딩을 통해 실제 적용까지 경험한 내용을 담았습니다.
Contents
- TypeScript 의 Decorator
 - Decorator Factory
 - Decorator Composition
 - Class Decorator
 - Method Decorator
 - Accessor Decorator
 - Property Decorator
 - Parameter Decorator
 - NestJS Clone
 - @Get, @Post, @Delete, @Put
 - @Query, @Body
 - @Controller
 - Controllers
 - Reference
 
TypeScript 의 Decorator
타입스크립트 공부를 하다 Decorator 에 대해 깊게 파보고 싶은 마음이 생겨서 깊게 파보게 되었다. Decorator 은 ECMAScript 의 공식 스펙은 아니지만, 예전부터 계속 잘 써보고 싶은 마음이 크게 생겨서 궁금했던 분야였다. 타입스크립트의 Decorator 는 ES6 에 제안되어 현재 Stage 2 상태이고, TypeScript 에서는 experimental feature 로 다루어지고 있다. 기본적인 내용이지만, TypeScript 의 Decorator 를 활성화 하려면 tsconfig 설정이 필요하다.
{
    "compilerOptions": {
        "target": "ES5",
        "experimentalDecorators": true
    }
}
Typescript 의 Decorator 는 기본적으로 @FunctionName 의 문법으로 사용한다. 중요한 점은, Function 을 지목하기만 할 뿐, 실행하지 않는다는 점이다. 나는 이러한 점에 대해 처음에 의문을 가졌었다. 많은 코드들에서 마치 Decorator 를 실행하는 듯한 코드들을 많이 봐왔기 때문이다. NestJS 에서 @Get("/users") 와 같이 데코레이터를 사용하는 것이 가장 대표적인 예시이다. 이러한 문법이 가능한 이유는, 해당 Decorator 는 Decorator Function 을 실행시키는 것이 아니라 Decorator Factory 를 통해 새로운 Decorator 를 동적으로 생성하는 것이기 때문이다.
Decorator Factory
많은 경우에는, Decorator 를 동적으로 생성해서 사용하고 싶은 경우가 많다. Decorator Factory 를 이용하면, Decorator 가 선언될 때 동적인 방식으로 Decorator 를 선언할 수 있다. NestJS 에서 사용하고 있는 Decorator 들 몇개만 봐도, Decorator Factory 의 이점을 찾아볼 수 있다.
@Controller("users")
@Get("/")
@Post("/save")
데코레이터 팩토리는 주로 parameter 를 받아서, 원하는 데코레이터를 생성해 주는 역할을 수행한다. 아래의 예시는 NestJS 를 따라해보며 생성한 @Get, @Post Decorator 의 코드 일부이다.
export function Get(path: string): MethodDecorator {
  return _generateRoute(Method.GET)(path);
}
export function Post(path: string): MethodDecorator {
  return _generateRoute(Method.POST)(path);
}
위에서 선언한 이후에, Controller 에서는 @Get("users"), @Post("users/save") 등과 같이 Decorator 를 사용할 수 있다.
Decorator Composition
여러 개의 Decorator 를 합성해서 사용할 수 있다. 이 때에, 경우에 따라서는 합성하는 순서가 동작에 중요한 역할을 할 수 있다. Decorator 적용 순서에 따라 기능이 변화하는 Decorator 는 좋지 않다고 생각하지만, 연습을 해보며 그런 케이스가 충분히 발생할 수 있다는 것을 알게 되었다. 여러 개의 Decorator 가 합성되는 방식은 고등학생 때 배우는 수학의 합성 함수 의 원리와 같다. 즉, (f∘g)(x) 의 결과는 f(g(x)) 와 결과가 같다. 좀 더 자세히 설명하자면, Decorator 는 다음과 같은 순서를 따르며 평가된다.
각 Decorator 는 Top - Down 방향으로 평가된다. 각 Decorator 는 Down - Top 의 방향으로 호출된다.
function f() {
    console.log("f(): evaluated");
    return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log("f(): called");
    }
}
function g() {
    console.log("g(): evaluated");
    return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log("g(): called");
    }
}
class C {
    @f()
    @g()
    method() {}
}
위의 코드를 실행해 보면, 다음과 같은 결과물이 찍힌다.
f(): evaluated
g(): evaluated
g(): called
f(): called
Class Decorator
Class Decorator 는 Class 의 constructor 에 적용되며, 해당 Class 의 정의를 수정할 수 있다. Class Decorator 가 값을 반환하면, Class 의 constructor 를 교체할 수 있다. 간단히, 아래와 같은 예시를 들 수 있다.
function classDecorator<T extends { new (...args: any[]): {} }>(target: T) {
  return class extends target {
    private newProperty = "new property";
    private wow = "abcd";
    constructor(...args: any) {
      super(...args);
      this.wow = "wow";
    }
  };
}
@classDecorator
class Greeter {
  property = "property";
  hello: string;
  constructor(m: string) {
    this.hello = m;
  }
}
console.log(new Greeter("world"));
/*
Greeter {
  property: 'property',
  hello: 'world',
  newProperty: 'new property',
  wow: 'wow'
}
*/
위의 예시는 새로운 생성자를 return 해주는 함수이다. 위의 코드를 실행해 보면, 기존 코드에는 없었던 wow 라는 property 도 추가되어 있는 것을 확인할 수 있다. 하지만 아쉬운 점은, Typescript 에서 wow property 에 대해 타입 추론을 지원해주지는 않는다. 
Method Decorator
Method Decorator 은 프로퍼티 설명자 (Property Descriptor) 에 적용되며, 메서드의 정의를 수정하는 데 사용할 수 있다. 메서드 데코레이터는 런타임에 다음 세 개의 인수와 함께 함수로 호출된다.
- static 멤버인 경우 클래스의 constructor, instance 멤버인 경우 클래스의 prototype
 - 멤버의 이름
 - 멤버의 Property Descriptor
 
만약 Method Decorator 가 값을 반환하게 되면, Method 의 Property Descriptor 로 설정된다. 또는 reflect-metadata 와 함께 메타프로그밍으로 유용하게 사용될 수 있다.
function enumerable(value: boolean) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        descriptor.enumerable = value;
    };
}
Accessor Decorator
접근자 Decorator 는 접근자의 Property Descriptor 를 수정하는 데에 사용될 수 있다. 접근자 데코레이터의 경우, 런타임에 세 가지 인수와 함께 호출된다.
- static 멤버인 경우 클래스의 constructor, instance 멤버인 경우 클래스의 prototype
 - 멤버의 이름
 - 멤버의 Property Descriptor
 
만약 접근자 Decorator 가 값을 반환하면, 멤버의 Property Descriptor 로 설정된다.
Property Decorator
Property Decorator 는 런타임시 다음의 두 개의 인수와 함께 함수로 호출된다.
- static 멤버인 경우 클래스의 constructor, instance 멤버인 경우 클래스의 prototype
 - 멤버의 이름
 
특이한 점은, Property Decorator 에서는 프로퍼티 설명자(Property Descriptor) 가 인수로 제공되지 않는다.
class Greeter {
    @format("Hello, %s")
    greeting: string;
    constructor(message: string) {
        this.greeting = message;
    }
    greet() {
        let formatString = getFormat(this, "greeting");
        return formatString.replace("%s", this.greeting);
    }
}
...
import "reflect-metadata";
const formatMetadataKey = Symbol("format");
function format(formatString: string) {
    return Reflect.metadata(formatMetadataKey, formatString);
}
function getFormat(target: any, propertyKey: string) {
    return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}
Parameter Decorator
Parameter Decorator 는 런타임시 세 개의 인수와 함께 함수로 호출된다.
- static 멤버인 경우 클래스의 constructor, instance 멤버인 경우 클래스의 prototype
 - 멤버의 이름
 - 함수의 매개 변수 목록에 있는 매개 변수의 index
 
Parameter Decorator 의 반환 값은 무시된다.
 class Greeter {
    greeting: string;
    constructor(message: string) {
        this.greeting = message;
    }
    @validate
    greet(@required name: string) {
        return "Hello " + name + ", " + this.greeting;
    }
}
...
import "reflect-metadata";
const requiredMetadataKey = Symbol("required");
function required(target: Object, propertyKey: string | symbol, parameterIndex: number) {
    let existingRequiredParameters: number[] = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyKey) || [];
    existingRequiredParameters.push(parameterIndex);
    Reflect.defineMetadata(requiredMetadataKey, existingRequiredParameters, target, propertyKey);
}
function validate(target: any, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) {
    let method = descriptor.value;
    descriptor.value = function () {
        let requiredParameters: number[] = Reflect.getOwnMetadata(requiredMetadataKey, target, propertyName);
        if (requiredParameters) {
            for (let parameterIndex of requiredParameters) {
                if (parameterIndex >= arguments.length || arguments[parameterIndex] === undefined) {
                    throw new Error("Missing required argument.");
                }
            }
        }
        return method.apply(this, arguments);
    }
}
NestJS Clone
처음 Typescript Decorator 를 파보기로 결정했을 때, Decorator 기반으로 동작하는 백엔드 Framework 를 흉내내보는 것을 목표로 잡았었다. 하다 보니 점점 재미를 붙이게 되어서, 조금씩 기능을 업데이트 해보려 한다. Github Repository
Decorator 의 기능은 알아보았으니, 이제 nodejs 의 http 모듈을 바탕으로 API 만드는 법을 살펴보자. 기본적으로, nodejs 의 순수 http 모듈을 통해 API 서버를 만드는 방법은 다음과 같다.
const port = process.env.PORT ?? 3000;
export const server = http.createServer();
server.on("request", (request: IncomingMessage, response: ServerResponse) => {
    if(request.url === "/users/get" && request.method === "get") {
        // API 로직 처리
    }
})
server.listen(port, () => {
  console.log(`server running at port ${port}`);
});
몇 줄 안되는 코드로 바로 백엔드 API 를 만들 수 있다는 점은 다시 봐도 놀랍지만, 위와 같이 다양한 API 를 만드려면 if 분기문이 많아질 수밖에 없다. 이러한 불편함 때문에 express 와 같은 다양한 프레임워크들이 나오게 되었는데, Decorator 를 깊게 파본 만큼 NestJS 클론 코딩을 목표로 잡았다.
먼저, API Endpoint 메소드 위에 붙일 Decorator 들을 살펴보자.
@Get, @Post, @Delete, @Put
// GET, POST, DELETE, PUT 등의 메소드를 reflect-metadata 를 이용해서 클래스의 정보로 기록한다.
function _generateRoute(method: Method) {
  return function (path: string): MethodDecorator {
    return function (
      target: object,
      key: string | symbol,
      desc: PropertyDescriptor
    ) {
      // API Endpoint 의 METHOD 정보를 메타데이터로 입력한다.
      Reflect.defineMetadata(
        ControllerMetadataKeys.METHOD,
        method,
        target,
        key
      );
      // API Endpoint 의 PATH 정보를 메타데이터로 입력한다.
      Reflect.defineMetadata(ControllerMetadataKeys.PATH, path, target, key);
    };
  };
}
// @Get, @Post, @Delete, @Patch, @Put 데코레이터 생성
export function Get(path: string): MethodDecorator {
  return _generateRoute(Method.GET)(path);
}
export function Post(path: string): MethodDecorator {
  return _generateRoute(Method.POST)(path);
}
export function Delete(path: string): MethodDecorator {
  return _generateRoute(Method.DELETE)(path);
}
export function Patch(path: string): MethodDecorator {
  return _generateRoute(Method.PATCH)(path);
}
export function Put(path: string): MethodDecorator {
  return _generateRoute(Method.PUT)(path);
}
아래의 데코레이터들은 간단히 메소드들의 Method, Path 정보를 메타데이터로 입력하는 역할을 수행한다.
다음은 Endpoint 의 Parameter Decorator 로 들어오는 @Query, @body 데코레이터이다.
@Query, @Body
export function _generateParamDecorator(
  type: ParameterMetadatdaKeys,
  name?: string
) {
  return function (
    target: Object,
    propertyKey: string | symbol,
    parameterIndex: number
  ) {
    Reflect.defineMetadata(type, { parameterIndex, name }, target, propertyKey);
  };
}
export function Query(name?: string): ParameterDecorator {
  return _generateParamDecorator(ParameterMetadatdaKeys.QUERY, name);
}
export function Body(name?: string): ParameterDecorator {
  return _generateParamDecorator(ParameterMetadatdaKeys.BODY, name);
}
@Get, @Post Decorator 와 유사하게, @Query, @Body 데코레이터도 reflect-metadata 의 기능을 이용해서 메타데이터를 기록하는 역할을 수행한다. 해당 메소드에 1. 어떠한 타입의 파라미터인지 (@Query / @Body), 2. 몇 번째 인덱스에 붙은 데코레이터인지, 3. 해당 파라미터 중 파라미터로 넘어온 name 값이 있는지 (ex. @Query("username")) 를 메타데이터로 기록한다.
@Controller
앞의 작업들은 주로 @Controller Decorator 에서 작업을 수행하기 위해 Metadata 를 잘 심어주는 역할을 한다고 보아도 무방하다. @Controller 데코레이터에서는, 각각의 API EndPoint 에서 등록한 Method, Path, Query, Body 정보를 각각 입력한다.
export function Controller(controllerPath: string): ClassDecorator {
  return function (constructor: Function) {
    const apis: IApi[] = [];
    const prototype = constructor.prototype;
    Reflect.ownKeys(prototype)
      .filter(
        (name) =>
          // 생성자를 제외한 함수들만 포함시킨다.
          name !== "constructor" && typeof prototype[name] === "function"
      )
      .forEach((key) => {
        // API Endpoint (Handler)
        const handler = prototype[key];
        const path = Reflect.getMetadata(
          ControllerMetadataKeys.PATH,
          prototype,
          key
        );
        const method = Reflect.getMetadata(
          ControllerMetadataKeys.METHOD,
          prototype,
          key
        );
        const query = Reflect.getOwnMetadata(
          ParameterMetadatdaKeys.QUERY,
          prototype,
          key
        );
        const body = Reflect.getOwnMetadata(
          ParameterMetadatdaKeys.BODY,
          prototype,
          key
        );
        if (path) {
          // API 정보에 push
          apis.push({
            method,
            path: `${controllerPath}${path}`,
            handler,
            query: query
              ? {
                  index: query.parameterIndex,
                  name: query.name,
                }
              : undefined,
            body: body
              ? {
                  index: body.parameterIndex,
                  name: body.name,
                }
              : undefined,
          });
        }
      });
    // Container 정보에 push
    getContainer().controllers.push({
      constructor,
      apis,
    });
  };
}
위의 과정으로 등록한 Controller 의 정보들은 다음과 같이 공통 handler 에 일괄적으로 등록할 수 있다.
export async function registerControllers(param: {
  server: Server;
  controllers: Function[];
}) {
  const { server } = param;
  server.on(
    "request",
    async (request: IncomingMessage, response: ServerResponse) => {
      getContainer().controllers.forEach(async (controller) => {
        for (const api of controller.apis) {
          // controller 안에 있는 API 목록들을 순회
          if (
            // HTTP Method 가 같고
            isSameMethod(request, api) &&
            // 같은 URL 요청이면
            normalizeUrl(request.url ?? "") === api.path
          ) {
            const parameters: any[] = [request, response];
            if (api.query) {
              parameters[api.query.index] = await parseRequestParam(
                ParameterMetadatdaKeys.QUERY,
                request,
                api.query.name
              );
            }
            if (api.body) {
              parameters[api.body.index] = await parseRequestParam(
                ParameterMetadatdaKeys.BODY,
                request,
                api.body.name
              );
            }
            response.setHeader("Content-Type", "application/json");
            api
              .handler(...parameters)
              .then((result: any) => {
                response.statusCode = HttpStatus.OK;
                response.write(
                  typeof result === "object" ? JSON.stringify(result) : result
                );
                response.end();
              })
              .catch((err: any) => {
                console.error(err);
                response.statusCode = HttpStatus.BAD_REQUEST;
                response.end();
              });
          }
        }
      });
    }
  );
}
Controllers
위의 결과까지 적용시키고 나면, 아래와 같이 Controller 로직을 작성할 수 있다.
import {
  Body,
  Controller,
  Get,
  Post,
  Query,
} from "../decorators/custom-decorator";
@Controller("/users")
export class UsersController {
  @Get("/get")
  async getUser(@Query("name") query: any, @Body() body: any) {
    return {
      success: true,
      query,
      body,
    };
  }
  @Post("/save")
  async saveUser(@Body("dana") body: any, @Query("queryString") query: any) {
    return {
      success: true,
      query,
      body,
    };
  }
}
막연한 호기심으로 시작한 일이었는데, 생각보다 깊이 파고들면서 나름 TypeScript 의 Decorator 세계에 재미를 좀 붙이게 되었다. NestJS 클론코딩을 하고 있자니, DI Container 도 제대로 구현해 보고 싶다는 생각이 생겼는데, 다음에 언젠가는 시간을 잡아서 이 분야도 제대로 파보고 싶다.
Reference
TypeScript Document TypeScript Decorator 직접 만들어보자
이것도 읽어보세요