본 포스트는 Sam Quinn의 “Bulletproof node.js project architecture” 글을 번역한 것입니다. 혼자 보기 너무 아까운 글이기에 번역하여 공유합니다.

Introduction

Express.js는 node.js REST API를 만드는데 좋은 프레임워크지만, 어떻게 node.js 프로젝트 구조를 잡아야 할지 알려주지 않습니다. 우습게 들릴 수도 있지만, 이건 매우 큰 문제입니다. 올바른 node.js 프로젝트 구조는 코드의 중복을 피해 주고 안정성을 높여주며, 당신의 서비스를 확장하는데 도움이 될 것입니다. 이 포스트는 다년간의 부족했던 설계와 나쁜 패턴, 그리고 수없이 많은 코드 리팩터링 경험을 통해 쓰인 하나의 리서치입니다.

목차

  • 폴더 구조
  • 3 계층 설계 (3 Layer Architecture)
  • Service 계층
  • Pub/Sub 계층
  • 의존성 주입 (Dependency Injection)
  • Unit Testing
  • 스케줄링 및 반복 작업 (Cron Jobs and Recurring Task)
  • 설정 및 시크릿 파일 (Configurations and secrets)
  • Loaders
  • 예제

폴더 구조 🏢

앞으로 설명할 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
└───jobs            # Jobs definitions for agenda.js
└───loaders         # Split the startup process into modules
└───models          # Database models
└───services        # All the business logic is here
└───subscribers     # Event handlers for async task
└───types           # Type declaration files (d.ts) for Typescript

이것은 단순히 파일을 정렬한 것이 아닙니다.

3 계층 설계 (3 Layer Architecture) 🥪

관심사 분리 원칙(principle of separation of concerns)을 적용하기 위해 비즈니스 로직을 node.js의 API Routes와 분리해줍니다.

언젠가는 반복되는 작업을 하다보면 CLI 도구를 통해 비즈니스 로직을 사용하고 싶어 할 것이기 때문입니다. 그리고 node.js server에서 API 호출을 하는 것은 좋은 생각은 아닙니다.

☠️비지니스 로직을 controller에 넣지 마세요!!☠️

아마 express.js controllers에 바로 애플리케이션의 비즈니스 로직을 구현하고 싶을 수 있습니다. 하지만 이렇게 코드를 작성하면 스파게티 코드가 되기 마련인데요, 유닛 테스트를 작성하다 보면 수많은 express.js의 req와 res 오브젝트를 다루게 될 것이기 때문입니다.

언제 클라이언트로 response를 보내야 할지, 그리고 언제 프로세스를 백그라운드에서 계속 실행해야 할지 구분하는 것은 매우 어렵습니다. 클라이언트로 response를 보낸 후에 프로세스 작업을 계속하기로 했다고 가정해봅시다.

다음은 하지 말아야 할 예시입니다.

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

  // 이것은 미들웨어나 Joi 같은 라이브러리가 처리해야합니다.
  const userDTO = req.body;
  const isUserValid = validators.user(userDTO)
  if(!isUserValid) {
    return res.status(400).end();
  }

  // 여기에 많은 비지니스 로직들 ..
  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);

  ...어쩌구...


  // 그리고 이건 모든걸 망치는 '최적화'입니다..
  // 클라이언트에게 응답합니다..
  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 계층에 비즈니스 로직을 넣으십시오 💼

비즈니스 로직은 service 계층에 있어야 합니다. 이는 분명한 목적이 있는 클래스들의 집합이며, SOLID 원칙을 node.js에 적용한 것입니다. 이 레이어에는 ‘SQL query’ 형태의 코드가 있어서는 안 됩니다. 그것은 data access layer에서 사용해야 합니다.

  • 코드를 express.js router에서 분리하십시오.
  • service 레이어에는 req와 res 객체를 전달하지 마십시오.
  • 상태 코드 또는 헤더와 같은 HTTP 전송 계층과 관련된 것들은 반환하지 마십시오.

예제

route.post('/', 
  validators.userSignup, // 이 미들웨어가 검증(validation)을 처리합니다.
  async (req, res, next) => {
    // router 레이어의 실제 책입입니다.
    const userDTO = req.body;

    // 서비스 레이어를 호출합니다.
    // 데이터 레이어 및 비즈니스 로직에 액세스하는 방법에 대한 추상화입니다.
    const { user, company } = await UserService.Signup(userDTO);

    // 클라이언트에게 응답합니다.
    return res.json({ user, company });
  });

다음은 service가 작동하는 방법입니다.

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);
    const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // 유저 생성은 사용자 또는 회사마다 다를 수 있습니다.
    
    ...어쩌구
    
    await EmailService.startSignupSequence(userRecord)

    ...저쩌구

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

클릭하시면 예제 Repository를 보실 수 있습니다.

Pub/Sub 계층도 사용하십시오 🎙️

