TIL

Express 폴더 구조

Express 폴더 구조

Express로 개발을 하면서 느꼈던 장점 중 하나가 자유도가 높다는 점이다. 폴더 구조나 툴, 미들웨어 등 선택할 수 있는 폭이 넓다. 하지만 큰 규모의 프로젝트를 해본적이 없는 나에겐 틀이 없다는게 더 어렵게 느껴졌다. (그래서 어느정도의 구조가 정해져 있는 nestjs가 나온걸수도..!)

 

그래서 검색을 해보다가 nestjs라는 좋은 프레임워크를 발견했지만 이미 개발을 시작한 상황에서 프레임워크를 변경하기는 어려웠다. + nestjs에 대해 조금 훑어보다가 Spring의 구조와 비슷한걸 깨달았다. 최근에 Spring을 공부하고 있는데 처음에는 구조 파악이 어려웠는데 이제 뭔가 느낌을 알 것 같은 그런 느낌..!!

 

프로젝트 초반에 가장 고민했던 부분은 프로젝트 구조였다. 어떤 식으로 구조를 짜야 추후 들어올 개발자와 함께 협업하기에도 효율적이고 스스로 개발하기도 편할까 고민하다가 좋은 글을 발견했다. 

Express.js is great frameworks for making a node.js REST APIs however it doesn’t give you any clue on how to organizing your node.js project.
익스프레스는 node.js rest api를 만들기 좋은 프레임워크지만 어떻게 node.js 프로젝트를 구조화할지에 대한 단서를 주지 않는다.

(위 링크에서 인용)

== 딱 내 고민...

위 글에서 얘기하는 폴더 구조는 다음과 같다.

src
│   app.js          # App entry point 진입점
└───api             # Express route controllers for all the endpoints of the app 엔드포인트에서 라우팅을 하는 부분
└───config          # Environment variables and configuration related stuff 환경 변수등 저장하는 폴더
└───jobs            # Jobs definitions for agenda.js agenda.js를 사용하는 경우에만!
└───loaders         # Split the startup process into modules 모듈의 시작점을 적어두는 부분
└───models          # Database models DB 모델들
└───services        # All the business logic is here 비즈니스 로직을 작성하는 부분
└───subscribers     # Event handlers for async task async에 대한 이벤트 핸들러
└───types           # Type declaration files (d.ts) for Typescript Typescript 사용하는 경우에만!

나는 개발할때 위의 구조에서 일부를 삭제하거나 추가해서 개발했다. (내가 사용한 툴이나 기술에 맞도록)

위의 구조에서 포인트는 아래와 같다.

 

3 Layer Architecture

관심사 분리 법칙(principle of separation of concerns, SoC) 는 컴퓨터 프로그램을 구별된 부분으로 분리시키는 디자인 원칙이다. 캡슐화(인터페이스가 있는 코드 부분안에 정보 숨기기)를 시킴으로써 분리시킨다. 관심사 분리를 이용하면 프로그램의 설계, 디플로이, 이용의 일부 관점에 더 높은 정도의 자유가 생긴다. 즉, 다른 부분의 세세한 사항을 몰라도 하나의 관심사의 코드 부분을 개선하거나 수정할 수 있게 된다. (from 위키)

 

여기서는 Controller <=> Service Layer <=> Data Access layer 로 분리해서 SoC 원칙을 적용시켰다. 여기서 포인트는 바로 다음의 문구이다.

Don’t put your business logic inside the controllers!!
비즈니스 로직을 controller에 작성하지마세요!!

비즈니스 로직은 service 레이어에 작성하고 controller는 단지 req를 받아서 res 처리를 해준다.

또한 비즈니스 로직에는 sql에 대한 부분이 들어가선 안되고 이또한 data access 레이어에 분리시켜줘야한다.

  • express.js 라우터에서 코드를 제거한다.
  • req, res 객체를 service 레이어에 전달하지 않는다.
  • service 레이어에서 status code나 header와 같은 HTTP transport layer와 관련있는 것들을 리턴하지 않는다.
// api

route.post('/', 
  validators.userSignup, // this middleware take care of validation
  async (req, res, next) => {
    // The actual responsability of the route layer.
    const userDTO = req.body;

    // Call to service layer.
    // Abstraction on how to access the data layer and the business logic.
    const { user, company } = await UserService.Signup(userDTO);

    // Return a response to client.
    return res.json({ user, company });
  });
// services

import UserModel from '../models/user';
import CompanyModel from '../models/company';

export default class UserService() {

  async Signup(user) {
    const userRecord = await UserModel.create(user);
    const companyRecord = await CompanyModel.create(userRecord); // needs userRecord to have the database id 
    const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // depends on user and company to be created
    
    ...whatever
    
    await EmailService.startSignupSequence(userRecord)

    ...do more stuff

    return { user: userRecord, company: companyRecord };
  }
}

 

Use a Pub/Sub layer too

Pub/Sub는 발행-구독 모델을 말한다. 발행-구독 모델에서 발신자의 메시지는 특별한 수신자가 정해져 있지 않다. 대신 발행된 메시지는 정해진 범주에 따라, 각 범주에 대한 구독을 신청한 수신자에게 전달된다. 수신자는 발행자에 대한 지식이 없어도 원하는 메시지만을 수신할 수 있다. 이러한 발행자와 구독자의 디커플링은 더 다이나믹한 네트워크 토폴로지와 높은 확장성을 허용한다. (from 위키)

