빠른 흐름
import { createServer } from "node:http";
const MAX_BODY = 1_048_576; // 1MB
const server = createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== "/users") {
res.writeHead(404);
res.end("Not Found");
return;
}
const contentType = req.headers["content-type"] ?? "";
if (!contentType.includes("application/json")) {
res.writeHead(415, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Unsupported Media Type" }));
return;
}
let body = "";
for await (const chunk of req) {
body += chunk;
if (body.length > MAX_BODY) {
res.writeHead(413, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Payload Too Large" }));
req.destroy();
return;
}
}
const payload = JSON.parse(body);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, name: payload.name }));
});
server.listen(3000);요청과 응답
먼저 눈에 들어와야 하는 기본형은 아래입니다.
- 경로/메서드 먼저 확인
content-type검사 후 본문 읽기for await (const chunk of req)로 누적- 누적 중간에 크기 제한 확인
JSON.parse()실패는 400으로 분리
1. 본문이 필요한 경로인지 먼저 확인한다
기본 HTTP 서버에서는 모든 요청이 같은 콜백으로 들어옵니다. 본문 파싱은 무겁고 실패 가능성도 있으니, 경로와 메서드가 맞을 때만 읽는 편이 좋습니다.
2. Content-Type을 확인한 뒤 읽는다
JSON을 기대하는 경로라면 application/json이 아닌 요청은 바로 415로 끊는 편이 낫습니다. 이 단계를 건너뛰면 잘못된 폼 데이터나 텍스트가 JSON.parse()에서 뒤늦게 터집니다.
3. 읽는 동안 크기 제한을 건다
작은 API 서버라도 본문을 무제한으로 합치면 메모리 사용량이 바로 공격 표면이 됩니다. for await ... of req 안에서 누적 크기를 확인하고, 한도를 넘기면 413과 함께 연결을 끊습니다.
4. 파싱 오류와 응답 형식을 같은 자리에서 정리한다
JSON.parse()는 실패할 수 있고, 실패 응답도 JSON으로 보내는 편이 클라이언트에서 다루기 쉽습니다. 성공/실패 모두 Content-Type: application/json을 맞추면 경계가 선명해집니다.
5. 본문 읽기 위치를 한 군데로 모은다
req는 스트림이라 두 군데에서 나눠 읽기 시작하면 흐름이 금방 꼬입니다.
로깅, 크기 제한, JSON 파싱은 보통 같은 블록 안에서 처리하는 편이 안전합니다.
어디서 검증할까
체크포인트
- JSON 요청인지 확인:
content-type검사 - 본문 읽기:
for await (const chunk of req) - 파싱 실패: 400
- 타입 불일치: 415
- 본문 초과: 413 +
req.destroy() - 본문 소비 위치는 한 곳으로 모은다
주의할 점
본문을 다 모은 뒤에야 크기를 검사하면 이미 늦다. 누적 중간에 한도를 확인하고 초과 즉시 req.destroy() 해야 메모리 공격과 불필요한 스트림 소비를 줄일 수 있다.
참고 링크
1 sources