서버를 처음 만들 때는 보통 이렇게 생각합니다.
"에러가 안 나면 좋겠다."
물론 좋습니다. 하지만 현실의 서버는 꽤 솔직합니다. 사용자가 이상한 값을 보내고, 없는 URL을 요청하고, JSON 형식을 틀리고, 코드 어딘가에서 undefined에 .map()을 호출해 버립니다. 결국 중요한 건 안 터지는 서버보다 터져도 덜 당황하는 서버입니다.
이번 장에서는 Express에서 에러를 다루는 기본 체력을 만듭니다. 에러 처리 미들웨어가 왜 필요한지, try/catch는 어디에 쓰는지, 상태 코드는 어떻게 고르는지, 그리고 디버깅은 어디서부터 시작하면 되는지를 차근차근 살펴보겠습니다.

에러를 대하는 자세부터 바꾸자
초보자 시절에는 에러를 만나면 내 코드가 완전히 망한 것처럼 느껴집니다. 하지만 서버 개발에서는 에러가 꽤 평범한 일입니다.
예를 들어 이런 상황은 늘 벌어집니다.
사용자가 숫자를 보내야 하는데 문자열을 보낸다.
존재하지 않는 할 일 ID를 조회한다.
요청 본문이 JSON 형식이 아니다.
데이터베이스나 파일 읽기에 실패한다.
코드에서 오타를 내서
ReferenceError가 발생한다.
이 중에는 "사용자가 잘못 요청한 에러"도 있고, "개발자가 고쳐야 할 버그"도 있습니다. 실무에서는 보통 운영 중에 예상 가능한 에러와 프로그래밍 실수로 생긴 에러를 나눠서 생각합니다.1
쉽게 말하면 이렇습니다.
사용자가 없는 ID를 요청해서 404가 나는 건 이상한 일이 아닙니다. 서버가 차분하게 "그건 없습니다"라고 답하면 됩니다. 반면 todos.findd()처럼 메서드 이름을 오타 낸 건 코드 버그이므로, 이건 개발자가 잡아야 합니다.
상태 코드부터 감 잡기
에러를 잘 다루려면 먼저 HTTP 상태 코드가 덜 낯설어야 합니다. 상태 코드는 서버의 표정입니다.
자주 만나는 코드는 이 정도만 먼저 익혀도 충분합니다.
200 OK: 요청이 잘 처리됨201 Created: 새 데이터가 만들어짐400 Bad Request: 요청 형식이나 값이 잘못됨401 Unauthorized: 인증이 필요함403 Forbidden: 인증은 되었지만 권한이 없음404 Not Found: 요청한 자원이 없음500 Internal Server Error: 서버 내부에서 예상 못 한 문제가 발생함
초보자에게 가장 흔한 실수는 모든 에러를 500으로 보내는 것입니다. 그러면 클라이언트는 뭐가 잘못됐는지 알기 어렵습니다.
예를 들어 할 일 수정 API가 있다고 해봅시다.
id가 숫자가 아니면 400그
id에 해당하는 할 일이 없으면 404서버 코드가 깨졌으면 500
같은 "실패"라도 이유가 다르니 상태 코드도 달라야 합니다.
Express는 에러를 어떻게 받는가
Express는 에러를 그냥 공중에 흩뿌리지 않고, 일정한 통로로 흘려보낼 수 있게 설계되어 있습니다. 그 통로의 핵심이 next(err)와 에러 처리 미들웨어입니다.2
동기 코드에서는 throw만 해도 Express가 잡아주는 경우가 있습니다.2
app.get('/boom', (req, res) => {
throw new Error('뭔가 터졌습니다');
})하지만 비동기 코드에서는 버전에 따라 감각이 조금 다릅니다.
Express 4에서는
async/await안의 에러를 보통try/catch로 잡아서next(err)로 넘기는 패턴을 많이 씁니다.Express 5에서는 Promise를 반환하는 라우트나 미들웨어에서 에러가 나면 자동으로 에러 핸들러로 전달됩니다.23
예를 들어 Express 4 스타일은 보통 이렇습니다.
app.get('/boom', async (req, res, next) => {
try {
const data = await someAsyncWork()
res.json(data)
} catch (err) {
next(err)
}
})반면 Express 5에서는 아래처럼 써도 rejected Promise가 에러 핸들러로 전달됩니다.23
app.get('/boom', async (req, res) => {
const data = await someAsyncWork()
res.json(data)
})다만 초보자 입장에서는 try/catch와 next(err) 흐름을 먼저 이해해 두는 편이 안전합니다. 버전이 달라도 에러가 어디로 흘러가는지 감을 잡기 쉽기 때문입니다.
에러 처리 미들웨어는 뭐가 다를까
일반 미들웨어는 보통 (req, res, next) 형태입니다. 그런데 에러 처리 미들웨어는 인자가 4개입니다.
(err, req, res, next)이 네 개짜리 모양을 봐야 Express가 "아, 얘는 에러 담당이구나" 하고 알아챕니다.2
가장 기본적인 형태는 이렇습니다.
app.use((err, req, res, next) => {
console.error(err.stack)
res.status(500).json({
message: '서버 내부 오류가 발생했습니다.'
})
})중요한 규칙이 하나 있습니다. 에러 처리 미들웨어는 라우트들보다 아래, 즉 마지막에 둬야 합니다. 그래야 위에서 발생한 에러를 모아서 처리할 수 있습니다.2
JavaScript로 만드는 기본 에러 처리 구조
이제 초보자에게 딱 맞는 형태로 Express 서버를 정리해 봅시다.
const express = require('express')
const app = express()
app.use(express.json())
const todos = [
{ id: 1, title: 'Express 복습', done: false },
{ id: 2, title: '에러 처리 배우기', done: false }
]
app.get('/todos/:id', (req, res, next) => {
try {
const id = Number(req.params.id)
if (Number.isNaN(id)) {
return res.status(400).json({ message: 'id는 숫자여야 합니다.' })
}
const todo = todos.find((item) => item.id === id)
if (!todo) {
return res.status(404).json({ message: '할 일을 찾을 수 없습니다.' })
}
res.json(todo)
} catch (err) {
next(err)
}
})
app.post('/todos', (req, res, next) => {
try {
const { title } = req.body
if (!title || typeof title !== 'string') {
return res.status(400).json({ message: 'title은 문자열이어야 합니다.' })
}
const newTodo = {
id: todos.length + 1,
title,
done: false
}
todos.push(newTodo)
res.status(201).json(newTodo)
} catch (err) {
next(err)
}
})
app.use((err, req, res, next) => {
console.error('에러 발생:', err.stack)
res.status(500).json({
message: '서버에서 문제가 발생했습니다.'
})
})
app.listen(3000, () => {
console.log('Server running on http://localhost:3000')
})이 코드에서 눈여겨볼 점은 세 가지입니다.
첫째, 사용자 입력 문제는 라우트 안에서 먼저 검사합니다. 이건 400이나 404처럼 비교적 예상 가능한 에러입니다.
둘째, 예상 못 한 문제는 next(err)로 넘깁니다. 그러면 마지막 에러 처리 미들웨어가 한 군데서 정리해 줍니다.
셋째, 클라이언트에게는 너무 자세한 내부 정보를 주지 않습니다. 개발할 때는 스택 트레이스가 반갑지만, 운영 환경에서는 서버 내부 구조를 그대로 보여주면 곤란합니다.2
매번 try/catch를 쓰기 귀찮다면
라우트가 많아지면 try/catch를 매번 쓰는 게 조금 귀찮아집니다. 이럴 때는 비동기 라우트를 감싸는 작은 헬퍼 함수를 만들어 둘 수 있습니다.
const asyncHandler = (handler) => {
return (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next)
}
}이제 이렇게 쓸 수 있습니다.
app.get('/todos/:id', asyncHandler(async (req, res) => {
const id = Number(req.params.id)
if (Number.isNaN(id)) {
return res.status(400).json({ message: 'id는 숫자여야 합니다.' })
}
const todo = todos.find((item) => item.id === id)
if (!todo) {
return res.status(404).json({ message: '할 일을 찾을 수 없습니다.' })
}
res.json(todo)
}))이 패턴은 Promise에서 발생한 에러를 마지막 .catch(next)로 넘기는 방식입니다. 보기에도 깔끔하고, 비슷한 코드를 줄일 수 있습니다. 다만 Express 5에서는 async 라우트의 rejected Promise를 자동으로 처리할 수 있으므로, 프로젝트 버전에 따라 필요성이 달라질 수 있습니다.23
커스텀 에러 객체를 만들면 코드가 편해진다
에러를 다루다 보면 이런 마음이 듭니다.
"이 에러가 404인지 400인지, 매번 직접 적기 귀찮다."
그럴 때는 상태 코드와 메시지를 함께 담는 커스텀 에러를 만들면 좋습니다.
class AppError extends Error {
constructor(statusCode, message) {
super(message)
this.statusCode = statusCode
}
}이제 라우트에서 이렇게 쓸 수 있습니다.
app.get('/todos/:id', (req, res, next) => {
try {
const id = Number(req.params.id)
if (Number.isNaN(id)) {
throw new AppError(400, 'id는 숫자여야 합니다.')
}
const todo = todos.find((item) => item.id === id)
if (!todo) {
throw new AppError(404, '할 일을 찾을 수 없습니다.')
}
res.json(todo)
} catch (err) {
next(err)
}
})그리고 에러 처리 미들웨어에서 상태 코드를 꺼내 쓰면 됩니다.
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500
const message = err.message || '서버 내부 오류가 발생했습니다.'
console.error(err.stack)
res.status(statusCode).json({
success: false,
message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
})
})이렇게 하면 에러 응답 형식도 일정해지고, 라우트 코드도 읽기 쉬워집니다.
에러 응답은 일관성이 생명이다
클라이언트 입장에서 제일 난감한 서버는 어떤 때는 문자열을 보내고, 어떤 때는 HTML을 보내고, 어떤 때는 JSON 구조가 제각각인 서버입니다.
초보 단계에서는 아래처럼 한 가지 형식으로 통일해 두는 게 좋습니다.
{
"success": false,
"message": "할 일을 찾을 수 없습니다."
}개발 환경에서는 여기에 stack을 잠깐 추가해도 좋습니다. 다만 운영 환경에서는 스택 정보를 숨기는 것이 안전합니다.2
예를 들어 이런 형태가 무난합니다.
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500
res.status(statusCode).json({
success: false,
message: err.message || '서버 내부 오류가 발생했습니다.',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
})
})한마디로, 에러 응답도 API의 일부입니다. 삐뚤빼뚤하면 프런트엔드가 운다, 정도로 기억하면 됩니다.
비동기 코드에서 try/catch는 어디까지 잡을까
try/catch는 만능 같아 보여도, 모든 상황을 자동으로 해결해 주지는 않습니다. 기본 원리는 간단합니다.
동기 코드에서는 throw된 에러를 잡을 수 있고, async/await 안에서도 await한 Promise의 실패를 잡을 수 있습니다.3
try {
const result = JSON.parse('{ broken json }')
} catch (err) {
console.error('파싱 실패:', err.message)
}app.get('/example', async (req, res, next) => {
try {
const data = await getData()
res.json(data)
} catch (err) {
next(err)
}
})그런데 콜백 기반 비동기 코드에서는 조금 더 조심해야 합니다.
const fs = require('fs')
app.get('/file', (req, res, next) => {
fs.readFile('./not-found.txt', 'utf-8', (err, data) => {
if (err) {
return next(err)
}
res.send(data)
})
})즉, 콜백의 첫 번째 인자로 들어오는 err를 확인하고 next(err)로 넘기는 패턴을 기억해 두면 됩니다.2
실전에서 자주 만나는 실행 오류들
이제부터는 진짜 도움이 되는 부분입니다. 초보자가 자주 보는 에러와 그 의미를 간단히 읽어 봅시다.
Cannot read properties of undefined
아주 유명한 단골손님입니다.
const user = undefined
console.log(user.name)이 에러는 보통 "있을 줄 알았는데 없었다"는 뜻입니다. 요청 본문을 잘못 읽었거나, 배열에서 찾은 결과가 없는데 바로 속성에 접근했을 때 자주 납니다.
이럴 때는 값을 먼저 확인하세요.
if (!user) {
return res.status(404).json({ message: '사용자를 찾을 수 없습니다.' })
}ReferenceError
변수 이름 오타에서 자주 발생합니다.
console.log(todoss)todos라고 써야 하는데 todoss라고 적은 식입니다. 이런 건 사용자가 고칠 수 있는 문제가 아니므로 500 계열 문제로 보고 개발자가 수정해야 합니다.
SyntaxError
문법이 틀렸거나, JSON 파싱이 실패했을 때 나타납니다.
JSON.parse('{ title: hello }')JSON은 쌍따옴표 규칙이 엄격합니다. 특히 API 테스트 중에 자주 마주칩니다.
TypeError
자료형이 기대와 다를 때 흔히 발생합니다.
const title = 123
title.toUpperCase()문자열 메서드를 숫자에 사용했으니 당연히 화를 냅니다.
ENOENT
파일을 읽으려 했는데 파일이 없을 때 자주 나오는 Node.js 에러 코드입니다.
fs.readFile('./없는파일.txt', 'utf-8', (err, data) => {
if (err) return next(err)
res.send(data)
})이런 에러는 메시지 안에 힌트가 많습니다. 파일 경로, 시스템 호출 이름, 에러 코드가 함께 나옵니다.
디버깅은 추리가 아니라 관찰이다
에러를 고칠 때 가장 흔한 실수는 머릿속으로만 추리하는 것입니다.
"아마 여기겠지?"
그러다 한 시간을 씁니다.
디버깅은 추리보다 관찰에 가깝습니다. 값이 실제로 무엇인지, 코드가 어디까지 실행됐는지, 요청이 어떤 형태로 들어왔는지를 확인해야 합니다.
가장 먼저 할 수 있는 도구는 역시 console입니다.
app.post('/todos', (req, res, next) => {
try {
console.log('req.body:', req.body)
const { title } = req.body
console.log('title:', title, 'type:', typeof title)
if (!title || typeof title !== 'string') {
throw new AppError(400, 'title은 문자열이어야 합니다.')
}
res.status(201).json({ message: 'ok' })
} catch (err) {
next(err)
}
})초보 단계에서는 console.log()가 부끄러운 도구가 아닙니다. 오히려 가장 빠른 도구입니다. 다만 무작정 많이 찍기보다 의심되는 지점 앞뒤로 찍는 게 좋습니다.
이럴 때 특히 유용합니다.
req.params,req.query,req.body내용 확인함수 진입 여부 확인
특정 변수의 타입과 값 확인
반복문이나 조건문이 어디까지 도는지 확인
console.log보다 한 단계 나아가기
콘솔도 종류를 나눠 쓰면 읽기 쉬워집니다.
console.log('일반 확인용 로그')
console.error('에러 로그')
console.warn('경고 로그')
console.table([{ id: 1, title: '공부' }, { id: 2, title: '복습' }])
console.time('db')
console.timeEnd('db')console.table()은 배열 데이터를 볼 때 꽤 보기 좋고, console.time()은 특정 작업이 얼마나 걸리는지 빠르게 확인할 때 유용합니다.
브레이크포인트 디버깅도 꼭 한 번 써보자
콘솔 로그만으로 부족하면 브레이크포인트를 써보면 됩니다. 브레이크포인트는 코드 실행을 잠깐 멈추고, 그 시점의 변수 상태를 들여다보는 기능입니다.
코드에 debugger를 넣을 수 있습니다.
app.get('/debug-me', (req, res) => {
const id = Number(req.query.id)
debugger
res.json({ id })
})그리고 Node를 디버그 모드로 실행합니다.
node --inspect app.js혹은 첫 줄부터 멈추고 싶다면 이렇게 실행합니다.
node --inspect-brk app.js이후 Chrome DevTools나 VS Code에서 연결해서 변수 값을 확인하고, 한 줄씩 실행 흐름을 따라갈 수 있습니다.4
처음 써 보면 약간 영화 속 해커가 된 기분이 들지만, 사실은 그냥 아주 정직한 현미경입니다.
에러 로그에 무엇을 남겨야 할까
에러가 났을 때 무작정 console.log(err)만 찍는 것도 나쁘진 않지만, 조금만 더 정보를 붙이면 나중에 훨씬 편합니다.
예를 들어 이런 정보가 있으면 좋습니다.
언제 발생했는지
어떤 URL에서 발생했는지
어떤 HTTP 메서드였는지
에러 메시지가 무엇인지
스택 트레이스가 무엇인지
간단히 이렇게 남길 수 있습니다.
app.use((err, req, res, next) => {
console.error('--- ERROR START ---')
console.error('time:', new Date().toISOString())
console.error('method:', req.method)
console.error('path:', req.originalUrl)
console.error('message:', err.message)
console.error(err.stack)
console.error('--- ERROR END ---')
res.status(err.statusCode || 500).json({
success: false,
message: err.message || '서버 내부 오류가 발생했습니다.'
})
})프로젝트가 커지면 Pino 같은 로깅 도구를 쓰기도 하지만, 지금 단계에서는 무슨 정보가 디버깅에 필요한지 감을 잡는 것이 더 중요합니다.5
이미 응답을 보내기 시작했다면 조심하자
조금 덜 초보자 같은 주의사항도 하나 알아 둡시다.
응답을 이미 보내기 시작한 뒤에 또 에러가 나면, 에러 처리 미들웨어가 깔끔하게 JSON을 다시 보내지 못할 수 있습니다. Express 공식 문서도 이 점을 주의하라고 설명합니다.2
그래서 커스텀 에러 핸들러에서는 이런 방어 코드를 넣기도 합니다.
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err)
}
res.status(err.statusCode || 500).json({
success: false,
message: err.message || '서버 내부 오류가 발생했습니다.'
})
})지금 당장 스트리밍 응답을 만들 일은 많지 않더라도, "응답은 한 번 시작되면 되돌리기 어렵다"는 감각은 기억해 두면 좋습니다.
TypeScript 버전으로 보면 뭐가 달라질까
자바스크립트에서 하던 흐름은 거의 같습니다. 달라지는 건 타입을 붙여서 실수를 더 빨리 잡는다는 점입니다.
먼저 커스텀 에러 타입을 만들어 봅시다.
export class AppError extends Error {
statusCode: number
constructor(statusCode: number, message: string) {
super(message)
this.statusCode = statusCode
Object.setPrototypeOf(this, AppError.prototype)
}
}그다음 에러 처리 미들웨어에 타입을 붙입니다.
import { NextFunction, Request, Response } from 'express'
import { AppError } from './AppError'
export const errorHandler = (
err: unknown,
req: Request,
res: Response,
next: NextFunction
) => {
if (res.headersSent) {
return next(err)
}
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
message: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
})
}
if (err instanceof Error) {
return res.status(500).json({
success: false,
message: err.message,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
})
}
return res.status(500).json({
success: false,
message: '서버 내부 오류가 발생했습니다.'
})
}여기서 err: unknown으로 받는 이유도 눈여겨볼 만합니다. TypeScript에서는 모든 에러가 자동으로 Error라고 보장되지 않기 때문입니다. 그래서 instanceof로 좁혀서 처리하는 습관이 안전합니다.
라우트도 비슷합니다.
import { Request, Response, NextFunction } from 'express'
import { AppError } from './AppError'
const todos = [
{ id: 1, title: 'TypeScript 연습', done: false },
{ id: 2, title: '에러 다루기', done: true }
]
export const getTodoById = (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const id = Number(req.params.id)
if (Number.isNaN(id)) {
throw new AppError(400, 'id는 숫자여야 합니다.')
}
const todo = todos.find((item) => item.id === id)
if (!todo) {
throw new AppError(404, '할 일을 찾을 수 없습니다.')
}
res.json(todo)
} catch (err) {
next(err)
}
}TypeScript의 장점은 여기서 드러납니다. statusCode가 있는 에러와 없는 에러를 더 분명하게 구분할 수 있고, VS Code가 매개변수와 응답 구조를 더 잘 도와줍니다.
TypeScript에서도 async 에러는 결국 같은 원리다
TypeScript라고 해서 에러가 착해지지는 않습니다. 비동기 에러를 에러 핸들러까지 흐르게 해야 하는 건 똑같습니다.23
export const getTodoAsync = async (
req: Request,
res: Response,
next: NextFunction
) => {
try {
const todo = await findTodoFromSomewhere(req.params.id)
if (!todo) {
throw new AppError(404, '할 일을 찾을 수 없습니다.')
}
res.json(todo)
} catch (err) {
next(err)
}
}즉, TypeScript는 에러 처리를 대신해 주는 도구가 아니라, 에러를 더 일찍 발견하게 도와주는 도구입니다.
프로세스 전체 수준의 예외는 어떻게 볼까
요청-응답 사이클 안에서 발생한 에러는 Express의 에러 처리 미들웨어가 다루는 것이 기본입니다. 그게 가장 먼저 챙겨야 할 안전망입니다.2
그리고 여기서 아주 중요한 점이 하나 있습니다. uncaughtException을 붙여서 프로세스를 계속 살려 두는 것은 권장되지 않습니다. Express 공식 운영 가이드는 이런 방식이 프로세스를 불안정하고 예측 불가능한 상태로 남길 수 있다고 설명합니다.3
즉, 초보자 단계에서는 이렇게 기억하는 편이 좋습니다.
먼저 라우트와 미들웨어 안에서 에러를 제대로 처리한다.
에러 핸들러를 마지막에 둔다.
프로세스 전체가 무너질 정도의 예외는 "억지로 계속 돌리기"보다 원인을 고치고, 필요하면 프로세스 매니저가 재시작하게 하는 쪽이 더 안전하다.3
이 감각이 중요합니다. 마지막 경보 장치가 있다고 해서, 고장 난 자동차를 계속 달리게 만들면 안 되는 것과 비슷합니다.
초보자를 위한 디버깅 순서
서버가 이상할 때는 감정적으로 달려들기보다 순서대로 보면 좋습니다.
에러 메시지를 끝까지 읽는다.
스택 트레이스에서 내 파일과 줄 번호를 찾는다.
req.params,req.query,req.body를 확인한다.문제 지점 바로 전후에
console.log()를 넣는다.값의 타입까지 확인한다.
그래도 모르겠으면 브레이크포인트로 멈춰 본다.
많은 초보자가 첫 줄만 보고 바로 검색합니다. 하지만 에러 메시지에는 생각보다 힌트가 많습니다. 특히 message, name, stack은 디버깅의 삼총사입니다.4
이번 장의 핵심 정리
에러 처리는 서버를 우아하게 만드는 기술입니다. 완벽한 서버를 만드는 기술이라기보다, 문제가 생겼을 때 서버가 무너지지 않고 설명 가능하게 만드는 기술에 가깝습니다.
이번 장에서 꼭 가져가야 할 감각은 이렇습니다.
400, 404, 500을 구분해서 보내자.
예상 가능한 에러와 버그를 구분하자.
next(err)로 에러를 중앙으로 모으자.에러 처리 미들웨어는 마지막에 둔다.
개발 중에는 스택을 보고, 운영에서는 내부 정보를 숨긴다.
디버깅은 추측이 아니라 관찰이다.
uncaughtException같은 전역 예외로 프로세스를 억지로 살려 두는 방식은 좋은 기본기가 아니다.3
서버는 언젠가 꼭 한 번은 삐끗합니다. 그런데 그 순간 "왜 이러지?" 하며 얼어붙는 사람과, 로그를 열고 상태 코드를 보고 차분히 따라가는 사람의 차이는 꽤 큽니다.
이 장의 목표는 서버를 절대 안 터지게 만드는 게 아니었습니다.
터졌을 때 덜 당황하는 법.
그게 바로 기초 체력입니다.
참고
1Express Error Handling Patterns
3Express Production Best Practices: Performance and Reliability
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.