pub/sub 패턴을 전형적인 3 계층 구조 범위를 넘어서지만 매우 유용합니다. 간단한 node.js API 앤드포인트에서 사용자를 생성한 뒤, third-party 서비스를 호출하거나, 서비스 분석을 시도하거나, 이메일 전송과 같은 작업을 하고 싶을 수 있습니다. 금세 간단한 “create” 작업이 여러 가지 일을 하기 시작할 것이며, 하나의 함수 안에 1000줄이 넘어가는 코드가 생길 것입니다.

이는 단일 책임 원칙(principle of single responsibility)을 위배합니다.

시작부터 책임들을 분리하여 간결하게 코드를 유지 관리할 수 있습니다.

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)

    ...어쩌구

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

}

독립적인 서비스들을 직접적으로 호출하는 것이 최선의 방법은 아닙니다.
더 좋은 접근법은 이벤트를 발생시키는 것입니다.
이렇게 한다면 이제 리스너들이 그들의 역할을 책임지게 됩니다.

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

이제 이벤트 핸들러/리스너들은 여러 파일로 나눌 수 있습니다.

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

await 구문을 try-catch block으로 감싸거나 ‘unhandledPromise’ 를 process.on(‘unhandledRejection’, cb)로 처리해 줄 수 있습니다.

의존성 주입 💉

의존성 주입(D.I), 또는 제어 역전(IoC)은 코드를 구조화하는데 많이 사용하는 패턴인데요, 생성자를 통해 클래스와 함수의 의존성을 전달해주는 방식입니다. 이를 통해 ‘호환 가능한 의존성(compatible dependency)'을 주입함으로써 유연하게 코드를 유지할 수 있습니다. 이는 service에 대한 유닛 테스트를 작성하거나, 다른 context에서 코드를 사용할 때 도움이 됩니다.

의존성 주입을 사용하지 않을 때

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

의존성 주입을 사용할 때

export default class UserService {
  constructor(userModel, companyModel, salaryModel){
    this.userModel = userModel;
    this.companyModel = companyModel;
    this.salaryModel = salaryModel;
  }
  getMyUser(userId){
    // 'this'를 통해 모델을 사용할 수 있습니다
    const user = this.userModel.findById(userId);
    return user;
  }
}

다음과 같이 직접 의존성을 주입해서 사용할 수 있습니다.

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

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

이 때문에 의존성 주입 프레임워크가 생기게 되었습니다.

단지 필요한 의존성만을 사용하는 사람이 직접 클래스에 선언하면 되고, 해당 클래스의 인스턴스가 필요할 때면 ‘Service Locator’를 호출하기만 하면 됩니다. node.js에 의존성을 사용할 수 있게 해주는 npm 라이브러리 typedi의 예시를 살펴봅시다.

공식 문서에서 typedi 사용법을 읽어보실 수 있습니다.

※경고 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는 UserService에 필요한 모든 종속성을 해결해줍니다. 
잘못된 service locator 호출은 좋지 않은 패턴(anti-pattern)입니다.

Node.js의 Express.js에서 의존성 주입 사용하기

의존성 주입을 express.js에서 사용하는 것이 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 });
  });

훌륭합니다. 너무나 잘 구조화가 되어 빨리 코딩하고 싶어 집니다.

유닛 테스트 예제 🕵🏻

이런 구조를 유지하고 의존성 주입을 사용하게 되면, 유닛 테스트는 정말 간단해집니다.
res/res 객체들과 require 호출들을 할 필요가 없습니다.

예제 : 회원가입 User 메서드 유닛 테스트
tests/unit/services/user.js

import UserService from '../../../src/services/user';

describe('User service unit tests', () => {
  describe('Signup', () => {
    test('사용자 레코드를 만들고 user_signup 이벤트를 내보내야 합니다.', async () => {
      const eventEmitterService = {
        emit: jest.fn(),
      };

      const userModel = {
        create: (user) => {
          return {
            ...user,
            _id: 'mock-user-id'
          }
        },
      };

      const companyModel = {
        create: (user) => {
          return {
            owner: user._id,
            companyTaxId: '12345',
          }
        },
      };

      const userInput= {
        fullname: 'User Unit Test',
        email: 'test@example.com',
      };

      const userService = new UserService(userModel, companyModel, eventEmitterService);
      const userRecord = await userService.SignUp(teamId.toHexString(), userInput);

      expect(userRecord).toBeDefined();
      expect(userRecord._id).toBeDefined();
      expect(eventEmitterService.emit).toBeCalled();
    });
  })
})

스케줄링 및 반복 작업 ⚡

