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
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) 를 사용한다. 서비스 레이어를 만들 때 지켜야할 것들은 다음과 같다.
- 라우터로부터 비지니스 로직을 분리하다.
req
나res
객체를 서비스 레이어에 전달하지 않는다.status code
나headers
와 같이 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 라는 모듈을 따로 만들고, 그 안에서 서비스를 만들기 위해 필요한 것들을 셋업할 수 있다. 이러한 방법은 간단하지만 의존하는 모듈이 많아지게 되면 매우 귀찮아지게 된다. 이를 좀 더 쉽게 하기 위해서는 Awilix
나 TypeDI
같은 라이브러리를 사용할 수 있다.
요약
- 라우터, 비지니스 로직, 데이터베이스 접근을 분리하자. 라우터는 컨트롤러가, 비지니스 로직은 서비스 레이어가, 데이터베이스 접근은 데이터베이스 접근 레이어가 맡도록 한다.
- 서비스 레이어에서 모델을 직접
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 |