끄적끄적

[NestJS] custom parameter decorator 본문

개발/js & ts & node.js

[NestJS] custom parameter decorator

코리이 2023. 10. 27. 01:21

Nestjs 를 개발하다보면 custom parameter decorator 를 개발할 필요가 있다. 물론 공식 문서 예제로 주어지는 @User 와 같은 형태가 존재하고 이를 자주 사용하곤 하지만 Nest 문서에서 주어진 것만 가지고 개발하기에는 한계점이 존재한다. 그래서 이번 포스팅에서는 nestjs 에서 기본적으로 제공하는 함수를 활용하는 것이 아닌 메타데이터를 직접 사용해서 custom parameter decorator 를 만드는 작업을 진행해보도록 한다.

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

뼈대 코드

들어가기전에 필요한 형태의 코드들을 나열해보자. User 의 형태는 아래 코드처럼 정의했다. isUser 함수는 테스트를 위한 간단한 예시이며, typia.createIs 와 같은 형식으로 만들경우 더욱 안전하게 User 가 존재하는지 확인할 수 있다.

export interface IUser {
    id: string;
    nickname: string;
}

export const isUser = (user: any): user is IUser => {
    return user && user['id'] && user['nickname'];
}

이때 정의된 getUsers controller method 는 인증이 완료된 경우에만 호출할 수 있다고 가정해보자. 즉, guard 를 통해 인증된 유저만이 사용할 수 있는 메서드이며 guard 내부에서 공식문서 처럼 request.user 에 user 정보를 넣는 일반적인 방법을 취하고 있다. 실험을 위해 UserGuard 인증 방식의 경우 header 에 userid 가 존재할 때 인증이 완료되었다고 생각하고 임의의 유저를 대입시키는 코드로 작성했다.

@UseGuards(UserGuard)
@Get('users')
getUsers(@Req() req: any) {
    return { ...req.user };
}
@Injectable()
export class UserGuard implements CanActivate {
    canActivate(context: ExecutionContext): boolean {
        const req = context.switchToHttp().getRequest();
        if (req.headers['userid'] && typeof req.headers['userid'] === 'string') {
            const user: IUser = {
                id: req.headers['userid'],
                nickname: faker.name.firstName(),
            }
            req.user = user;
        }
        return true;
    }
}

아래처럼 호출해보면 user 정보를 잘 반환하는 것을 확인할 수 있다.

curl http://localhost:3000/users --header 'userid: 12345'
# 결과: {"id":"12345","nickname":"Mario"}

제공된 함수를 활용한 커스텀 파라미터 데코레이터 (공식 문서 예제)

이제 Custom parameter decorator 를 구현해보자 NestJS 에서는 method 에 @Req() 와 같은 형태의 파라미터 데코레이터를 만들 수 있는 createParamDecorator 라는 생성함수를 제공한다. 이를 활용하여 optional 값에 따라 user를 반환하거나 반환하지 않는 User 데코레이터를 만들어보자.

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { IUser, isUser } from './UserModel';

export const User = createParamDecorator((data: { optional?: boolean } | undefined, ctx: ExecutionContext): IUser | IUser[keyof IUser] | undefined => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;

    if (data?.optional) {
        return user;
    }

    if (isUser(user)) {
        return user;
    }

    throw new Error('invalid users');
});

그리고 이 데코레이터를 @Req() 대신 @User() 로 바꿔서 사용해보자. 이때 optional 값에 따라 유저의 존재 유무를 판단하고 검증을 수행하므로 optional 이 true, false 인 두 가지 방식을 모두 테스트해보기 위해 아래처럼 optional api endpoint 를 하나 더 만들어보자. 그 후 curl 을 이용해 get 호출을 해보면 user 가 존재하지 않는 경우에는 optional 이 가능한지 여부에 따라 에러가 발생하거나 빈값을 반환하며, user 가 존재하는 경우에는 user 정보를 반환하는 결과를 얻을 수 있다.

@UseGuards(UserGuard)
@Get('users')
getUsers(@User() user: IUser) {
    return { ...user };
}

