Study/Node.JS

[Express] Express 미들웨어 & 에러 핸들링

the.Dev.Cat 2026. 4. 8. 12:27
Node.js 워크북 — Express 미들웨어 & 에러 핸들링

1. 미들웨어 개념과 동작 원리

Express에서 미들웨어는 요청(req)과 응답(res) 사이에서 동작하는 독립적인 함수다. 클라이언트가 서버로 요청을 보내면, 그 요청이 라우트 핸들러에 도달하기 전에 미들웨어들이 순서대로 실행된다. HTTP 응답이 완료될 때까지 이 미들웨어 사이클이 계속된다.

req, res, next 역할

인자 역할
req클라이언트가 보낸 요청 객체. URL, 헤더, 바디, 쿠키 등 요청 정보를 담는다.
res서버가 클라이언트에게 보낼 응답 객체. res.json(), res.send() 등으로 응답을 완성한다.
next다음 미들웨어로 실행을 넘기는 함수. 호출하지 않으면 체인이 멈춘다.

myLogger 미들웨어 예제

import express from 'express';

const app = express();
const port = 3000;

const myLogger = (req, res, next) => {
    console.log('LOGGED');
    next();
};

app.use(myLogger);

app.get('/', (req, res) => {
    console.log('/');
    res.send('Hello UMC!');
});

app.get('/hello', (req, res) => {
    console.log('/hello');
    res.send('Hello world!');
});

app.listen(port, () => {
    console.log(`Example app listening on port ${port}`);
});

app.use(myLogger)로 등록하면 모든 요청마다 LOGGED가 콘솔에 찍힌다. next()를 호출해야만 다음 미들웨어 또는 라우트 핸들러로 실행이 넘어간다. next()를 생략하면 요청이 그 자리에서 멈추고 응답이 내려오지 않는다.

미들웨어 체인 흐름도

Request
클라이언트
morgan
로깅
express.json
바디 파싱
isLogin
인증 체크
Route
Handler
Response
응답
인증 실패 시 next(error)
Error Handler
전역 에러 핸들러

미들웨어는 app.use()에 등록된 순서대로 실행된다. 각 미들웨어에서 next()를 호출해야 다음 단계로 넘어간다. 에러가 발생해 next(err)를 호출하면 일반 미들웨어를 건너뛰고 에러 핸들링 미들웨어로 바로 이동한다.

2. 자주 쓰는 미들웨어

morgan — 요청 로깅

클라이언트의 요청과 서버의 응답 정보를 자동으로 콘솔에 기록해주는 미들웨어다. [HTTP 메서드] [주소] [상태 코드] [응답 속도] - [응답 바이트] 형태로 출력되어 개발 중 디버깅에 유용하다. dev 포맷은 개발 환경에, combined 포맷은 배포 환경에 주로 쓴다.

npm install morgan
import express from 'express';
import morgan from 'morgan';

const app = express();
app.use(morgan('dev'));

app.get('/test', (req, res) => {
  res.send('Hello!');
});

cookie-parser — 쿠키 파싱

브라우저는 쿠키를 서버로 보낼 때 하나의 긴 문자열 형태(userId=123; theme=dark)로 전송한다. 이를 직접 파싱하려면 ;=로 분리하는 귀찮은 작업이 필요하다. cookie-parser는 이 과정을 자동으로 처리해 req.cookies 객체로 바로 꺼내 쓸 수 있게 해준다.

npm install cookie-parser
import cookieParser from 'cookie-parser';

app.use(cookieParser());

app.get('/getcookie', (req, res) => {
    const myCookie = req.cookies.myCookie;
    res.send(`당신의 쿠키: ${myCookie}`);
});

express.json() / express.urlencoded()

클라이언트가 요청 바디에 데이터를 담아 보내도, Express는 기본적으로 이를 해석하는 기능이 없다. 이 미들웨어들이 없으면 req.bodyundefined로 남는다. 두 미들웨어는 예전에는 별도 패키지(body-parser)였지만 지금은 Express에 내장되어 있어 추가 설치 없이 사용할 수 있다.

