나만의 서버 프레임워크 만들기 - 인증, 트랜잭션, 템플릿 엔진, 자동 생성 도구 만들기

Jinseok Eo·2023년 9월 15일
0
post-thumbnail

지난 글에서는 Node.js 서버 프레임워크를 동작시키기 위해 필요한 여러가지 필수 기술을 어떻게 구현할 수 있는지 간략하게 정리를 해보았습니다. 사실 저는 이 프레임워크를 위해 바퀴를 재발명하면서 수 많은 기술적인 난관들을 만났습니다. 말 그대로 고난과 역경이었는데요. 아직도 해결되지 못한 과제들이 많이 있습니다. 이번 시간에는 인증 개념과 트랜잭션 처리, 그리고 추가로 템플릿 엔진과, 타입스크립트 컴파일러에 대해서도 다뤄보려고 합니다.

로깅 화면

우선 기능 구현에 대한 설명을 하려면 제가 만든 서버 프레임워크에는 무슨 기능들이 있는지 알아봐야 합니다. 읽는데에는 시간이 그리 많이 걸리진 않습니다.

소개

@Session 데코레이터

StingerLoom에선 세션 기반 인증을 지원합니다.

SessionObject를 상속받은 클래스를 아래 예제와 같이 세션 오브젝트로 사용할 수 있습니다.

@Controller("/auth")
export class AuthController {
    constructor(private readonly authService: AuthService) {}

    @Post("/login")
    async login(
        @Session() session: SessionObject,
        @Body() loginUserDto: LoginUserDto,
    ) {
        return await this.authService.login(session, loginUserDto);
    }
}

세션의 처리

조금 더 실용적인 예제는 아래와 같습니다.

@Injectable()
export class AuthService {
    @Autowired()
    userService!: UserService;

    async login(session: SessionObject, loginUserDto: LoginUserDto) {
        const user = await this.userService.validateUser(loginUserDto);
        session.authenticated = true;
        session.user = user;

        return ResultUtils.successWrap({
            message: "로그인에 성공하였습니다.",
            result: "success",
            data: session.user,
        });
    }

    async checkSession(session: SessionObject) {
        return ResultUtils.success("세션 인증에 성공하였습니다", {
            authenticated: session.authenticated,
            user: session.user,
        });
    }
}

현재 버전에서는 위와 같이 세션 오브젝트를 사용하여 인증을 구현할 수 있습니다.

Session Guard

세션 인증은 @Session() 데코레이터를 사용하여 세션 오브젝트를 주입받아서 처리할 수 있고 SessionGuard를 추가하여 세션 인증을 처리할 수 있습니다.

Guard 인터페이스 (NestJS에서 CanActivate)를 구현하여야 하는데 코드는 다음과 같습니다.

@Injectable()
export class SessionGuard implements Guard {
    canActivate(context: ServerContext): Promise<boolean> | boolean {
        const req = context.req;
        const session = req.session as SessionObject;

        if (!session) {
            return false;
        }

        if (!session.authenticated) {
            return false;
        }

        return true;
    }
}

위 가드를 providers에 추가하고 아래와 같이 컨트롤러나 라우터에 붙여서 사용할 수 있습니다.

@Controller("/auth")
export class AuthController {
    constructor(private readonly authService: AuthService) {}

    @Get("/session-guard")
    @UseGuard(SessionGuard)
    async checkSessionGuard(@Session() session: SessionObject) {
        return ResultUtils.success("세션 가드 통과", session);
    }
}

위와 같이 하면 세션 인증을 통과한 로그인 사용자의 경우에만 라우터가 실행됩니다.

인증이 되지 않은 사용자의 경우에는 401 오류가 발생합니다.

Custom Parameter Decorator

인증을 수행하다보면 사용자로부터 매개변수를 받아서 데이터베이스에 있는 값을 검증하여 유효성을 판단하는 일이 많습니다.