@UseGuards(UserGuard)
@Get('users-optional')
getUsersOptional(@User({ optional: true }) user: IUser) {
    return { ...user };
}
# 아래는 에러를 반환한다. (user 정보가 없으므로 throw)
curl http://localhost:3000/users

# 아래는 빈 값을 반환한다.
curl http://localhost:3000/users-optional

# 아래는 유저 정보를 반환한다.
curl http://localhost:3000/users --header 'userid: 12345'
curl http://localhost:3000/users-optional --header 'userid: 12345'

이것이 공식 문서에서 제공하는 커스텀 데코레이터의 끝이다. 확실히 프레임워크 답게 간단하면서 많은 생각할 필요를 없도록 만들어주고 있는걸 확인할 수 있다. 하지만 위 코드에는 문제점이 하나 존재하는데, optional: true 인 controller 메서드를 확인해보면 IUser 가 optional 로 정의하고 있지 않음을 확인할 수 있다. 

 

즉 type-safe 하지 않아 실제 사용할때 실수를 할 가능성이 농후해진다. 또한 user: IUser 로 파라미터를 정의했지만 user: string 과 같이 타입을 잘못 지정하는 실수를 하게 되면 예상과는 다른 에러가 발생할 수도 있는 등 여러 문제점이 존재하는 코드가 될 수 있다. 그 외에도 개발하다 보면 은근히 파라미터 데코레이터를 커스텀하고 싶은 생각이 많이 들게 될 것이다. 그렇기 때문에 제공된 함수가 아니라 원하는 방식대로 커스터마이징 할 수 있도록 데코레이터를 수정해보자

createParamDecorator 뜯어보기

커스텀 데코레이터를 커스터마이징 하기 위해서는 createParamDecorator 함수가 어떤 일을 하는지 확인해 볼 필요가 있다. 간략하게 정의해보면 다음과 같다.

 

  1. 기존에 존재하는 metadata 정보를 가져와서
  2. 커스텀 데코레이터에서 새롭게 정의한 로직을 기존에 존재하던 metadata 에 추가한다.
  3. 이때 key 로 CUSTOM_ROUTE_ARGS_METADATA 를 포함시켜서 추후 사용할 때 이를 활용할 수 있도록 한다.

아래 코드는 NestJS 레포에서 가져왔으며, 위에서 이야기한 해석을 순서대로 확인해 볼 수 있도록 주석을 달아놨다.

function createParamDecorator<
    FactoryData = any,
    FactoryInput = any,
    FactoryOutput = any,
>(
    factory: CustomParamFactory<FactoryData, FactoryInput, FactoryOutput>,
    enhancers: ParamDecoratorEnhancer[] = [],
): (
    ...dataOrPipes: (Type<PipeTransform> | PipeTransform | FactoryData)[]
) => ParameterDecorator {
    const paramtype = uid(21);
    return (
        data?,
        ...pipes: (Type<PipeTransform> | PipeTransform | FactoryData)[]
    ): ParameterDecorator =>
        (target, key, index) => {

            // 1. ROUTE_ARGS_METADATA 를 key 로 가지고 있는 metadata 를 가져온다.
            const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, key as any) || {};

            // 2. 데코레이터에 사용할 data 및 pipe 를 정의한다.
            const isPipe = (pipe: any) => pipe && (
                (isFunction(pipe) && pipe.prototype && isFunction(pipe.prototype.transform))
                || isFunction(pipe.transform)
            );
            const hasParamData = isNil(data) || !isPipe(data);
            const paramData = hasParamData ? (data as any) : undefined;
            const paramPipes = hasParamData ? pipes : [data, ...pipes];

            // 3. customKey 를 CUSTOM_ROUTE_ARGS_METADATA 를 포함해 생성한다.
            const customKey = `${paramtype}${CUSTOM_ROUTE_ARGS_METADATA}:${index}`;

            // 4. metadata 에 새로운 데코레이터 로직(factory & pipe 등) 을 추가한다..
            const metadata = {
                ...args,
                [customKey]: {
                    index,
                    factory,
                    paramData,
                    ...(paramPipes as PipeTransform[]),
                },
            }

            // 5. ROUTE_ARGS_METADATA 를 key 로 가지고 있는 metadata 를 새로운 값으로 저장한다.
            Reflect.defineMetadata(
                ROUTE_ARGS_METADATA,
                metadata,
                target.constructor,
                key as any,
            );
            enhancers.forEach(fn => fn(target, key, index));
        };
}

 

