나만의 Node.js 서버 프레임워크 만들기

Jinseok Eo·2023년 8월 24일
0
post-thumbnail

필자는 지난달 회사에서 시간이 남을 때 직접 나만의 프레임워크를 만들어보면 어떨까하는 생각을 했었고 지난달부터 작업에 돌입했다. 이러한 작업을 시작하게 된 이유는 토비의 스프링 3.1 세트 때문이었다. 비록 노드 백엔드 개발자이지만 이 책은 DI나 IoC의 필요성에 대해서 알려주면서 점점 코드를 확장해나가는데 다른 진영의 책이지만 영감을 주는 데에는 충분했다. NestJS를 재작년 초부터 주력으로 쓰고 있기 때문에 이 프레임워크에서 제공하는 기능을 최대한 구현해보는 것을 목표로 잡았다.

사용한 기술

우선 사용하고 있는 기술은 아래와 같다.

  • fastify
  • typeorm
  • typedi
  • reflect-metadata
  • mariadb
  • class-transformer
  • class-validator
  • http-status

우선 실제 네스트의 코어는 강한 결합도를 줄위기 디커플링 패턴으로 거론되는 수 많은 패턴들이 적용되어있지만 이러한 부분은 추후 생각해보기로 하고 작업에 돌입했었다.

현재 나만의 Node.js 서버 프레임워크는 Controller, Get, Post, Patch, Delete, Put, InjectRepository, Req, Body, Header, ExceptionFilter, Catch, BeforeCatch, AfterCatch, Injectable 데코레이터를 지원하고 있다.

Container와 Controller

첫 작업은 컨테이너라고 불리는 객체를 구현해야 했다. 컨테이너를 구축하려면 우선 reflect-metadata 모듈을 이용하여 메타데이터를 취득해야 한다.

메타데이터라는 것은 우리가 데코레이터라고 부르는 @Controller와 같은 것들로 취득할 수 있다. 아래는 나만의 서버 프레임워크에서 실제 동작하는 샘플 코드이다.

@Controller("/user")
export class UserController {
    constructor(
        // Point는 injectable한 클래스가 아니므로 매번 인스턴스화됩니다.
        private readonly point: Point,
        // UserService는 injectable한 클래스이므로 싱글톤 인스턴스로 관리됩니다.
        private readonly userService: UserService,
    ) {}

    @Get("/point")
    async getPoint() {
        this.point.move(5, 5);
        return {
            x: this.point.x,
            y: this.point.y,
        };
    }

    @Post()
    public async create(@Body() createUserDto: CreateUserDto) {
        return await this.userService.create(createUserDto);
    }

    @Header("Content-Type", "application/json")
    @Get()
    public async getUser(@Req() req: FastifyRequest) {
        return await this.userService.getUser(req.ip);
    }
}

코드를 살펴보면 생성자를 통해 의존성을 주입하는 DI 기능과 제어의 역전(Inversion Of Control)이라고 불리는 기능이 구현되었다는 것을 알 수 있다. 필자는 초기 버전에서는 이것을 매우 간단하게 시작하여 코드의 흐름을 간단히 전개할 수 있었지만 한 달이 지난 시점에서는 수 많은 파일로 디커플링이 이루어져 필자 조차도 흐름을 제법 파고 들어야 하는 수준에 이르렀다.

데코레이터를 통해 클래스나 메소드 그리고 매개변수에 대한 정보를 취득할 수 있다. 작업하면서 알게된 사실은 메타데이터는 역순으로 취득된다는 사실이었다. 즉, 우리가 1줄부터 마지막줄까지 차례로 읽지만 메타데이터의 경우, PropertyDecorator -> MethodDecorator -> ClassDecorator 순으로 역순으로 수집이 가능했다.

Container는 이렇게 수집한 메타데이터를 저장하는 역할을 한다. 단일 객체가 아니며 용도에 따라 분리되어있는데 필자 같은 경우, 메타데이터를 저장하는 용도와 싱글턴 인스턴스를 저장하는 컨테이너, 그리고 그외 매개변수용 컨테이너도 존재한다. DI를 하려면 이러한 컨테이너에서 필요한 정보를 찾아내야 한다.

