클래스 기반으로 ExpressJS 앱 작성하기

PEPPERMINT100·2020년 12월 19일
7

서론

ExpressJS는 가장 널리 사용되는 NodeJS의 백엔드 프레임워크이다. 공식문서에는 nodejs 미니멀리스트를 위한 프레임워크라고 쓰여있고 python의 flask와 비슷한 구조로 코드가 작성된다. 하지만 expressjs 그리고 flask를 프레임워크라 하기엔 조금 민망할 정도로 개발자가 각자 자유도 높은 코드를 작성할 수 있다.

간단히 비교해 보자면 Java의 Spring Framework는 요청과 응답을 처리할 때 controller, service, repository라는 세 가지 레이어로 코드의 역할을 나누어 처리를 한다. controller에서 요청 및 응답의 URL을 매칭하고 service를 통해 비즈니스 로직을 처리하며 repository를 통해 데이터베이스에 접근한다. 그리고 어노테이션을 통해 표시를 함으로서 코드가 어떤 역할을 하게 될 것인지 미리 스프링에게 알려주어 알아서 컨트롤 하도록 한다.

Python의 DJango는 프로젝트 내에 app이라는 구조로 역할을 나누어 urls.py를 통해 매핑하고 model에서 데이터베이스의 엔티티를 설정하며 views에서 모든 비즈니스 로직을 처리한다. 그리고 이 모두를 manage.py의 커맨드를 통해 db 마이그레이션이나 앱 생성, 슈퍼 유저 생성 등 다양한 작업을 쉽게 할 수 있도록 도와준다.

즉, 결론은 프레임워크라 함은 코드 작성 방식이 어느정도는 정해져 있고 중요하거나 복잡한 일을 이미 안전하고 간단하게 프레임 워크가 처리해 주는 부분이 많다는 것이다.

하지만 우리 express는 그렇지 않다. 그저 app과 router, request, response 이 정도를 던져주고 나머지는 알아서 외부 라이브러리로 개발자가 그림을 그리길 원한다. 마치 리액트와 비슷하다. 리액트 자체는 뭔가 컴팩트하고 많은 기능을 하지 않지만 다양한 라이브러리를 통해 정말 다양한 방식으로 코드 작성이 가능하다.

그래서

이렇게 생겨먹은 라이브러리 like 프레임워크의 영역에서는 코드 작성 방식이 중요하다. 그렇지 않으면 매 번 코드를 다르게 작성하게 되고 만약 협업하는 경우에는 서로 작성한 코드를 서로 고쳐주거나 점검하기가 굉장히 어려워진다. 혼자 작업하는 경우에도 내 코드를 알아보는데 시간이 오래걸리는 경우가 생길 수 있다.

따라서 개인적으로 reactexpress를 많이 사용하기 때문에 코드 작성 방식을 굉장히 일정하고 안정적이게 유지하고 싶다는 욕구가 있었다. 구글링을 통해 다양한 글을 접했고 다른 개발자의 코드도 많이 참고하였고, 유지 보수 및 깔끔한 코드, 그리고 재사용성을 고려한다면 역시 class 기반으로 작성하는 것이 좋다고 느끼게 되었다. 이를 위해 다른 언어의 백엔드 프레임워크도 조금씩 배워보았으며 지금의 expressjs 코드 작성 방식을 발견하게 되었고 이를 공유하려 한다.

절대 정답은 아니며 더 좋은 코드 작성 방식이 있을 수 있고 그런 방식을 발견하면 필자도 바로 갈아탈 것이다.

작성 방식

먼저 기본적인 nodejsexpressjs의 사용 방법을 알고 있다는 가정하에 글을 작성해 보겠다. 먼저 App.ts라는 파일을 생성하고 아래와 같이 코드를 작성한다.

import express from "express";

class App {
    private app: express.Application;
    private port: number;

    constructor(appConfig : { 
        port: number
        , middlewares: Array<any>
        , routes: Array<any> 
    }){
        this.app = express();
        this.port = appConfig.port;
        this.applyMiddlewares(appConfig.middlewares);
        this.applyRoutes(appConfig.routes);
    }


    private applyMiddlewares(middlewares: any){
        middlewares.forEach((middleware: any) => {
            this.app.use(middleware);
        })
    }

    private applyRoutes(routes: any){
        routes.forEach((route: any) => {
            this.app.use(route.url, route.controller);
        })
    }

    public listen(){
        this.app.listen(this.port, () => {
            console.log(`server started on PORT ${this.port}`);
        })
    }
}