코드를 확인해보면 데코레이터를 만드는 작업은 메타데이터에 생성한 로직을 저장하는 작업 외엔 진행하는게 없다. 저장한 메타데이터를 사용하는 코드가 궁금하다면 가장 아래 챕터에 간략하게 정리해놨으니 참고해보길 바란다.

돌아와서 커스텀 데코레이터를 확인해보니 메타데이터만 형식에 맞게 저장만 해주면 쉽게 우리가 사용하고 싶은대로 NestJS 파라미터 데코레이터를 개발할 수 있을 것 같다. 그러므로 이제부터 type-safe 한 User 데코레이터를 개발해보자

메타데이터를 직접 활용한 파라미터 데코레이터 커스터마이징

우선 createParamDecorator 함수를 활용해서 만든 User 데코레이터에서 우리가 필요한 코드만 분리시켜보자. 실제로 pipe 로직은 사용하지 않을 것이며(필요한 경우 추가해도 무방하다.) factory 함수는 외부에 따로 정의해서 직접적으로 코드에 넣을 것이다. 유틸 함수를 정의한 후 만든 후 데코레이터를 생성하는게 아니라 직접 데코레이터로 정의하도록 했다.

아래 User 데코레이터를 사용하면 기존에 createParamDecorator 를 활용해서 작성한 데코레이터와 동일한 기능을 하게 된다.

const paramType = uid(21);
const factory = (data: { optional?: boolean } | undefined, ctx: ExecutionContext): IUser | IUser[keyof IUser] | undefined => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;

    if (data?.optional) {
        return user;
    }

    if (isUser(user)) {
        return user;
    }

    throw new Error('invalid users');
};

export const User = (params?: { optional?: boolean }): ParameterDecorator => (
    classType,
    methodName,
    index
) => {

    const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, classType.constructor, methodName as any) || {};

    Reflect.defineMetadata(
        ROUTE_ARGS_METADATA,
        // metadata 를 추가하는 함수는 nest 에서 제공하는 걸 활용한다.
        assignCustomParameterMetadata(
            args,
            paramType,
            index,
            factory,
            params,
        ),
        classType.constructor,
        methodName as any,
    );
}

뼈대가 완성되었다면 type-safe 한 데코레이터로 커스터마이징 하기 위해 UserParameterDecorator 타입을 정의하자. 타입에 관한 이야기는 범위를 넘어가므로 궁금하다면 따로 공부하기를 추천한다.

export type UserParameterDecorator<Optional extends boolean> = <
    TTarget extends object,
    TMethod extends (string | symbol | undefined),
    TIndex extends number,
>(target: TTarget, methodName: TMethod, index: TIndex) => void | CheckUserParamType<TTarget, TMethod, TIndex, Optional>

declare const error: unique symbol;
export type TypeError<M extends string> = { [error]: M };

export type CheckUserParamType<
    TTarget,
    TMethod,
    TIndex extends number,
    Optional extends boolean
> = Optional extends false ?
    IUser extends ParamType<TTarget, TMethod, TIndex>
        ? ParamType<TTarget, TMethod, TIndex> extends IUser
            ? never
            : TypeError<'User type is invalid'>
        : TypeError<'User type is invalid'>
    : IUser | undefined extends ParamType<TTarget, TMethod, TIndex>
        ? ParamType<TTarget, TMethod, TIndex> extends IUser | undefined
            ? never
            : TypeError<'User type is invalid'>
        : TypeError<'User type is invalid'>
;

export type ParamType<TTarget, TMethod, TIndex extends number> = TMethod extends keyof TTarget
    ? TTarget[TMethod] extends (...args: infer P) => any
        ? P[TIndex]
        : never
    : never