export function Controller(path: string): ClassDecorator {
    return function (target: any) {
        const scanner = Container.get(ControllerScanner);
        const metadataScanner = Container.get(MetadataScanner);

        const params = ReflectManager.getParamTypes(target) || [];

        Reflect.defineMetadata(CONTROLLER_TOKEN, path, target);

        // 매개변수 주입을 위해 매개변수를 스캔합니다.
        const parameters: DynamicClassWrapper<any>[] = [];
        params.forEach((param: any, index: number) => {
            const targetName = param.name;

            ParameterListManager.invoke(targetName)?.(param, parameters);
        });

        /**
         * 컨트롤러 메타데이터를 등록합니다
         * 리플렉션의 경우, 컨트롤러 데코레이터에서 컨트롤러에 대한 메타데이터를 설정해야 합니다.
         * 이렇게 하면 컨트롤러 클래스만 전달하면 메타데이터를 리플렉션으로 가져올 수 있습니다.
         * 하지만 다음 코드는 스캐너를 통해 메타데이터를 수집하는 코드입니다.
         * 이 로직은 향후 동일 동작이 보장될 경우, 리플렉션 방식으로 변경될 수도 있습니다.
         */
        const name = createUniqueControllerKey(target.name, scanner);
        scanner.set(name, {
            path,
            target,
            routers: metadataScanner.allMetadata(),
            type: "controller",
            parameters: parameters,
        });

        metadataScanner.clear();

        return target;
    };
}

필자가 구현한 컨트롤러 데코레이터의 코드는 위와 같다. 컨트롤러 데코레이터는 가장 마지막에 실행되기 때문에 이 데코레이터에서는 지금까지의 데이터를 모두 수집할 수 있다. 네스트에서는 리플렉션을 통해 모든 데이터를 저장하고 있지만 필자 같은 경우, 첫 개발할 때 리플렉션과 메타데이터를 저장하는 메타데이터 스캐너라는 저장소를 만들어서 이러한 정보들을 저장하였다.

export type Type = Function | string | symbol | undefined;
/**
 * @class ReflectManager
 * @description
 * 이 클래스는 데코레이터를 통해 수집한 메타데이터를 관리하기 위해 만든 유틸리티 클래스입니다.
 * 메타 데이터는 파일 단위로 순서대로 읽어가면서 수집이 되기 때문에 동작 순서를 보장해야 하는 경우에는
 * 메타데이터 수집 완료 이후, 서버 시작 단계에서 수집된 메타데이터를 읽고 수집된 메타데이터를 바탕으로 필요한 데이터를 만들어냅니다.
 *
 * design: 접두사를 사용하면 타입스크립트 컴파일러가 타입 정보를 보존하기 때문에 타입 정보를 추출할 수 있습니다.
 *
 * https://github.com/microsoft/TypeScript/blob/d0684f789b6e8368789c0f9e09f5b5217f59de2b/src/compiler/transformers/ts.ts#L1139
 * https://github.com/microsoft/TypeScript/blob/d0684f789b6e8368789c0f9e09f5b5217f59de2b/src/compiler/transformers/ts.ts#L1071
 *
 * 링크는 타입스크립트 컴파일러의 소스 코드이며 design:* 키를 사용하면 타입 정보를 특별히 보존하는 것을 확인할 수 있습니다.
 */
export class ReflectManager {
    /**
     * 타입을 반환합니다.
     *
     * @param target
     */
    public static getType(target: object): Type | undefined;

    /**
     * 타입을 반환합니다.
     *
     * @param target
     * @param key
     */
    public static getType(
        target: object,
        key?: string | symbol | undefined,
    ): Type | undefined {
        if (key) return Reflect.getMetadata("design:type", target, key!);
        return Reflect.getMetadata("design:type", target);
    }

    /**
     * 매개변수의 타입을 반환합니다.
     *
     * @param target
     */
    public static getParamTypes(target: object): Type[] | undefined;

    /**
     * 매개변수의 타입을 반환합니다.
     *
     * @param target
     * @param key
     */
    public static getParamTypes(
        target: object,
        key: string | symbol | undefined,
    ): Type[] | undefined;
    public static getParamTypes(
        target: object,
        key?: string | symbol | undefined,
    ) {
        if (key) return Reflect.getMetadata("design:paramtypes", target, key!);
        return Reflect.getMetadata("design:paramtypes", target);
    }

    /**
     * 반환 값의 타입을 반환합니다.
     *
     * @param target
     * @param
     */
    public static getReturnType(target: object): Type | undefined;

    /**
     * 반환 값의 타입을 반환합니다.
     *
     * @param target
     * @param key
     */
    public static getReturnType(
        target: object,
        key?: string | symbol | undefined,
    ) {
        if (key) return Reflect.getMetadata("design:returntype", target, key!);
        return Reflect.getMetadata("design:returntype", target);
    }

    /**
     * 타입 정보를 반환합니다.
     *
     * @param target
     */
    public static getTypeInfo(target: object): any;

    /**
     * 타입 정보를 반환합니다.
     *
     * @param target
     * @param key
     */
    public static getTypeInfo(
        target: object,
        key: string | symbol | undefined,
    ): any;

    public static getTypeInfo(
        target: object,
        key?: string | symbol | undefined,
    ) {
        if (key) return Reflect.getMetadata("design:typeinfo", target, key);
        return Reflect.getMetadata("design:typeinfo", target);
    }

