Skip to main content
Summary

오류 메시지는 서버가 보내는 단서다

서버를 만들다 보면 빨간 글씨를 자주 만납니다. 처음에는 이 글씨가 "실패했다"는 말처럼 보입니다. 하지만 오류 메시지는 벌점표가 아니라 단서입니다. 서버가 멈춘 위치, 찾지 못한 파일, 이미 보낸 응답 같은 정보를 우리에게 알려 줍니다.

이번 장에서 만들 것은 오류를 일부러 확인해 보고, 읽는 순서를 익히는 작은 Express 서버입니다. 목적은 모든 오류를 외우는 것이 아닙니다. 오류가 났을 때 다음 순서로 원인을 좁히는 습관을 만드는 것입니다.

  1. 터미널의 핵심 문장을 찾는다.

  2. 파일명과 줄 번호를 찾는다.

  3. 최근에 고친 코드를 확인한다.

  4. 요청이 어느 라우트와 미들웨어를 지나갔는지 따라간다.

오류를 만나는 일은 정상적인 학습 과정입니다. 중요한 것은 빨간 글씨를 없애는 속도가 아니라, 빨간 글씨를 읽는 방향입니다.

오류가 났을 때 당황하지 않는 법AI 생성 이미지

터미널 오류 메시지를 읽는 순서

무엇을 만들고 있는지부터 정리하겠습니다. 지금은 서버가 오류를 낼 때, 터미널에서 어디를 봐야 하는지 연습합니다.

열 파일은 프로젝트 폴더의 app.js입니다. 서버는 다음 명령으로 실행합니다.

node app.js

오류가 나면 터미널에 여러 줄이 한꺼번에 나옵니다. 이때 처음부터 끝까지 모두 이해하려고 하면 지칩니다. 먼저 세 가지만 찾습니다.

Error: Cannot find module 'express'
    at ...
    at Object.<anonymous> (/Users/me/express-practice/app.js:1:17)

첫째, 핵심 문장입니다.

Cannot find module 'express'

이 문장은 "express라는 모듈을 찾지 못했다"는 뜻입니다.

둘째, 파일명입니다.

app.js

오류가 어느 파일에서 시작되었는지 알려 줍니다.

셋째, 줄 번호와 칸 번호입니다.

app.js:1:17

app.js의 1번째 줄, 17번째 칸 근처에서 문제가 시작되었다는 뜻입니다. 줄 번호는 범인이라기보다 출발점입니다. 그 줄에서 불러온 파일이나 그 줄 위아래의 코드가 원인일 수 있습니다.

오류를 읽을 때는 "빨간 글씨 전체"가 아니라 "핵심 문장 → 파일명 → 줄 번호" 순서로 봅니다.

Cannot find module: 필요한 물건을 못 찾는 오류

Cannot find module은 서버가 필요한 도구나 파일을 찾지 못할 때 자주 나옵니다. 예를 들어 app.js에 이런 코드가 있다고 하겠습니다.

const express = require('express');

이 줄은 "설치된 Express를 가져와서 express라는 이름으로 쓰겠다"는 뜻입니다. 그런데 Express가 설치되어 있지 않거나, 엉뚱한 폴더에서 다른 파일을 실행하고 있으면 다음과 비슷한 오류가 납니다.

Error: Cannot find module 'express'

확인 순서는 다음과 같습니다.

  1. 터미널이 프로젝트 폴더 안에 있는지 확인합니다.

pwd

Windows PowerShell에서는 다음 명령을 사용할 수 있습니다.

Get-Location
  1. package.json이 있는지 확인합니다.

ls

Windows PowerShell에서는 다음 명령도 가능합니다.

dir
  1. Express를 설치합니다.

npm install express

성공하면 package.json의 dependencies 안에 express가 보입니다. 다시 서버를 실행합니다.

node app.js

만약 오류 메시지가 Cannot find module './routes/reservations'처럼 점(.)이나 슬래시(/)가 들어간 파일 경로를 가리킨다면, 설치 문제가 아니라 파일 이름이나 폴더 위치 문제일 가능성이 큽니다. 그때는 실제 파일 이름, 대소문자, 경로를 먼저 확인합니다.

이 오류는 코드 실력이 부족해서 생기는 문제가 아닙니다. 서버가 "필요한 물건이 작업실에 없다"고 말하는 상황입니다. 작업실 위치와 설치 상태, 파일 경로를 차례대로 확인하면 됩니다.

req.body is undefined: 요청 본문을 아직 해석하지 못한 오류

폼이나 JSON API를 만들 때 자주 만나는 문제가 있습니다.

TypeError: Cannot read properties of undefined

또는 코드에서 req.body를 찍었을 때 다음처럼 보일 수 있습니다.

undefined

req.body는 손님이 들고 온 신청서의 내용과 비슷합니다. 그런데 서버는 신청서를 자동으로 읽지 못합니다. "이 신청서는 JSON이다", "이 신청서는 HTML 폼이다"라고 먼저 알려 주어야 합니다.

