02 [NestJS] Node.js 개발자를 위한 TypeScript 데코레이터 이해하기

02 [NestJS] Node.js 개발자를 위한 TypeScript 데코레이터 이해하기

Node.js로 개발하다가 NestJS를 처음 접하면 가장 낯선 것 중 하나가 바로 데코레이터(@)다. @Controller(), @Get(), @Injectable() 같은 것들을 보면서 "이게 뭐지?"라는 생각이 들었다. 그래서 데코레이터가 무엇인지, 어떻게 동작하는지, 그리고 왜 NestJS에서 이렇게 많이 사용하는지 알아보겠다.

데코레이터란 무엇인가?

데코레이터는 클래스, 메소드, 프로퍼티, 매개변수에 메타데이터를 추가하는 특별한 선언이다. 쉽게 말해서 "이 클래스는 컨트롤러야", "이 메소드는 GET 요청을 처리해"라는 정보를 코드에 표시하는 방법이다.

일반적인 Node.js에서는 다음과 같이 라우터를 정의했을 것이다:

// 기존 Express.js 방식
const express = require('express');
const app = express();

app.get('/users', (req, res) => {
  res.json({ message: 'Get all users' });
});

app.post('/users', (req, res) => {
  res.json({ message: 'Create user' });
});

하지만 NestJS에서는 데코레이터를 사용해서 이렇게 작성한다:

// NestJS 데코레이터 방식
@Controller('users')
export class UsersController {
  @Get()
  getAllUsers() {
    return { message: 'Get all users' };
  }

  @Post()
  createUser() {
    return { message: 'Create user' };
  }
}

데코레이터를 사용하면 코드가 더 선언적이고 읽기 쉬워진다. 메소드를 보기만 해도 어떤 HTTP 메소드를 처리하는지 바로 알 수 있다.

TypeScript에서 데코레이터 활성화하기

데코레이터는 TypeScript의 실험적 기능이기 때문에 별도로 활성화해야 한다. tsconfig.json에서 다음 옵션을 설정해야 한다:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
  • experimentalDecorators: 데코레이터 사용을 허용한다
  • emitDecoratorMetadata: 런타임에 타입 정보를 사용할 수 있도록 메타데이터를 생성한다

데코레이터의 종류

데코레이터는 적용되는 위치에 따라 4가지 종류가 있다:

1. 클래스 데코레이터

클래스 전체에 적용되는 데코레이터다.

// 간단한 클래스 데코레이터 만들어보기
function MyClassDecorator(target: any) {
  console.log('클래스 데코레이터가 실행되었습니다:', target.name);
  // 클래스에 새로운 프로퍼티 추가
  target.prototype.decoratedProperty = 'Hello from decorator!';
}

@MyClassDecorator
class TestClass {
  name = 'Test';
}

const instance = new TestClass();
console.log((instance as any).decoratedProperty); // "Hello from decorator!"

NestJS에서는 @Controller(), @Injectable(), @Module() 등이 클래스 데코레이터다.

2. 메소드 데코레이터

메소드에 적용되는 데코레이터다.

function LogMethod(target: any, propertyName: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value;
  
  descriptor.value = function (...args: any[]) {
    console.log(`${propertyName} 메소드가 호출되었습니다. 인수:`, args);
    const result = method.apply(this, args);
    console.log(`${propertyName} 메소드가 완료되었습니다. 결과:`, result);
    return result;
  };
}

class Calculator {
  @LogMethod
  add(a: number, b: number): number {
    return a + b;
  }
}

const calc = new Calculator();
calc.add(2, 3); 
// 출력:
// add 메소드가 호출되었습니다. 인수: [2, 3]
// add 메소드가 완료되었습니다. 결과: 5

NestJS에서는 @Get(), @Post(), @Put(), @Delete() 등이 메소드 데코레이터다.

3. 프로퍼티 데코레이터

클래스의 프로퍼티에 적용되는 데코레이터다.

function DefaultValue(value: any) {
  return function (target: any, propertyName: string) {
    target[propertyName] = value;
  };
}

class User {
  @DefaultValue('Unknown')
  name: string;
  
  @DefaultValue(0)
  age: number;
}

const user = new User();
console.log(user.name); // "Unknown"
console.log(user.age);  // 0

4. 매개변수 데코레이터

메소드의 매개변수에 적용되는 데코레이터다.

function LogParameter(target: any, propertyName: string, parameterIndex: number) {
  console.log(`${propertyName} 메소드의 ${parameterIndex}번째 매개변수에 데코레이터가 적용되었습니다.`);
}

class TestService {
  testMethod(@LogParameter param1: string, @LogParameter param2: number) {
    return `${param1}: ${param2}`;
  }
}

NestJS에서는 @Body(), @Param(), @Query() 등이 매개변수 데코레이터다.

NestJS에서 데코레이터가 동작하는 방식

