인프런 커뮤니티 질문&답변
Express 에러 처리 관련 질문 드려요.
해결된 질문
작성
·
29
0
Express는 비동기 에러를 잡지 못한다고 강의에서 배웠습니다. 이게 혹시 싱글 스레드와 관련이 있을까요? 그렇다면 스프링의 경우는 어떻게 다를까요?
Express는 에러 발생 지점에 가까이 정의된 에러 처리 미들웨어를 우선적으로 처리한다고 배웠습니다. 단순 코드적으로 위치가 가까운 거 맞나요? 에러 처리 미들웨어가 여러 개 있고 파일 내에 섞여있으면 코드 파악이 어려워질 수 있을 것 같아요. 실무에서 보통 권장되는 방식은 뭘까요? 에러처리 미들웨어를 하나만 두는지, 나눠서 같은 코드 위치에 두는지, 나누서 서로 다른 코드 위치 (라우터 사이)에 두는지 궁금합니다.
답변 2
0
안녕하세요 TAESUN님, 남겨주신 질문은 단순히 프레임워크의 사용법을 넘어 언어의 동작 원리와 아키텍처 설계까지 닿아 있는 아주 수준 높은 질문입니다. 우선 Express가 비동기 에러를 잡지 못하는 현상(Express 4 기준)은 싱글 스레드 자체의 문제라기보다는 자바스크립트의 비동기 처리 방식과 Express의 설계 시점 간의 차이에서 기인한다고 볼 수 있습니다.
Express 4가 만들어질 당시에는 Promise나 async/await가 표준이 아니었기 때문에 Express의 라우터는 비동기 함수가 반환하는 Promise를 기다려주지 않고 바로 다음 로직으로 넘어가 버리거나 리턴해 버리게 됩니다. 따라서 비동기 로직 내부에서 에러가 발생하더라도, 즉 Promise Rejection이 일어나더라도 이미 동기적 실행 흐름이 끝난 Express의 에러 처리 미들웨어는 이를 감지할 수 없게 되어 서버가 멈추거나 타임아웃이 발생하게 되는 것입니다.
이를 해결하기 위해서는 비동기 함수 내부에서 try-catch 블록을 사용하여 에러를 잡은 뒤 반드시 next(err)를 호출하여 Express에게 에러가 발생했음을 명시적으로 알려주어야 하는데, 이를 코드로 확인해보면 다음과 같이 문제 상황과 해결책을 비교해 볼 수 있습니다.
// [케이스 1: Express가 잡지 못하는 비동기 에러 (문제 상황)]
// 이 코드는 클라이언트에게 아무런 응답을 주지 못하고 서버 콘솔에 Unhandled Rejection 경고만 남긴 채 요청이 타임아웃됩니다.
app.get('/async-error', async (req, res, next) => {
const user = await User.findById(req.params.id); // 여기서 에러가 발생하면?
if (!user) throw new Error('User not found');
res.json(user);
});
// [케이스 2: try-catch를 사용한 수동 처리 (해결책)]
// 비동기 안에서 발생한 에러를 잡아서 next()로 넘겨주면 Express가 인지합니다.
app.get('/async-fixed', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) throw new Error('User not found');
res.json(user);
} catch (err) {
next(err); // 핵심: 에러를 명시적으로 다음 미들웨어(에러 핸들러)로 넘겨야 함
}
});
반면 Spring 같은 멀티 스레드 기반 프레임워크는 요청마다 독립적인 스레드가 할당되고 해당 스레드 내의 스택 프레임에서 예외가 발생하면 상위 호출 스택으로 예외가 전파되는 구조를 가지기 때문에, 비동기나 동기 여부와 상관없이 트라이-캐치 혹은 전역 예외 처리기가 이를 안정적으로 잡아낼 수 있다는 차이점이 있습니다.
두 번째로 질문 주신 에러 처리 미들웨어의 우선순위는 말씀하신 '코드 위치'가 맞는데, 정확히는 코드가 작성된 물리적 거리라기보다 app.use를 통해 등록된 순서가 중요합니다. Express는 미들웨어를 배열 형태의 스택으로 관리하므로 요청이 들어오면 등록된 순서대로 미들웨어를 거치게 되고, 에러가 발생하여 next(err)가 호출되면 그 시점 이후에 등록된 미들웨어 중 인자가 4개(err, req, res, next)인 미들웨어를 찾아 이동하기 때문입니다. 이러한 흐름을 직관적으로 이해하기 위해 next(err)가 호출되었을 때 중간에 있는 일반 미들웨어를 건너뛰고 바로 에러 핸들러로 '점프'하는 과정을 아래 코드로 확인해 볼 수 있습니다.
// [미들웨어 스택 및 에러 전파 순서 시각화]
// 1. 가장 먼저 등록된 일반 미들웨어 (실행됨 O)
app.use((req, res, next) => {
console.log('1. 첫 번째 미들웨어: 실행됨');
next(); // 다음 미들웨어로 이동
});
// 2. 에러를 발생시키는 라우터 (실행됨 O)
app.get('/test', (req, res, next) => {
console.log('2. 라우터: 에러 발생 시도');
// next()에 인자를 넣으면 Express는 이를 에러로 간주합니다.
next(new Error('Something went wrong!'));
});
// 3. 라우터와 에러 핸들러 사이에 있는 일반 미들웨어 (건너뜀 X)
// 중요: 위에서 next(err)가 호출되었기 때문에 이 미들웨어는 무시되고 건너뜁니다.
app.use((req, res, next) => {
console.log('3. 두 번째 미들웨어: 실행되지 않음 (Skip)');
next();
});
// 4. 에러 처리 미들웨어 (실행됨 O)
// Express는 인자가 4개(err, req, res, next)인 미들웨어를 찾아 이곳으로 점프합니다.
app.use((err, req, res, next) => {
console.log('4. 에러 핸들러: 도착! 에러 메시지 ->', err.message);
res.status(500).send('에러 처리 완료');
});
마지막으로 실무에서의 권장 방식에 대해 말씀드리면, 수강생님이 우려하신 대로 에러 처리 미들웨어가 라우터 사이사이에 흩어져 있는 것은 유지보수 측면에서 최악의 패턴 중 하나로 간주되어 실무에서는 거의 사용하지 않습니다. 일반적으로는 '전역 에러 처리(Global Error Handling)' 패턴을 사용하여 모든 비즈니스 로직을 등록한 후 app.js의 가장 마지막 부분에 단 하나의 전역 에러 처리 미들웨어를 두는 방식을 표준처럼 사용합니다. 이때 라우터 내부에서 발생하는 비동기 에러를 놓치지 않기 위해 express-async-errors 라이브러리를 사용하거나 모든 라우터 함수를 감싸는 래퍼(Wrapper) 함수를 만들어 에러 발생 시 강제로 next(err)를 호출하도록 처리합니다.
또한, 단순히 에러를 잡는 것을 넘어 AppError나 HttpException 같은 커스텀 에러 클래스를 정의하여 에러의 종류와 메시지를 체계적으로 관리하고, 최종적으로 전역 핸들러에서 이 에러 타입에 따라 적절한 응답을 보내거나 Sentry 같은 모니터링 도구로 로그를 전송하는 것이 시니어 레벨에서 설계하는 견고한 에러 처리 아키텍처이며, 이를 실제 코드로 구현하면 다음과 같은 구조가 됩니다.
// 1. 커스텀 에러 클래스 정의 (utils/AppError.js)
// 실무 팁: Error 클래스를 상속받아 상태 코드와 운영(Operational) 에러 여부를 구분합니다.
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true; // 프로그래밍 버그가 아닌, 우리가 예측한 에러임을 표시
}
}
// 2. 비동기 에러 래퍼 함수 (utils/catchAsync.js)
// 실무 팁: 반복되는 try-catch 블록을 제거하기 위한 고차 함수입니다.
const catchAsync = fn => {
return (req, res, next) => {
fn(req, res, next).catch(next); // Promise가 reject되면 자동으로 next(err)를 호출
};
};
// 3. 실제 컨트롤러 사용 예시 (controllers/userController.js)
// 코드가 훨씬 깔끔해지고 비즈니스 로직에만 집중할 수 있습니다.
const getUser = catchAsync(async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) {
// 여기서 던진 에러는 자동으로 Global Handler로 전달됩니다.
// 404 같은 구체적인 상태 코드를 지정할 수 있습니다.
return next(new AppError('해당 ID의 사용자를 찾을 수 없습니다.', 404));
}
res.status(200).json({ status: 'success', data: user });
});
// 4. 전역 에러 처리 미들웨어 (app.js 하단)
// 실무 팁: 개발 환경과 운영 환경(Production)의 에러 응답을 다르게 주는 것이 보안상 중요합니다.
app.use((err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
// 개발 환경: 디버깅을 위해 상세한 스택 트레이스 포함
if (process.env.NODE_ENV === 'development') {
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
} else {
// 운영 환경: 사용자에게는 정제된 메시지만 전달하고 내부 정보는 숨김
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message
});
} else {
// 프로그래밍 버그나 라이브러리 에러 등은 로그로 남기고 사용자에겐 일반 메시지 전달
console.error('ERROR 💥', err); // Sentry 등으로 전송 가능
res.status(500).json({
status: 'error',
message: '서버 내부에서 문제가 발생했습니다. 잠시 후 다시 시도해주세요.'
});
}
}
});
답변이 도움이 되었기를 바라며, 실무적인 관점에서 고민하는 모습이 매우 인상적입니다.
참고해주세요!
0
안녕하세요, 인프런 AI 인턴입니다. TAESUN님.
Express.js의 에러 처리 관련 질문을 주셨군요. 에러 처리는 모든 서버사이드 애플리케이션에서 중요한 부분이기 때문에 잘 이해하고 넘어가시면 좋을 것입니다.
1. Express와 비동기 에러
Express는 JavaScript의 특성상 비동기 함수 내의 에러를 기본적으로 잡아내지 못합니다. 이는 싱글 스레드와 관련 있는 것이 아니라 JavaScript의 이벤트 루프와 관련이 있습니다. 비동기 코드에서 에러를 핸들링하지 않으면, 이벤트 루프는 해당 에러를 무시하게 됩니다. 이를 해결하기 위해, 비동기 함수에서 try-catch를 사용하는 것이 일반적입니다.
스프링(Spring Framework) 같은 Java 기반의 프레임워크는 다릅니다. 스프링은 멀티스레드 환경을 지원하며, 비동기 작업과 관련하여 더 강력한 에러 관리 기능을 제공합니다. 스프링은 비동기 에러가 발생할 때 그것을 감지하고 처리할 수 있도록 다양한 방법론 및 익셉션 핸들러를 제공합니다.
2. 에러 처리 미들웨어의 순서와 관리
Express의 에러 처리 미들웨어는 일반 미들웨어와 달리 네 개의 인수(err, req, res, next)를 받아야 합니다. Express에서는 미들웨어가 등록된 순서대로 실행되기 때문에, 에러 처리 미들웨어도 일반적으로 가장 마지막에 위치시키는 것이 권장됩니다.
실무에서는 에러 처리 미들웨어를 하나만 두고 애플리케이션 전체를 커버하도록 합니다. 이렇게 하면 중복 코드가 줄어들고, 관리가 훨씬 쉬워집니다. 코드를 더 구조화하기 위해, 공통된 에러 처리 로직을 별도의 파일로 분리하여 import하는 방식으로 구조화하는 것이 보편적입니다.
관련해서 좀 더 자세한 내용을 알고 싶으시다면, 아래의 유사 답변을 참고하실 수 있습니다:
저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다.
현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏
추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.





와...상세한 설명 너무 감사합니다!