Express.js 완벽 가이드 — 내부 구조·미들웨어·라우팅·프로덕션
이 글의 핵심
Express의 미들웨어 스택·라우터·요청·응답 객체·에러 파이프라인을 내부 관점에서 이해하고, 프로덕션에서 통하는 운영 패턴까지 정리합니다.
들어가며
Express는 http 위에 올라간, 말하자면 얇은 프레임워크다. “미들웨어”랑 “라우팅”이 문서에만 잡히는 단어가 아니라, 실제로는 스택·레이어로 박혀 있고, 요청이 거기를 타고 흐른다. 나는 이걸 순서로 몇 번이나 갈렸다. 그중 제일 뼈아팠던 건, 미들웨어 순서 실수로 req.body가 늘 undefined로만 보이던 그날이었고, 원인은 한 줄: express.json()을 라우트 뒤에 붙여 둔 것. 이런 사고는 로그로도 잘 안 남는다. “왜 Postman만 될까?” 같은 환상만 남는다. 이 글은 그때의 나 같은 사람을 위한, 스택이 어떻게 도는지 + 에러·운영 쪽까지 한 번에 정리한 쪽집게다.
1. 미들웨어 체인
1.1 앱은 “함수 배열”에 가깝다
express()랑 Router는 내부에 stack 배열이 있고, app.use, app.get은 거기 레이어를 쌓는 것과 같다. 머릿속 그림은 이렇다.
요청 → [ layer0 → layer1 → layer2 → … → 응답 또는 에러 ]
1.2 next = “다음 레이어로”
next()는 그냥 “다음으로 넘겨”다. next(err)는 일반 체인을 건너뛰고 네 델 (err, req, res, next)로 간다. Express 에러 모델의 뼈대다.
// 개념용 의사 코드 (실제 소스랑 1:1은 아님)
function handle(req, res, stack, index = 0) {
const layer = stack[index];
if (!layer) return; // 끝인데 응답도 없으면 hang 위험
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도 잊는 패턴, 나 포함해서 다들 해본다. “응답 보냄”이나 next 중 둘 중 하나는 꼭 보장해라. 이거 안 지키면 운에 맡기는 디버깅이 된다.
1.3 미들웨어 vs 라우트
실행은 같다. 다른 건 경로·메서드가 레이어에 어떻게 붙느냐다. app.use('/api', r)는 /api로 시작하는 애들이 r 쪽으로 더 들어가고, app.get은 GET만. 그래서 위에서 아래가 곧 우선순위다. 인증·로깅·바디 파서는 라우트보다 위 — 이건 취향이 아니라 실수 비용 문제다.
2. 그때: 미들웨어 순서 실수로 삼 일 난 리얼 스토리
미들웨어 순서 실수로 꽤 망가진 적이 있다. (지금은 웃을 수 있음.)
- 증상: 프론트에서 JSON POST 했는데 핸들러에서만
req.body가 없다. Postman이나curl로는 “가끔” 된다고 착각할 수 있다 — 실은 라우트 등록 순서·미들웨어 마운트를 바꾸느라 재현이 흔들렸던 것. - 내가 한 짓:
app.post('/api/...', ...)를 먼저 쭉 박고, 나중에app.use(express.json())을 “정리하려고” 아래에 추가했다. 스택이 위에서만 내려가니, 그 POST 핸들러는 json 파서를 안 거친다. - 고친 것:
express.json({ limit: '1mb' })를 그 라우트들보다 위로. 한 줄이었는데 삼 일 걸렸다. (주말엔 “혹시 CORS?” “혹시 프록시?”만 늘었다.)
내 의견이면, Express 쓰면 “순서 버그”는 필수 스킬이다. APM이 없을 때는 console.log에 req._parsedUrl·미들웨어 이름 정도 찍어서 “지금 이 요청이 스택의 어디냐”를 의심하는 수밖에 없다. 지금이면 req.id나 스코프에 debugLabel을 하나 붙이는 쪽이 낫다.
// 잘못된 예 — 파싱이 라우트 뒤
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 }));
이 한 편(위/아래)이 “왜 undefined?” 를 만든다. 문서는 말해주지만, 손이 먼저 가면 순서가 먼저 틀어진다.
3. Router와 경로
express.Router() 는 작은 앱이다. app.use('/api/v1', api) 는 /api/v1 밑에서 api의 스택을 한 번 더 탄다.
const express = require('express');
const app = express();
const api = express.Router();
api.use((req, res, next) => {
req.requestId = Math.random().toString(36).slice(2);
next();
});
api.get('/health', (req, res) => res.json({ ok: true }));
app.use('/api/v1', api);
경로는 내부에서 path-to-regexp 쪽으로 매칭된다. 내 기준으로는 와일드카드·애매한 정규식 라우트는 팀이 커질수록 피하는 게 이득이다. /users/new 랑 /users/:id 같이 쓰면 구체 경로를 위에. 안 그러면 :id가 new를 먹는다. app.param 은 쓰면 문서 꼭. 안 그러면 “왜 이 파라미터만 이상한 SELECT?” 가 나온다.
app.route('/resource').get().post() 는 같은 URL에 메서드만 모아 두는 가독성 패턴. 내부는 Route 객체에 레이어가 모이는 쪽으로 가닥 잡으면 됨.
4. Request / Response
req / res 는 Node의 IncomingMessage / ServerResponse 를 Express가 메서드만 얹은 거다. 새 HTTP 스택을 지은 게 아니다.
req.params / req.query / req.body 는 이렇게만 기억해도 된다 — 표 말고 줄글로.
- params: 라우트가 뜯은 경로 조각.
/users/:id의id. - query: URL 뒤
?a=1파싱. - body:
express.json()같은 미들웨어가req에 넣어 준다. 없으면 undefined — 그게 정상. 그리고 방금 말한 순서 잘못이면 영원히 undefined다.
res.json / res.send 는 이미 headersSent 인데 또 쏘면 지옥(에러 or 무시). 비동기 체인에서 이중 응답만 조심해도 팀이 편하다.
5. 에러 처리
5.1 네 델 = 에러 미들웨어
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) 가 나오면 에러 레이어를 찾는다. 없으면 기본 finalhandler 쪽. 프로덕션에선 스택/메시지 노출 각오하고 짠다.
5.2 async 는 Promise 거부를 자동으로 next에 안 묶는다
그래서 래퍼를 쓴다. (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 });
}));
5.3 404 vs 에러 핸들러 순서
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 캐치는 “매칭 끝까지 갔다” 이후, 네 델은 최하단. 바꾸면 404가 에러로 가거나, 반대로 꼬인다. 이것도 순서 — Express는 순서의 예술이다 (비꼬는 말 30%, 진심 70%).
6. 프로덕션에서 말이 통하는 쪽
6.1 trust proxy
Nginx, Ingress, Cloudflare 뒤에 두면 trust proxy 를 홉 수에 맞게. 안 그러면 req.ip 이 전부 프록시 IP, rate limit 키도 망가진다. 반대로 너무 넓게 믿으면 IP 스푸핑 — 숫자나 목록으로 제한해라.
app.set('trust proxy', 1);
6.2 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: 헤더 기본세트. “안 켠 것”이 보안 이슈로 보일 때가 있다.
- compression: 대역이 병목일 때 의미. CPU랑 트레이드오프.
- limit: 대용량 JSON에 메모리 죽이는 것 방지. — 이 limit도 라우트 위에 둬야 한다는 뜻이다. (또 순서.)
6.3 그레이스풀 셧다운
CPU 먹는 건 워커 큐로. K8s면 SIGTERM 온다. server.close 쪽은 예전에 그렇게 짰다.
const server = app.listen(process.env.PORT || 3000);
function shutdown(signal) {
console.log(signal);
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
6.4 로그
console.log 만으로는 나중에 요청 ID로 못 묶는다. req.requestId 정도는 Router 예제처럼 맨 앞에서.
프로덕션에서 자주 쓰는 질문 (표 대신): 관측 — p95/p99, 상관 ID, 외부 의존성 타임아웃이 보이냐. 안전 — 검증·권한·시크릿이 경로마다 일관되냐. 신뢰 — 멱등 아닌 곳에 재시도는 없냐, 서킷/백오프는 있냐. 배포 — 롤백·카나리가 문서 있냐. 스테이징은 데이터 양·RTT 를 프로덕션에 가깝게 — 이거 안 맞으면 “로컬에선 됐는데” 가 영원하다.
7. 최소 스켈레톤
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);
내부 흐름, 한 판으로만
요청이 어디서 파싱·검증되고, 핵심 로직이 어디서 돌고, I/O 가 어디서 터지는지 머릿속에 그림이 있으면 장애 때 빨리 좁힌다. 아래 mermaid 는 “추상화” — Express 한정이 아니라 경로 잡는 용.
flowchart TD A[입력·요청] --> B[파싱·검증] B --> C[핵심 연산] C --> D[부작용: I/O·동시성] D --> E[결과·로그·메트릭]
sequenceDiagram participant C as 클라이언트 participant B as 경계(프록시·게이트웨이) participant D as DB·API·큐 C->>B: 요청 B->>D: I/O D-->>B: 지연/실패 B-->>C: 응답+코드/ID
트러블슈팅 (표 말고 그냥 쓴다)
- 가끔만 실패: 레이스, 타임아웃, DNS, 외부 API. 최소 재현 스크립트 + 트레이스.
- 느리다: N+1, 동기 I/O, 락, 직렬화 폭탄, 캐시 미스. 한 번에 한 가지.
- 메모리가 올라간다: 캐시 상한, 리스너 누수, 큰 버퍼, 커넥션 반환.
- 배포만 실패: env, lockfile, 이미지 버전, CI 로그.
- 로컬과 prod만 다름: 시크릿, 리전, 기본값. 설정 스키마로 고정.
- 데이터가 엇나간다: 비멱등 재시도, 캐시 무효화 누락, 부분 쓰기.
순서 추천: (1) 재현 (2) 최근 변경 (3) 환경 차이 (4) 메트릭/로그로 가설 (5) 수정 후 부하/회귀.
정리 (편한 말)
Express 는 스택 위에 경로 매칭을 얹은 것. next / next(err) 가 제어권, req/res 는 코어에 붙은 헬퍼. 미들웨어 순서 실수 한 번이면 body도, 404도, 에러 핸들러도 전부 이상한 곳으로 간다 — 나는 그걸 express.json 한 줄로 배웠다. 더 파고 싶으면 lib/router/layer.js, lib/application.js 따라가 보면 스택 순회가 눈에 들어온다.
자주 나오는 꼬리질문 (본문 밑)
실무에 언제 쓰냐? — 미들웨어 순서, Router, req/res, trust proxy, 셧다운까지 한 번에 읽는 용.
뭐부터 읽지? — 이 블로그 Node/Express 쪽이면 앞뒤 글 링크 타고. C++ 시리즈랑은 별개다 (예전 푸터가 엉뚱하게 붙어 있었음).
깊게? — Express 공식 문서 + 소스. 프레임워크 밖 HTTP (헤더, status, keep-alive) 를 아는 것도 순서 맞출 때 도움 된다.
검색에 걸릴 키워드
Express, Node.js, 미들웨어, REST API, 백엔드, 웹 프레임워크.