[Express] 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.body는 undefined로 남는다. 두 미들웨어는 예전에는 별도 패키지(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.errorCode | string | 에러 식별 코드 (예: "U001") |
error.reason | string | 에러 원인 메시지 |
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)을 호출해 기본 Error의 message를 설정하고, 추가로 reason과 data를 인스턴스에 담는다.
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는 비즈니스 로직 레벨의 세부 사유를 식별한다. 두 가지를 함께 사용해야 클라이언트가 에러를 정확히 이해하고 적절히 처리할 수 있다.
미들웨어는 등록 순서가 실행 순서다. 헬퍼 미들웨어는 라우트보다 앞에, 에러 핸들러는 라우트보다 뒤에 위치해야 한다.