JSON 요청을 받을 때는 라우트보다 위에 이 미들웨어가 있어야 합니다.

app.use(express.json());

HTML 폼 제출을 받을 때는 이 미들웨어가 필요합니다.

app.use(express.urlencoded({ extended: true }));

중요한 점은 순서입니다.

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.post('/reservations', (req, res) => {
  console.log(req.body);
  res.json({ message: '예약을 받았습니다.' });
});

미들웨어는 요청이 지나가는 안내 데스크입니다. 안내 데스크를 라우트 아래에 두면, 손님은 이미 접수 창구에 도착한 뒤입니다. 그래서 req.body를 쓰는 라우트보다 먼저 등록해야 합니다. Express의 미들웨어는 등록된 순서대로 실행되며, 다음 단계로 넘기려면 next()를 호출하거나 응답을 끝내야 합니다.1

성공하면 POST 요청을 보냈을 때 터미널에 다음처럼 내용이 찍힙니다.

{ name: '민지', date: '2026-07-01' }

값이 보이지 않는다면 요청 도구에서 Content-Type이 application/json인지 확인합니다. HTML 폼이라면 <input>에 name 속성이 빠지지 않았는지도 함께 봅니다. name="email"처럼 이름표가 있어야 서버가 그 값을 req.body.email처럼 꺼낼 수 있습니다.

EADDRINUSE: 이미 누가 그 포트를 쓰고 있다는 뜻

서버를 실행했는데 이런 오류가 나올 수 있습니다.

Error: listen EADDRINUSE: address already in use :::3000

이 문장은 어렵게 보이지만 뜻은 단순합니다. 3000번 접수 창구가 이미 사용 중이라는 뜻입니다. 보통 이전에 실행한 서버가 아직 꺼지지 않았을 때 생깁니다.

먼저 터미널에서 서버를 종료합니다.

Ctrl + C

그리고 다시 실행합니다.

node app.js

터미널 창을 여러 개 열어 두었다면 다른 창에서 같은 서버가 실행 중일 수 있습니다. 그 창을 찾아 Ctrl + C로 끕니다.

당장 3000번을 쓸 수 없다면 포트 번호를 바꾸어도 됩니다.

app.listen(3001, () => {
  console.log('서버가 http://localhost:3001 에서 실행 중입니다.');
});

브라우저 주소도 함께 바꾸어야 합니다.

http://localhost:3001

포트 오류는 코드 내용보다 실행 상태와 관련된 경우가 많습니다. "서버가 몇 개 켜져 있나?"를 먼저 확인합니다.

Cannot set headers after they are sent: 응답을 두 번 보낸 오류

Express에서 한 요청에는 응답을 한 번만 보내야 합니다. 손님 한 명에게 주문 결과를 이미 말했는데, 잠시 뒤 또 다른 결과를 말하려는 상황과 비슷합니다.

다음 코드는 문제가 있습니다.

app.post('/reservations', (req, res) => {
  const { name } = req.body;

  if (!name) {
    res.status(400).json({ message: '이름은 필수입니다.' });
  }

  res.json({ message: '예약이 완료되었습니다.' });
});

name이 없으면 400 응답을 보냅니다. 그런데 함수가 거기서 멈추지 않고 아래의 성공 응답까지 실행합니다. 그래서 응답을 두 번 보내려 합니다.

해결 방법은 응답 뒤에 return을 붙이는 것입니다.

app.post('/reservations', (req, res) => {
  const { name } = req.body;

  if (!name) {
    return res.status(400).json({ message: '이름은 필수입니다.' });
  }

  return res.json({ message: '예약이 완료되었습니다.' });
});

return은 여기서 "응답을 보냈으니 이 라우트의 일을 끝낸다"는 표시입니다. 특히 검증 실패, 권한 없음, 데이터 없음처럼 중간에 응답을 보내는 코드에서는 return 습관이 큰 도움이 됩니다.

404와 500은 서로 다른 문제다

오류 응답을 볼 때는 404와 500을 구분해야 합니다.

404는 "그 주소를 찾지 못했다"는 뜻입니다. 예를 들어 서버에 /products 라우트만 있는데 브라우저가 /product로 요청하면 404가 맞습니다.

app.get('/products', (req, res) => {
  res.json({ message: '상품 목록입니다.' });
});

브라우저에서 다음 주소로 가면 성공합니다.

http://localhost:3000/products

하지만 다음 주소는 다릅니다.

http://localhost:3000/product

s 하나가 빠졌습니다. 이때는 서버 내부가 고장 난 것이 아니라, 맞는 접수 창구를 찾지 못한 것입니다.

404 처리는 보통 모든 라우트 아래에 둡니다.

app.use((req, res) => {
  res.status(404).json({ message: '요청한 주소를 찾을 수 없습니다.' });
});

500은 "주소는 찾았지만 서버 안에서 처리하다가 문제가 생겼다"는 뜻입니다. 예를 들어 JSON 파일을 읽다가 실패했거나, 코드에서 예상하지 못한 값에 접근했을 수 있습니다.

