Promise 기반 오류 처리를 위해 Express.js 경로 사용
게시 됨: 2022-03-11Express.js 태그라인은 사실입니다. "Node.js를 위한 빠르고, 의견이 없고, 미니멀한 웹 프레임워크"입니다. Promise 사용을 규정하는 현재 JavaScript 모범 사례에도 불구하고 Express.js는 기본적으로 Promise 기반 라우트 핸들러를 지원하지 않습니다.
많은 Express.js 튜토리얼이 해당 세부 사항을 생략하면서 개발자는 종종 각 경로에 대한 결과 전송 및 오류 처리 코드를 복사하여 붙여넣는 습관을 갖게 되어 기술적인 부채가 발생합니다. 오늘 다룰 기술로 이 반패턴(및 그 여파)을 피할 수 있습니다. 저는 수백 개의 경로가 있는 앱에서 성공적으로 사용한 기술입니다.
Express.js 경로의 일반적인 아키텍처
사용자 모델에 대한 몇 가지 경로가 있는 Express.js 튜토리얼 애플리케이션으로 시작하겠습니다.
실제 프로젝트에서는 관련 데이터를 MongoDB와 같은 일부 데이터베이스에 저장합니다. 그러나 우리의 목적을 위해 데이터 저장소 세부 사항은 중요하지 않으므로 단순성을 위해 조롱할 것입니다. 우리가 단순화하지 않을 것은 좋은 프로젝트 구조, 즉 모든 프로젝트 성공의 절반에 대한 열쇠입니다.
Yeoman은 일반적으로 훨씬 더 나은 프로젝트 스켈레톤을 생성할 수 있지만, 필요한 경우에는 express-generator로 프로젝트 스켈레톤을 만들고 불필요한 부분을 제거하면 됩니다.
bin start.js node_modules routes users.js services userService.js app.js package-lock.json package.json우리는 우리의 목표와 관련이 없는 나머지 파일의 줄을 줄였습니다.
다음은 기본 Express.js 애플리케이션 파일 ./app.js .
const createError = require('http-errors'); const express = require('express'); const cookieParser = require('cookie-parser'); const usersRouter = require('./routes/users'); const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use('/users', usersRouter); app.use(function(req, res, next) { next(createError(404)); }); app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); module.exports = app; 여기에서 Express.js 앱을 만들고 JSON 사용, URL 인코딩 및 쿠키 구문 분석을 지원하는 몇 가지 기본 미들웨어를 추가합니다. 그런 다음 /users 용 usersRouter 를 추가합니다. 마지막으로 경로를 찾을 수 없는 경우 수행할 작업과 오류를 처리하는 방법을 지정합니다. 이는 나중에 변경할 것입니다.
서버 자체를 시작하는 스크립트는 /bin/start.js 입니다.
const app = require('../app'); const http = require('http'); const port = process.env.PORT || '3000'; const server = http.createServer(app); server.listen(port); 우리의 /package.json 도 베어본입니다.
{ "name": "express-promises-example", "version": "0.0.0", "private": true, "scripts": { "start": "node ./bin/start.js" }, "dependencies": { "cookie-parser": "~1.4.4", "express": "~4.16.1", "http-errors": "~1.6.3" } } /routes/users.js 에서 일반적인 사용자 라우터 구현을 사용합시다.
const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { userService.getAll() .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); module.exports = router; 모든 사용자를 가져오는 / 및 ID별로 단일 사용자를 가져오는 /:id 의 두 가지 경로가 있습니다. 또한 이 데이터를 가져오는 약속 기반 메서드가 있는 /services/userService.js 를 사용합니다.
const users = [ {id: '1', fullName: 'User The First'}, {id: '2', fullName: 'User The Second'} ]; const getAll = () => Promise.resolve(users); const getById = (id) => Promise.resolve(users.find(u => u.id == id)); module.exports = { getById, getAll }; 여기서 우리는 실제 DB 커넥터 또는 ORM(예: Mongoose 또는 Sequelize)을 사용하지 않고 단순히 Promise.resolve(...) 로 데이터 가져오기를 모방합니다.
Express.js 라우팅 문제
라우트 핸들러를 살펴보면 각 서비스 호출이 중복 .then(...) 및 .catch(...) 콜백을 사용하여 데이터나 오류를 클라이언트에 다시 보내는 것을 볼 수 있습니다.
언뜻 보기에 이것은 심각해 보이지 않을 수 있습니다. 몇 가지 기본적인 실제 요구 사항을 추가해 보겠습니다. 특정 오류만 표시하고 일반적인 500레벨 오류는 생략해야 합니다. 또한 이 논리를 적용할지 여부는 환경을 기반으로 해야 합니다. 그러면 예제 프로젝트가 2개의 경로에서 200개의 경로가 있는 실제 프로젝트로 성장하면 어떻게 될까요?
접근법 1: 효용 함수
아마도 resolve 와 reject 를 처리하기 위해 별도의 유틸리티 함수를 만들고 Express.js 경로의 모든 곳에 적용할 것입니다.
// some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err) => res.status(500).send(err); // routes/users.js router.get('/', function(req, res) { userService.getAll() .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); }); 더 좋아 보입니다. 데이터 및 오류 전송 구현을 반복하지 않습니다. 그러나 우리는 여전히 모든 경로에서 이러한 핸들러를 가져와서 then() 및 catch() 에 전달된 각 프라미스에 추가해야 합니다.
접근 방식 2: 미들웨어
또 다른 솔루션은 약속과 관련하여 Express.js 모범 사례를 사용하는 것입니다. 오류 전송 논리를 Express.js 오류 미들웨어( app.js 에 추가됨)로 이동하고 next 콜백을 사용하여 비동기 오류를 전달합니다. 기본 오류 미들웨어 설정은 간단한 익명 함수를 사용합니다.
app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); Express.js는 함수 서명에 4개의 입력 인수가 있기 때문에 이것이 오류에 대한 것임을 이해합니다. (모든 함수 객체에는 함수가 예상하는 매개변수의 수를 설명하는 .length 속성이 있다는 사실을 활용합니다.)
next 을 통해 오류를 전달하면 다음과 같습니다.
// some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); // routes/users.js router.get('/', function(req, res, next) { userService.getAll() .then(data => handleResponse(res, data)) .catch(next); }); router.get('/:id', function(req, res, next) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(next); }); 공식 모범 사례 가이드를 사용하더라도 여전히 모든 경로 핸들러에서 handleResponse() 약속이 필요 next .
더 나은 접근 방식으로 이를 단순화해 보겠습니다.
접근 3: 약속 기반 미들웨어
JavaScript의 가장 큰 특징 중 하나는 동적 특성입니다. 런타임에 모든 개체에 필드를 추가할 수 있습니다. Express.js 결과 개체를 확장하는 데 사용할 것입니다. Express.js 미들웨어 기능은 그렇게 하기에 편리한 장소입니다.
우리의 promiseMiddleware() 함수
Express.js 경로를 보다 우아하게 구성할 수 있는 유연성을 제공하는 Promise 미들웨어를 만들어 보겠습니다. /middleware/promise.js 라는 새 파일이 필요합니다.