비즈니스 로직이 service layer에 캡슐화되었습니다. 이는 스케줄링 작업(cron jobs)들을 더 하기 쉽게 해 줍니다. 코드 실행을 지연시키는 작업들을 할 때는 절대로 node.js의 setTimeout* *이나 다른 원시적인 방법(primitive way)을 사용하면 안 됩니다. 대신에 데이터베이스에서 작업을 유지하고 실행하는 프레임워크를 사용해야 합니다. 이 방식을 통해 실패한 작업을 제어하고, 성공한 작업들로부터 피드백을 받을 수 있습니다. 이 부분에 대해서는 다른 글로 작성해두었는데요, node.js의 태스크 매니저인 agenda.js를 사용하는 가이드를 읽어보십시오.

설정 및 시크릿 파일 🤫

Twelve-Factor App의 battle-tested 개념에 따라 node.js에서 API Key와 데이터베이스 연결과 관련된 설정들을 저장하는 가장 좋은 방법은 dotenv를 사용하는 것입니다. .env 파일을 만들되 절대 커밋하지 마십시오. (하지만 repository에 기본 값들로 채워져 있어야 하긴 합니다.) 이후 npm 패키지인 dotenv 는 .env 파일을 로드하여 안에 있는 값들을 node.js의 process.env 객체에 대입할 것입니다. 이것으로도 충분하지만, 몇 가지 추가적인 단계를 소개하고자 합니다. config/index.ts 파일에서 npm 패키지 dotenv가 .env 파일을 로드하고, 객체를 사용하여 변수들을 저장합니다. 이를 통해 코드 구조화를 할 수 있고 자동 완성을 사용할 수 있습니다.

config/index.js

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,
  },
  paypal: {
    publicKey: process.env.PAYPAL_PUBLIC_KEY,
    secretKey: process.env.PAYPAL_SECRET_KEY,
  },
  mailchimp: {
    apiKey: process.env.MAILCHIMP_API_KEY,
    sender: process.env.MAILCHIMP_SENDER,
  }
}

이렇게 함으로써 process.env.MY_RANDOM_VAR 명령어들이 난무해지는 것을 막을 수 있으며, 코드 자동 완성을 이용해 env 변수명들의 이름을 다시 확인하지 않아도 됩니다.

Loaders 🏗️

저는 이 패턴을 W3Tech microframework에서 가져왔지만, 이 패키지를 사용하지는 않습니다. 아이디어는 node.js 서비스의 시작 프로세스를 테스트 가능한 모듈로 나누는 것입니다. 전형적인 express.js app 시작 부분을 보시죠.

const mongoose = require('mongoose');
const express = require('express');
const bodyParser = require('body-parser');
const session = require('express-session');
const cors = require('cors');
const errorhandler = require('errorhandler');
const app = express();

app.get('/status', (req, res) => { res.status(200).end(); });
app.head('/status', (req, res) => { res.status(200).end(); });
app.use(cors());
app.use(require('morgan')('dev'));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json(setupForStripeWebhooks));
app.use(require('method-override')());
app.use(express.static(__dirname + '/public'));
app.use(session({ secret: process.env.SECRET, cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });

require('./config/passport');
require('./models/user');
require('./models/company');
app.use(require('./routes'));
app.use((req, res, next) => {
  var err = new Error('Not Found');
  err.status = 404;
  next(err);
});
app.use((err, req, res) => {
  res.status(err.status || 500);
  res.json({'errors': {
    message: err.message,
    error: {}
  }});
});


... 더 많은 것들

... 아마 Redis를 시작할지도

... 더 많은 미들웨어들

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

// async 함수를 사용하여 우리의 서버를 시작
startServer();

보시는 바와 같이, 이 부분은 매우 지저분합니다.

이를 다루는 효과적인 방법은 다음과 같습니다.

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는 간단한 목적이 있는 작은 파일들입니다.

loaders/index.js

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

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

  // ... 더 많은 loaders가 가능합니다

  // ... agenda 초기화
  // ... 또는 Redis 등등
}

다음은 express loader입니다.

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

  // ...미들웨어들

  // express app으로 return
  return app;
})

다음은 mongo loader입니다.

loaders/mongoose.js

import * as mongoose from 'mongoose'
export default async (): Promise<any> => {
  const connection = await mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });
  return connection.connection.db;
}

Conclusion

지금까지 프로덕션 테스트를 거친 node.js 프로젝트 구조에 대해 자세히 살펴보았습니다. 다음은 요약된 팁입니다.

  • 3 계층 구조를 사용하십시오. (3 layer architecture)
  • 비즈니스 로직을 express.js의 controller에 넣지 마십시오.
  • 백그라운드 작업을 할 때는 PubSub 패턴을 사용하고 이벤트를 발생시키십시오.
  • 마음의 평화를 위해 의존성 주입을 사용하십시오.
  • 비밀번호, secrets와 API key들을 절대 누출하지 말고 configuration manager를 사용하십시오.
  • node.js 서버 설정 파일을 작은 모듈들로 분리하여 독립적으로 로드할 수 있게 하십시오.

클릭하시면 예제 repository를 보실 수 있습니다.