특히 사용자 관련 정보가 세션에서 꺼내서 값을 검증해야 할 수도 있습니다.

createCustomParamDecorator 함수를 이용하면 이러한 처리를 돕는 자신만의 ParameterDecorator를 만들 수 있습니다.

다음은 유저 정보와 유저 ID를 세션으로부터 취득하는 예제입니다.

export const User = createCustomParamDecorator((data, context) => {
    const req = context.req;
    const session = req.session as SessionObject;

    if (!session) {
        return null;
    }

    return session.user;
});

유저 ID는 아래와 같이 취득할 수 있습니다.

export const UserId = createCustomParamDecorator((data, context) => {
    const req = context.req;
    const session = req.session as SessionObject;

    if (!session) {
        return null;
    }

    return session.user.id;
});

최종 사용법은 아래와 같습니다.

@Controller("/auth")
export class AuthController {
    constructor(private readonly authService: AuthService) {}

    @Get("/session-guard")
    @UseGuard(SessionGuard)
    async checkSessionGuard(
        @Session() session: SessionObject,
        @User() user: any,
        @UserId() userId: string,
    ) {
        return ResultUtils.success("세션 가드 통과", {
            user,
            userId,
        });
    }
}

조회하면 결과는 아래와 같이 출력됩니다.

{
    "message": "세션 가드 통과",
    "result": "success",
    "data": {
        "user": {
            "id": "4500949a-3855-42d4-a4d0-a7f0e81c4054",
            "username": "abcd",
            "role": "user",
            "createdAt": "2023-08-28T09:22:37.144Z",
            "updatedAt": "2023-08-28T09:22:37.144Z"
        },
        "userId": "4500949a-3855-42d4-a4d0-a7f0e81c4054"
    }
}

트랜잭션

트랜잭션은 작업의 완전성과 데이터의 정합성을 보장하기 위한 기능입니다. 즉, 어떤 작업을 완벽하게 처리하지 못했을 때 원 상태로 복구할 수 있도록 해주는 기능입니다.

StingerLoom에서는 이러한 트랜잭션 처리를 위해서 @Transactional이라는 데코레이터를 지원합니다.

스프링에서 영감을 받은 이 데코레이터의 트랜잭션 격리 수준은 생략 시 REPETABLE READ가 기본값입니다.

트랜잭션 격리 수준이란 여러 트랜잭션이 동시에 처리될 때, 특정 트랜잭션이 다른 트랜잭션의 변경 사항을 볼 수 있는 수준을 말합니다.

크게 4가지로 나뉘는데, READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE이 있습니다.

@Transactional 기능은 현재 @Injectable 데코레이터가 붙은 클래스에만 적용됩니다.

또한 트랜잭션 처리를 위해서는 효율적인 검색을 위해 @TransactionalZone 데코레이터를 클래스에 마킹하여야 합니다.

@TransactionalZone 데코레이터는 트랜잭션 처리를 위한 EntityManagerQueryRunner를 주입받을 메소드를 찾아서 트랜잭션 처리를 수행합니다.

다음은 트랜잭션을 처리하는 심플한 예시입니다.

Transaction Entity Manager를 사용하는 경우

transactionalEntityManager 속성을 true로 설정하면, Transaction Entity Manager를 자동으로 주입받을 수 있습니다.

Transaction Entity Manager를 사용하면 트랜잭션 엔티티 매니저를 사용하여 단일이 아닌 여러 쿼리를 트랜잭션으로 처리를 할 수 있게 됩니다.

@TransactionalZone()
@Injectable()
export class AuthService {
    constructor(private readonly userService: UserService) {}

    // Skip...

