본문 바로가기

Node.js

Express Project Structure

Node.js REST API 프로젝트 구조에 대해 좋은 포스팅이 있어서 나름대로 요약해보기로 했다. 참고한 사이트는 아래에서 확인할 수 있다.

프로젝트 구조


src
│   app.js          # App entry point
└───api             # Express route controllers for all the endpoints of the app
└───config          # Environment variables and configuration related stuff
└───loaders         # Split the startup process into modules
└───models          # Database models
└───services        # All the business logic is here
└───subscribers     # Event handlers for async task

3 Layer Architecture

image

3 Layer Architecture의 목적은 비지니스 로직을 라우터로부터 분리하는 것이다. 따라서 비지니스 로직이 컨트롤러 안에 있으면 안 된다.

 

❌ 이렇게 하면 안 된다.

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

  const userDTO = req.body;

    // validation -> 미들웨어나 라이브러리를 이용
  const isUserValid = validators.user(userDTO)
  if(!isUserValid) {
    return res.status(400).end();
  }

  // 유저 등록하는 비지니스 로직
  const userRecord = await UserModel.create(userDTO);
  delete userRecord.password;
  delete userRecord.salt;

  ...whatever...

  res.json({ user: userRecord, company: companyRecord });

  // 클라이언트에게 응답을 보냈음에도 코드 실행이 계속된다 :(
  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)
});

비지니스 로직은 서비스 레이어(service layer)로 분리한다. 이 레이어에서는 SQL 쿼리문이 존재해서는 안 된다. 데이터 접근을 위해서는 데이터 접근 레이어(Data Acess Layer) 를 사용한다. 서비스 레이어를 만들 때 지켜야할 것들은 다음과 같다.

  • 라우터로부터 비지니스 로직을 분리하다.
  • reqres 객체를 서비스 레이어에 전달하지 않는다.
  • status codeheaders와 같이 HTTP transport layer 와 관련된 것들을 반환하지 않는다.

 

✨ 예시

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 });
  });
// service layer

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

Pub/Sub layer

위의 서비스 레이어에서는 유저를 생성하는 아주 간단한 일만 수행한다. 그러나 유저 생성 시 해야 하는 일이 많아지게 되면 코드가 길어지고, 한 함수 가 여러 가지 일을 수행하게 된다. 이는 단일 책임 원칙을 위반한다. 따라서 처음부터 책임을 분리하는 것이 좋다.

또한 위의 코드처럼 의존하는 서비스를 명령형(imperative)으로 호출하는 것보다 이벤트를 emit하는 게 더 좋다. 이벤트에 대해서 무엇을 해야 하는 지는 이벤트 리스너들의 책임이 된다. 또한 이렇게 되면 이벤트 리스너들을 여러 파일로 나누는 게 가능해진다.

// service layer

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

Dependency Injection

Dependency Injection 이란 의존하는 모듈을 모듈 안에서 직접 만들거나 require / import 하는 대신 파라미터로 넘겨주는 것을 말한다. D.I 를 사용하지 않을 경우 다음과 같이 데이터 접근 레이어의 모델들을 직접 가져와야 한다.

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

class UserService {
  constructor(){}
  getUsers(){
    // Calling UserModel
    ...
  }
}

이렇게 되면 서비스가 특정 모델과 결합(coupled)되게 된다. 또한 getUsers 함수가 제대로 작동하는 지 테스트하고 싶을 때 UserModel 을 stub 해야 하는 귀찮음이 발생한다.

const UserModel = require('../models/user');
const UserService = require('./user');
const sinon = require('sinon') ;
const assert = require('assert');

describe('User service', () => {
  it('get users', async () => {
    const users = [{
      id: 1,
      firstname: 'Joe',
      lastname: 'Doe'
    }];

    sinon.stub(UserModel, 'findAll', () => {
      return Promise.resolve(users)
    });

    assert.deepEqual(await UserService.getUsers(), users);
  });
});

 

❓ stub