app.use(express.json());
app.use(express.urlencoded({ extended: false }));
미들웨어 파싱 대상 Content-Type
express.json()JSON 형태의 요청 바디application/json
express.urlencoded()HTML <form> 형태의 요청 바디application/x-www-form-urlencoded

3. 라우트별 미들웨어 응용

전역 미들웨어 vs 라우트별 미들웨어

app.use(fn)으로 등록하면 모든 요청에 대해 미들웨어가 실행된다. 반면 특정 라우트에만 미들웨어를 적용하고 싶다면 라우트 메서드의 두 번째 인자로 직접 넘기면 된다.

방식 적용 범위 예시
전역 미들웨어모든 요청app.use(morgan('dev'))
라우트별 미들웨어특정 라우트만app.get('/mypage', isLogin, handler)

isLogin 인증 체크 미들웨어

로그인한 사용자만 접근할 수 있는 라우트를 보호할 때 활용한다. 쿠키에 사용자 정보가 있으면 next()로 다음 단계로 진행하고, 없으면 401 응답으로 차단한다.

const isLogin = (req, res, next) => {
    const { username } = req.cookies;

    if (username) {
        next();
    } else {
        res.status(401).send('로그인이 필요합니다.');
    }
};

app.get('/', (req, res) => {
    res.send('메인 페이지 (누구나 접근 가능)');
});

app.get('/mypage', isLogin, (req, res) => {
    res.send(`마이페이지: ${req.cookies.username}님 환영합니다.`);
});

/mypage 라우트에만 isLogin이 끼워져 있다. 메인 페이지(/)는 누구나 접근할 수 있고, 마이페이지는 쿠키에 username이 있는 경우에만 통과된다. 실제 서비스에서는 쿠키 값을 위조할 수 있으므로 JWT 기반 인증을 사용한다.

4. API 응답 표준화

API 응답 형태가 제각각이면 프론트엔드 개발자가 매 API마다 다른 방식으로 처리해야 한다. 성공은 "OK", "Success", "success" 등 중 무엇인지 알 수 없고, 에러 메시지가 어디에 담겨오는지도 다르다. 이를 해결하기 위해 응답 구조를 하나의 형태로 통일한다.

표준 응답 구조

필드 타입 설명
resultType"SUCCESS" | "FAIL"요청 성공 여부
error객체 | null실패 시 에러 정보. 성공이면 null
error.errorCodestring에러 식별 코드 (예: "U001")
error.reasonstring에러 원인 메시지
error.data객체 | null추가 디버깅 데이터
success객체 | null성공 시 응답 데이터. 실패면 null

성공 응답 예시

{
  "resultType": "SUCCESS",
  "error": null,
  "success": {
    "email": "test@example.com",
    "name": "엘빈",
    "preferCategory": ["과일", "생선"]
  }
}

실패 응답 예시

{
  "resultType": "FAIL",
  "error": {
    "errorCode": "U001",
    "reason": "이미 존재하는 이메일입니다.",
    "data": null
  },
  "success": null
}

5. res.success / res.error 헬퍼 미들웨어

모든 컨트롤러에서 응답 구조를 직접 조립하면 코드가 중복되고 실수가 생긴다. res 객체에 헬퍼 메서드를 추가하는 미들웨어를 앱 최상단에 등록하면, 이후 모든 라우트에서 간편하게 사용할 수 있다.

src/index.js — 헬퍼 미들웨어 등록

app.use((req, res, next) => {
    res.success = (success) => {
        return res.json({ resultType: 'SUCCESS', error: null, success });
    };

    res.error = ({ errorCode = 'unknown', reason = null, data = null }) => {
        return res.json({
            resultType: 'FAIL',
            error: { errorCode, reason, data },
            success: null,
        });
    };

    next();
});

이 미들웨어는 반드시 라우트보다 먼저 등록해야 한다. 등록 후부터는 모든 컨트롤러에서 아래처럼 간단하게 응답할 수 있다.