    /**
     * 컨트롤러인지 여부를 반환합니다.
     */
    public static isController(target: object): boolean {
        return Reflect.getMetadata(CONTROLLER_TOKEN, target) !== undefined;
    }

    /**
     * 주입 가능한지 여부를 반환합니다.
     *
     * @param target
     * @returns
     */
    public static isInjectable(target: object): boolean {
        if (!Object.getPrototypeOf(target)) {
            return false;
        }
        return Reflect.getMetadata(INJECTABLE_TOKEN, target) !== undefined;
    }

    /**
     * Repository인지 여부를 반환합니다.
     *
     * @param target
     * @returns
     */
    public static isRepository(target: any): boolean {
        if (!Object.getPrototypeOf(target)) return false;
        return (
            Reflect.getMetadata(REPOSITORY_ENTITY_METADATA, target) !==
            undefined
        );
    }

    /**
     * Entity를 반환합니다.
     * @param target
     * @returns
     */
    public static getRepositoryEntity(target: any): any {
        return Reflect.getMetadata(REPOSITORY_ENTITY_METADATA, target);
    }
}

하지만 메타데이터 스캐너는 그저 컨테이너에서 수월하게 정보를 취득할 수 있게 해주는 보조적인 수단일 뿐이었다. 사실 대부분은 Reflect를 사용하여 메타데이터를 저장해야 한다. 필자는 이러한 작업을 수월하게 할 수 있도록 위와 같이 ReflectManager라는 정적 클래스를 만들었다.

Injectable

@Injectable 데코레이터가 붙은 클래스는 다른 클래스의 생성자에 주입될 수 있다. 또한 컨테이너로부터 주입을 받을 수 있는 객체가 된다. 이를 Injectable한 객체라고 부른다. 이러한 객체들은 생성자 매개변수의 타입을 분석하여 메타데이터를 수집하고 인스턴스를 오직 하나만 생성한다. 생성자에서 주입되는 인스턴스들은 서버 컨테이너에서 싱글턴으로 관리되고 있다.

하지만 나만의 서버 프레임워크에서는 @Injectable 데코레이터를 붙이지 않아도 여전히 주입이 가능하다. 조금의 차이가 있는데 @Injectable 데코레이터가 클래스에 마킹되어있지 않은 경우가 있다. 이러한 클래스는 단순히 디폴트 생성자를 통해 매번 인스턴스화된다. 즉, 서버 컨테이너에서 관리되지 않는다. 디폴트 생성자를 서버 코어에서 자동으로 호출해야 하기 때문에 애초에 매개변수 정보를 알 필요가 없는 객체의 경우에는 이렇게 초기화를 할 수 있을 것이다.

하지만 대부분의 경우에는 @Injectable 데코레이터를 사용하여 Injectable한 객체로 만드는 게 재사용성에 있어서 유리하다고 말할 수 있다.

@Injectable()
export class UserService {
    constructor(
        @InjectRepository(User)
        private readonly userRepository: Repository<User>,
        private readonly discoveryService: DiscoveryService,
    ) {}

    async create(createUserDto: CreateUserDto) {
        const newUser = await this.userRepository.create(createUserDto);
        return await this.userRepository.save(newUser);
    }

    async getUser(ip: string) {
        const user = await this.userRepository.find();
        return {
            user,
            ip,
        };
    }
}

Exception Filter와 AOP

Exception Filter는 오류를 처리 및 재정의할 수 있는 데코레이터로 네스트에서는 @Catch로 표현되는 Injectable한 객체이다. 나만의 서버 프레임워크에서는 @ExceptionFilter 데코레이터를 상단에 붙이고 데코레이터의 인자로는 오류 클래스를 지정해야 한다. 이후에는 해당 오류 클래스에 해당하는 오류가 발생하면 @Catch 데코레이터가 붙은 메소드가 실행된다. @BeforeCatch 데코레이터가 붙은 메소드는 @Catch 데코레이터가 붙은 메소드가 실행되기 전에 실행되고, @AfterCatch 데코레이터가 붙은 메소드는 @Catch 데코레이터가 붙은 메소드가 실행된 후에 실행된다.

@ExceptionFilter(InternalServerException)
export class InternalErrorFilter implements Filter {
    private readonly logger = new Logger();

    @BeforeCatch()
    public beforeCatch() {
        this.logger.info("before catch");
    }

    @Catch()
    public catch(error: any) {
        this.logger.info("[서버 내부 오류] " + error.message);

        return {
            message: error.message,
            status: error.status,
            result: "failure",
        };
    }

    @AfterCatch()
    public afterCatch() {
        this.logger.info("after catch");
    }
}

