Search

Express를 NestJS처럼 사용하기 (feat. InversifyJS)

주제
Backend
날짜
2023/01/12

Express, NestJS 구현 차이점

Routing

// ExpressJS app.use("/lecture", require("./lectureRouter")) app.use("/student", require("./studentRouter"))
JavaScript
복사
//NestJS import { Module } from '@nestjs/common'; import { CatsController } from './cats.controller'; import { CatsService } from './cats.service'; @Module({ controllers: [CatsController], providers: [CatsService], }) export class CatsModule {}
TypeScript
복사
위 코드처럼 Express는 라우팅을 할 때 app.use 처럼 등록을 해서 사용한다.
하지만, NestJS는 Module별로 나누어 라우팅을 한다.

Controller

//ExpressJS router.route('/') .get((req,res) => {})
JavaScript
복사
//NestJS import { Controller, Get } from '@nestjs/common'; @Controller('cats') export class CatsController { @Get() findAll(): string { return 'This action returns all cats'; } }
TypeScript
복사
코드만 봤을 땐 Express쪽이 편해보인다.
Express가 간결해 보이지만, 가독성,확장성 등을 고려했을 때 NestJS쪽이 구조가 더 명확하고 좋다고 생각한다.

Service

//ExpressJS router.route('/') .get((req,res) => { // 서비스 코드 })
JavaScript
복사
//NestJS import { Injectable } from '@nestjs/common'; import { Cat } from './interfaces/cat.interface'; @Injectable() export class CatsService { private readonly cats: Cat[] = []; create(cat: Cat) { this.cats.push(cat); } findAll(): Cat[] { return this.cats; } }
TypeScript
복사
Express는 컨트롤러 내부에 코드를 작성하거나 함수를 만들어 작업하기도 한다.
하지만, Nest의 경우 서비스를 따로 class로 분리하여 class안에서 여러 함수를 만들어 컨트롤러별로 기능을 구현한다.

Express를 NestJS처럼 구현하기

일반적인 Express 코드

import cookieParser from "cookie-parser"; import express from "express"; import path from "path"; import { PORT } from "./modules/env"; import { default as apiRouter } from "./routes"; const port = PORT || "3001"; const __dirname = path.resolve(""); const app = express(); app.set("trust proxy", 1); app.use(express.json()); app.use(cookieParser()); app.use("/api", apiRouter); app.use(express.static(path.resolve(__dirname, "public"))); app.get("*", (req, res) => { res.sendFile(path.resolve(__dirname, "public", "index.html")); }); app.listen(port, () => { console.log(`server is running on port:${port}`); });
TypeScript
복사
import express, { Request, Response } from "express"; import { query } from "../db/mysql"; import { GET_PRODUCT_DETAIL, QUERY_GET_PRODUCTS_LIST, QUERY_GET_PRODUCTS_LIST_FOR_USER } from "../db/queryList"; import { clearTokenInCookies, validate } from "../middleware/auth"; import { ACCESS_TOKEN_SECRETKEY } from "../modules/env"; import { sign, verify } from "../modules/jwt"; import authRouter from "./auth"; const router = express.Router(); router.use("/auth", authRouter); router.get("/products", (req: Request, res: Response) => { const location = parseInt(req.query["location"] as string); const category = parseInt(req.query["category"] as string); const userId = ""; const SQL = userId ? QUERY_GET_PRODUCTS_LIST_FOR_USER(location, category, parseInt(userId)) : QUERY_GET_PRODUCTS_LIST(location, category); query(SQL).then((lists) => res.json(lists)); }); router.post("/product", (req, res) => { const productId = parseInt(req.body["productId"]); query(GET_PRODUCT_DETAIL, productId).then((detail) => res.json(detail)); }); router.post("/validate", validate, (req: Request, res: Response) => { const data = verify(req.cookies["access_token"], ACCESS_TOKEN_SECRETKEY!); console.log("data", data); res.json({ userLoggedIn: true, ...data.payload }); }); router.post("/logout", (req: Request, res: Response) => { clearTokenInCookies(res).json({ msg: "user logout" }); }); export default router;
TypeScript
복사
일반적으로 찾을 수 있는 Express로 작성한 코드는 대부분은 이런 스크립트와 같이 작성되어서 main 함수에서 app.listen을 호출한다.
위 코드는 초기에 Express를 학습하면서 작성했던 코드이다.
딱 봐도 가독성이 좋지 않으며, 여러 문제점이 보인다.

