Node.js와 Express로 API 서버를 구축하다 보면 누구나 한 번쯤은 아찔한 경험을 합니다. async/await를 사용해 컨트롤러 로직을 깔끔하게 작성했는데, 정작 비동기 함수 내부에서 발생한 에러가 서버 전체를 멈춰버리는 현상이죠. 분명 에러 핸들링 미들웨어도 등록했는데 말입니다.
왜 이런 일이 발생할까요? Express의 기본 에러 핸들링 메커니즘은 동기적으로 발생한 에러만 감지할 수 있기 때문입니다. async 함수가 반환하는 Promise가 reject되었을 때 발생하는 에러는 Express의 이벤트 루프에서 '떠다니는' 상태가 되어, 우리가 설정한 에러 핸들러까지 도달하지 못하고 프로세스를 중단시킵니다.
이 문제를 해결하기 위해 모든 async 컨트롤러마다 try...catch 블록으로 감싸고, catch 블록에서 next(error)를 수동으로 호출하는 코드를 반복적으로 작성하고 계시진 않나요? 코드는 지저분해지고, 개발자는 지쳐갑니다.
하지만 이 지루하고 위험한 반복 작업을 우아하게 해결할 방법이 있습니다. 바로 'Promise 미들웨어(Promise Middleware)' 패턴을 도입하는 것입니다. 오늘은 이 Promise 미들웨어의 원리부터 2025년 기준 가장 현명하게 적용하는 최신 트렌드까지, Node.js 비동기 에러 핸들링의 모든 것을 파헤쳐 보겠습니다.
Node.js와 Express, 왜 Promise 에러에 취약할까?
이 문제를 이해하려면 Express 미들웨어의 동작 방식을 먼저 살펴봐야 합니다. Express는 미들웨어 체인(chain)을 기반으로 동작합니다. 요청이 들어오면 등록된 미들웨어 함수들이 순차적으로 실행되죠.
// 일반적인 동기 미들웨어
app.get('/', (req, res, next) => {
// 이 코드 블록은 동기적입니다.
if (someCondition) {
// 동기 에러는 Express가 즉시 감지합니다.
throw new Error('동기적 에러 발생!');
}
res.send('성공');
});
// 에러 핸들링 미들웨어 (맨 마지막에 위치)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('서버 내부 오류');
});
위 코드에서 throw new Error(...)가 실행되면, Express는 즉시 실행을 중단하고 인자가 4개인 에러 핸들링 미들웨어로 제어권을 넘깁니다.
하지만 async/await가 등장하면서 상황이 복잡해졌습니다.
// async/await를 사용한 비동기 컨트롤러
app.get('/user/:id', async (req, res, next) => {
// 이 함수는 Promise를 반환합니다.
const user = await db.users.findById(req.params.id);
if (!user) {
// 여기서 발생한 에러는 'reject'된 Promise가 됩니다.
throw new Error('사용자를 찾을 수 없습니다.');
}
res.json(user);
});
Express 4.x 버전은 async 함수가 반환한 Promise의 reject 상태를 자동으로 감지하지 못합니다. throw new Error는 Promise.reject(new Error(...))와 동일하게 동작하고, 이 'reject'된 Promise는 Express 미들웨어 체인 외부에서 처리되지 않은 예외(Unhandled Promise Rejection)가 되어 Node.js 프로세스를 멈추게 만듭니다.
구시대의 해결책: 반복적인 try...catch
이 문제를 해결하는 가장 고전적인 방법은 모든 비동기 라우트 핸들러를 try...catch로 감싸는 것이었습니다.
// 고전적인 try...catch 방식
app.get('/user/:id', async (req, res, next) => {
try {
const user = await db.users.findById(req.params.id);
if (!user) {
throw new Error('사용자를 찾을 수 없습니다.');
}
res.json(user);
} catch (error) {
// 에러를 'next' 함수로 전달해야만
// 중앙 에러 핸들러가 동작합니다.
next(error);
}
});
이 방식은 동작은 하지만, 모든 비동기 컨트롤러마다 동일한 보일러플레이트 코드를 반복해야 한다는 치명적인 단점이 있습니다. 이는 DRY(Don't Repeat Yourself) 원칙에 위배되며, 개발자가 실수로 try...catch나 next(error)를 누락할 경우 서버는 다시금 비동기 에러에 무방비 상태가 됩니다.
구원투수의 등장: Promise 미들웨어 패턴
이 반복 작업을 해결하기 위해 등장한 것이 바로 'Promise 미들웨어' 또는 'Async Wrapper' 패턴입니다.
Promise 미들웨어란 정확히 무엇인가?
Promise 미들웨어는 **비동기 미들웨어 함수(라우트 핸들러)를 인자로 받아, 새로운 미들웨어 함수를 반환하는 고차 함수(Higher-Order Function)**입니다.
이 래퍼(wrapper) 함수가 하는 일은 간단합니다.
- 원본 비동기 함수를 실행시킵니다.
- 해당 함수가 반환하는 Promise(혹은
async함수의 실행 결과)를 가져옵니다. - 그 Promise에
.catch(next)를 붙여줍니다.
이 .catch(next)가 핵심입니다. Promise가 reject되면(즉, 비동기 에러가 발생하면), .catch가 이 에러를 잡아 Express의 next 함수에 인자로 전달합니다. next 함수에 에러 객체가 전달되면, Express는 이 요청을 즉시 중앙 에러 핸들러로 보냅니다.
기본 Promise 미들웨어 직접 구현하기 (DIY)
Promise 미들웨어의 원리는 놀라울 정도로 간단합니다. 직접 한번 만들어 볼까요?
/**
* 비동기 라우트 핸들러를 감싸는 래퍼 함수
* @param {Function} fn - async (req, res, next) => { ... } 형태의 비동기 핸들러
* @returns {Function} (req, res, next) => { ... } 형태의 일반 미들웨어
*/
const asyncHandler = (fn) => (req, res, next) => {
// fn(req, res, next)의 실행 결과는 Promise입니다.
// Promise.resolve()로 한 번 더 감싸주어,
// 혹시 모를 동기 함수나 일반 값을 반환하는 경우에도
// Promise 체인을 보장합니다.
Promise.resolve(fn(req, res, next)).catch(next);
};
단 몇 줄의 코드로 try...catch 지옥을 해결할 래퍼 함수 asyncHandler가 완성되었습니다.
실제 Express 라우트에 적용하는 방법
이제 이 asyncHandler를 사용해 아까의 '위험한' 예제 코드를 수정해 보겠습니다.
// [BEFORE] try...catch로 지저분한 코드
app.get('/user/:id', async (req, res, next) => {
try {
const user = await db.users.findById(req.params.id);
if (!user) throw new Error('사용자를 찾을 수 없습니다.');
res.json(user);
} catch (error) {
next(error);
}
});
// [AFTER] asyncHandler로 깔끔해진 코드
app.get('/user/:id', asyncHandler(async (req, res) => {
// next는 래퍼 함수가 처리하므로 인자에서 제외해도 됩니다.
const user = await db.users.findById(req.params.id);
if (!user) {
// 여기서 throw된 에러는 asyncHandler의 .catch(next)가 잡습니다.
throw new Error('사용자를 찾을 수 없습니다.');
}
res.json(user);
}));
어떤가요? try...catch 구문이 완전히 사라지고, 우리는 오직 비즈니스 로직에만 집중할 수 있게 되었습니다. 에러가 발생하면 asyncHandler가 알아서 next(error)를 호출해 줄 것입니다.
2025년 기준, 가장 현명한 Promise 미들웨어 전략 비교
우리가 직접 asyncHandler를 만들 수도 있지만, 이미 수많은 개발자가 검증하고 사용 중인 훌륭한 라이브러리들이 있습니다. 그리고 더 나아가, Express 프레임워크 자체도 진화하고 있습니다.
전략 1: express-async-handler (가장 대중적인 선택)
express-async-handler는 우리가 위에서 만든 asyncHandler와 거의 동일한 기능을 하는 매우 가볍고 인기 있는 라이브러리입니다.
- 설치:
npm install express-async-handler - 사용법:
-
JavaScript
const asyncHandler = require('express-async-handler'); app.get('/user/:id', asyncHandler(async (req, res) => { const user = await db.users.findById(req.params.id); if (!user) throw new Error('사용자를 찾을 수 없습니다.'); res.json(user); })); - 장점: 매우 가볍고(의존성 0), 사용법이 직관적이며, Express 4.x 환경에서 가장 널리 쓰이는 사실상의 표준입니다.
- 단점: 모든 비동기 라우트 핸들러를 개별적으로 감싸줘야 하는 '번거로움'은 여전합니다.
전략 2: express-promise-router (라우터 레벨의 해결책)
express-promise-router는 조금 다른 접근 방식을 취합니다. express.Router()를 대체하는 새로운 라우터를 제공하며, 이 라우터에 등록된 모든 핸들러는 자동으로 Promise 에러 처리가 적용됩니다.
- 설치:
npm install express-promise-router - 사용법:
-
JavaScript
// const router = require('express').Router(); (X) const Router = require('express-promise-router'); const router = Router(); // (O) // 이제 asyncHandler로 감쌀 필요가 없습니다! router.get('/user/:id', async (req, res) => { const user = await db.users.findById(req.params.id); if (!user) throw new Error('사용자를 찾을 수 없습니다.'); res.json(user); }); app.use(router); - 장점: 라우트 핸들러를 하나하나 감쌀 필요가 없어 코드가 극도로 깔끔해집니다.
- 단점:
express의 기본 라우터를 교체해야 한다는 점이 부담스러울 수 있으며,express-async-handler에 비해 인지도가 낮은 편입니다.
전략 3: Express 5.x의 내장 기능 (미래의 표준)
가장 중요하고 흥미로운 소식입니다. 오랫동안 베타 버전이었던 Express 5.x가 릴리스 후보(RC) 단계를 거쳐 안정화되고 있습니다. (2025년 기준, 많은 프로덕션 환경에서 사용 시작)
Express 5.0부터는 Promise 미들웨어 래퍼가 '내장'되었습니다.
즉, Express 5.x 버전을 사용한다면 아무런 추가 라이브러리나 래퍼 함수 없이도 비동기 에러 핸들링이 자동으로 동작합니다.
// Express 5.x 버전
// (별도 라이브러리 설치나 래퍼 함수가 필요 없습니다)
app.get('/user/:id', async (req, res, next) => {
// async 함수에서 throw된 에러를
// Express 5가 자동으로 감지하여 next(error)를 호출합니다.
const user = await db.users.findById(req.params.id);
if (!user) {
throw new Error('사용자를 찾을 수 없습니다.');
}
res.json(user);
});
- 장점: 추가 작업이 전혀 필요 없습니다. 프레임워크 레벨에서 완벽하게 지원하므로, 가장 이상적이고 깔끔한 방식입니다.
- 단점: 기존 Express 4.x 프로젝트를 Express 5.x로 마이그레이션해야 합니다. (물론, 대부분의 경우 마이그레이션은 간단합니다.)
비교 분석: 나에게 맞는 솔루션은?
어떤 전략을 선택해야 할지 고민되시나요? 다음 표를 참고해 보세요.
Promise 미들웨어 200% 활용 꿀팁
Promise 미들웨어를 도입했다고 해서 모든 게 끝난 것은 아닙니다. 이들이 '전달'한 에러를 멋지게 '처리'하는 과정이 남아있습니다.
1. 중앙 에러 핸들러(Central Error Handler)는 필수!
Promise 미들웨어(혹은 Express 5)는 비동기 에러를 next(error)로 전달해 주는 '전달자' 역할까지입니다. 이 에러를 최종적으로 받아 사용자에게 적절한 응답을 보내는 '해결사'가 필요합니다.
이것이 바로 중앙 에러 핸들링 미들웨어입니다. 이 미들웨어는 app.js 또는 server.js 파일의 모든 app.use 및 라우트 등록 마지막에 위치해야 합니다.
// ... (모든 라우터 등록)
app.use('/users', userRouter);
app.use('/posts', postRouter);
// ... (여기까지 모든 요청 처리)
// 404 처리 미들웨어 (선택 사항)
app.use((req, res, next) => {
res.status(404).send('페이지를 찾을 수 없습니다.');
});
// ⭐ 중앙 에러 핸들링 미들웨어 ⭐
// 반드시 4개의 인자 (err, req, res, next)를 모두 가져야 합니다.
app.use((err, req, res, next) => {
console.error('🔥 중앙 에러 핸들러:', err.stack);
// 커스텀 에러 분기 처리 (예시)
if (err.name === 'ValidationError') {
return res.status(400).json({ message: err.message });
}
// 기본 500 서버 에러 처리
res.status(err.status || 500).json({
message: err.message || '서버 내부에서 알 수 없는 오류가 발생했습니다.'
});
});
Promise 미들웨어와 중앙 에러 핸들러는 하나의 세트입니다. 둘 중 하나라도 없으면 비동기 에러 처리는 완성되지 않습니다.
2. async/await와 미들웨어 체인(Chain) 활용
Promise 미들웨어는 여러 미들웨어가 체인으로 연결될 때 더욱 빛을 발합니다. 예를 들어, '인증' 미들웨어와 '권한' 미들웨어를 거쳐 최종 컨트롤러가 실행되는 경우를 생각해 보세요.
const asyncHandler = require('express-async-handler');
// 1. 비동기 인증 미들웨어 (예: JWT 토큰 검증)
const authMiddleware = async (req, res, next) => {
const token = req.headers['authorization'];
const user = await AuthService.verifyToken(token); // 비동기
req.user = user;
next();
};
// 2. 비동기 권한 미들웨어 (예: DB에서 유저 권한 확인)
const adminMiddleware = async (req, res, next) => {
const hasPermission = await PermissionService.checkAdmin(req.user.id); // 비동기
if (!hasPermission) {
throw new Error('관리자 권한이 없습니다.'); // 에러 발생
}
next();
};
// 3. 비동기 메인 컨트롤러
const mainController = async (req, res) => {
const data = await AdminService.getDashboardData(); // 비동기
res.json(data);
};
// 모든 비동기 미들웨어를 asyncHandler로 감싸줍니다.
router.get(
'/admin/dashboard',
asyncHandler(authMiddleware),
asyncHandler(adminMiddleware),
asyncHandler(mainController)
);
이제 authMiddleware, adminMiddleware, mainController 중 어느 곳에서 비동기 에러가 발생하든, asyncHandler가 이를 감지하여 즉시 중앙 에러 핸들러로 에러를 전달합니다. try...catch가 전혀 보이지 않는 매우 깨끗하고 선언적인 코드가 완성되었습니다.
결론: 더 이상 비동기 에러를 두려워하지 마세요
Node.js와 Express 환경에서 비동기 에러 처리는 오랫동안 개발자들을 괴롭혀 온 난제였습니다. 모든 컨트롤러를 try...catch로 도배하는 것은 고통스러운 일이었죠.
하지만 Promise 미들웨어 패턴의 등장은 이러한 반복적인 작업을 제거하고, 개발자가 오롯이 비즈니스 로직에만 집중할 수 있도록 환경을 바꾸어 놓았습니다.
- Express 4.x 사용자라면, 지금 바로
express-async-handler를 도입하여 코드를 리팩토링해 보세요. - 새로운 프로젝트를 시작한다면, 주저하지 말고 Express 5.x 버전을 선택하여 프레임워크가 제공하는 강력한 비동기 에러 핸들링을 경험해 보세요.
더 이상 UnhandledPromiseRejectionWarning 경고에 가슴을 쓸어내리지 않아도 됩니다. Promise 미들웨어라는 든든한 안전망을 설치하고, 더 견고하고 우아한 Node.js 애플리케이션을 만들어 나가시길 바랍니다.