    /**
     * Transaction EntityManager를 사용하여 트랜잭션을 제어합니다.
     * @param em
     * @returns
     */
    @Transactional({
        isolationLevel: TransactionIsolationLevel.REPEATABLE_READ,
        transactionalEntityManager: true,
    })
    async checkTransaction(em?: EntityManager) {
        const users = (await em?.queryRunner?.query(
            "SELECT * FROM user;",
        )) as User[];

        return ResultUtils.success("트랜잭션을 확인하였습니다.", {
            users: plainToClass(User, users),
        });
    }
}

위 코드를 보면 주입 받은 트랜잭션 엔티티 매니저의 인스턴스인 em을 사용해야 트랜잭션으로 처리가 됩니다.

QueryRunner를 사용하는 경우

제가 자주 사용하는 방법인데요. 바로 QueryRunner를 사용하는 방법이 있습니다.

QueryRunner를 사용하는 경우, 트랜잭션을 상세하게 제어할 수 있는데, @Transactional()이라고 표시된 메소드는 자동으로 QueryRunner를 주입받습니다.

또한 오류가 발생하면 자동으로 롤백 처리까지 해줍니다.

처음에 이것을 설계할 때 QueryRunner가 인터페이스라서 QueryRunner를 주입받는 것이 불가능하다고 생각했었는데요.

이는 @InjectQueryRunner()를 통해 해결할 수 있었습니다.

따라서 QueryRunner 인스턴스를 제대로 주입받으려면 @InjectQueryRunner() 데코레이터를 사용해야 합니다.

그럼 예제를 볼까요?

@TransactionalZone()
@Injectable()
export class AuthService {
    constructor(private readonly userService: UserService) {}

    /**
     * QueryRunner를 사용하여 트랜잭션을 제어합니다.
     * @param queryRunner
     * @returns
     */
    @Transactional()
    async checkTransaction2(@InjectQueryRunner() queryRunner?: QueryRunner) {
        const users = await queryRunner?.query("SELECT * FROM user;");

        return ResultUtils.success("트랜잭션을 확인하였습니다.", {
            users: plainToClass(User, users),
        });
    }
  
    @BeforeTransaction()
    async beforeTransaction(txId: string) {
        // 트랜잭션이 시작되기 전에 아래 코드가 실행됩니다.
    }

    @AfterTransaction()
    async afterTransaction(txId: string) {
        // 트랜잭션이 종료된 후에 아래 코드가 실행됩니다.
    }

    @Commit()
    async commit(txId: string) {
        // 트랜잭션이 커밋된 후에 아래 코드가 실행됩니다.
    }

    @Rollback()
    async rollback(txId: string, error: any) {
        // 트랜잭션이 롤백된 후에 아래 코드가 실행됩니다.
        // 이 메소드는 오류가 발생했을 때만 실행됩니다.
    }    
}

예제를 보면 굉장히 심플하다는 것을 알 수 있습니다. 반환까지 오류가 발생하지 않으면 트랜잭션이 정상적으로 커밋됩니다.

QueryRunner@InjectQueryRunner() 데코레이터를 통해 주입받을 수 있습니다.

다음은 또 다른 예제인 회원 가입 예제입니다.

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

    @Transactional()
    async create(
        createUserDto: CreateUserDto,
        @InjectQueryRunner() queryRunner?: QueryRunner,
    ) {
        const safedUserDto = createUserDto as Record<string, any>;
        if (safedUserDto.role) {
            throw new BadRequestException("role 속성은 입력할 수 없습니다.");
        }

        const newUser = await this.userRepository.create(createUserDto);
        const res = await queryRunner?.manager.save(newUser);

        return ResultUtils.success("유저 생성에 성공하였습니다.", res);
    }

    // Skip...
}

중간에 오류 처리 로직이 보이실 겁니다. 심플하게 생각할 수 있는데요. 위 코드에서 오류가 throw되면 자동으로 트랜잭션이 롤백 처리됩니다.

대신, 트랜잭션이 필요한 부분은 주입되는 queryRunner를 통해 처리해야 합니다.

