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 직접 만들어보자
이것도 읽어보세요