티스토리 뷰

Develop!/JS

Node.js express 클린 Architecture

ddiyoung 2022. 8. 30. 18:09

1학기부터 학교 총동아리 연합회의 웹 벡엔드 유지보수를 맡아서 일하고 있다.

이 웹 사이트의 경우 작년에 선배가 만들어둔 코드인데 급하게 만들다 보니 유지보수가 꽤나 어렵고 코드의 가독성이 높지 못해서 리팩토링을 하려고 한다.

깨끗한 아키텍쳐를 위해서 프론트엔드를 맡은 친구에게 참고할만한 레퍼런스가 있는지 물어보았고 백엔드도 경험이 있던 친구는 나에게 글 하나를 추천해줬다.

해당 글을 번역하면서 백엔드를 리팩토링 해보자 한다.

https://dev.to/santypk4/bulletproof-node-js-project-architecture-4epf

 

Bulletproof node.js project architecture 🛡️

A simple yet powerful project architecture for node.js REST APIs 💎

dev.to

 

Introduction

Express.js 는 REST API를 만들기에 매우 좋은 프레임 워크이다. 하지만 Express.js 는 당신의 node.js 프로젝트를 어떻게 구성해야할지에 대해서는 알려주지 않는다.

바보같은 소리처럼 들리겠지만, 이건 실질적인 문제이다.

당신의 Node.js의 프로젝트 구조의 올바른 구성은 코드의 중복성을 피할 것이며, 안정성을 향상시켜줄 것이며, 잠재적으로 당신의 서비스가 커지는데 도움을 줄 것이다. 만약 당신이 올바르게 했다면,

이 글은 나의 바보같은 node.js 구조, 나쁜 패턴, 그리고 코드를 리팩토링하며 보낸 수없이 많은 시간들로 부터 확장된 연구이다.

The folder structure

필자가 말하는 Node.js 프로젝트의 구조이다.

필자는 모든 Node.js의 REST API 서비스들은 이렇게 만들어서 사용한다. 이제 같이 모든 컴포넌트가 하는 것을 자세히 살펴보자.

3 Layer architecture

이 아이디어는 Node.js API 라우터로부터 비지니스 로직을 움직이려는 우려를 분리하기 위해서 사용 됐다.

왜냐하면 언젠가, 당신은 당신의 비지니스 로직을 CLI 도구에서 사용하고 싶어한다. 이러한 것이 충분하지 않다면 당신은 일을 반복적으로 하는것에 빠질 것이다.

Node.js 서버 그자체로 API를 호출하는 것은 좋은 아이디어는 아닌 거 같다... 
(And make an API call from the node.js server to itself it's not a good idea...)

Controller들 안에 당신의 비지니스 로직을 넣지 마!!

당신은 당신의 어플리케이션의 비지니스 로직을 저장하기 위해서 express.js 컨트롤러를 사용하고 싶어 한다. 하지만, 이것은 스파게티 코드가 되는 지름길이다. 당신이 unit tests를 필요로 할 때마다 당신은 복잡한 req 또는 res의 express.js object들을 다루어야 할 것이다.

이것은 reponse를 보낼 때, 그리고 background에서 계속 processing 되고 있을 때, 구별하기에 배우 어렵다. reponse를 클라이언트에게 보내고 난 다음에 이야기 해보자.

여기에 예시 코드가 있다.

  route.post('/', async (req, res, next) => {

    // This should be a middleware or should be handled by a library like Joi.
    const userDTO = req.body;
    const isUserValid = validators.user(userDTO)
    if(!isUserValid) {
      return res.status(400).end();
    }

    // Lot of business logic here...
    const userRecord = await UserModel.create(userDTO);
    delete userRecord.password;
    delete userRecord.salt;
    const companyRecord = await CompanyModel.create(userRecord);
    const companyDashboard = await CompanyDashboard.create(userRecord, companyRecord);

    ...whatever...


    // And here is the 'optimization' that mess up everything.
    // The response is sent to client...
    res.json({ user: userRecord, company: companyRecord });

    // But code execution continues :(
    const salaryRecord = await SalaryModel.create(userRecord, companyRecord);
    eventTracker.track('user_signup',userRecord,companyRecord,salaryRecord);
    intercom.createUser(userRecord);
    gaAnalytics.event('user_signup',userRecord);
    await EmailService.startSignupSequence(userRecord)
  });

이 layer는 당신의 비지니스 로직이 존재한다.

분명한 목적을 가진 class들의 집합이다. node.js 에 SOLID 원칙을 적용시켜서 따라오자.

이 layer에서는 'SQL query'가 존재해서는 안된다. SQL query는 data access layer에서 사용해라.

1. express.js 라우터로부터 코드를 옮겨라.
2. req 와 res object를 service layer로 통과시키지 마라.
3. HTTP transport 층과 관련된 어떠한 것도 리턴시키지 마라. (status code, service layer로 부터 온 headers 같은)

Example

  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 });
    });

 

 

여기에 당신의 서비스가 뒤에서 어떻게 작동되고 있는지 알려주고 있다.

  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 };
    }
  }

https://github.com/santiq/bulletproof-nodejs

 

GitHub - santiq/bulletproof-nodejs: Implementation of a bulletproof node.js API 🛡️

Implementation of a bulletproof node.js API 🛡️. Contribute to santiq/bulletproof-nodejs development by creating an account on GitHub.

github.com

예시 repository!! 전체 코드가 필요할 시 참고하자!

Pub/Sub 레이어도 사용하자!

