이번에 새롭게 라이브러리를 만드는 프로젝트에 참여하게 되었어요. 프로젝트 시작 전 TypeSrcript 데코레이터, reflect-metadata에 대해서 공부를 하려고 합니다.
데코레이터는 클래스, 속성, 메서드, 파라미터 등에 메타데이터를 추가하거나 동작을 바꾸는 함수입니다.
| 데코레이터 종류 | 대상 | 예시 |
|---|---|---|
| 클래스 데코레이터 | 클래스 자체 | @Controller() |
| 속성 데코레이터 | 클래스 프로퍼티 | @body('username') |
| 메서드 데코레이터 | 클래스 메서드 | @Get() |
| 파라미터 데코레이터 | 메서드의 매개변수 | @Param('id') |
데코레이터는 실제 동작을 바꾸지는 않습니다.
reflect-metadata를 써서 메타데이터를 붙여 놓고, 미들웨어나 프레임워크 코드가 나중에 그 정보를 읽어서 처리하는 구조입니다.
아래 코드와 같이 "name이라는 필드에는 req.body.username을 넣어줘야 해"라는 정보를 저장합니다.
Reflect.defineMetadata('cargo:body', [{ propertyKey: 'name', fieldName: 'username' }], target.constructor);
데코레이터는 클래스가 정의될 때 실행됩니다. 인스턴스를 만들기 전에도 데코레이터는 이미 실행되어 있습니다.
데코레이터는 내부적으로 메타 데이터를 저장하지 않습니다. 따라서 저장하려면 reflect-metadata를 사용해야 합니다.
아래처럼 데코레이터에서 내에서 사용할 메타데이터를 런타임에서 조회해서 처리할 수 있습니다.
import 'reflect-metadata';
// 저장
Reflect.defineMetadata('key', 'value', target);
// 읽기
const value = Reflect.getMetadata('key', target);
데코레이터는 다음과 같이 함수입니다.
// body.decorator.ts
import 'reflect-metadata';
const BODY_METADATA_KEY = 'cargo:body';
export function body(fieldName: string): PropertyDecorator {
return (target, propertyKey) => {
const existing =
Reflect.getMetadata(BODY_METADATA_KEY, target.constructor) || [];
Reflect.defineMetadata(
BODY_METADATA_KEY,
[...existing, { propertyKey, fieldName }],
target.constructor
);
};
}
// create-user.dto.ts
import { body } from './body.decorator';
export class CreateUserDto {
@body('username')
name!: string;
@body('email')
email!: string;
}
function bindingCargo(DtoClass: any) {
return (req, res, next) => {
const dto = new DtoClass();
const bodyMeta = Reflect.getMetadata('cargo:body', DtoClass) || [];
for (const { propertyKey, fieldName } of bodyMeta) {
dto[propertyKey] = req.body?.[fieldName]; // 핵심!
}
(req as any).__cargo = dto;
next();
};
}
app.post('/user', bindingCargo(CreateUserDto), (req, res) => {
const dto = getCargo<CreateUserDto>(req);
res.json({ name: dto.name, email: dto.email });
});
1) 클래스가 로딩 되는 시점(앱 로딩시)에 @body()가 실행되고 메타데이터가 저장됩니다.
[
{ propertyKey: 'name', fieldName: 'username' },
{ propertyKey: 'email', fieldName: 'email' }
]
cargo:body라는 키로 CreateUserDto 클래스에 저장됩니다.Reflect.getMetadata('cargo:body', CreateUserDto);
// 결과
[
{ propertyKey: 'name', fieldName: 'username' },
{ propertyKey: 'email', fieldName: 'email' }
]
2) 요청이 들어오면 bindingCargo()가 메타데이터 조회해서 req.body에서 필요한 필드를 읽어서 DTO에 넣습니다.
{
"username": "은영",
"email": "ey@gmail.com"
}
다음과 같이 매핑됩니다.
dto.name → "은영"
dto.email → "ey@gmail.com"
3) 컨트롤러에서는 getCargo()로 DTO 꺼내서 사용합니다.