Express.js 내부 이해하기: 미들웨어 체인 순서, Router, 에러 파이프라인, 운영 패턴
이 글의 핵심
Express는 http 모듈 위의 얇은 계층이라 문제 대부분이 미들웨어를 어떤 순서로 쌓았느냐에서 생깁니다. req.body가 undefined로 나오는 전형적인 순서 실수를 따라가며, Express 4와 5에서 달라진 async 에러 처리, 404와 에러 핸들러 배치, trust proxy와 keep-alive 연결 때문에 멈추는 셧다운 같은 운영 함정을 짚습니다.
들어가며
Express는 Node.js의 http 모듈 위에 올라간 얇은 프레임워크입니다. “미들웨어”와 “라우팅”은 문서에만 나오는 개념이 아니라, 실제로 내부에 레이어 배열(스택)로 존재하고 요청이 그 배열을 위에서부터 차례로 통과합니다. 그래서 Express에서 생기는 문제의 상당수는 코드 내용보다 등록 순서에서 나옵니다.
제가 가장 기억에 남는 순서 실수는 req.body가 계속 undefined로 나오던 경우였습니다. 원인은 express.json()을 라우트보다 뒤에 등록한 한 줄이었는데, 이런 실수는 에러 로그를 남기지 않기 때문에 CORS나 프록시 설정부터 의심하며 시간을 쓰기 쉽습니다. 이 글은 스택이 어떻게 순회되는지부터 에러 파이프라인, 운영 설정까지를 순서라는 관점에서 정리합니다.
미들웨어 체인
앱은 “함수 배열”에 가깝다
express()가 만드는 앱과 express.Router()는 내부에 레이어 배열을 가지고 있고, app.use, app.get 같은 호출은 그 배열 끝에 레이어를 하나씩 추가합니다. 머릿속 그림은 다음과 같습니다.
요청 → [ layer0 → layer1 → layer2 → … → 응답 또는 에러 ]
각 레이어는 경로 조건(/api로 시작하는가), 메서드 조건(GET인가), 그리고 실행할 함수를 가집니다. 요청이 들어오면 라우터는 배열을 앞에서부터 훑으며 조건에 맞는 레이어의 함수를 호출하고, 그 함수가 next()를 부르면 다음으로 맞는 레이어를 찾아 계속 진행합니다.
next = “다음 레이어로”
next()는 “다음 레이어로 넘기라”는 뜻입니다. next(err)처럼 인자를 넘기면 일반 레이어를 모두 건너뛰고 인자가 4개인 에러 미들웨어 (err, req, res, next)를 찾아갑니다. 이것이 Express 에러 모델의 뼈대입니다. 예외로 next('route')는 현재 라우트의 남은 핸들러만 건너뛰고, next('router')는 현재 Router를 빠져나갑니다.
// 개념용 의사 코드 (실제 소스와 1:1로 대응하지는 않음)
function handle(req, res, stack, index = 0) {
const layer = stack[index];
if (!layer) return; // 끝까지 왔는데 응답이 없으면 요청이 멈춤
const fn = layer.handle;
function next(err) {
if (err) return handleError(err, req, res, stack);
handle(req, res, stack, index + 1);
}
fn(req, res, next);
}
이 구조에서 가장 흔한 실수는 미들웨어를 async로 바꾼 뒤 await만 하고 next()도 res.send()도 호출하지 않는 분기를 남기는 것입니다. 그러면 요청은 스택 어딘가에서 멈추고, 클라이언트는 응답 없이 기다리다 타임아웃이 납니다. 에러도 로그도 없으니 원인을 찾기 어렵습니다. 모든 분기에서 “응답을 보냈는가, 아니면 next를 호출했는가” 둘 중 하나가 반드시 성립하도록 작성해야 합니다.
미들웨어 vs 라우트
실행 방식은 같고, 다른 점은 경로와 메서드가 레이어에 어떻게 붙느냐입니다. app.use('/api', r)는 /api로 시작하는 모든 요청을 모든 메서드에 대해 r로 보내고, 이때 r 안에서 보는 req.url에서는 /api 부분이 잘려 있습니다. app.get('/api/users', ...)는 경로가 정확히 일치하는 GET 요청만 받습니다. 결국 위에서 아래로 등록한 순서가 곧 우선순위이므로, 인증·로깅·바디 파서처럼 모든 요청에 먼저 적용되어야 하는 미들웨어는 라우트보다 위에 둬야 합니다. 이것은 스타일 문제가 아니라 동작이 달라지는 문제입니다.
req.body가 undefined인 이유: 순서 실수 따라가기
순서 실수가 어떻게 드러나는지 전형적인 흐름으로 보겠습니다.
- 증상: 프론트엔드에서 JSON으로 POST했는데 핸들러에서
req.body가undefined입니다. 라우트 등록 순서를 이리저리 바꾸며 테스트하다 보면 어떤 때는 되고 어떤 때는 안 되는 것처럼 보여 재현이 흔들립니다. - 원인:
app.post('/api/...', ...)를 먼저 여러 개 등록하고, 나중에 “정리”하면서app.use(express.json())을 파일 아래쪽에 추가했습니다. 스택은 위에서 아래로만 흐르므로 그 POST 핸들러는 JSON 파서를 거치지 않습니다. - 해결:
express.json({ limit: '1mb' })를 그 라우트들보다 위로 옮깁니다.
// 잘못된 예 — 파싱이 라우트 뒤
app.post('/api/items', (req, res) => res.json({ body: req.body }));
app.use(express.json()); // 너무 늦음
// 올바른 순서
app.use(express.json({ limit: '1mb' }));
app.post('/api/items', (req, res) => res.json({ body: req.body }));
순서가 맞는데도 req.body가 비어 있다면 다음으로 확인할 것은 Content-Type 헤더입니다. express.json()은 기본적으로 Content-Type: application/json인 요청만 파싱하고, 헤더가 없거나 text/plain이면 조용히 건너뜁니다. fetch로 body: JSON.stringify(data)만 넘기고 headers를 빠뜨리면 브라우저는 text/plain;charset=UTF-8로 보내므로, 서버 코드는 멀쩡한데 body만 비는 증상이 됩니다. HTML 폼 제출(application/x-www-form-urlencoded)은 express.urlencoded()가 따로 필요합니다. 참고로 Express 4에서는 파서가 없으면 req.body가 undefined, Express 5에서도 파싱되지 않은 요청의 req.body는 undefined이므로, req.body.name에 바로 접근하면 TypeError: Cannot read properties of undefined (reading 'name')가 납니다.
스택의 어디에서 요청이 멈추거나 우회하는지 모를 때는 각 미들웨어 앞에 이름을 찍는 로깅 미들웨어를 잠깐 끼워 보는 것이 가장 빠릅니다. 요청 ID를 맨 앞에서 붙여 두면 여러 요청이 섞인 로그에서도 한 요청의 경로를 따라갈 수 있습니다.
Router와 경로
express.Router()는 작은 앱입니다. app.use('/api/v1', api)로 마운트하면 /api/v1 아래 요청은 api의 스택을 한 번 더 순회합니다.
const express = require('express');
const crypto = require('crypto');
const app = express();
const api = express.Router();
api.use((req, res, next) => {
req.requestId = crypto.randomUUID();
next();
});
api.get('/health', (req, res) => res.json({ ok: true }));
app.use('/api/v1', api);
요청 ID는 Math.random()으로도 만들 수 있지만 충돌 가능성이 있고 형식이 일정하지 않으므로, Node 14.17 이상에서 제공하는 crypto.randomUUID()가 더 적합합니다. 프록시가 이미 X-Request-Id를 붙여 준다면 그 값을 재사용해야 프록시 로그와 앱 로그를 연결할 수 있습니다.
경로 매칭은 내부적으로 path-to-regexp가 담당합니다. /users/new와 /users/:id를 함께 쓴다면 구체적인 경로를 위에 둬야 합니다. 순서가 반대면 :id가 new라는 문자열을 먼저 받아 가서 /users/new 핸들러는 영영 실행되지 않고, new를 ID로 조회하는 쿼리가 나갑니다. Express 5로 올리면서 가장 많이 부딪히는 변화도 여기에 있습니다. Express 5는 path-to-regexp 8.x를 쓰기 때문에 app.get('*', ...)처럼 이름 없는 와일드카드가 허용되지 않고 app.get('/*splat', ...)처럼 이름을 붙여야 하며, 선택 파라미터 문법도 /:id?에서 {/:id}로 바뀌었습니다. 기존 라우트를 그대로 두고 업그레이드하면 서버 시작 시 TypeError: Missing parameter name 같은 에러가 납니다.
app.route('/resource').get(...).post(...)는 같은 URL에 대한 메서드별 핸들러를 한곳에 모으는 가독성 패턴입니다. 내부적으로는 하나의 Route 객체에 메서드별 레이어가 모입니다.
Request / Response
req와 res는 Node의 IncomingMessage와 ServerResponse 객체에 Express가 편의 메서드를 덧붙인 것입니다. 새로운 HTTP 스택을 만든 것이 아니므로, Node의 스트림 동작(예: req는 한 번만 읽을 수 있음)이 그대로 적용됩니다.
- params: 라우트 패턴이 경로에서 뽑아낸 값입니다.
/users/:id의id가 여기에 들어갑니다. 값은 항상 문자열이라 숫자 비교 전에 변환이 필요합니다. - query: URL의
?a=1&b=2부분을 파싱한 객체입니다. Express 4의 기본 파서(qs)는?a[b]=1같은 중첩 객체와 배열을 만들어 주는데, 이 때문에req.query.name이 문자열이 아니라 배열이나 객체일 수 있다는 점을 검증에서 놓치기 쉽습니다. Express 5는 기본값이 단순 파서로 바뀌어 중첩 구문을 해석하지 않습니다. - body:
express.json()같은 미들웨어가 요청 본문을 읽어서req에 넣어 줍니다. 파서가 없거나 순서가 잘못되었으면undefined입니다.
응답 쪽에서 가장 흔한 에러는 Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client입니다. res.json()을 호출한 뒤 return 없이 코드가 계속 진행되어 다른 분기에서 또 응답을 보내거나, 응답을 보낸 뒤 next()까지 호출해 뒤쪽 핸들러가 다시 응답하려 할 때 납니다. if (!user) return res.status(404).json(...)처럼 응답 직후 return하는 습관이 대부분을 막아 줍니다.
에러 처리
인자 4개 = 에러 미들웨어
app.use((req, res, next) => { /* … */ });
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({ message: err.message });
});
next(err)가 호출되거나 동기 핸들러에서 예외가 던져지면 Express는 스택에서 다음 에러 레이어를 찾습니다. 하나도 없으면 기본 핸들러(finalhandler)가 응답하는데, NODE_ENV가 production이 아니면 스택 트레이스를 HTML로 그대로 보여 줍니다. 위 예시처럼 err.message를 클라이언트에 그대로 돌려주는 것도 프로덕션에서는 DB 에러 메시지 같은 내부 정보를 노출할 수 있으므로, 5xx일 때는 일반 메시지를 보내고 상세 내용은 로그에만 남기는 편이 안전합니다.
에러 미들웨어에서 흔한 함정은 ESLint의 no-unused-vars 규칙 때문에 쓰지 않는 next를 지우는 것입니다. 인자가 3개가 되면 Express는 그 함수를 일반 미들웨어로 보고 에러가 그냥 지나가 버립니다. _next처럼 이름을 바꾸고 린트 규칙에서 밑줄 인자를 예외로 두는 방법을 씁니다.
Express 4의 async는 Promise 거부를 next에 넘기지 않는다
Express 4는 핸들러의 반환값을 보지 않습니다. async 핸들러에서 예외가 나면 거부된 Promise만 남고 에러 핸들러는 호출되지 않아, 요청은 응답 없이 멈추고 Node는 UnhandledPromiseRejection 경고를 냅니다(Node 15 이상에서는 기본 설정에서 프로세스가 종료됩니다). 그래서 Express 4에서는 래퍼를 씁니다.
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 findUser(req.params.id);
if (!user) {
const err = new Error('Not found');
err.status = 404;
throw err;
}
res.json({ user });
}));
Express 5(2024년 10월 정식 출시)는 이 문제를 프레임워크 차원에서 해결했습니다. 핸들러가 Promise를 반환하고 그것이 거부되면 자동으로 next(err)를 호출하므로 위 래퍼 없이 app.get('/users/:id', async (req, res) => { ... })만 써도 에러 핸들러로 넘어갑니다. 다만 핸들러 안에서 시작한 콜백 기반 비동기 작업(setTimeout, 이벤트 리스너 콜백 등)에서 던진 예외는 여전히 잡히지 않으므로, 그런 곳에서는 직접 next(err)를 호출해야 합니다. 두 버전이 섞인 코드베이스에서는 package.json의 Express 버전을 먼저 확인하는 습관이 필요합니다.
404와 에러 핸들러의 순서
app.use('/api', apiRouter);
app.use((req, res) => {
res.status(404).json({ error: 'NOT_FOUND' });
});
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: 'INTERNAL' });
});
404 핸들러는 “어떤 라우트에도 매칭되지 않고 끝까지 내려온 요청”을 받는 일반 미들웨어이므로 모든 라우트 뒤에 둡니다. Express에는 “404 에러”라는 개념이 따로 없고, 단지 아무도 응답하지 않았을 뿐입니다. 에러 핸들러는 그보다도 아래, 스택 맨 끝에 둡니다. 404 핸들러를 라우트보다 위에 두면 모든 요청이 404가 되고, 에러 핸들러를 라우트보다 위에 두면 라우트에서 발생한 에러가 그 아래에서 찾을 에러 레이어가 없어 기본 핸들러로 떨어집니다.
프로덕션 설정
trust proxy
Nginx, Kubernetes Ingress, Cloudflare 같은 프록시 뒤에서 실행하면 Express가 보는 TCP 연결의 상대방은 클라이언트가 아니라 프록시입니다. trust proxy를 설정하지 않으면 req.ip가 전부 프록시 IP가 되어 IP 기반 rate limit이 모든 사용자를 한 명으로 취급하고, req.protocol이 http로 보여 HTTPS 리다이렉트가 무한 반복되거나 secure 쿠키가 설정되지 않습니다.
app.set('trust proxy', 1); // 프록시 1홉만 신뢰
반대로 true로 두면 X-Forwarded-For의 가장 왼쪽 값, 즉 클라이언트가 마음대로 넣을 수 있는 값을 IP로 믿게 되어 rate limit 우회가 가능해집니다. 실제 프록시 홉 수나 프록시 IP 대역('loopback, 10.0.0.0/8')처럼 좁혀서 지정합니다. express-rate-limit 최신 버전은 이 설정이 잘못된 것을 감지하면 ERR_ERL_UNEXPECTED_X_FORWARDED_FOR 같은 경고를 로그에 남기므로, 그런 메시지가 보이면 이 설정부터 확인하세요.
Helmet, compression, 바디 제한
const helmet = require('helmet');
const compression = require('compression');
app.disable('x-powered-by');
app.use(helmet());
app.use(compression());
app.use(express.json({ limit: '1mb' }));
- Helmet:
Content-Security-Policy,Strict-Transport-Security등 보안 헤더의 기본 세트를 붙입니다. 기본 CSP가 엄격해서, 외부 CDN 스크립트나 인라인 스크립트를 쓰는 페이지는 적용 직후 브라우저 콘솔에Refused to load the script ... because it violates the following Content Security Policy directive에러가 나며 깨집니다. API 서버라면 문제없지만, HTML을 서빙한다면 CSP 지시어를 직접 조정해야 합니다. - compression: 응답을 gzip으로 압축해 대역폭을 줄이는 대신 CPU를 씁니다. Nginx나 CDN이 이미 압축한다면 앱에서 또 할 필요가 없고, 이중으로 하면 CPU만 낭비합니다.
- limit: 큰 JSON 본문이 메모리를 잡아먹는 것을 막습니다. 기본값은
100kb이고, 넘으면PayloadTooLargeError: request entity too large(413)가 납니다. 이 옵션 역시 라우트보다 위에 등록되어야 효과가 있습니다.
그레이스풀 셧다운
Kubernetes는 파드를 종료할 때 SIGTERM을 보내고, 기본 30초(terminationGracePeriodSeconds) 안에 끝나지 않으면 SIGKILL로 강제 종료합니다. 그 사이에 새 연결은 받지 않고 처리 중인 요청은 마무리해야 사용자가 502를 보지 않습니다.
const server = app.listen(process.env.PORT || 3000);
function shutdown(signal) {
console.log(`${signal} received, closing server`);
server.close(() => process.exit(0));
server.closeIdleConnections?.(); // Node 18.2+
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
server.close()는 새 연결을 받지 않을 뿐, 이미 열린 keep-alive 연결은 클라이언트가 끊을 때까지 남겨 둡니다. 그래서 요청이 없는데도 콜백이 불리지 않아 셧다운이 매번 타임아웃까지 기다리는 증상이 흔합니다. Node 18.2부터는 server.closeIdleConnections()로 유휴 연결을 바로 닫을 수 있고, Node 19부터는 server.close()가 유휴 연결을 자동으로 닫습니다. 위 코드의 setTimeout(...).unref()는 그래도 끝나지 않는 경우를 위한 안전장치이며, unref() 덕분에 이 타이머 때문에 프로세스가 살아 있지는 않습니다. DB 커넥션 풀이나 메시지 큐 소비자도 server.close 콜백 안에서 순서대로 닫아야 진행 중인 쿼리가 끊기지 않습니다.
CPU를 오래 쓰는 작업(이미지 변환, 대용량 JSON 처리)은 요청 핸들러에서 직접 하지 말고 워커 스레드나 별도 작업 큐로 보내야 합니다. Node는 이벤트 루프 하나로 모든 요청을 처리하므로, 한 요청이 CPU를 수백 밀리초 잡으면 그동안 다른 모든 요청이 멈춥니다.
로그
console.log만으로는 여러 요청의 로그가 섞였을 때 한 요청을 추적할 수 없습니다. Router 예제처럼 스택 맨 앞에서 요청 ID를 붙이고, pino나 winston 같은 구조화 로거로 요청 ID·경로·상태 코드·소요 시간을 JSON으로 남기면 로그 수집 시스템에서 바로 필터링할 수 있습니다. console.log는 동기적으로 stdout에 쓰는 경우가 있어(파일이나 TTY일 때) 트래픽이 많을 때 응답 지연의 원인이 되기도 합니다.
최소 스켈레톤
const express = require('express');
const helmet = require('helmet');
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(helmet());
app.use(express.json({ limit: '1mb' }));
app.get('/health', (req, res) => res.json({ status: 'ok' }));
app.use((req, res) => res.status(404).json({ error: 'NOT_FOUND' }));
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({ error: 'INTERNAL' });
});
const server = app.listen(process.env.PORT || 3000);
이 스켈레톤의 순서 자체가 이 글의 요약입니다. 전역 설정(trust proxy, x-powered-by) → 보안 헤더 → 바디 파서 → 라우트 → 404 → 에러 핸들러 순으로, 모든 요청에 먼저 적용될 것이 위에, “아무도 처리하지 않은 요청”과 “에러”를 받는 것이 맨 아래에 옵니다.
트러블슈팅: 증상별로 먼저 볼 곳
- 요청이 응답 없이 멈춤: 어떤 미들웨어가
next()도 응답도 하지 않는 분기가 있거나, Express 4에서async핸들러의 예외가 래퍼 없이 버려진 경우입니다. req.body가 undefined: 바디 파서가 라우트보다 아래 있거나, 클라이언트가Content-Type: application/json을 보내지 않은 경우입니다.ERR_HTTP_HEADERS_SENT: 응답 뒤에return이 빠져 두 번 응답하거나, 응답 후next()를 호출한 경우입니다.- 에러 핸들러가 호출되지 않음: 인자가 4개가 아니거나(린트가
next를 지웠을 가능성), 라우트보다 위에 등록된 경우입니다. req.ip가 전부 같은 값: 프록시 뒤인데trust proxy를 설정하지 않은 경우입니다.- 배포 때마다 502가 잠깐 남: 셧다운 시 keep-alive 연결이 정리되지 않거나,
SIGTERM을 처리하지 않아 진행 중인 요청이 끊기는 경우입니다. - Express 5로 올린 뒤 서버가 안 뜸:
*,/:id?같은 경로 문법이 새path-to-regexp에서 허용되지 않는 경우입니다.
라우터 스택 순회 코드를 직접 읽어 보기
미들웨어 순서 문제를 한 번 겪고 나면 내부 코드를 직접 보는 것이 가장 빠릅니다. Express 4에서는 lib/router/index.js와 lib/router/layer.js, Express 5에서는 별도로 분리된 router 패키지에 스택 순회 코드가 있습니다. next()가 배열의 다음 레이어를 찾아 경로가 맞으면 호출하고, next(err)로 넘어오면 인자가 네 개인 에러 핸들러만 골라 호출하는 구조가 생각보다 짧게 드러납니다. body가 파싱되지 않거나 에러 핸들러가 호출되지 않는 증상은 대부분 이 순회 순서로 설명됩니다.