그럼 이제 이 타입을 데코레이터에 적용해보자. User 데코레이터 정의부분만 만든 타입으로 변경해주면 된다.

export const User = <Optional extends boolean = false>(params?: { optional?: Optional }): UserParameterDecorator<Optional> => (
    classType,
    methodName,
    index
) => {
    // 기존과 동일
}

새롭게 정의한 데코레이터를 적용해보자. 블로그의 코드 스니펫은 컴파일 에러를 확인하기 어려우므로 스크린샷을 첨부했다.

 

잘못된 타입을 적용한 경우 에러를 반환하고 있음을 알 수 있다.

이제부터 커스터마이징한 데코레이터를 사용할 수 있게 되었다.

파라미터 데코레이터 커스터마이징 했을 때 단점

물론 이를 사용하면 단점도 존재하는데 당연히 famework 를 벗어난 코드일 가능성이 있다는 것이다. 만약 createParamDecorator 함수가 변한다면 커스터마이징한 데코레이터는 모두 수정해 줘야 할 것이다. 하지만 데코레이터를 작성하고 이에 대한 테스트코드가 존재한다면 실제 사용함에 있어서 문제가 될 만한 상황을 방지할 수 있다. 실제로 이와 같은 방식을 프로덕션에 사용해도 크게 문제가 된 적은 없었다. 다들 편한 코딩을 하기를 바라며 블로그를 마치도록 하겠다.

 


참고: 파라미터 메타데이터를 사용하는 NestJS 코드

RouterExecutionContext 에서 앱 실행시 라우터에서 저장한 custom decorator 메타데이터를 불러와 적용하는 걸 확인할 수 있다. 코드량이 블로그에 쓰기엔 많기 때문에 실제로 사용되는 부분만 빼서 가져와봤다. NestJS 특징중에 하나가 단순 wrapper 이기 때문에 코드 자체가 어렵지 않아 이해하는데 큰 어려움은 없을 것이다. 하지만 굳이 이해하려 애쓰지는 않아도 된다.

class RouterExecutionContext {

    public exchangeKeysForValues(
        keys: string[],
        metadata: Record<number, RouteParamMetadata>,
        contextFactory?: (args: unknown[]) => ExecutionContextHost,
    ): ParamProperties[] {
        /* ... */

        return keys.map(key => {

            /* ... */

            // CUSTOM_ROUTE_ARGS_METADATA 를 포함하고 있는 경우의 metadata 값을 가져온다.
            if (key.includes(CUSTOM_ROUTE_ARGS_METADATA)) {
                const { factory } = metadata[key];
                const customExtractValue = this.contextUtils.getCustomFactory(
                    factory,
                    data,
                    contextFactory,
                );
                return { index, extractValue: customExtractValue, type, data, pipes };
            }

            /* ... */
        });
    }

    public create(/* ... */) {
        /* ... */

        // 파라미터 메타데이터를 가져오는 함수를 만든다. (실제로는 getMetadata 메서드에 존재)
        const getParamsMetadata = (
            moduleKey: string,
            contextId = STATIC_CONTEXT,
            inquirerId?: string,
        ) => this.exchangeKeysForValues(/* ... */);

        // 파라미터 및 pipe 정보를 가져오고 pipe 를 적용시키는 함수를 정의한다.
        const paramsOptions = this.contextUtils.mergeParamsMetatypes(
            getParamsMetadata(moduleKey, contextId, inquirerId),
            paramtypes,
        );
        const pipes = this.pipesContextCreator.create(/* ... */);
        const fnApplyPipes = this.createPipesFn(pipes, paramsOptions);

        // handler 에 fnApplyPipes 를 적용한다.
        const handler = <TRequest, TResponse>(
            args: any[],
            req: TRequest,
            res: TResponse,
            next: Function,
        ) => async () => {
            fnApplyPipes && (await fnApplyPipes(args, req, res, next));
            return callback.apply(instance, args);
        };
        
        /* ... */
    }
}