pub/sub 패턴은 기존의 3 계층 아키텍처를 뛰어 넘는다. 하지만 매우 유용하다.

User를 만드는 간단한 Node.js API 엔드포인트는 서드파티를 부르고 싶어하는 서비스이다. 아마 분석 서비스 또는 email sequence를 시작하기 위해서일 것이다.

조만간 그 간단한 "Create" 명령은 몇개로 나누어질 것이다. 그리고 당신은 1000줄 이내에 끝낼 수 있다. 모두 하나의 function으로.

그것은 single responsibility 원칙을 벗어난다.

그래서, 시작부터 responsibilities를 분리하는 것이 더 낫다. 그래서 당신의 코드는 유지보수가 가능하도록 남는다.

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

  export default class UserService() {

    async Signup(user) {
      const userRecord = await UserModel.create(user);
      const companyRecord = await CompanyModel.create(user);
      const salaryRecord = await SalaryModel.create(user, salary);

      eventTracker.track(
        'user_signup',
        userRecord,
        companyRecord,
        salaryRecord
      );

      intercom.createUser(
        userRecord
      );

      gaAnalytics.event(
        'user_signup',
        userRecord
      );

      await EmailService.startSignupSequence(userRecord)

      ...more stuff

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

  }

의존적인 서비스를 피할 수 없이 call 하는 것은 가장 좋은 방법이 아니다.

더 좋은 방법은 'user가 이메일과 함께 가입하는 것'과 같은 이벤트를 피하는 것이다.

당신이 그렇게 한다면, 그들의 일을 하는 listeners의 책임이다.

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

  export default class UserService() {

    async Signup(user) {
      const userRecord = await this.userModel.create(user);
      const companyRecord = await this.companyModel.create(user);
      this.eventEmitter.emit('user_signup', { user: userRecord, company: companyRecord })
      return userRecord
    }

  }

이제 당신은 event를 handlers/listener로 여러 파일들로 분리할 수 있다.

  eventEmitter.on('user_signup', ({ user, company }) => {

    eventTracker.track(
      'user_signup',
      user,
      company,
    );

    intercom.createUser(
      user
    );

    gaAnalytics.event(
      'user_signup',
      user
    );
  })
eventEmitter.on('user_signup', async ({ user, company }) => {
    const salaryRecord = await SalaryModel.create(user, company);
  })
  eventEmitter.on('user_signup', async ({ user, company }) => {
    await EmailService.startSignupSequence(user)
  })

Dependency Injection

D.I 또는 inversion of control (IoC)는 당신의 코드를 구성하는데 도움을 주는 흔한 패턴이다.  'Injecting' 또는 당신의 class 또는 function의 dependencies가 constructor(생성자)를 통과함으로써.

이러한 방식을 통해서 당신은 'compatible dependency'를 주입시킬 수 있는 유연성을 얻게 된다. 예를 들어서 당신이 서비스를 위해서 unit test를 작성하거나, 또는 서비스가 다른 부분에서 사용될 때이다.

Code with no D.I

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';  
  class UserService {
    constructor(){}
    Sigup(){
      // Caling UserMode, CompanyModel, etc
      ...
    }
  }

Code with manul dependency Injection

  export default class UserService {
    constructor(userModel, companyModel, salaryModel){
      this.userModel = userModel;
      this.companyModel = companyModel;
      this.salaryModel = salaryModel;
    }
    getMyUser(userId){
      // models available throug 'this'
      const user = this.userModel.findById(userId);
      return user;
    }
  }

이제 당신은 custom dependencies들을 주입시킬 수 있다.

  import UserService from '../services/user';
  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  const salaryModelMock = {
    calculateNetSalary(){
      return 42;
    }
  }
  const userServiceInstance = new UserService(userModel, companyModel, salaryModelMock);
  const user = await userServiceInstance.getMyUser('12346');

서비스가 가질 수 있는 종속성의 양은 무한하며, 새로운 서비스를 추가할 때 모든 인스턴스화를 리팩터링하는 것은 지루하고 오류가 발생하기 쉬운 작업이다.

종속성 주입 framework들이 생기게된 이유가 바로 이것이다.이 아이디어는 class에서 당신의 종속성을 선언한다. 그리고 당신이 그 class를 인스턴스화 하려고 할 때, 당신은 그저 'Service Locator'를 call 한다.예시를 보자.* typescript 예시임을 주의하라

  import { Service } from 'typedi';
  @Service()
  export default class UserService {
    constructor(
      private userModel,
      private companyModel, 
      private salaryModel
    ){}

    getMyUser(userId){
      const user = this.userModel.findById(userId);
      return user;
    }
  }

 

services/user.ts

이제 typedi는 사용자 서비스에 필요한 모든 종속성을 해결한다.

  import { Container } from 'typedi';
  import UserService from '../services/user';
  const userServiceInstance = Container.get(UserService);
  const user = await userServiceInstance.getMyUser('12346');

service Locator 호출을 남용하는 것은 안티 패턴이다.

Node.js에서 express.js와 함께 D.I를 사용하는 것

express.js에서 D.I. 를 사용하는 것은 이 node.js 프로젝트 아키텍쳐의 마지막 퍼즐이다.

Routing Layer

  route.post('/', 
    async (req, res, next) => {
      const userDTO = req.body;

      const userServiceInstance = Container.get(UserService) // Service locator

      const { user, company } = userServiceInstance.Signup(userDTO);

      return res.json({ user, company });
    });

 

'Develop! > JS' 카테고리의 다른 글

Express Architecture 적용하기  (0) 2022.08.30
export default vs export  (0) 2022.08.24