Node.js 프로젝트 구조를 설계할때 도움이 됐던 글들을 정리하고, 나는 어떻게 처리했는데 공유한다. 😄
best practice를 알아보기 위해 많은 글들을 찾아봤고 그 결과, 좋은 글의 내용을 프로젝트에 녹여낸 것 같아서 뿌듯하다.
코드는 여기서 확인할 수 있고 도움이 됐던 글들은 끝에서 볼 수 있다.
그럼 시작!
폴더 구조
3 계층 설계
관심사 분리 원칙을 위해 API Route와 비즈니스 로직을 분리하고, 비즈니스 로직과 Data Access Layer를 분리했다.
Express controller에 비즈니스 로직을 작성해도 되지만 결국 스파게티 소스가 될 수 밖에 없다.
예를 들어 신규 회원 등록 API를 만드는 경우, 처음에는 아래처럼 사용자 데이터를 추가하는 코드만 필요하지만
await UserModel.create(userDTO);
가입 알림 이메일 전송한다거나 사용자 이미지를 생성하는 기능이 추가될 경우 Controller(router)가 지저분해질 뿐더러 어떤 역할을 하는지 한눈에 알 수 없다.
또 사용자 이미지를 생성하는 기능이 마이페이지에서도 추가될 경우 중복되는 부분이 생기게 된다.
이러한 부분을 Util 폴더로 분리해도 되지만 비즈니스 로직들을 service 폴더로 분리하고 Data에 Access 하는 부분(ODM)을 Model로 분리하여 3계층 구조로 만들었다.
아래는 이 글에 있는 예시다.
🙅♀️ X
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)
});
🙆♀️ O
// 라우터
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
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 };
}
}
설정 및 시크릿 파일
dotenv를 사용해서 API KEY나 데이터 베이스 연결 등과 관련된 설정을 저장한다.
npm 패키지인 dotenv는 .env 파일을 로드해서 node.js의 process.env 객체에 대입해준다.
이렇게만 해도 충분하지만 한단계를 더 둬서 config/index.js를 이용해 코드를 구조화하고 자동 완성을 사용할 수 있도록 구성한다.
주의! .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,
}
}
Loader
node.js 서비스 시작 프로세스를 테스트 가능한 모듈로 나누는 것이다.
이렇게 하면 지저분한 app.js를 정리할 수 있고 모듈별로 테스트할 수 있다.
폴더 구조
app.js, loader 구성
// ---------------------
// app.js
// ---------------------
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();
// ---------------------
// loader/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');
}
// ---------------------
// loader/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 }));
return app;
})
CORS White List 관리
보통 Node.js에서 CORS(Cross-Origin Resource Sharing)를 처리하는 경우 npm cors 모듈을 이용해 처리한다.
모든 CORS 요청을 활성화할 수 있지만 보안이 문제가 될 수 있다. 개발 단계에서는 상관없지만 배포 시 특정 도메인만 허용하도록 설정했다.
// 모든 CORS 요청 활성화
app.use(cors());
// CORS 구성
var corsOptions = {
origin: 'http://example.com',
optionsSuccessStatus: 200
}
cors(corsOptions);
// White List 관리
var allowlist = ['http://example1.com', 'http://example2.com']
var corsOptionsDelegate = function (req, callback) {
var corsOptions;
if (allowlist.indexOf(req.header('Origin')) !== -1) {
corsOptions = { origin: true }
} else {
corsOptions = { origin: false }
}
callback(null, corsOptions)
}
cors(corsOptionsDelegate)
Node.js 비동기 에러 핸들링하기
Express에서 에러를 처리할 경우 보통 아래와 같이사용한다.
app.get("/User", async function (req, res) {
let users
try {
// 비즈니스 로직
} catch (error) {
res.status(500).json({ error: error.toString() })
}
res.json({ users })
})
하지만 모든 요청과 각 계층에 try/catch를 추가하는건 너무 비효율적이고 응답 형식을 통일시킬 수 없다.
익스프레스에서는 에러 처리 미들웨어를 지원한다.
4개의 파라미터를 받는 미들웨어를 에러 처리 미들웨어라고 한다. 에러가 발생한 경우 이 요청은 미들웨어로 들어오게 된다.
app.use(function (error, req, res, next) {
res.json({ message: error.message })
})
하지만 에러가 비동기로 발생하게 될 경우 서버는 에러를 catch할 수 없고 망가지게 된다...😢
비동기니까 어쩔 수 없는건가.. 싶었는데 이 글을 보고방법을 찾았다.
에러 처리 헬퍼 함수를 이용하면 비동기로도 에러를 핸들링 할 수 있다.
비동기 미들웨어 함수에서 에러 처리 헬퍼 함수를 호출하면 모든 비동기 예외가 오류 처리 미들웨어로 들어온다. 👍👍👍
에러 처리 헬퍼 함수
// 에러 처리 헬퍼 함수
function wrapAsync(fn) {
return function (req, res, next) {
// 모든 오류를 .catch() 처리하고 체인의 next() 미들웨어에 전달하세요
// (이 경우에는 오류 처리기)
fn(req, res, next).catch(next)
}
}
app.get(
"*",
wrapAsync(async function (req, res) {
await new Promise(resolve => setTimeout(() => resolve(), 50))
// 비동기 에러
throw new Error("woops")
})
)
app.use(function (error, req, res, next) {
// wrapAsync() 때문에 호출된다!
res.json({ message: error.message })
})
에러 처리 조금 더 👏
위에서 본 것 처럼, 기본적으로 사용하는 에러 처리 미들웨어는 아래와 같다.
app.use(function (error, req, res, next) {
res.json({ message: error.message })
})
에러 처리 미들웨어를 이용하면 JWT 오류는 HTTP 400번을 응답한다, 데이터베이스 오류는 500번을 응답한다 같은 규칙을 정의할 수 있다.
app.use(function handleMongoError(error, req, res, next) {
if (error instanceof mongoose.Error) return res.status(400).json({ type: "MongoError", message: error.message });
next(error);
});
app.use(function handlejwtError(error, req, res, next) {
if (error instanceof jsonwebtoken.TokenExpiredError) return res.status(401).json({ type: "TokenExpiredError", message: error.message });
if (error instanceof jsonwebtoken.JsonWebTokenError) return res.status(401).json({ type: "JsonWebTokenError", message: error.message });
next(error);
});
하나 더, 커스텀 에러 클래스를 사용해 에러 규칙을 규칙화시킬 수 있다.
보통 에러를 throw할때 아래와 같이 사용한다.
throw new Error("woops");
커스텀 에러 클래스를 만들어 에러 throw 시 response 내용을 정의할 수 있도록 했다.
CustomError.js
class CustomError extends Error {
constructor(type = 'GENERIC', status = 400, ...params) {
super(...params)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, CustomError)
}
this.type = type
this.status = status
}
}
export { CustomError };
호출 시
import { CustomError } from "../../CustomError.js";
throw new CustomError('JsonWebTokenError', 401, 'User not found');
에러 처리 미들웨어
// custom error handler
app.use(function handlecustomError(error, req, res, next) {
if (error instanceof CustomError) {
const { status, type, message } = error;
return res.status(status).send({ type, message });
}
next(error);
});
마무리
Node.js 프로젝트를 설계하려는 사람들에게 도움이 됐으면 해서 열심히 정리해봤다.
혹시 도움이 되셨다면 댓글 달아주시면 감사하겠습니다. 🙏
API? End Point?
API란 프로그램들이 서로 상호작용하는 것을 도와주는 매개체다. 즉 두 시스템, 어플리케이션이 상호작용(소통) 할 수 있게 하는 프로토콜의 총 집합
End Point는 API가 서버에서 리소스에 접근할 수 있도록 가능하게 하는 URL
관심사 분리 원칙?
쉽게 말해 여러 가지를 동시에 신경 쓰면 복잡하니, 각각 따로 분리해서 생각하는 것
👉 참조글
참조 글 견고한 node.js 프로젝트 설계하기
에러 처리를 위한 익스프레스 가이드
cors 화이트 리스트 관리하기
📚 깃허브 주소
'Node.js' 카테고리의 다른 글
swagger로 API 문서 자동화하기(nodeJS) (0) | 2022.11.01 |
---|---|
NodeJS 환경에서 부하테스트 진행하기(Artillery 이용) (1) | 2022.05.19 |
Node.js란? Node.js 특징 정리(이벤트 기반, 논 블로킹 I/O 모델) (1) | 2021.11.19 |
[Node.js] __dirname is not defined 에러 (0) | 2021.01.27 |
[Node.js] n으로 쉽게 Node 버전변경하기(커맨드 2번으로 끝!) (0) | 2021.01.10 |