사내 카드뉴스 관리 도구에 글 등록 API를 붙이던 날이었습니다. 라우터를 만들고 API 테스트 도구로 JSON을 던졌는데, 서버가 500을 뱉으며 이렇게 찍혔습니다. TypeError: Cannot destructure property 'title' of 'req.body' as it is undefined.
당황스러웠던 건 요청 자체는 분명히 도착하고 있었다는 점입니다. 라우터 첫 줄에 찍어 둔 로그는 정상적으로 출력됐고, 상태 코드도 404가 아니었습니다. 경로는 맞게 잡혔는데 본문만 사라져 있었습니다.
결론부터 적으면 원인은 코드 한 줄이었습니다. 다만 그 한 줄을 찾기까지 라우터와 모델, CORS 설정을 차례로 의심하며 한참을 돌아갔습니다. 이 글은 그 과정과, 같은 실수를 다시 하지 않으려고 만들어 둔 안전장치를 정리한 기록입니다.
증상: 요청은 도착하는데 본문만 비어 있다
문제가 난 코드는 특별할 게 없었습니다. 글을 하나 만드는 흔한 등록 API입니다.
// routes/articles.js
const router = require('express').Router();
const { Article } = require('../models');
router.post('/', async (req, res) => {
const { title, slug, authorId } = req.body; // 여기서 터졌습니다
const created = await Article.create({ title, slug, authorId });
res.status(201).json(created);
});
module.exports = router;
구조 분해 할당은 대상이 undefined이면 바로 예외를 던집니다. 즉 이 에러는 “본문 안에 title이 없다”가 아니라 “본문이라는 객체 자체가 없다”는 뜻입니다. 값이 비었을 뿐이라면 title이 undefined가 됐을 뿐 예외까지 나지는 않았을 겁니다. 메시지를 이렇게 한 번 번역해 두면 의심할 범위가 확 좁아집니다.
먼저 확인한 것: 요청이 어떤 모습으로 들어왔나
본문이 통째로 없다면 둘 중 하나입니다. 클라이언트가 안 보냈거나, 서버가 못 읽었거나. 이걸 가르려면 원본 요청을 그대로 들여다봐야 합니다. 라우터 앞에 임시 미들웨어를 하나 끼웠습니다.
app.use((req, res, next) => {
console.log('[REQ]', req.method, req.originalUrl);
console.log(' content-type :', req.headers['content-type']);
console.log(' content-length:', req.headers['content-length']);
console.log(' body typeof :', typeof req.body);
next();
});
출력은 이랬습니다.
[REQ] POST /articles
content-type : application/json
content-length: 84
body typeof : undefined
content-length가 84였습니다. 요청 본문은 84바이트짜리로 멀쩡히 도착해 있었다는 뜻입니다. 클라이언트는 무죄였습니다. 그런데도 req.body가 undefined라면, 도착한 바이트를 객체로 바꿔 줄 사람이 서버 쪽에 없다는 이야기가 됩니다.
진짜 원인: 본문은 저절로 해석되지 않는다
서버에 도착하는 요청 본문은 그냥 바이트 덩어리입니다. 이걸 JSON으로 읽어 자바스크립트 객체로 바꾸고 req.body에 얹어 주는 것은 프레임워크가 알아서 해 주는 일이 아니라, 본문 파서 미들웨어가 하는 일입니다.
제 진입 파일에는 그 파서가 없었습니다. CORS는 붙여 뒀고 라우터도 연결해 뒀는데, 정작 본문을 읽어 줄 줄이 빠져 있었습니다. 파서가 없으면 req.body는 아예 만들어지지 않고 그대로 undefined로 남습니다.
예전 버전에서는 body-parser라는 별도 패키지를 설치해 썼습니다. 지금은 같은 기능이 프레임워크에 내장돼 있어 따로 설치할 필요가 없습니다. 오래된 예제를 보고 패키지부터 찾다가 시간을 더 쓰는 경우가 많은데, 내장 함수만으로 충분합니다.
수정: 붙이는 것보다 붙이는 자리가 중요하다
파서를 추가했습니다. 중요한 건 라우터를 등록하기 전에 넣어야 한다는 점입니다.
// app.js
const express = require('express');
const app = express();
app.use(express.json()); // (1) 먼저 본문을 읽고
app.use(express.urlencoded({ extended: true })); // 폼 전송도 함께
app.use('/articles', require('./routes/articles')); // (2) 그다음 라우터
module.exports = app;
미들웨어는 등록한 순서대로 실행됩니다. 파서를 라우터 아래에 적어 두면 라우터가 먼저 요청을 받아 처리를 끝내 버리고, 파서 차례는 영영 오지 않습니다. 코드에는 분명히 존재하는데 동작만 안 하는 상태라, 이 경우가 오히려 더 헷갈립니다.
순서를 판단하는 기준은 단순합니다. 요청을 가공하는 미들웨어는 위로, 요청을 소비하는 라우터는 아래로. CORS나 로깅, 인증처럼 요청을 손보는 것들은 전부 위쪽에 모아 두는 편이 안전합니다.
Content-Type이 다르면 파서도 달라진다
파서를 붙였는데도 여전히 본문이 비어 있다면, 다음으로 볼 것은 요청 헤더의 Content-Type입니다. 각 파서는 자기 담당 형식이 아니면 그냥 지나갑니다.
| Content-Type | 필요한 처리 | 결과 |
|---|---|---|
| application/json | express.json() | 객체로 파싱 |
| application/x-www-form-urlencoded | express.urlencoded() | 객체로 파싱 |
| multipart/form-data | multer 등 별도 라이브러리 | 파일과 필드 분리 |
| text/plain | express.text() | 문자열로 전달 |
| 헤더 없음 | 어떤 파서도 처리하지 않음 | 빈 객체 |
특히 파일 업로드는 파서를 붙였다고 해결되지 않습니다. 내장 파서는 multipart/form-data를 다루지 못하므로 전용 라이브러리를 따로 써야 합니다. 테스트 도구에서 본문 형식을 raw JSON이 아닌 form-data로 두고 보내다가 헤매는 일도 흔합니다.
참고로 파서를 붙인 뒤에는 req.body가 undefined가 아니라 빈 객체가 됩니다. 증상이 “터진다”에서 “값이 안 들어온다”로 바뀌므로, 이 차이만으로도 파서가 동작하는지 아닌지를 구분할 수 있습니다.
다시 안 겪으려고 넣어 둔 안전장치
원인이 허무했던 만큼, 다음에는 에러 메시지만 보고 바로 알아채도록 장치를 두 개 넣었습니다.
첫째, 본문이 필요한 경로에서 본문이 비었으면 500이 아니라 400으로 명확히 돌려주는 검증 미들웨어입니다. 서버 잘못이 아니라 요청이 잘못됐다는 사실을 상태 코드로 먼저 알려 주는 편이 훨씬 빨리 풀립니다.
const requireFields = (...keys) => (req, res, next) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({
message: '요청 본문을 읽지 못했습니다. Content-Type과 본문 파서를 확인하세요.'
});
}
const missing = keys.filter((k) => req.body[k] === undefined);
if (missing.length) {
return res.status(400).json({ message: '누락된 항목: ' + missing.join(', ') });
}
next();
};
router.post('/', requireFields('title', 'slug', 'authorId'), createArticle);
둘째, 요청 로그에 Content-Type을 항상 남기게 했습니다. 이번처럼 본문이 얽힌 문제는 헤더 한 줄만 남아 있어도 진단 시간이 크게 줄어듭니다. 어떤 값을 남기고 어떤 값을 가려야 하는지는 운영 로깅 체크리스트: 로그 레벨부터 마스킹까지에 정리해 두었습니다.
검증 자체를 프레임워크 차원에서 공통화하는 방법도 있습니다. 스프링 진영에서 같은 고민을 정리한 글은 Spring Boot Validation 공통 에러처리입니다. 언어는 달라도 잘못된 요청을 경계에서 걸러 낸다는 원칙은 같습니다.
정리
같은 증상을 만났을 때 순서대로 확인하면 되는 것들입니다.
- 메시지를 번역합니다. 구조 분해에서 난 예외는 값이 아니라 대상 자체가 없다는 뜻입니다.
- content-length를 봅니다. 0이 아니면 클라이언트는 정상이고 서버가 못 읽은 것입니다.
- 파서가 있는지, 라우터보다 위에 있는지 봅니다. 있어도 아래에 있으면 동작하지 않습니다.
- Content-Type과 파서가 짝이 맞는지 봅니다. multipart는 별도 라이브러리가 필요합니다.
- 본문 검증은 경계에서 합니다. 400으로 먼저 걸러 내면 다음 사람이 헤매지 않습니다.
덧붙이면, 이렇게 등록한 데이터가 실제로 어떤 규칙으로 저장되고 지켜지는지는 키와 무결성 — 기본키와 외래키가 데이터를 지키는 방식에서 다뤘습니다. 애플리케이션 검증과 데이터베이스 제약조건은 둘 다 있어야 제 몫을 합니다.