만약, 롤백 처리 후에 특정 코드를 실행하고싶다면 다음과 같이 할 수 있습니다.

    @Rollback()
    async rollback(txId: string, error: any) {
        // 트랜잭션이 롤백된 후에 아래 코드가 실행됩니다.
        // 이 메소드는 오류가 발생했을 때만 실행됩니다.
    }

@Rollback() 데코레이터를 붙이고 메소드의 첫 번째 인자로는 트랜잭션 ID가, 두 번째 인자로는 오류 객체가 전달됩니다.

트랜잭션 ID는 실제 트랜잭션의 ID가 아니며 서버에서 관리하는 트랜잭션 ID입니다.

Template Engine

템플릿 엔진은 @View 데코레이터를 사용하여 HTML 파일을 렌더링할 수 있습니다.

먼저 필요한 패키지를 설치해야 합니다. 터미널에서 다음과 같이 입력합니다.

yarn add @fastify/view handlebars

bootstrap.ts 파일에서 템플릿 엔진을 미들웨어로 등록하면 모든 컨트롤러에서 템플릿 엔진을 사용할 수 있습니다.

    /**
     * 미들웨어를 추가합니다.
     *
     * @returns
     */
    protected applyMiddlewares(): this {
        const app = this.app;

        app.register(fastifyCookie, {
            secret: process.env.COOKIE_SECRET,
            hook: "onRequest",
        });

        app.register(fastifyFormdody);
        app.register(fastifySession, {
            secret: process.env.SESSION_SECRET,
        });

        app.register(view, {
            engine: {
                handlebars,
            },
            root: `${__dirname}/views`,
            includeViewExtension: true,
        });

        return this;
    }

컨트롤러에서는 @View 데코레이터를 사용하면 템플릿과 매핑할 수 있습니다.

@Controller("/")
export class AppController {
    /**
     * 로그인 페이지를 표시합니다.
     */
    @View("login")
    login() {
        return {
            username: "아이디",
            password: "비밀번호",
        };
    }

    /**
     * 로그인된 유저만 접근할 수 있는 페이지입니다.
     */
    @View("memberInfo")
    @UseGuard(SessionGuard)
    async memberInfo(@User() user: UserEntity) {
        return {
            username: user.username,
        };
    }
}

만약 뷰의 경로와 라우트의 경로가 다르다면 다음과 같이 @Render 데코레이터를 사용하여 템플릿 리소스의 경로를 지정할 수 있습니다.

@Controller("/")
export class AppController {
    /**
     * 로그인된 유저만 접근할 수 있는 페이지입니다.
     */
    @Get("/info")
    @Render("memberInfo")
    @UseGuard(SessionGuard)
    async memberInfo(@User() user: UserEntity) {
        return {
            username: user.username,
        };
    }
}

필요한 매개변수를 반환하면 각 템플릿 엔진에서 이를 처리할 수 있습니다.

다음은 handlebars 템플릿 엔진을 사용한 로그인 예제입니다.

<!-- login.hbs -->
<html lang="ko">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>템플릿 렌더링 예제</title>
    </head>
    <body>
        <div>
            <h2>로그인</h2>
            <form action="/auth/login" method="post">
                <input type="text" name="username" placeholder="{{username}}" />
                <input
                    type="password"
                    name="password"
                    placeholder="{{password}}"
                />
                <input type="submit" value="login" />
            </form>
        </div>
    </body>
</html>

세션 정보를 표시하는 예제입니다.

<!-- memberInfo.hbs -->
<html lang="ko">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>세션 예제</title>
    </head>
    <body>
        <p>로그인한 유저 정보는 <strong>{{username}}</strong>입니다.</p>
    </body>
</html>

CLI

StingerLoom에서는 CLI을 통해 모듈 파일을 쉽게 만들 수 있습니다.

CLI은 매우 지능적이며 타입스크립트 컴파일러(Typescript Compiler)를 통해 모듈 정보를 읽고,