NestJS는 데코레이터를 통해 메타데이터를 수집하고, 이를 바탕으로 의존성 주입과 라우팅을 자동으로 처리한다. 다음 코드를 보자:

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(id);
  }

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
}

여기서 일어나는 일들을 단계별로 보면:

  1. @Controller('users'): 이 클래스가 '/users' 경로를 처리하는 컨트롤러라고 표시
  2. @Get(): HTTP GET 요청을 처리하는 메소드라고 표시
  3. @Get(':id'): GET 요청이지만 동적 경로 매개변수를 받는다고 표시
  4. @Param('id'): URL 매개변수 'id'를 메소드 매개변수로 주입
  5. @Body(): 요청 본문을 메소드 매개변수로 주입

NestJS는 애플리케이션 시작 시 이러한 메타데이터를 읽어서 라우터를 자동으로 구성한다.

커스텀 데코레이터 만들기

NestJS에서는 자주 사용하는 로직을 커스텀 데코레이터로 만들 수 있다. 예를 들어 현재 사용자 정보를 가져오는 데코레이터를 만들어보자:

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

// 현재 사용자 정보를 가져오는 커스텀 데코레이터
export const CurrentUser = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.user;
  },
);

// 사용 예시
@Controller('profile')
export class ProfileController {
  @Get()
  getProfile(@CurrentUser() user: User) {
    return user;
  }
}

이렇게 하면 @CurrentUser() 데코레이터 하나로 현재 로그인한 사용자 정보를 쉽게 가져올 수 있다.

데코레이터 vs 기존 방식 비교

기존 Express.js 방식과 비교해보면 데코레이터의 장점이 명확해진다:

Express.js 방식

// 라우터 정의가 분산되어 있음
app.get('/users', getUsersHandler);
app.post('/users', createUserHandler);
app.get('/users/:id', getUserHandler);

// 핸들러 함수들이 별도로 정의됨
function getUsersHandler(req, res) {
  // 로직
}

function createUserHandler(req, res) {
  // 로직
}

NestJS 데코레이터 방식

// 라우터와 핸들러가 한 곳에 정의됨
@Controller('users')
export class UsersController {
  @Get()
  getUsers() {
    // 로직
  }

  @Post()
  createUser() {
    // 로직
  }
}

데코레이터 방식의 장점:

  1. 코드 구성이 명확함: 관련된 라우트들이 한 클래스 안에 모여 있다
  2. 가독성이 높음: 메소드를 보면 어떤 HTTP 메소드를 처리하는지 바로 알 수 있다
  3. 타입 안정성: TypeScript의 타입 체크를 받을 수 있다
  4. 재사용성: 공통 로직을 데코레이터로 만들어서 재사용할 수 있다

메타데이터와 리플렉션

데코레이터는 메타데이터를 저장하는 방식으로 동작한다. NestJS는 reflect-metadata 라이브러리를 사용해서 런타임에 이 메타데이터를 읽는다:

import 'reflect-metadata';

function SetMetadata(key: string, value: any) {
  return function (target: any) {
    Reflect.defineMetadata(key, value, target);
  };
}

@SetMetadata('role', 'admin')
class AdminController {}

// 런타임에 메타데이터 읽기
const role = Reflect.getMetadata('role', AdminController);
console.log(role); // "admin"

이 원리를 이해하면 NestJS가 어떻게 의존성 주입과 라우팅을 자동화하는지 알 수 있다.

실습: 간단한 로깅 데코레이터 만들기

이제 직접 데코레이터를 만들어보자. 메소드 실행 시간을 측정하는 데코레이터를 만들어보겠다:

function MeasureTime(target: any, propertyName: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value;
  
  descriptor.value = async function (...args: any[]) {
    const start = Date.now();
    const result = await method.apply(this, args);
    const end = Date.now();
    
    console.log(`${propertyName} 실행 시간: ${end - start}ms`);
    return result;
  };
}

@Controller('test')
export class TestController {
  @Get()
  @MeasureTime
  async slowMethod() {
    // 1초 대기
    await new Promise(resolve => setTimeout(resolve, 1000));
    return { message: 'Done!' };
  }
}

이 데코레이터를 사용하면 메소드가 실행될 때마다 소요된 시간이 로그에 출력된다.

마무리

데코레이터는 처음에는 낯설지만, 익숙해지면 매우 강력한 도구다. 코드를 더 선언적이고 읽기 쉽게 만들어주며, 공통 로직을 재사용하기에도 좋다.

NestJS를 사용할 때는 데코레이터를 "마법"으로 생각하지 말고, 메타데이터를 추가하는 도구라고 이해하는 것이 중요하다. 이렇게 이해하면 NestJS의 다른 기능들도 훨씬 쉽게 받아들일 수 있을 것이다.

다음 포스팅에서는 NestJS의 의존성 주입 시스템에 대해 알아보겠다. 데코레이터와 함께 NestJS의 핵심을 이루는 개념이다.