404는 주소 문제에 가깝고, 500은 서버 내부 처리 문제에 가깝습니다. 이 둘을 구분하면 확인할 곳이 줄어듭니다.

오류 처리 미들웨어 만들기

Express에는 오류를 마지막에 모아 처리하는 특별한 미들웨어가 있습니다. 일반 미들웨어가 보통 (req, res, next)를 받는다면, 오류 처리 미들웨어는 앞에 err가 하나 더 붙습니다.

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ message: '서버에서 문제가 발생했습니다.' });
});

네 인자의 역할은 다음과 같습니다.

  • err: 앞에서 넘어온 오류 정보입니다.

  • req: 어떤 요청에서 문제가 생겼는지 확인할 수 있는 요청 정보입니다.

  • res: 클라이언트에게 오류 응답을 보낼 때 사용합니다.

  • next: 이 오류를 다음 오류 처리 미들웨어로 넘길 때 사용합니다.

Express는 오류 처리 미들웨어를 구분할 때 이 네 인자 형태를 사용합니다. 그래서 (err, req, res, next) 모양을 지켜야 하며, 보통 모든 라우트와 404 처리 아래쪽에 둡니다.2

전체 순서는 이렇게 잡으면 됩니다.

app.get('/products', (req, res) => {
  res.json({ message: '상품 목록입니다.' });
});

app.use((req, res) => {
  res.status(404).json({ message: '요청한 주소를 찾을 수 없습니다.' });
});

app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ message: '서버에서 문제가 발생했습니다.' });
});

동기 코드에서 발생한 오류는 Express가 처리 흐름으로 가져올 수 있습니다. 파일 읽기처럼 나중에 결과가 돌아오는 비동기 작업의 오류는 next(err)로 넘겨야 오류 처리 미들웨어가 받을 수 있습니다.2

예를 들어 파일 읽기에서 실패하면 이렇게 넘깁니다. 아래 코드를 실제로 사용할 때는 파일 위쪽에 const fs = require('fs');도 함께 있어야 합니다.

const fs = require('fs');

app.get('/reservations', (req, res, next) => {
  fs.readFile('reservations.json', 'utf-8', (err, data) => {
    if (err) {
      return next(err);
    }

    const reservations = JSON.parse(data);
    return res.json(reservations);
  });
});

여기서 next(err)는 "이 오류는 일반 응답으로 처리하지 말고, 아래쪽의 오류 처리 담당자에게 넘겨 달라"는 뜻입니다.

console.log는 흐름을 따라 필요한 곳에만 찍는다

오류가 나면 console.log를 많이 넣고 싶어집니다. 기록을 남기는 것은 좋습니다. 다만 너무 많이 찍으면 오히려 중요한 정보가 묻힙니다.

먼저 요청이 들어왔는지 확인합니다.

app.use((req, res, next) => {
  console.log(`[요청] ${req.method} ${req.url}`);
  next();
});

그다음 문제가 의심되는 라우트 안에서 필요한 값만 확인합니다.

app.post('/reservations', (req, res) => {
  console.log('[예약 요청 body]', req.body);

  const { name, date } = req.body;

  if (!name || !date) {
    return res.status(400).json({ message: '이름과 날짜가 필요합니다.' });
  }

  return res.status(201).json({ message: '예약이 저장되었습니다.' });
});

좋은 기록은 질문에 답합니다.

  • 요청이 서버에 도착했나?

  • 어떤 주소와 방식으로 왔나?

  • req.body에 값이 들어왔나?

  • 조건문에서 어느 쪽으로 갔나?

  • 응답은 한 번만 보냈나?

반대로 개인정보, 비밀번호, 토큰 같은 값은 터미널에 찍지 않습니다. 학습용 서버라도 이 습관은 중요합니다.

오류를 만났을 때의 짧은 점검표

서버가 멈추거나 예상과 다르게 응답한다면 다음 순서로 확인합니다.

  1. 터미널에서 핵심 문장을 찾습니다.

  2. 파일명과 줄 번호를 확인합니다.

  3. 방금 수정한 코드를 되돌아봅니다.

  4. npm install이 필요한 오류인지 확인합니다.

  5. express.json() 또는 express.urlencoded()가 라우트보다 위에 있는지 봅니다.

  6. 같은 포트로 서버를 두 번 켜지 않았는지 확인합니다.

  7. 한 요청에서 res.json()이나 res.send()를 두 번 호출하지 않았는지 봅니다.

  8. 주소 문제라면 404, 서버 처리 문제라면 500으로 나누어 생각합니다.

오류를 잘 다루는 사람은 오류가 안 나는 사람이 아닙니다. 오류가 났을 때 읽는 순서를 가진 사람입니다. 이 순서가 생기면 Express 서버는 더 이상 검은 상자가 아닙니다. 요청이 어디로 들어와서, 어디를 지나고, 어디에서 멈췄는지 차분히 따라갈 수 있습니다.

참고

1Express 미들웨어 사용 가이드

2Express 오류 처리 가이드

책 목록

비개발자를 위한 Node.js Express 서버 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기