컨트롤러나 서비스 파일을 자동으로 추가하고 주입합니다.

아래는 Typescript Compiler를 이용한 Auto ImportModule 의존성 자동 추가를 하는 구동 화면입니다.

Typescript Compiler를 이용한 Auto Import 및 Module 의존성 자동 추가

실행하려면 아래와 같이 하시기 바랍니다

yarn cli

구현 방법

위 섹션에서는 사용법에 대해서 알아보았는데 지금부터는 구현 방법에 대해서 알아보겠습니다.

세션 인증 처리

세션 인증 처리는 fastify의 기본 사용법과 동일합니다. 우선 미들웨어로 fastifyCookiefastifySession를 추가해야 합니다

모든 코드가 포함되어있는 것은 아니지만,

Route의 실행을 담당하고 처리하는 RouterExecutionContext에서 세션 정보를 매개변수로 넘겨야 하는지를 판단하게 되는데요.

이 객체에서 세션 객체를 Proxy 객체에 래핑하여 반환하게 됩니다.

Proxy로 만들면, 세션 처리 전/후로 해줘야 할 어떤 작업들을 자동화할 수 있습니다.

/**
 * 세션을 프록시로 만듭니다.
 *
 * @param req
 * @returns
 */
export function createSessionProxy(req: FastifyRequest) {
    return new Proxy(req.session, {
        get: (target, prop) => {
            return Reflect.get(target, prop);
        },
        set: (target, prop, value) => {
            Reflect.set(target, prop, value);
            req.session.save();
            return true;
        },
    });
}

이것으로 인증이 수행되는 것은 아닙니다.

실제로 인증을 수행하려면 AuthGuard를 만들어야 합니다.

Session의 경우, SessionGuard를 만들어야 합니다.

SessionGuardRouter가 수행되기 전에 세션 인증이 되어있는지를 확인하는 클래스입니다.

@TransactionalZone()
@Injectable()
export class AuthService {
    constructor(private readonly userService: UserService) {}

    async login(session: SessionObject, loginUserDto: LoginUserDto) {
        const user = await this.userService.validateUser(loginUserDto);

        // Nest에서는 이 부분이 LocalStrategy로 분리되어있습니다.
        session.authenticated = true;
        session.user = user;

        return ResultUtils.successWrap({
            message: "로그인에 성공하였습니다.",
            result: "success",
            data: session.user,
        });
    }
}

위와 같이 로그인이 처리되면 아래와 같이 Route가 수행되기 전에 세션 인증을 거치도록 가드를 걸어줄 수 있습니다.

@Controller("/auth")
export class AuthController {
    constructor(private readonly authService: AuthService) {}

    @Get("/session-guard")
    @UseGuard(SessionGuard)
    async checkSessionGuard(@Session() session: SessionObject) {
        return ResultUtils.success("세션 가드 통과", session);
    }
}

GuardRouterExecutionContext에서 수행되는데요.

가드 또한 DI가 가능한 싱글턴 인스턴스입니다.

하지만 프레임워크에서는 세션 인증에 대한 부분이 직접 작성되진 않습니다.

사용자가 직접 인증 가드를 만들어야 합니다. 이 가드를 소비하는 곳은 Route 입니다.

정리하자면, GuardConsumer에서는 사용자가 직접 작성한 Guard를 찾아서 실행하는 역할을 담당합니다.

주로 메소드에 가드가 마킹되어있는지 확인하고 요청을 통과시킬 것인지 아닌지를 결정하고 있습니다.

https://github.com/biud436/stingerloom/blob/2c63d927cd29b45bc9fba9f139aa07f8eeeb3113/packages/IoC/GuardConsumer.ts#L58

for (const guard of guards) {
    const context = new ServerContext(req);
    const result = await guard.canActivate(context);

    if (!result) {
        throw new UnauthorizedException("접근 권한이 없습니다.");
    }
}