export default App;
// App.ts

코드를 조금씩 나누어서 살펴보자.

    private app: express.Application;
    private port: number;

    constructor(appConfig : { 
        port: number
        , middlewares: Array<any>
        , routes: Array<any>
        , db: any }){
        this.app = express();
        this.port = appConfig.port;
        this.applyMiddlewares(appConfig.middlewares);
        this.applyRoutes(appConfig.routes);
    }

먼저 express 앱과 포트 번호, 미들웨어, 다양한 url 적용을 도와줄 라우터 등을 받는다. 이 곳에 env나 데이터베이스 역시 받아서 사용할 수 있다.

private applyMiddlewares(middlewares: any){
        middlewares.forEach((middleware: any) => {
            this.app.use(middleware);
        })
    }

    private applyRoutes(routes: any){
        routes.forEach((route: any) => {
            this.app.use(route.url, route.controller);
        })
    }

    public listen(){
        this.app.listen(this.port, () => {
            console.log(`server started on PORT ${this.port}`);
        })
    }

그리고 위와 같이 for문을 통해 미들웨어와 라우터들을 적용시켜 준다. 받아올 미들웨어와 라우터의 구조 역시 아래 설명하도록 하겠다. 그리고 listen 메소드까지 작성해준다. 그리고 index.ts라는 이름으로 파일을 또 작성하고 아래와 같이 작성한다.

const appConfig = {
   port: 5000,
   routes: [
       // 라우터들을 여기에 작성한다.
       authController,
       cafeController,
       commentController,
       likeController
   ],
   middlewares: [
      // 미들웨어를 여기에 작성한다.
       cors(corsConfig),
       express.json(),
       express.urlencoded({ extended: false }),
       cookieParser()
   ]
}

const app: App = new App(appConfig);

app.listen();

이제 이 파일이 base 파일이 된다. ts-node를 통해 이 파일을 실행시킴으로서 서버가 실행된다. 기본적으로 미들웨어와 라우터들을 배열안에 넣음으로서 App.ts의 메소드가 for문을 통해 미들웨어와 라우터를 적용할 수 있도록 한다.

라우터 안에 들어가는 컨트롤러들은 아래와 같이 작성한다.

import { basicController } from '../basicController';
import express, { Request, Response } from "express";
import CustomException from '../../exceptions/CustomException';
import CafeService from '../../services/Cafe/CafeService';
import CafeCreateRequest from '../../types/Cafe/CafeCreateRequest';
import AuthController from '../Auth/AuthController';
import { Cafe } from '../../entities/Cafe/Cafe';

class CafeController implements basicController{
    public url = "/cafe";
    public controller = express.Router(); 
    public cafeService: CafeService;
    public authController: AuthController;
    
    constructor(cafeService: CafeService, authController: AuthController) {
        this.cafeService = cafeService;
        this.authController = authController;
        this.init();
    }

    public init(){
        this.controller.get("/", this.getAllCafe);
        this.controller.post("/create", this.authController.requireAuth ,this.createCafe);
        this.controller.get("/:cafeId", this.authController.requireAuth ,this.getCafeByCafeId)
        this.controller.post("/:userId", this.authController.requireAuth ,this.getAllCafeByUserId)
        this.controller.put("/update/:cafeId", this.authController.requireAuth ,this.updateCafeByCafeId);
        this.controller.delete("/delete/:cafeId", this.authController.requireAuth, this.deleteCafeByCafeId);
    }

 })
    }

    getAllCafeByUserId = async (req: Request, res: Response) => {
        const { userId } = req.params;
        
        await this.cafeService.getAllCafeByUserId(userId)
        .then((resp: Array<Cafe>) => {
            res.json({ cafe: resp });
        }) 
        .catch((err:CustomException) => {
            if(err){
                res.status(err.status).json({ message: err.message });
            }
        })
    }
    
    ...

export default CafeController;

실제로 작성 중인 코드의 일부분을 가져오고 필요없는 부분을 걷어내보았다. 알아볼 수 없는 import 문이나 코드는 무시해도 좋고 코드의 구조만 보면 된다.

    public url = "/cafe";
    public controller = express.Router(); 
    public cafeService: CafeService;
    public authController: AuthController;
    
    constructor(cafeService: CafeService, authController: AuthController) {
        this.cafeService = cafeService;
        this.authController = authController;
        this.init();
    }