Class로 변환

NestJS처럼 필요한 Component를 수동으로 직접 하나씩 주입했다.
Class : controller, service, repository, ExpressApp
Function : main
Controller 부분에서 Router를 어떻게 작성하느냐에 따라서 스타일이 다를 수 있다.

Repository

mysql의 pool을 이용해 DB를 사용하기 때문에 mysql.Pool을 주입했다.
class Repository { private dataSource: mysql.Pool constructor(dataSource: mysql.Pool) { this.dataSource = dataSource; } //...생략 } export default Repository;
TypeScript
복사

Service

Repository를 주입받는다.
import Repository from "@db/repository"; class LectureService { private Repository: Repository; constructor(repository: Repository) { this.repository = repository; } //...생략 } export default LectureService;
TypeScript
복사

Controller

Router 설정에 필요한 url path와 비즈니스 로직, 즉 service를 주입받는다.
constructor에서 본인의 Router를 하나 생성하고, addListener 메서드를 통해서 각각의 CRUD 작업을 등록했다.
그리고 밖의 App에서 사용할 수 있도록 Router를 반환한다.
import { Router } from "express"; import LectureService from "./lecture.service"; class LectureController { private lectureService: LectureService; private readonly router: Router; private path = "/lecture"; constructor(lectureService: LectureService) { this.lectureService = lectureService; this.router = Router(); this.addListener(); } initRouter(): Router { return this.router; } addListener(): void { const router = Router(); router .get('/:id', (req, res) => { //... 생략 res.json(result); }).post('/', (req, res) => { //... 생략 res.json(result); }).put('/:id', (req, res) => { //... 생략 res.json(result); }).delete('/:id', (req, res) => { //... 생략 res.json(result); }) this.router.use(this.path, router); } } export default LectureController;
TypeScript
복사

ExpressApp

Controller들을 등록하고, 설정하는 곳이다.
서버를 시작하고, 필요한 옵션들을 등록하고, Controller에서 받은 Router들을 등록한다.
import express, { Application } from "express"; export class ExpressApp { private app: Application; private controllers: Array<any>; constructor(app: Application, controllers: Array<any>) { this.app = app; this.controllers = controllers; this.initOptions(); this.registRouter(); } getApp() { return this.app; } initOptions() { this.app.use(express.json()); this.app.use(express.urlencoded({ extended: true })); } registRouter() { this.controllers.forEach((controller) => { this.app.use("/api", controller.initRouter()); }); } startServer(port: number) { this.app.listen(port, () => { console.log(` ##### Server listening on port: ${port} #####`); }); } }
TypeScript
복사

app.module

NestJS의 app.module의 역할을 비슷하게 수행한다.
Component에 필요한 것들을 주입해준다.
import { Application } from "express"; import { container } from "@config/inversify.config"; import LectureController from "@lecture/lecture.controller"; import { TYPES } from "@config/types.config"; import StudentController from "./domain/student/student.controller"; import { ExpressApp } from "./app"; export const AppModule = (app: Application) => { // ??? routerList.push(new LectureController(new LectureService(new Repository(mysql.Pool)), "/lecture")) const expressApp: ExpressApp = new ExpressApp(app, routerList); return expressApp; };
TypeScript
복사

문제점

위와 같이 할 경우 각각의 필요한 부분에 수동으로 주입하는 코드가 너무 복잡해진다.
routerList.push(new LectureController(new LectureService(new Repository(mysql.Pool)), "/lecture"))
TypeScript
복사
NestJS의 경우 Component를 자동으로 등록하고 주입 해준다.
하지만, Express에는 그런 기능이 없기 때문에 Class를 사용해 의존성 주입을 받는다면 상당히 복잡해진다.

해결 방법: DI, IoC

NodeJS에서는 이러한 문제를 해결해주는 InversifyJS 라는 라이브러리가 있다.
InversifyJS는 IoC(Inversion of Control) 컨테이너이다.
DI를 구현하기 위해 TypeScript에서 많이 사용되는 것은 InversifyJS, TypeDI, Tsyringe 등이 있다.
DI를 구현하기 위해 사용자들이 많이 사용하고 있고 가벼운 InversifyJS를 채택했다.

구현

InversifyJS를 활용해 의존성 주입과 IoC를 진행한다.
일단 주입받고 주입할 객체들은 총 4가지이다.
Controller, Service, Repository, DataSource

Symbol을 통해 정의

const TYPES = { LectureController: Symbol.for("LectureController"), LectureService: Symbol.for("LectureService"), StudentController: Symbol.for("StudentController"), StudentService: Symbol.for("StudentService"), DataSource: Symbol.for("DataSource"), Repository: Symbol.for("Repository"), }; export { TYPES };
TypeScript
복사
이렇게 사용할 객체들의 이름들을 정의해둬야 나중에 필요한것을 주입받을때 사용할 수 있다.

Inversify Container에 담기

지금까지 만들었던 객체들을 bind 해주는 작업이다.
container.bind<{name of class}>({type of class}).to({value of class})...{option}
TypeScript
복사
1.
class에는 아까 만들었던 Controller, Service 등등의 Class 또는 Interface로 정의된 것을 넣는다.
2.
type에는 Symbol을 통해 정의했던 값을 넣는다.
3.
value에는 실제 값이 들어가면 되는다.
a.
아래에는 namevalue가 같이 들어가있는데 일반적으로 구현체는 직접 사용하면 확장성이나 그런면에서 불리함이 있기 때문에 interface를 name으로 넣고, value에는 class를 넣는 방식을 사용한다.
import { Container } from "inversify"; import "reflect-metadata"; import LectureService from "@lecture/lecture.service"; import LectureController from "@lecture/lecture.controller"; import Repository from "@db/repository"; import { DataSource } from "@config/dataSource"; import { TYPES } from "./types.config"; import StudentController from "../domain/student/student.controller"; import StudentService from "../domain/student/student.service"; const container = new Container(); container.bind<LectureService>(TYPES.LectureService).to(LectureService).inSingletonScope(); container.bind<LectureController>(TYPES.LectureController).to(LectureController).inSingletonScope(); container.bind<StudentService>(TYPES.StudentService).to(StudentService).inSingletonScope(); container.bind<StudentController>(TYPES.StudentController).to(StudentController).inSingletonScope(); container.bind<Repository>(TYPES.Repository).to(Repository).inSingletonScope(); container.bind<typeof DataSource>(TYPES.DataSource).toConstantValue(DataSource); export { container };
TypeScript
복사
끝에 inSingletonScope()를 붙이면 NestJS 처럼 싱글톤으로 구성이 된다.
import "reflect-metadata"를 해줘야한다. 그래야 런타임때에 어노테이션(@)가 적용된다.
1번만 import 해야 한다. (InversifyJS Github, Github issue)

Class에 inject

@injectable() class LectureController implements Controller { private lectureService: LectureService; private readonly router: Router; private path = "/lecture"; constructor(@inject(TYPES.LectureService) lectureService: LectureService) { this.lectureService = lectureService; this.router = Router(); this.addListener(); } }
TypeScript
복사
Class 위에는 @injectable()를 작성하고, constructor에는 주입받고 싶은 값 앞에 @inject({Symbol정의한것})을 넣어주면 된다.

직접 객체 가져오기

위에서 작성했던 container에서 원하는 Class와 Type을 작성하면 된다.
BEFORE
routerList.push(new LectureController(new LectureService(new Repository(mysql.Pool)), "/lecture"))
TypeScript
복사
AFTER
const routerList = []; const lectureController = container.get<LectureController>(TYPES.LectureController); routerList.push(lectureController);
TypeScript
복사
훨신 깔끔해졌다.