트랜잭션

AOP를 활용한 트랜잭션의 처리

StingerLoom에서는 AOP를 활용하여 각 싱글턴 인스턴스가 생성될 때

이 인스턴스가 트랜잭션 존인지 체크하고 트랜잭션 존이라면 트랜잭션 처리에 필요한 기능들 자동화합니다.

https://github.com/biud436/stingerloom/blob/2c63d927cd29b45bc9fba9f139aa07f8eeeb3113/packages/IoC/ContainerManager.ts#L102

await TransactionManager.checkTransactionalZone(
    TargetInjectable,
    targetInjectable,
    instanceScanner,
);

Transactional 데코레이터는 트랜잭션 격리 수준과 매개변수 주입과 탐색을 위한 몇 가지 메타데이터를 저장합니다.

TransactionManager에서 이 정보들을 가지고 기존 메소드를 후킹하여 데코레이터로 수집한 정보들을 가지고 새로운 코드를 넣는 것입니다.

export class TransactionManager {
    private static LOGGER = new Logger(TransactionManager.name);
    private static txManagerConsumer = new TransactionEntityManagerConsumer();
    private static txQueryRunnerConsumer = new TransactionQueryRunnerConsumer();

    public static async checkTransactionalZone(
        TargetInjectable: ClazzType<any>,
        targetInjectable: InstanceType<any>,
        instanceScanner: InstanceScanner,
    ) {
        if (ReflectManager.isTransactionalZone(TargetInjectable)) {
            // 후킹 수행
        }
    }
}

트랜잭션 존이라면 아래와 같이 getPrototypeMethods 함수를 통해 타겟에 대한 메소드를 모두 취득해야 합니다.

const getPrototypeMethods = (obj: any): string[] => {
    const properties = new Set<string>();
    let currentObj = obj;
    do {
        Object.getOwnPropertyNames(currentObj).map((item) =>
            properties.add(item),
        );

        currentObj = Object.getPrototypeOf(currentObj);
    } while (
        Object.getPrototypeOf(currentObj) &&
        Object.getPrototypeOf(currentObj) !== null
    );

    return [...properties.keys()].filter(
        (item) => typeof obj[item as any] === "function",
    );
};

취득된 메소드에서 Transactional 데코레이터가 마킹된 메소드인지 확인하고 기존 메소드를 트랜잭션 기능이 들어간 새로운 메소드로 대체해주면 됩니다.

// 모든 메소드를 순회합니다.
for (const method of getPrototypeMethods(targetInjectable)) {
    // 데이터베이스 인스턴스를 가져옵니다.
    const database = instanceScanner.get(Database) as Database;

    // 메소드가 트랜잭셔널이라면
    if (
        ReflectManager.isTransactionalZoneMethod(
            targetInjectable,
            method,
        )
    ) {
        const wrapTransaction = () => {
            // 후킹 함수 생성
        }

        try {
            // 기존 메소드를 대체합니다.
            targetInjectable[method] = wrapTransaction();
            
        } catch (err: any) {
            throw new InternalServerException(
                `트랜잭션을 실행하는 도중 오류가 발생했습니다: ${err.message}`,
            );
        }
    }
};

wrapTransaction 메소드는 트랜잭션 영역을 만드는데요.

두 개의 케이스로 나뉩니다.

첫째, EntityManager를 주입하는 Consumer가 있고,

둘째, QueryRunner를 주입하는 Consumer가 있습니다.

이렇게 역할이 분담되었지만,

모든 Consumer는 기존의 원본 함수를 대체하고 트랜잭션 기능 커밋롤백 개념이 들어간 함수로 바꿔주는 역할을 하고 있습니다.

const originalMethod = targetInjectable[method as any];
// 트랜잭션 격리 레벨을 가져옵니다.
const transactionIsolationLevel =
    TransactionManager.getTransactionIsolationLevel(
        targetInjectable,
        method,
    );
