Menu

Express 에러 처리: 에러 미들웨어와 비동기 에러

Express 에러 핸들러는 라우트 뒤에 등록하는, 인자가 네 개인 (err, req, res, next) 미들웨어입니다. Express 5는 던져진 에러, next(err) 호출, async 핸들러에서 reject된 Promise를 그곳으로 보냅니다.

이 페이지에는 실행 가능한 에디터가 있습니다. 편집하고 실행하면 결과를 바로 볼 수 있습니다.

Express는 인자 네 개 (err, req, res, next)를 받고 모든 라우트 뒤에 등록하는 특별한 미들웨어로 에러를 처리합니다. Express는 라우트 안에서 던져진 모든 에러, next(err)에 넘긴 모든 에러, 그리고 Express 5에서는 async 핸들러에서 reject된 모든 Promise를 그곳으로 보냅니다. 핸들러가 상태 코드를 정하고 에러 응답 하나를 보내므로, 나머지 코드는 그냥 던지기만 하면 됩니다.

GET /users/99는 404와 {"error":"No user with id 99"}로 응답하고, Console에는 에러 핸들러가 남긴 줄이 보입니다. GET /users/1을 보내면 라우트가 평소처럼 응답합니다. 에러 핸들러는 무언가 잘못됐을 때만 실행됩니다.

Express가 대신 잡아 주는 에러

라우트에서 에러 핸들러에 닿는 방법은 세 가지이고, Express 5에서는 셋 다 똑같이 동작합니다.

GET /async는 500과 {"error":"Rejected in an async route"}로 응답하고, GET /sync와 GET /next도 각자의 메시지로 같은 핸들러에 도착합니다. 이제 에러 핸들러의 매개변수에서 next를 지우고 GET /sync를 다시 보내 보세요. 매개변수가 세 개면 Express는 그것을 일반 미들웨어로 보고 건너뛴 뒤, 기본 HTML 에러 페이지로 응답합니다.

비동기 에러: Express 5 vs Express 4

/async 라우트가 동작하는 것은 Express 5가 reject된 Promise를 잡기 때문입니다. Express 4는 잡지 않았습니다. 에러는 처리되지 않은 reject가 되고 요청은 멈춰 있었습니다.

Express 4Express 5
동기 핸들러에서 throw잡음잡음
next(err)잡음잡음
async 핸들러에서 reject된 Promise못 잡음, 요청이 멈춤잡음
setTimeout이나 이벤트 콜백 안의 에러못 잡음, 프로세스 종료못 잡음, 프로세스 종료

Express 4에서는 async 핸들러마다 감싸거나 express-async-errors 패키지를 설치하세요.

// Express 4 only
const asyncHandler = (fn) => (req, res, next) =>
    Promise.resolve(fn(req, res, next)).catch(next);

app.get('/users/:id', asyncHandler(async (req, res) => {
    const user = await db.findUser(req.params.id);
    res.json(user);
}));

마지막 줄은 두 버전이 같습니다. Express는 자신이 호출하지 않은 콜백 안에서 던져진 에러를 볼 수 없습니다. 콜백 API는 Promise로 바꾸고(Node.js 대부분에는 fs/promises 같은 Promise 버전이 있습니다) await하세요.

404 핸들러와 일관된 JSON 에러

모든 에러가 같은 모양이면 API를 쓰기 쉬워집니다. 에러에 status를 주고, 일치하지 않는 라우트를 404 에러로 바꾸고, 핸들러 하나가 모두를 포맷하게 하세요.

GET /nope는 어떤 라우트와도 일치하지 않으므로 404 핸들러가 그것을 에러로 바꾸고, 응답은 {"error":"Cannot GET /nope","status":404}입니다. GET /users/99를 보내면 라우트가 던진 에러도 같은 모양이 됩니다. 본문 {"name": }으로 POST /users를 보내면 express.json()이 파싱에 실패하고 status가 400인 에러로 next()를 호출하므로, 응답은 {"error":"Unexpected token '}', \"{\"name\": }\" is not valid JSON","status":400}입니다.

스택 트레이스를 노출하지 마세요

직접 만든 HttpError의 메시지는 클라이언트를 위해 쓴 것입니다. 그 밖의 모든 것(데이터베이스 드라이버, TypeError를 던지는 오타)에서 온 메시지와 스택은 파일 경로, 쿼리, 라이브러리 버전을 드러낼 수 있습니다. 위의 핸들러는 모든 5xx에 Internal server error를 보내고, 실제 에러는 대신 서버에 기록합니다. if (res.headersSent) return next(err);는 응답 일부가 이미 전송된 뒤에 생긴 에러를 처리합니다. 그때 안전한 방법은 Express가 연결을 닫게 하는 것뿐입니다.

Express의 기본 핸들러는 NODE_ENV를 따릅니다. NODE_ENV가 production이 아니면 HTML 페이지에 스택 트레이스를 넣습니다. 운영 서버에서는 설정하세요(NODE_ENV=production node server.js). 하지만 그것에 기대지는 마세요. err.stack을 절대 보내지 않는 커스텀 핸들러가 더 안전합니다.

흔한 실수

에러 핸들러를 라우트보다 먼저 등록하는 것. Express는 에러를 앞으로만 넘기므로, 라우트 위에 있는 핸들러는 그 라우트의 에러를 보지 못합니다. 404 핸들러 다음, 맨 마지막에 두세요.

에러를 잡고 아무것도 하지 않는 것. 로그만 남기는 try/catch는 요청을 붙잡아 둡니다. catch에서 next(err)를 호출하거나, 다시 던지거나, 응답을 보내세요.

응답을 두 번 보내는 것. res.json() 뒤에 에러를 던지면 에러 핸들러가 다시 보내려고 합니다. 예제처럼 res.headersSent를 확인하세요.

일반 미들웨어와 next()의 동작은 Express 미들웨어를 참고하세요.

자주 묻는 질문

Express에서 에러 핸들러를 여러 개 둘 수 있나요?

네. 순서대로 실행되고, 각각 응답을 보내거나 next(err)를 호출해 에러를 넘깁니다. 흔한 방식은 로그를 남기는 핸들러와 응답을 보내는 핸들러로 나누는 것입니다.

에러 핸들러가 에러를 던지면 어떻게 되나요?

Express는 새 에러를 다음 에러 핸들러에 넘기고, 없으면 내장 핸들러에 넘겨 500 페이지를 보냅니다. 에러 핸들러 자체가 실패하지 않도록 단순하게 유지하세요.

에러 응답에는 어떤 상태 코드를 써야 하나요?

클라이언트가 잘못 보냈다면 4xx(400 잘못된 입력, 401 로그인 안 됨, 403 권한 없음, 404 없음, 409 충돌), 내 코드가 실패했다면 500을 쓰세요. 본문에 에러를 담아 200으로 응답하는 일은 절대 하지 마세요.

여러 필드의 검증 에러는 어떻게 반환하나요?

목록과 함께 400으로 응답하세요. 예를 들면 { "errors": [{ "field": "email", "message": "is required" }] }입니다. zod를 쓴다면 에러 핸들러에서 result.error.issues를 이 모양으로 바꿔, 모든 라우트가 잘못된 입력을 같은 방식으로 알리게 하세요.

Coddy 프로그래밍 언어 일러스트

Coddy로 코딩 배우기

시작하기