const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err = {}) => res.status(err.status || 500).send({error: err.message}); module.exports = function promiseMiddleware() { return (req,res,next) => { res.promise = (p) => { let promiseToResolve; if (p.then && p.catch) { promiseToResolve = p; } else if (typeof p === 'function') { promiseToResolve = Promise.resolve().then(() => p()); } else { promiseToResolve = Promise.resolve(p); } return promiseToResolve .then((data) => handleResponse(res, data)) .catch((e) => handleError(res, e)); }; return next(); }; } app.js 에서 전체 Express.js app 개체에 미들웨어를 적용하고 기본 오류 동작을 업데이트하겠습니다.
const promiseMiddleware = require('./middlewares/promise'); //... app.use(promiseMiddleware()); //... app.use(function(req, res, next) { res.promise(Promise.reject(createError(404))); }); app.use(function(err, req, res, next) { res.promise(Promise.reject(err)); }); 오류 미들웨어를 생략하지 않았습니다 . 이것은 여전히 우리 코드에 존재할 수 있는 모든 동기 오류에 대한 중요한 오류 처리기입니다. 그러나 오류 전송 논리를 반복하는 대신 오류 미들웨어는 이제 res.promise() 로 전송된 res.promise() Promise.reject() 호출을 통해 동기 오류를 동일한 중앙 handleError() 함수에 전달합니다.
이것은 다음과 같은 동기 오류를 처리하는 데 도움이 됩니다.
router.get('/someRoute', function(req, res){ throw new Error('This is synchronous error!'); }); 마지막으로 /routes/users.js에서 새로운 /routes/users.js res.promise() 를 사용하겠습니다.
const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { res.promise(userService.getAll()); }); router.get('/:id', function(req, res) { res.promise(() => userService.getById(req.params.id)); }); module.exports = router; .promise() 의 다른 용도에 주목하세요. 함수나 약속을 전달할 수 있습니다. 함수를 전달하면 약속이 없는 메서드에 도움이 될 수 있습니다. .promise() 는 이것이 함수임을 확인하고 이를 약속으로 래핑합니다.
실제로 클라이언트에 오류를 보내는 것이 더 나은 곳은 어디입니까? 좋은 코드 구성 질문입니다. 오류 미들웨어(오류와 함께 작동해야 하기 때문에) 또는 약속 미들웨어(이미 응답 개체와 상호 작용이 있기 때문에)에서 이를 수행할 수 있습니다. 나는 모든 응답 작업을 promise 미들웨어의 한 곳에 유지하기로 결정했지만 각자의 코드를 구성하는 것은 각 개발자의 몫입니다.
기술적으로 res.promise() 는 선택 사항입니다.
res.promise() 를 추가했지만 사용에 제한이 없습니다. 필요할 때 응답 객체로 직접 작업할 수 있습니다. 이것이 유용한 두 가지 경우인 리디렉션과 스트림 파이핑을 살펴보겠습니다.
특수 사례 1: 리디렉션
사용자를 다른 URL로 리디렉션한다고 가정합니다. userService.js 에 getUserProfilePicUrl() 함수를 추가해 보겠습니다.
const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`); 이제 직접 응답 조작을 사용하여 async / await 스타일의 사용자 라우터에서 사용하겠습니다.
router.get('/:id/profilePic', async function (req, res) { try { const url = await userService.getUserProfilePicUrl(req.params.id); res.redirect(url); } catch (e) { res.promise(Promise.reject(e)); } }); async / await 를 사용하고 리디렉션을 수행하며 (가장 중요하게) 오류 처리를 위해 res.promise() 를 사용했기 때문에 여전히 오류를 전달할 중앙 위치가 하나 있다는 점에 유의하세요.
특수 사례 2: 하천 배관
프로필 사진 경로와 마찬가지로 스트림 파이핑은 응답 개체를 직접 조작해야 하는 또 다른 상황입니다.
현재 리디렉션 중인 URL에 대한 요청을 처리하기 위해 일반적인 그림을 반환하는 경로를 추가해 보겠습니다.
먼저 새로운 /assets/img 하위 폴더에 profilePic.jpg 를 추가해야 합니다. (실제 프로젝트에서는 AWS S3와 같은 클라우드 스토리지를 사용하지만 파이핑 메커니즘은 동일합니다.)
/img/profilePic/:id 요청에 대한 응답으로 이 이미지를 파이프해 보겠습니다. /routes/img.js 에 새 라우터를 만들어야 합니다.
const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); router.get('/:id', function(req, res) { /* Note that we create a path to the file based on the current working * directory, not the router file location. */ const fileStream = fs.createReadStream( path.join(process.cwd(), './assets/img/profilePic.png') ); fileStream.pipe(res); }); module.exports = router; 그런 다음 app.js 에 새로운 /img 라우터를 추가합니다.
app.use('/users', require('./routes/users')); app.use('/img', require('./routes/img')); 한 가지 차이점은 리디렉션의 경우와 비교할 때 두드러집니다. /img 라우터에서 res.promise() 를 사용하지 않았습니다! 오류가 전달되는 이미 파이프된 응답 개체의 동작이 스트림 중간에 오류가 발생하는 경우와 다르기 때문입니다.
Express.js 개발자는 Express.js 애플리케이션에서 스트림으로 작업할 때 주의를 기울여야 하며 오류가 발생하는 시기에 따라 다르게 처리해야 합니다. 파이핑( res.promise() 이 우리를 도울 수 있음)과 미드스트림(.on .on('error') 핸들러를 기반으로 함) 전에 오류를 처리해야 하지만 더 자세한 내용은 이 기사의 범위를 벗어납니다.
res.promise() 향상
res.promise() 를 호출 할 때와 마찬가지로 우리는 우리가 가지고 있는 방식으로 구현 하지 않습니다. promiseMiddleware.js 는 호출자가 응답 상태 코드, 콘텐츠 유형 또는 프로젝트에 필요할 수 있는 기타 항목을 지정할 수 있도록 res.promise() 의 일부 옵션을 허용하도록 보강될 수 있습니다. 도구를 구성하고 필요에 가장 잘 맞도록 코드를 구성하는 것은 개발자의 몫입니다.
Express.js 오류 처리와 최신 Promise 기반 코딩의 만남
여기에 제시된 접근 방식은 우리가 시작한 것보다 더 우아한 라우트 핸들러 를 허용하고 app.js의 오류 처리 덕분에 단일 지점에서 결과 및 오류 ( app.js res.promise(...) 외부에서 발생한 오류도 포함)를 처리할 수 있습니다. 그래도 강제로 사용하지 않고 우리가 원하는 대로 극단적인 경우를 처리할 수 있습니다.
이 예제의 전체 코드는 GitHub에서 사용할 수 있습니다. 거기에서 개발자는 데이터가 없는 경우 응답 상태를 200 대신 204로 변경하는 것과 같이 필요에 따라 handleResponse() 함수에 사용자 정의 로직을 추가할 수 있습니다.
그러나 오류에 대한 추가 제어가 훨씬 더 유용합니다. 이 접근 방식은 프로덕션에서 다음 기능을 간결하게 구현하는 데 도움이 되었습니다.
- 모든 오류를
{error: {message}}로 일관되게 형식 지정 - 상태가 제공되지 않으면 일반 메시지를 보내고 그렇지 않으면 지정된 메시지를 전달합니다.
- 환경이
dev(또는test등)인 경우error.stack필드를 채웁니다. - 데이터베이스 색인 오류(즉, 고유 색인 필드가 있는 일부 엔터티가 이미 존재함)를 처리하고 의미 있는 사용자 오류로 적절하게 응답합니다.
이 Express.js 라우트 로직은 서비스를 건드리지 않고 모두 한 곳에 있었습니다. 분리되어 코드를 훨씬 더 쉽게 유지 관리하고 확장할 수 있었습니다. 이것은 간단하지만 우아한 솔루션이 프로젝트 구조를 획기적으로 개선할 수 있는 방법입니다.
Toptal 엔지니어링 블로그에 대한 추가 정보:
- Node.js 오류 처리 시스템을 구축하는 방법