// 트랜잭션을 시작합니다.
const dataSource = database.getDataSource();
const entityManager = dataSource.manager;
// 트랜잭션 엔티티 매니저가 필요한가?
const transactionalEntityManager =
    TransactionManager.getTxManager(
        targetInjectable,
        method,
    );
const callback = async (...args: any[]) => {
    return new Promise((resolve, reject) => {
        if (transactionalEntityManager) {
            // 트랜잭션 엔티티 매니저를 실행합니다.
            this.txManagerConsumer.execute(
                entityManager,
                transactionIsolationLevel,
                targetInjectable,
                method,
                args,
                originalMethod,
                resolve,
                reject,
            );
        } else {
            this.txQueryRunnerConsumer.execute(
                dataSource,
                transactionIsolationLevel,
                targetInjectable,
                method,
                originalMethod,
                reject,
                resolve,
                args,
            );
        }
    });
};
return callback;

TransactionEntityManagerConsumerTransactionQueryRunnerConsumer는 생성된 트랜잭션 코드 블럭 안에서 아래와 같이 원본 함수를 호출하는 역할을 수행합니다.

이는 원본 함수를 가로채고 기존의 내용을 새로운 메소드로 바꿔주는 것입니다.

const result = originalMethod.call(
    targetInjectable,
    ...args,
);
// 비동기인지 동기인지를 확인한다
if (result instanceof Promise) {
    return resolve(await result);
} else {
    resolve(result);
}

Consumer로 분리된 이유는 TypeORM과 강하게 결합된 코드이고 또 길기 때문에 별도의 클래스로 모듈화하여 분리를 한 것입니다.

이 트랜잭션 기능은 따로 네스트에도 포팅을 해놓았습니다. 물론 NestJS에는 좋은 라이브러리가 이미 있어서 직접 만들 필요는 없었지만 제가 원하는 형태로 사용하기 위해 포팅을 해놓았는데요.

제 프레임워크와의 차이점이 있다면 NestJS에서는 getPrototypeMethods가 아니라 메타데이터 스캐너(MetadataScanner)를 이용하여 메소드를 취득한다는 것입니다. 또한 싱글턴 범위에 대한 인스턴스 탐색은 디스커버리 서비스(DiscoveryService)를 대신 이용하는 방식으로 코드를 수정하였습니다.

이렇게 쉽게 바꿀 수 있었던 이유는 아무래도 최대한 용도에 맞게, 플랫폼이나 프레임워크에 종속되지 않은 코드로 분리했기 때문일 것입니다.

템플릿 엔진의 구현

템플릿 엔진은 @View 또는 @Render 데코레이터로 HTML을 렌더링할 수 있게 되어있습니다. 다음과 같은 의존성 모듈이 필요합니다.

yarn add @fastify/view handlebars

구현은 @stingerloom/routerRenderConsumer에서 담당하고 있는데요. RenderConsumer의 내용은 아래와 같습니다.

/**
 * @class RenderConsumer
 * @description
 * 이 클래스는 HTML을 렌더링하기 위한 컨슈머 클래스입니다.
 */
export class RenderConsumer {
    constructor(private readonly targetController: ClazzType<unknown>) {}

    /**
     * 렌더링을 해야 한다면 true를 반환합니다.
     * @param routerName
     * @returns
     */
    public isRender(routerName: string) {
        const { targetController } = this;

        const render = Reflect.getMetadata(
            RENDER_TOKEN,
            targetController,
            routerName,
        );

        return !!render;
    }

    /**
     * 렌더링을 수행합니다.
     *
     * @param res
     * @param routerName
     * @param result
     * @returns
     */
    public execute(res: FastifyReply, routerName: string, result: unknown) {
        const { targetController } = this;

        if (this.isRender(routerName)) {
            const path = Reflect.getMetadata(
                RENDER_PATH_TOKEN,
                targetController,
                routerName,
            );

            const resExtend = res as any;
            if (resExtend.view) {
                return resExtend.view(path, result);
            }
        }

        return res.send(result);
    }
}