이 부분을 통해 Dependency Injection을 한다. 이 부분에 대해 모른다면 어려운 개념은 아니니 간단하게 공부를 하고 오도록 한다.

   public init(){
       this.controller.get("/", this.getAllCafe);
       this.controller.post("/create", this.authController.requireAuth ,this.createCafe);
       this.controller.get("/:cafeId", this.authController.requireAuth ,this.getCafeByCafeId)
       this.controller.post("/:userId", this.authController.requireAuth ,this.getAllCafeByUserId)
       this.controller.put("/update/:cafeId", this.authController.requireAuth ,this.updateCafeByCafeId);
       this.controller.delete("/delete/:cafeId", this.authController.requireAuth, this.deleteCafeByCafeId);
   }

그리고 위와 같이 라우터의 통신 방식을 저장해준다. 만약 인증이 필요한 경우에는 중간에 미들웨어를 통해 인증을 진행하고 메소드를 실행할 수 있다. 이후엔 각자의 로직에 맞게 메소드를 작성해주면 되겠다.

이러한 방식으로 코드 작성자의 의도에 맞게 service 레이어와 repository 레이어도 클래스 기반으로 작성하면 된다.

일반적으로 controller에서는 url 처리, 파라미터 처리 이 후 레이어의 에러 코드 해석을 통한 응답 정도만 하고 repository에서는 db 접근에 관련된 부분만 처리하고 나머지는 전부 service에서 처리한다고한다.

해결하지 못한 부분

사실 나의 index.ts의 위에는 이러한 코드가 숨어있다.

const likeRepository = new LikeRepository(dbCore);
const cafeRepository = new CafeRepository(dbCore);
const commentRepository = new CommentRepository(dbCore);
const userRepository = new UserRepository(dbCore);
const likeService = new LikeService(likeRepository, cafeRepository);
const commentService = new CommentService(commentRepository, userRepository, cafeRepository);
const cafeService = new CafeService(cafeRepository);
const jwtService = new JwtService(process.env.JWT_KEY || "secret", process.env.JWT_EXPIRE_DATE || "3600");
const bcryptEncoder = new BcryptEncoder(process.env.BCRYPT_SALT || "10");
const authRepository = new AuthRepository(dbCore);
const authService = new AuthService(authRepository, userRepository, bcryptEncoder, jwtService);
const authController = new AuthController(authService);
const cafeController = new CafeController(cafeService, authController);
const commentController = new CommentController(commentService, authController);
const likeController = new LikeController(likeService, authController);

이 부분이 바로 Dependency Injection을 필자도 공부하고 직접 구현한 것으로 스프링의 빈과 @Autowired로 처리될 부분을 직접 작성한 것이다.

이러한 방식은 보기에도 좋지 않고 관리도 굉장히 힘들다. 각자 서로가 서로를 의존하고 있기 때문에 각 클래스들을 선언할 때의 순서도 신경써주어야 한다. 특히 새로운 클래스를 새로 넣어 줄때 굉장히 불편하다. 이러한 부분을 더 좋게 처리하는 방법은 아직 찾지 못했다.

결론

더 좋은 코드 작성 방식을 위해 쓸데 없이 많은 고민을 했고 다양한 개발자들의 코드들을 참고해왔다. 하지만 역시 expressjs에서는 대부분 간단하게 함수, 모듈 기반으로 라우터를 작성하고 그 라우터 내에서 db 접근까지 전부하는 경우가 대부분이었다.

분명 간단한 경우엔 이 방법이 더 좋을 수 있다. 코드 작성도 금방하고 사실 알아보는 것도 간단하면 쉽게 알아볼 수 있기 때문이다. 나와 같이 공부하는 입장에서도 왠만하면 복잡하고 규모있는 코드를 작성할 일이 없기 때문에 그렇게 하는 것도 맞다.

하지만 개인적인 욕심으로 이 후에 아무도 안보더라도 쉽게 알아볼 수 있고 쉽게 고칠 수 있는 코드를 작성하고 싶다라는 욕심에 이러한 방법을 찾게 되었고 이럴거면 nestjs를 공부하는게 더 좋아보인다.

하지만 nestjs의 필요성을 느끼는데에는 성공한것 같다.

profile
기억하기 위해 혹은 잊어버리기 위해 글을 씁니다.

1개의 댓글

comment-user-thumbnail
2021년 6월 29일

저도 express.js로 클래스 방식으로 개발공부중입니다. express.js로 전형적인 개발방식이 없는것 같네요 자료도 너무없고, 제 개발방식이 맞는지 알기 어려운것 같습니다.ㅠㅠ

답글 달기