Node.js · 기본
Node.js로 만드는 작은 API
Node.js JSON 요청 본문 읽기와 입력 검증 - 400 413 415 422 구분 (Node.js API 5단원)
조각으로 오는 요청 본문을 크기 제한과 함께 모으고, 문법 오류와 값 오류를 다른 상태 코드로 알려 준다. 허락한 필드만 골라 저장하고 201 과 location 으로 답한다.
개발자 · 원고 갱신
이 단원에서 배우는 것
읽기만 하던 서버에 POST /memos를 붙여 메모를 받는다. 쓰기 API에서 가장 중요한 원칙은 하나다. 클라이언트가 보낸 것은 무엇이든 믿지 않는다. 우리 프론트엔드만 이 API를 부른다는 보장은 없다. 누군가 curl로 숫자, 배열, 깨진 JSON, 10MB짜리 문자열을 보낼 수 있다.
- 요청 본문이 조각(
Buffer)으로 도착한다는 것을 알고,for await로 모아 JSON으로 바꾼다. - 본문 크기 제한(413), 형식 확인(415), JSON 문법 오류(400), 값 검증 실패(422)를 구분한다.
- 검증 함수가 "허락한 필드만 골라 새 객체"를 돌려주게 만든다.
- 만든 자원의 주소를
201 Created와location헤더로 알려 준다.
문제 상황
프론트엔드 담당이 "메모 추가" 화면을 만들었다. 요청 본문은 {"title": "...", "done": false}다. 처음 떠오르는 구현은 이렇다.
const memo = JSON.parse(body);
memo.id = nextId++;
memos.push(memo);
이 세 줄에는 구멍이 많다. 본문이 JSON이 아니면 JSON.parse가 던진 오류로 요청이 500이 되거나 서버가 죽는다. title이 숫자여도, 비어 있어도, 5만 글자여도 저장된다. {"title":"x","isAdmin":true}처럼 모르는 필드도 그대로 저장된다. 본문을 받는 동안 크기를 확인하지 않으면 거대한 요청 하나로 서버 메모리를 채울 수 있다. 예제는 ch05 폴더에 둔다.
완성 코드
본문 읽기와 검증: body.mjs
// body.mjs — 요청 본문을 읽어 JSON 으로 바꾸고, 메모 입력값을 검사한다
const MAX_BYTES = 10 * 1024; // 메모 하나에 10KB 면 충분하다
export class BodyError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
export async function readJson(req) {
const type = req.headers['content-type'] ?? '';
if (!type.startsWith('application/json')) {
throw new BodyError(415, 'content-type 은 application/json 이어야 합니다');
}
const chunks = [];
let size = 0;
for await (const chunk of req) { // 본문은 조각(Buffer)으로 나뉘어 도착한다
size += chunk.length;
if (size > MAX_BYTES) throw new BodyError(413, `본문은 ${MAX_BYTES}바이트까지입니다`);
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString('utf8');
try {
return JSON.parse(text);
} catch {
throw new BodyError(400, 'JSON 형식이 아닙니다');
}
}
export function validateMemo(input) {
const errors = [];
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
return { errors: ['본문은 JSON 객체여야 합니다'] };
}
const title = typeof input.title === 'string' ? input.title.trim() : input.title;
if (typeof title !== 'string') errors.push('title 은 문자열이어야 합니다');
else if (title.length === 0) errors.push('title 은 비어 있을 수 없습니다');
else if (title.length > 50) errors.push('title 은 50자 이하여야 합니다');
if (input.done !== undefined && typeof input.done !== 'boolean') {
errors.push('done 은 true 또는 false 여야 합니다');
}
const unknown = Object.keys(input).filter((k) => !['title', 'done'].includes(k));
if (unknown.length) errors.push(`모르는 필드: ${unknown.join(', ')}`);
if (errors.length) return { errors };
return { value: { title, done: input.done ?? false } }; // 허락한 필드만 골라 새 객체로
}
서버: server.mjs
4단원 서버에서 목록 검색 기능을 덜어 내고 POST를 더했다.
// server.mjs — 4단원 서버에 POST /memos 를 더했다
import { createServer } from 'node:http';
import { readJson, validateMemo, BodyError } from './body.mjs';
const PORT = 3700;
const HOST = '127.0.0.1';
const memos = [
{ id: 1, title: 'Node 설치 확인', done: true },
{ id: 2, title: '모듈 복습', done: false },
];
let nextId = 3;
function sendJson(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(body),
});
res.end(body);
}
async function handle(req, res) {
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
const path = url.pathname;
if (req.method === 'GET' && path === '/memos') {
return sendJson(res, 200, { items: memos, count: memos.length });
}
if (req.method === 'POST' && path === '/memos') {
let input;
try {
input = await readJson(req);
} catch (err) {
if (err instanceof BodyError) return sendJson(res, err.status, { error: err.message });
throw err;
}
const { value, errors } = validateMemo(input);
if (errors) return sendJson(res, 422, { error: '입력값을 확인하세요', details: errors });
const memo = { id: nextId++, ...value };
memos.push(memo);
res.setHeader('location', `/memos/${memo.id}`);
return sendJson(res, 201, memo);
}
sendJson(res, 404, { error: `${req.method} ${path} 는 없는 주소입니다` });
}
const server = createServer((req, res) => {
handle(req, res).catch((err) => {
console.error('처리 중 오류:', err);
if (!res.headersSent) sendJson(res, 500, { error: '서버 오류' });
});
});
server.listen(PORT, HOST, () => {
console.log(`메모 API 대기 중: http://${HOST}:${PORT}`);
});
시험용 요청 모음: post-cases.mjs
// post-cases.mjs — 올바른 요청과 잘못된 요청을 차례로 보내 본다
const URL_MEMOS = 'http://127.0.0.1:3700/memos';
const json = { 'content-type': 'application/json' };
const cases = [
['정상', json, JSON.stringify({ title: ' POST 연습 ' })],
['done 포함', json, JSON.stringify({ title: '검증 함수 쓰기', done: true })],
['깨진 JSON', json, '{"title": "닫는 괄호 없음"'],
['title 숫자', json, JSON.stringify({ title: 42 })],
['빈 title·모르는 필드', json, JSON.stringify({ title: ' ', owner: 'admin' })],
['배열 본문', json, JSON.stringify([{ title: '배열' }])],
['form 형식', { 'content-type': 'application/x-www-form-urlencoded' }, 'title=hello'],
['너무 큰 본문', json, JSON.stringify({ title: 'x'.repeat(20000) })],
];
for (const [name, headers, body] of cases) {
const res = await fetch(URL_MEMOS, { method: 'POST', headers, body });
const location = res.headers.get('location');
console.log(`[${name}] ${res.status}${location ? ' location=' + location : ''}`);
console.log(` ${await res.text()}`);
}
const list = await (await fetch(URL_MEMOS)).json();
console.log('최종 메모 수:', list.count, list.items.map((m) => m.title));
줄별 해설
본문은 조각으로 온다
const chunks = [];
let size = 0;
for await (const chunk of req) { // 본문은 조각(Buffer)으로 나뉘어 도착한다
size += chunk.length;
if (size > MAX_BYTES) throw new BodyError(413, `본문은 ${MAX_BYTES}바이트까지입니다`);
chunks.push(chunk);
}
const text = Buffer.concat(chunks).toString('utf8');
HTTP 요청의 본문은 한 덩어리로 오지 않는다. 네트워크 사정에 따라 여러 Buffer 조각으로 나뉘어 도착하고, req는 3단원의 파일 스트림처럼 읽을 수 있는 스트림이다. for await로 조각을 모으다가 합계가 10KB를 넘으면 그 자리에서 멈춘다. 끝까지 다 받은 다음 크기를 확인하면 이미 메모리를 다 쓴 뒤라 늦다. 조각을 문자열로 하나씩 이어 붙이지 않고 Buffer.concat으로 합친 뒤 한 번에 toString하는 데도 이유가 있다. 한글 한 글자(3바이트)가 두 조각에 걸쳐 잘려 올 수 있어서, 조각마다 문자열로 바꾸면 그 글자가 깨진다.
그림 5-1. 조각으로 오는 본문과 한글 바이트
실패를 종류별로 나눈다
그림 5-2. POST 본문이 저장되기까지 거치는 검사
| 상황 | 상태 코드 | 뜻 |
|---|---|---|
| 본문이 JSON 형식이라고 밝히지 않음 | 415 | Unsupported Media Type. 이 형식은 받지 않는다 |
| 본문이 너무 큼 | 413 | Content Too Large. 크기 제한을 넘었다 |
| JSON 문법이 틀림 | 400 | Bad Request. 요청 자체를 해석할 수 없다 |
| JSON은 맞는데 값이 규칙에 어긋남 | 422 | Unprocessable Content. 알아듣긴 했지만 처리할 수 없는 값이다 |
400과 422를 나누는 것은 팀마다 다르다. 모든 입력 오류를 400으로 통일하는 API도 많다. 중요한 것은 클라이언트가 고칠 수 있게 무엇이 틀렸는지 알려 주는 것, 그리고 이 오류들이 절대 500(서버 잘못)으로 나가지 않게 하는 것이다. 이 책은 "문법은 400, 값은 422"로 정했다.
BodyError는 Error를 상속해 status를 하나 더 들고 다니는 오류다. readJson은 무엇이 잘못됐는지만 던지고, 어떤 응답을 보낼지는 서버 쪽 catch가 err.status를 보고 정한다. BodyError가 아닌 오류는 다시 던져서, 맨 바깥의 .catch가 500으로 처리하게 둔다. 6단원에서 이 방식을 모든 오류로 넓힌다.
검증: 틀린 것을 모두 모아 알려 준다
export function validateMemo(input) {
const errors = [];
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
return { errors: ['본문은 JSON 객체여야 합니다'] };
}
const title = typeof input.title === 'string' ? input.title.trim() : input.title;
if (typeof title !== 'string') errors.push('title 은 문자열이어야 합니다');
else if (title.length === 0) errors.push('title 은 비어 있을 수 없습니다');
else if (title.length > 50) errors.push('title 은 50자 이하여야 합니다');
검증 함수는 첫 번째 문제에서 멈추지 않고 errors 배열에 전부 모은다. 사용자가 한 번 고치고 다시 보냈는데 또 다른 오류가 나오는 일을 줄여 준다. typeof null이 'object'이고 배열도 'object'라서, "평범한 객체"인지 확인하려면 세 조건이 모두 필요하다. title은 앞뒤 공백을 지운 뒤에 길이를 잰다. 공백만 있는 제목 ' '은 빈 제목으로 본다.
const unknown = Object.keys(input).filter((k) => !['title', 'done'].includes(k));
if (unknown.length) errors.push(`모르는 필드: ${unknown.join(', ')}`);
if (errors.length) return { errors };
return { value: { title, done: input.done ?? false } }; // 허락한 필드만 골라 새 객체로
}
모르는 필드는 조용히 버리지 않고 알려 준다. 클라이언트가 titel처럼 오타를 냈을 때 "title이 비었다"와 "모르는 필드: titel"이 함께 나오면 원인을 바로 안다. 마지막 줄이 가장 중요하다. 받은 객체를 그대로 저장하지 않고 허락한 필드만 골라 새 객체를 만든다. 그래야 클라이언트가 id나 isAdmin 같은 값을 끼워 넣어도 저장되지 않는다(연습 문제 3번).
| 대상 | 규칙 | 어기면 |
|---|---|---|
| 본문 전체 | JSON 객체 (배열 · null 아님) | 422 |
title | 문자열, 앞뒤 공백 제거 후 1~50자 | 422 |
done | 없으면 false, 있으면 불리언 | 422 |
| 그 밖의 필드 | 받지 않는다 | 422 모르는 필드 |
201과 location
res.setHeader('location', `/memos/${memo.id}`);
return sendJson(res, 201, memo);
새 자원을 만들었을 때는 200 대신 201 Created를 쓰고, 새 자원의 주소를 location 헤더에 담는다. 본문에는 서버가 정한 id와 기본값(done: false)이 채워진 최종 모습을 돌려준다. 클라이언트는 이 응답만으로 화면을 갱신할 수 있다.
비동기 핸들러의 오류를 놓치지 않기
handle(req, res).catch((err) => {
handle이 async 함수가 되었으므로 안에서 던진 오류는 거부된 Promise가 된다. createServer는 이 Promise를 기다려 주지 않는다. .catch를 달지 않으면 2단원의 "처리되지 않은 거부"가 되어 요청 하나 때문에 서버 전체가 꺼진다. 여기서 잡아 500으로 답하고, 이미 응답을 보내기 시작했는지(res.headersSent) 확인해 두 번 보내는 일을 막는다.
실제 실행 결과
한 터미널에서 ch05 폴더의 node server.mjs를 띄우고, 다른 터미널에서 요청 모음을 보냈다.
$ node post-cases.mjs
[정상] 201 location=/memos/3
{"id":3,"title":"POST 연습","done":false}
[done 포함] 201 location=/memos/4
{"id":4,"title":"검증 함수 쓰기","done":true}
[깨진 JSON] 400
{"error":"JSON 형식이 아닙니다"}
[title 숫자] 422
{"error":"입력값을 확인하세요","details":["title 은 문자열이어야 합니다"]}
[빈 title·모르는 필드] 422
{"error":"입력값을 확인하세요","details":["title 은 비어 있을 수 없습니다","모르는 필드: owner"]}
[배열 본문] 422
{"error":"입력값을 확인하세요","details":["본문은 JSON 객체여야 합니다"]}
[form 형식] 415
{"error":"content-type 은 application/json 이어야 합니다"}
[너무 큰 본문] 413
{"error":"본문은 10240바이트까지입니다"}
최종 메모 수: 4 [ 'Node 설치 확인', '모듈 복습', 'POST 연습', '검증 함수 쓰기' ]
정상 요청 두 건은 201과 새 주소를 받았고, 제목 앞뒤 공백은 지워졌다. 나머지 여섯 건은 각자 다른 상태 코드와 이유를 받았다. 빈 title·모르는 필드는 두 문제를 한 번에 알려 줬다. 마지막 목록을 보면 실패한 요청은 하나도 저장되지 않았다. 서버 쪽 출력은 시작 메시지 한 줄뿐이다. 입력 오류는 서버 잘못이 아니므로 500 기록이 남지 않았다.
메모 API 대기 중: http://127.0.0.1:3700
실무에서 자주 틀리는 것
1. 받은 객체를 통째로 저장한다
memos.push({ id, ...input })은 편하지만 클라이언트가 보낸 모든 필드가 저장된다. 나중에 role이나 ownerId 같은 필드가 생기면 그대로 권한 문제가 된다. 검증을 통과한 값으로 새 객체를 만드는 습관이 가장 싼 보안이다.
2. 크기 제한 없이 본문을 받는다
본문을 끝까지 모으는 코드는 제한이 없으면 공격자가 정한 만큼 메모리를 쓴다. 프레임워크의 본문 파서도 기본 제한이 있고(보통 100KB 안팎), 필요한 만큼만 올려 쓴다.
3. 모든 오류를 500으로 보낸다
try { ... } catch { res.writeHead(500) } 하나로 끝내면 클라이언트는 자기 잘못인지 서버 잘못인지 모른다. 모니터링에서도 500이 늘어 장애처럼 보인다. 입력 오류는 400대, 서버 잘못만 500이다.
4. 프론트엔드에서 검증했으니 서버는 생략한다
화면의 입력 제한은 사용 편의를 위한 것이다. 요청은 화면을 거치지 않고도 보낼 수 있다. 서버 검증은 언제나 필요하고, 프론트엔드 검증은 있으면 좋은 것이다.
연습 문제
{"title": 42}에는 422,{"title": "닫는 괄호 없음"에는 400을 돌려줬다. 두 요청의 차이를 한 문장으로 설명하라.title에 이모지 😀 30개를 보내면 통과하는가?'😀'.length는 얼마인가? "눈에 보이는 글자 수"로 50자를 세려면 어떻게 고치는가?validateMemo의 마지막 줄 대신return { value: input }으로 바꾸고, 서버는{ id: nextId++, ...value }로 저장한다고 하자. 모르는 필드 검사가 없다면 클라이언트가{"title": "x", "id": 1}을 보냈을 때 무슨 일이 생기는가?
정답과 해설
- 400은 본문을 JSON으로 읽을 수조차 없는 경우이고, 422는 JSON으로는 읽었지만 값이 약속한 규칙에 맞지 않는 경우다. 클라이언트 입장에서 400은 "보내는 코드가 고장 났다", 422는 "사용자 입력을 다시 받아라"에 가깝다.
- 통과하지 못한다. 😀는 UTF-16 코드 단위 2개로 이루어져
'😀'.length가 2다. 30개면length가 60이라 "50자 이하" 검사에 걸린다(검증 스크립트로 확인). 눈에 보이는 글자에 가깝게 세려면 코드 포인트 단위로 쪼개는[...title].length를 쓴다. 이것도 가족 이모지처럼 여러 코드 포인트가 합쳐진 글자는 여러 개로 세므로, 정확히 하려면Intl.Segmenter를 쓴다. 실무에서는 "DB 컬럼에 몇 글자까지 들어가는가"와 같은 기준으로 세는 것이 가장 중요하다. - 객체 펼치기(
...)는 뒤에 오는 값이 앞의 값을 덮어쓰므로 저장되는id가 서버가 정한 번호가 아니라 클라이언트가 보낸 1이 된다(검증 스크립트로 확인). 이미 1번 메모가 있으면 같은id가 두 개 생기고,GET /memos/1은 둘 중 먼저 찾은 것만 돌려준다. 허락한 필드만 골라 새 객체를 만드는 것이 이런 사고를 막는다.
상태 코드가 늘어나면서 서버 코드에 if와 sendJson이 흩어지기 시작했다. 다음 단원에서 오류를 한곳에서 처리하도록 구조를 정리하고, 수정·삭제까지 붙여 API를 완성한다.
READER FEEDBACK
질문·오탈자·의견
내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.