isRender 메소드에서 렌더링을 해야 하는지 여부를 가져오고, FastifyFastifyReply 객체의 view 메소드를 활용하여 렌더링을 실시합니다.

RenderConsumerRouterExecutionContext에서 소비합니다.

위 코드와 같이 렌더링 여부를 반환하고 실행해주는 것입니다. 주의할 점은 반환 함수의 비동기/동기 여부를 체크하는 것입니다. 비동기일 땐, Promise가 반환되기 때문에 반환형의 타입을 체크해줘야 합니다. 비동기라면 함수를 평가하고 await 키워드를 추가하여 실행해줘야 합니다.

형태를 보시면 알겠지만 이 렌더링 컴포넌트 코드에선 트랜잭션을 구현했을 때와 동일하게 AOP 기술이 활용되었다는 것을 알 수 있습니다.

이것으로 렌더링 컴포넌트 구현은 끝났습니다.

타입스크립트 컴파일러

Auto Import의 구현과 Compile Process

inquirer, Hygen, Typescript Compiler API를 활용하여 터미널을 통해 파일을 자동으로 생성하고 import 링크를 거는 커맨드라인 기능입니다. 심오하다고 생각할 수 있겠지만 이것을 어떻게 구현하였는지 타입스크립트 컴파일러에 대한 지식이 전혀 없는 상태에서 도대체 어떻게 알아낼 수 있었는 지 걸음마를 떼는 아이의 심정으로 순서대로 알아보려고 합니다.

먼저 이 기능을 만들게 된 계기는 지금까지 NestJS라는 프레임워크를 사용하면서 @nestjs/cli에 있는 시멘틱 생성기를 잘 이용하였기 때문입니다. 이 툴은 커맨드라인에서 명령어만 입력하면 루비 온 레일즈 마냥 서버 개발에 필요한 파일들을 모조리 생성하고 링크합니다. 굉장히 편리한데, 직접 사용해보지 않으면 필요 이유가 와닿지 않을 수도 있는데요. 그런 익숙함 탓에 제가 만든 프레임워크가 허전하게 느껴졌고 이러한 기능 추가가 필요함을 인지하게 된 것입니다.

아래 스크린샷은 GIF로 촬영하였는데 커스텀 프레임워크의 CLI 도구의 최종 구동 화면입니다. 기본적으로 이 도구는 Hygen을 이용하여 파일을 생성합니다. 추가로 타입스크립트 컴파일러 API를 통해 컴파일러 수준에서 의존성을 모듈에 자동으로 추가하는 기능을 가지고 있습니다.

Hygen은 간단하게 미리 정의된 파일을 생성하는 라이브러리입니다. 위와 같이 템플릿 파일들을 정의해두면 호출 시 대체 가능하다고 표시된 변수에 입력된 내용이 채워집니다. 참고로 NestJS에서는 @nestjs/schematics 패키지에서 이러한 작업을 수행하고 있습니다. 주요 로직은 미리 작성해놓은 자바스크립트와 타입스크립트 템플릿 파일에 문자열을 대체하는 방식을 가지는데요. 이 방법도 매우 효율적인 방법이죠.

컴파일러 API를 사용하면 파일의 문법을 수정하거나 자바스크립트로 변환하거나 서비스를 통해 포맷 등을 재지정할 수도 있는데, 제 프레임워크에는 파일 수정 기능이 필요했습니다.

그런데 이 타입스크립트 컴파일러를 이용하기 위해서는 타입스크립트 컴파일 프로세스 과정부터 알아봐야 합니다.

이쯤에서 멈추고 추후에 이어서 작성하도록 하겠습니다.

참고 링크

나만의 서버 프레임워크 깃허브 저장소

0개의 댓글