Express 앱이 커질수록 각 Route마다 에러를 직접 처리하면 코드가 중복되고 일관성이 깨진다. next(err)를 통해 에러를 중앙 Error Middleware로 흘려보내면 응답 형식을 한 곳에서 통제할 수 있다. 이 흐름을 구조적으로 이해하면 디버깅과 유지보수가 훨씬 쉬워진다.
Express의 일반 Middleware는 인자가 3개(req, res, next)지만, Error Middleware는 반드시 4개(err, req, res, next)여야 한다. 인자 수가 정확히 4개여야 Express가 에러 핸들러로 인식한다.
Request
│
▼
Route Handler ──── throw / next(err) ────► Error Middleware
│ │
▼ ▼
Response (정상) Error Response (JSON)
Error Middleware — (err, req, res, next) 시그니처를 가진 특수 Middleware. 다른 Middleware가 next(err)를 호출할 때만 실행된다.
동기 코드에서는 try-catch로 에러를 잡은 뒤 next(err)에 넘긴다.
app.get('/users/:id', (req, res, next) => {
try {
const user = getUserById(req.params.id);
if (!user) throw new Error('User not found');
res.json(user);
} catch (err) {
next(err);
}
});
async 함수 안에서 발생한 에러는 자동으로 Express에 전달되지 않는다. Promise rejection을 next로 연결하는 래퍼가 필요하다.
// 유틸 래퍼
const asyncHandler = (fn: Function) =>
(req: Request, res: Response, next: NextFunction) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/posts', asyncHandler(async (req, res) => {
const posts = await Post.findAll();
res.json(posts);
}));
asyncHandler — async 함수의 rejected Promise를 자동으로 next(err)로 전달하는 패턴. Express 5부터는 async 라우트가 기본 지원된다.
HTTP 상태 코드를 에러 객체에 담으면 중앙 핸들러에서 분기 없이 처리할 수 있다.
class AppError extends Error {
constructor(public message: string, public statusCode: number) {
super(message);
this.name = 'AppError';
}
}
// 사용
throw new AppError('권한이 없습니다.', 403);
모든 에러가 모이는 곳. 반드시 다른 app.use 이후 마지막에 등록해야 한다.
app.use((err: AppError, req: Request, res: Response, next: NextFunction) => {
const status = err.statusCode ?? 500;
const message = err.message ?? 'Internal Server Error';
console.error(`[${status}] ${message}`);
res.status(status).json({ error: message });
});
| 방식 | 장점 | 단점 |
|---|---|---|
| 각 Route에서 직접 처리 | 즉각적, 명시적 | 코드 중복, 응답 형식 불일치 |
| 중앙 Error Middleware | 응답 형식 통일, 로깅 집중화 | 초기 설계 필요 |
| 커스텀 에러 클래스 | 상태 코드 포함, 타입 안전 | 클래스 정의 추가 |
중앙 Error Middleware 패턴은 규모가 커질수록 이점이 뚜렷해진다. Route는 비즈니스 로직에만 집중하고, 에러 응답의 형식과 로깅은 한 곳에서 관리하는 구조가 Express 앱의 표준 설계다.