견고한 node.js 아키텍쳐 설계하기

  02 Mar 2020


견고한 node.js 아키텍쳐

폴더 구조

  • app.js : 앱의 진입 포인트
  • api : 앱의 모든 엔드포인트를 위한 라우트 컨트롤러(endpoint : 최종 목적지)
  • config : configuration과 관련된 것들과 환경 변수들
  • jobs : agenda.js에 대한 작업 정의 // cronjob을 관리하기 위한 노드 모듈
  • loaders : 시작 프로세스를 모듈로 분할
  • models : 데이터베이스 모델
  • services : 비즈니스 로직은 모두 여기!
  • subscribers : 비동기 테스크를 위한 이벤트 핸들러
  • types : 타입스크립트를 위한 타입 정의 파일들

3 계층 설계 - 3 Layer Architecture

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

비즈니스 로직 : 데이터를 생성&표시&저장&변경 하는 파트를 일컫는다

👉 언젠가 반복되는 작업을 하다보면 CLI 도구를 통해 비즈니스 로직을 사용하고 싶어질 것임.
👉 node.js server에서 API를 호출하는 것은 좋은 생각은 아니다.

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

👉 스파게티 코드가 되어버릴 것이다.

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)
});
  • 이 코드의 문제점
    1. 중간에 많은 business 로직이 존재한다.
    2. res.json()에 복잡한 것들의 최종적인 것을 담아서 response로 만든다.
    3. 하지만 이 이후에도 다른 코드의 execution이 일어난다.
비즈니스 로직은 service 계층에 있어야 한다.

Service Layer(API 꼐층)에 SQL query의 형태의 코드가 있어서는 안된다. 이는 Data Access Layer계층에 있어야 한다.

  • 해당 코드를 router에서 분리하기
  • service 레이어에는 req와 res 객체를 전달하지 말기
  • 상태 코드 또는 헤더와 같은 HTTP 전송 계층과 관련된 것들은 반환하지 말기
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 });
  });

User Service를 호출함

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

👉 비즈니스 로직은 Service Layer에 캡슐화!

백그라운드 작업에는 Pub/Sub 계층도 사용하십시오 with Event
  • 전형적인 3계층 구조 범위를 넘어서지만 매우 유용함. IF, 간단한 node.js API 엔드포인트에서 유저를 생성한 이후, third-party (제 3자)서비스를 호출하거나 서비스 분석을 시도하거나, 이메일 전송과 같은 작업을 하고 싶다?
    👉 create라는 작업이 여러 일을 하게 될 것이고, 하나의 함수안에 1000줄이 넘어가는 코드가 생길 것
    이는 단일 책임 원칙을 위배한다!
    👉 해당 코드를 분리하여 간결하게 코드를 유지(함수로 빼내기)
    👉 독립적인 서비스들을 직접 호출하는 것은 최선의 방법이 아니다!
    👉 이벤트를 발생시켜 리스너들이 해당 일을 하도록 한다!
의존성 주입

의존성 주입(D.I), 제어 역전은 코드를 구조화하는데 많이 사용되는 패턴이다. 생성자를 통해 클래스와 함수의 의존성을 전달해주는 방식이다.
👉 호환가능한 의존성 을 주입함으로써 유연하게 코드를 유지할 수 있다.
(어떠한 생성자내에 클래스를 전달함으로써 의존성을 높이는 방식) 👉 서비스가 가질 수 있는 종속성의 양은 무한하고, 새 인스턴스를 추가할 때 서비스의 모든 인스턴스화를 리팩터링하는 것은 지루하고 오류가 발생하기 쉬운 작업이다(각 객체간 의존성이 높기 때문)

의존성 주입 프레임워크의 등장
  • 클래스의 인스턴스가 필요할 때, Service Locator를 호출하면 됨.
Unit Test

👉 req/res 객체들과 require의 호출없이 해당 service 레이어의 함수만으로 유닛 테스트를 실행할 수 있습니다.

스케줄링 및 반복작업

node.js의 task manager는 agenda.js를 이용해서 가능함

설정 및 시크릿 파일 with configuration manager(dotenv)

API Key와 데이터베이스 연결과 관련된 설정을 저장하는 가장 좋은 방식은 dotenv를 사용하는 것이다. 👉 dotenv는 .env파일을 로드하여 안에 있는 값들을 node.js의 process.env 객체에 대입한다. 👉 추가적으로, config/index.jx 파일에서 npm 패키지 dotenv가 .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,
  },
  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,
  }
}

잘 이해가 안가네?

  • Service에서 model.create하면 이게 data line 계층 아닌가..??
  • 의존성 주입은 잘 모르겟음. 더 공부해야겠다

적용하면 좋을 사항

  • dotenv를 이용해 env파일안에 데이터를 로드해서 사용하는 방식
  • layer를 나누어 service layer계층으로 현재 API들을 빼내서 관리하는 것 👉 이렇게 바꾸면 나중에 테스트 코드를 수월하게 작성할 수 있을 것
  • 이전에 isNew/isUp과 관련했던 이슈를 agenda.js를 이용해서 쉽게 구현할 수 있었을 듯 (하지만 이 역시 어떻게 체크할지 핸들링하는 이슈가 있었겟지?)
  • 다른 코드는 함수화 해서 이벤트로 관리하는 게 좋을듯 (이런 작업을 쓰는게 거의 없을듯)

참고자료

견고한 node.js 설계하기 : hopsprings

...