Back-end

[node.js] 견고하게 node.js 설계하기

hjr067 2024. 9. 23. 21:56

Sam Quinn의 Bulletproof node.js project architecture 를 번역한 것

 

Bulletproof node.js project architecture 🛡️

Get the latest articles in your inbox. Join the other 2000+ savvy node.js developers who get article updates. You will receive only high-quality articles about Node.js, Cloud Computing and Javascript front-end frameworks.

www.softwareontheroad.com

- 폴더 구조

- 3 계층 설계 (3 Later 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

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

더보기
my-web-app/
│
├── src/
│   ├── controllers/
│   │   ├── authController.js
│   │   ├── userController.js
│   │   ├── friendController.js
│   │   ├── commentController.js
│   │   └── scrapController.js
│   │
│   ├── models/
│   │   ├── User.js
│   │   ├── Friend.js
│   │   ├── Comment.js
│   │   └── Scrap.js
│   │
│   ├── routes/
│   │   ├── authRoutes.js
│   │   ├── userRoutes.js
│   │   ├── friendRoutes.js
│   │   ├── commentRoutes.js
│   │   └── scrapRoutes.js
│   │
│   ├── middleware/
│   │   ├── authMiddleware.js
│   │   └── errorMiddleware.js
│   │
│   ├── config/
│   │   ├── db.js
│   │   └── passport.js
│   │
│   ├── utils/
│   │   ├── logger.js
│   │   └── validator.js
│   │
│   ├── tests/
│   │   ├── auth.test.js
│   │   ├── user.test.js
│   │   └── friend.test.js
│   │
│   ├── index.js
│   └── app.js
│
├── .env
├── package.json
├── package-lock.json
├── Dockerfile
├── .dockerignore
├── nginx.conf
└── docker-compose.yml

 

3 계층 설계

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

언젠가 반복되는 작업을 하다 보면 CLI(Command Line Interface - 사용자가 명령어를 입력하여 상호작용하는) 도구를 통해 비즈니스 로직을 사용하고 싶어질 것이기 때문이다.

Node.js 서버에서 반복적으로 직접 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 };
  }
}

 

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('unhandledRegection',cb)로 처리해 줄 수 있다.

 

의존성 주입

의존성 주입(D.I), 또는 제어 역전(IoC)은 코드를 구조화하는데 많이 사용하는 패턴인데, 생성자를 통해 클래스와 함수의 의존성을 전달해주는 방식이다. 이를 통해 '호환 가능한 의존성'을 주입함으로써 유연하게 코드를 유지할 수 있다. 이는 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의 예시를 살펴보자.

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

 

설정 및 시크릿 파일

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

 

예제 repository