src/controllers/user.controller.js

import { StatusCodes } from 'http-status-codes';
import { bodyToUser } from '../dtos/user.dto.js';
import { userSignUp } from '../services/user.service.js';

export const handleUserSignUp = async (req, res, next) => {
    const user = await userSignUp(bodyToUser(req.body));
    res.status(StatusCodes.OK).success(user);
};

6. 커스텀 Error 클래스

전역 에러 핸들러가 err.errorCode를 꺼내 쓰려면, 에러 객체에 errorCode 속성이 있어야 한다. JavaScript 기본 Error에는 이 속성이 없으므로 직접 상속받아 커스텀 에러 클래스를 만든다.

에러 코드는 도메인별로 접두사를 붙이는 방식이 일반적이다. 유저 관련은 U, 인증 관련은 A 등으로 구분한다.

src/errors.js

export class DuplicateUserEmailError extends Error {
    errorCode = 'U001';

    constructor(reason, data) {
        super(reason);
        this.reason = reason;
        this.data = data;
    }
}

errorCode = "U001"은 클래스 필드로 고정 값을 선언한다. constructor에서 super(reason)을 호출해 기본 Errormessage를 설정하고, 추가로 reasondata를 인스턴스에 담는다.

src/services/user.service.js — 커스텀 에러 던지기

import { DuplicateUserEmailError } from '../errors.js';

export const userSignUp = async (db, data) => {
    const joinUserId = await addUser(db, data);

    if (joinUserId === null) {
        throw new DuplicateUserEmailError('이미 존재하는 이메일입니다.', data);
    }

    ...
};

서비스 레이어에서 에러가 발생하면 직접 try/catch하지 않고 그냥 던진다. 잡는 것은 전역 에러 핸들러의 역할이다.

7. 전역 에러 핸들링 미들웨어

Express는 인자가 정확히 4개인 미들웨어를 에러 핸들러로 인식한다. 첫 번째 인자가 err이다. 이 미들웨어는 모든 라우트 등록이 끝난 뒤 마지막에 등록해야 한다.

src/index.js — 전역 에러 핸들러

app.use((err, req, res, next) => {
    if (res.headersSent) {
        return next(err);
    }

    res.status(err.statusCode || 500).error({
        errorCode: err.errorCode || 'unknown',
        reason: err.reason || err.message || null,
        data: err.data || null,
    });
});

res.headersSent는 이미 응답이 전송 중인 경우를 방어한다. 커스텀 에러 클래스에 errorCode가 있으면 그 값을 사용하고, 없으면 "unknown"으로 대체한다.

에러 핸들링 분기 흐름도

Route Handler
라우트 핸들러
throw Error
전역 에러 핸들러
(err, req, res, next)
errorCode 분기
err.errorCode 확인
↙ U001 기타 ↘
이메일 중복 응답
errorCode: "U001"
일반 에러 응답
errorCode: "unknown"

errorCode를 분리하는 이유

모든 에러를 "unknown"으로만 응답하면 프론트엔드는 "서버 오류가 발생했습니다"라는 말 외에 사용자에게 아무것도 알려줄 수 없다. 구체적인 errorCode를 내려주면 클라이언트가 분기 처리를 할 수 있다.

  • U001을 받으면 "이미 존재하는 이메일입니다." 안내
  • U002를 받으면 "비밀번호는 8자 이상이어야 합니다." 안내
  • A001을 받으면 로그인 페이지로 리다이렉트

에러 코드는 HTTP 상태 코드(400, 401, 500)와는 별개다. HTTP 상태 코드는 요청 처리의 대략적인 결과를 나타내고, errorCode는 비즈니스 로직 레벨의 세부 사유를 식별한다. 두 가지를 함께 사용해야 클라이언트가 에러를 정확히 이해하고 적절히 처리할 수 있다.

미들웨어는 등록 순서가 실행 순서다. 헬퍼 미들웨어는 라우트보다 앞에, 에러 핸들러는 라우트보다 뒤에 위치해야 한다.