Sooner than later, that simple “create” operation will be doing several things, and you will end up with 1000 lines of code, all in a single function. That violates the principle of single responsibility. So, it’s better to separate responsibilities from the start, so your code remains maintainable.
간단한 create가 여러가지를 하게 될 수 있는 하나의 함수에 1000개가 넘는 라인의 코드를 초래할 수 있다. 이것은 single responsibility의 원칙에 어긋난다. 따라서 responsibilities를 나눠서 유지가능하게 코드를 짜는게 좋다.

이는 명령형 호출을 통해서 구현할 수도 있지만 listener를 통해 효과적으로 구현할 수 있다.

자세한 코드는 위의 링크에서 확인가능하다.

 

Dependency Injection

Dependency Injection(의존성 주입, DI)를 설명하면 다음과 같다. 의존성은 B클래스에서 A클래스를 사용하는 경우 B클래스는 A클래스에 의존관계가 생겼다고 말하며 내부가 아니라 외부에서 객체를 생성해서 넣어주는 것을 주입한다고 말한다. 즉, 의존성 주입은 내부에서 만든 변수를 외부에서 넣어준다는 말이다. 여기서 의존성 분리에 대한 개념이 추가된다. 의존성 주입은 의존성을 분리시켜 사용한다. 이는 인터페이스를 사용해서 수행한다. 이렇게 되면 제어의 주체가 역전되며 이러한 상황을 IoC (Inversion Of Control) 패턴이라고 한다.

 

하지만 이 패턴을 지키는 것은 어렵다. (서비스가 가진 의존성이 무수히 많기 때문에..) 그래서 존재하는 것이 dependency injection 프레임워크다. 이 글에서는 typedi를 이용했다. 이 방식을 이용하면 unit test를 하는데 훨씬 수월해진다.

 

Cron Jobs and recurring task

위에서 비즈니스 로직의 분리가 되었으니 cron job을 수행하기 더 쉬워졌다.

코드의 수행을 딜레이 시키기 위해서 setTimeout과 같은 방식에 의존해서는 안된다. 그래서 프레임워크를 사용한다. 그러면 실패한 작업이나 성공했을 경우에 대한 피드백을 컨트롤 할 수 있을 것이다. 이 글에서는 agenda를 사용했다.

 

Configurations and secrets

api keys나 중요한 값들은 dotenv를 사용해서 저장하는 것이 좋다. 이는 .env 파일을 추가함으로써 구현할 수 있다. 이 글에서는 .env파일에 환경 변수들을 넣어주는 것 뿐만이 아니라 이를 코드에 작성하는 과정 하나를 더 거쳐서 config 폴더에 넣어줬다. 이를 통해서 환경변수들이 매우 많을 경우 구조화 해서 사용할 수 있고, 코드 자동완성까지 되서 기존에 .env만 사용할때보다 훨씬 편하게 사용할 수 있다.

const dotenv = require('dotenv');
// config() will read your .env file, parse the contents, assign it to process.env.
dotenv.config();

export default {
  port: process.env.PORT,
  databaseURL: process.env.DATABASE_URI,
  paypal: {
    publicKey: process.env.PAYPAL_PUBLIC_KEY,
    secretKey: process.env.PAYPAL_SECRET_KEY,
  }
}

 

Loaders

nodejs 서비스의 시작 단계를 loaders 폴더에 따로 분리해서 사용할 수 있다. 기존에는 app.js 파일에 모든 미들웨어나 시작에 필요한 것들을 다 작성해 줬었는데 이를 분리함으로써 편하고 깔끔하게 구현할 수 있다.

// app.js

const loaders = require('./loaders');
const express = require('express');

async function startServer() {

  const app = express();

  await loaders.init({ expressApp: app });

  app.listen(process.env.PORT, err => {
    if (err) {
      console.log(err);
      return;
    }
    console.log(`Your server is ready !`);
  });
}

startServer();
// loaders/index.js

import expressLoader from './express';
import mongooseLoader from './mongoose';

export default async ({ expressApp }) => {
  const mongoConnection = await mongooseLoader();
  console.log('MongoDB Initialized');
  await expressLoader({ app: expressApp });
  console.log('Express Initialized');

  // ... more loaders can be here

  // ... Initialize agenda
  // ... or Redis, or whatever you want
}
// loaders/express.js

import * as express from 'express';
import * as bodyParser from 'body-parser';
import * as cors from 'cors';

export default async ({ app }: { app: express.Application }) => {

  app.get('/status', (req, res) => { res.status(200).end(); });
  app.head('/status', (req, res) => { res.status(200).end(); });
  app.enable('trust proxy');

  app.use(cors());
  app.use(require('morgan')('dev'));
  app.use(bodyParser.urlencoded({ extended: false }));

  // ...More middlewares

  // Return the express app
  return app;
})

 

결론

  • Use a 3 layer architecture.
  • Don’t put your business logic into the express.js controllers.
  • Use PubSub pattern and emit events for background tasks.
  • Have dependency injection for your peace of mind.
  • Never leak your passwords, secrets and API keys, use a configuration manager.
  • Split your node.js server configurations into small modules that can be loaded independently.

 

References

https://softwareontheroad.com/ideal-nodejs-project-structure/

https://ko.wikipedia.org/wiki/%EA%B4%80%EC%8B%AC%EC%82%AC_%EB%B6%84%EB%A6%AC

https://ko.wikipedia.org/wiki/%EB%B0%9C%ED%96%89-%EA%B5%AC%EB%8F%85_%EB%AA%A8%EB%8D%B8

https://medium.com/@jang.wangsu/di-dependency-injection-%EC%9D%B4%EB%9E%80-1b12fdefec4f

728x90
반응형