이 부분은 ContainerManager의 한 부분에서 구현되고 있다. ExceptionScanner가 가용 exception을 잡아낸다. BeforeCatch@AfterCatch() 같은 데코레이터도 존재하는데 이 데코레이터들은 스프링의 포인트컷, 그 중 Advice를 참조한 것이다. AOP를 구현하기 위한 Rule이라고 볼 수 있다. this.app.setErrorHandler는 Fastify에 의존성이 있는 예외 처리 핸들러이다. 다양한 플랫폼을 지원하려면 의존성을 풀어내는 것이 시급한 과제이다. 실제 네스트 코어에서는 이 부분을 Proxy객체를 사용하여 풀어냈다. 즉, ExceptionZone 내에서 exception을 잡아내는 방식을 사용하였다.

    /**
     * 예외 처리를 스캔하고 예외를 캐치합니다.
     */
    private async registerExceptions() {
        // Exception 스캐너 생성
        const exceptionScanner = Container.get(ExceptionScanner);

        const instanceScanner = Container.get(InstanceScanner);
        this.app.setErrorHandler((err, _request, _reply) => {
            let errorData = {
                status: HttpStatus.INTERNAL_SERVER_ERROR,
            } as any;

            for (const {
                target,
                exception,
                handlers,
            } of exceptionScanner.makeExceptions()) {
                if (err.name === exception.name) {
                    const ExceptionFilter = target as ClazzType<any>;

                    // Advice 처리
                    handlers.forEach((catcher) => {
                        const { advice } = catcher;
                        const context = instanceScanner.wrap(ExceptionFilter);

                        switch (advice) {
                            case "throwing":
                                errorData = (catcher.handler as any).call(
                                    context,
                                    err,
                                );
                                break;
                            case "before-throwing":
                            case "after-throwing":
                                (catcher.handler as any).call(context);
                                break;
                            default:
                                break;
                        }
                    });
                }
            }

            if (!errorData) {
                errorData = err;
            }

            _reply.status(errorData?.status || 500).send(errorData);
        });
    }

실행하면 아래와 같이 오류 전/후로 메시지가 출력된다는 것을 알 수 있다.

즉, 예외 메소드는 @BeforeCatch -> @Catch -> @AfterCatch 순으로 실행되고, 각 예외 처리 컨텍스트는 instanceScanner.wrap(ExceptionFilter);에 의해서 예외 처리 클래스 당 하나의 인스턴스를 공유하는 공유 인스턴스로 구동된다. 공유 인스턴스라는 것은 인스턴스를 하나만 만든다는 것이다. 내부적으로 Map으로 관리되는 인스턴스 관리자에 단 하나의 인스턴스만 할당되는 방식으로 구현하였다.

서버의 시작

서버 실행을 위한 코드는 bootstrap.ts에서 정의된다. 미리 빌트인으로 구현된 ServerBootstrapApplication을 상속하여 필요한 모듈 정보를 기입해야 한다. 나만의 서버 프레임워크를 구현할 때 개인적으로는 NestJS나 앵귤러의 모듈 시스템과 비슷하게 가고 싶진 않았다. 하지만 만들다보니 이것은 필수불가결한 설계임을 알게되었다. 메타데이터를 수집하려면 서버 파일 어딘가에 파일이 아래와 같이 클래스가 임포트되어야 했다. 이러한 임포트 클래스들을 관리하려면 임포트되는 파일들을 하나로 묶어야 했다. 따라서 모듈 밖에는 답이 나오질 않았다.

/**
 * @class StingerLoomBootstrapApplication
 * @description
 * 서버 실행을 위한 기능들이 구현되어있는 클래스입니다.
 * 필수 기능들은 ServerBootstrapApplication 클래스에 있고 이를 상속받아 구현되었습니다.
 *
 * `beforeStart` 메서드를 오버라이딩하여 필요한 기능들을 추가할 수 있습니다.
 */
export class StingerLoomBootstrapApplication extends ServerBootstrapApplication {
    override beforeStart(): void {
        this.moduleOptions = ModuleOptions.merge({
            controllers: [PostController, UserController],
            providers: [InternalErrorFilter, UserService],
            configuration: databaseOption,
        });
    }
}

Promise.resolve(new StingerLoomBootstrapApplication().start()).catch((err) => {
    console.error(err);
});

네스트의 모듈 개념은 나만의 서버 프레임워크에서는 아직 구현되어있지 않은데 모듈 개념이 있으면 모듈 별로 각 모듈의 설정 파일을 개인화 할 수 있다. 나만의 서버 프레임워크에서는 컨테이너에서 키를 클래스 별로 관리하기 때문에 모듈이 개념이 존재할 경우, 네임스페이스 개념이 있는 토큰을 도입해야 한다. 이 부분은 머리 아픈 부분이므로 나중에 고민해봐야 할 것 같다.

Router Path Mapping


나만의 Node.js 서버 프레임워크

https://github.com/biud436/stingerloom

0개의 댓글