stub 이란 모듈의 실제 행동을 고정된 응답을 가지는 함수 또는 객체로 대체(replace)하는 것을 말한다. stub 은 테스트를 위한 가정이라고 볼 수 있다. 외부 서비스가 특정 응답을 반환할 때 테스트하고자 하는 함수가 어떻게 작동하는 지를 알 수 있다. 위의 코드에서 getUsers 함수는 UserModel 로부터 모든 유저들을 가져온다. 그리고 UserModel 은 실제 데이터베이스를 쿼리하여 유저 레코드들을 가져오는 역할을 할 것이다. 그러나 테스트 시 실제 데이터베이스를 접근하는 것은 여러 문제(데이터베이스가 아직 존재하지 않거나, 네트워크 연결 에러가 발생하거나, 실제 데이터를 의도치 않게 변경하는 등의 문제)가 있을 수 있기 때문에 stub으로 테스트와 외부 서비스 호출을 분리하는 것이 좋다.

 

 

위의 예시처럼 서비스에서 UserModel을 직접 import 하지 않고, 파라미터로 넘기면 stub 을 사용하지 않아도 되므로 테스트를 좀 더 쉽게 할 수 있게 된다.

export default class UserService {
  constructor(userModel){
    this.userModel = userModel;
  }

  getUsers(){
    // models available throug 'this'
  }
}
const UserService = require('./user');
const assert = require('assert');

describe('Users service', () => {
  it('gets users', async () => {
    const users = [{
      id: 1,
      firstname: 'Joe',
      lastname: 'Doe'
    }];

    const userModel = {
      findAll: async () => {
        return users
      }
    };

    const userService = new UsersService(userModel);

    assert.deepEqual(await userService.getUsers(), users);
  });
});

D.I 의 단점은 필요한 파라미터들을 이전에 모두 셋업해야 한다는 것이다. 위의 예시에서 UserService 를 만들기 위해서는 UserModel 을 미리 만들어야 하는 것처럼 말이다. 이를 해결하기 위해 service container 라는 모듈을 따로 만들고, 그 안에서 서비스를 만들기 위해 필요한 것들을 셋업할 수 있다. 이러한 방법은 간단하지만 의존하는 모듈이 많아지게 되면 매우 귀찮아지게 된다. 이를 좀 더 쉽게 하기 위해서는 AwilixTypeDI 같은 라이브러리를 사용할 수 있다.

요약

  1. 라우터, 비지니스 로직, 데이터베이스 접근을 분리하자. 라우터는 컨트롤러가, 비지니스 로직은 서비스 레이어가, 데이터베이스 접근은 데이터베이스 접근 레이어가 맡도록 한다.
  2. 서비스 레이어에서 모델을 직접 require / import 하지 말고, 파라미터로 넘겨받자. 결합도가 낮아지고 테스트가 용이해진다.

장고(Django) 프로젝트 구조 따라하기


codus-w-django
├── README.md
├── accounts
│   ├── admin.py
│   ├── apps.py
│   ├── forms.py
│   ├── migrations
│   ├── models.py
│   ├── templates
│   ├── tests.py
│   ├── urls.py
│   └── views.py
├── board
│   ├── admin.py
│   ├── apps.py
│   ├── forms.py
│   ├── migrations
│   ├── models.py
│   ├── static
│   ├── templates
│   ├── tests.py
│   ├── urls.py
│   └── views.py
├── codus
│   ├── asgi.py
│   ├── helpers.py
│   ├── settings.py
│   ├── static
│   ├── templates
│   ├── urls.py
│   └── wsgi.py
├── db.sqlite3
├── demo.gif
├── manage.py
└── media

장고는 앱 기반의 프로젝트 구조를 가진다. 위의 예시는 내가 처음으로 만든 장고 웹 프로젝트의 디렉토리 구조인데, accounts, board, codus 앱으로 나누어져 있는 것을 확인할 수 있다. codus 앱은 메인 앱으로 프로젝트 세팅을 설정하고 각 url 에 해당하는 앱을 매칭시켜주는 역할을 한다. accounts 앱은 로그인과 회원가입 등 유저 계정과 관련된 앱이고, board 는 게시판과 관련된 앱이다. 이처럼 장고는 프로젝트를 특정 역할을 담당하는 앱으로 나누고, 그 안에서 다시 model-view-controller 를 분리하는 방식을 사용한다.

express 앱에서도 이러한 방식을 사용하는 것도 시도해볼만 한 것 같다. 이렇게 구조화하게 되면 앱 내에서 자신이 필요한 모듈들만 관리하면 되기 때문에 코드를 찾거나 수정하는 것이 더 쉬워질 것 같다.


Reference

'Node.js' 카테고리의 다른 글

Handling multipart/form-data with multer  (0) 2021.10.26
Express Middleware: The Basics  (0) 2021.09.27