13편부터 Express 로 들어가지만, 그 전에 Node 내장 http 모듈만으로 서버를 만들어 본다. 이유는 둘 — ① Express 가 결국 무엇을 자동화해주는지 이해, ② 작은 마이크로서비스·CLI 헬스체크 등 라이브러리 없이 한 파일로 끝내고 싶을 때 실용적.
20줄이면 HTTP 서버 한 대 완성. 그 뒤로 더 필요한 게 보이면 그게 Express 의 역할.
1. 가장 단순한 서버 — 10줄
// server.js
import http from 'node:http';
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('안녕, Node 서버!');
});
server.listen(3000, () => {
console.log('http://localhost:3000');
});
node server.js 실행 후 브라우저에서 localhost:3000 — "안녕, Node 서버!" 가 뜬다. 끝.
핵심은 createServer 의 콜백 (req, res) => {...}. 요청이 올 때마다 호출되고 두 개를 받는다. req(IncomingMessage) 는 들어온 요청, res(ServerResponse) 는 보낼 응답. Express 의 req·res 도 결국 이걸 감싼 것.
2. req — 들어온 요청 읽기
req 객체에서 자주 쓰는 4가지.
const server = http.createServer((req, res) => {
console.log(req.method); // 'GET' | 'POST' | ...
console.log(req.url); // '/blog/hello?lang=ko'
console.log(req.headers); // { host, 'user-agent', cookie, ... }
console.log(req.httpVersion); // '1.1'
// ...
});
주의 — req.url 은 경로+쿼리가 합쳐진 raw 문자열이다. 분리하려면 URL 객체 사용.
const url = new URL(req.url, `http://${req.headers.host}`);
console.log(url.pathname); // '/blog/hello'
console.log(url.searchParams.get('lang')); // 'ko'
왜 URL 인자에 host 가 필요? — req.url 은 보통 절대 경로가 아니라 path 부터 시작 (/foo). URL 생성자는 절대 URL 을 원해서 host 를 두 번째 인자로 베이스 제공. 표준 보일러플레이트 한 줄.
3. res — 응답 보내기
응답은 3단계. 헤더 → 본문 → 끝.
// ① 헤더
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
});
// ② 본문 (여러 번 write 가능)
res.write('{"hello":');
res.write('"world"}');
// ③ 끝 (마지막 인자에 추가 데이터도 가능)
res.end();
// 또는 한 번에:
res.end('{"hello":"world"}');
일상에선 거의 res.writeHead + res.end(본문) 두 줄로 끝. write 를 여러 번 쓰는 건 Streaming 응답 (10편 참조)에서 의미가 있다.
4. 라우팅 — 직접 분기
Express 없이 라우팅은 if/switch 로 직접.
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const route = `${req.method} ${url.pathname}`;
switch (route) {
case 'GET /':
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<h1>홈</h1>');
break;
case 'GET /api/health':
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', ts: Date.now() }));
break;
case 'POST /api/echo':
// 본문 파싱 (아래 섹션)
break;
default:
res.writeHead(404);
res.end('Not Found');
}
});
3~5 라우트면 이렇게도 충분. 동적 세그먼트·미들웨어·에러 처리가 필요해지면 그제야 Express 가 가치 있다.
5. POST 본문 파싱 — chunk 모으기
req 는 사실 Readable Stream. POST 본문이 큰 경우 chunk 단위로 도착한다.
if (route === 'POST /api/echo') {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
try {
const data = JSON.parse(body);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: data }));
} catch {
res.writeHead(400);
res.end('Invalid JSON');
}
});
return;
}
또는 10편의 for await 패턴이 더 깔끔:
async function readBody(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf-8');
}
// 라우터 안에서
const body = await readBody(req);
const data = JSON.parse(body);
본문 크기 제한 — raw http 는 본문 크기를 알아서 제한하지 않는다. 악의적으로 1GB POST 를 보내면 메모리 폭발. 실전에선 누적 길이 체크해서 일정 이상이면 즉시 res.writeHead(413); res.end() 로 차단. Express 도 express.json({ limit: '100kb' }) 같은 옵션으로 같은 일을 한다.
여기까지 — Express 가 자동화하는 일이 정확히 보인다. 라우팅 트리, 본문 파싱, 미들웨어 체인, 에러 캐치, 200 응답 자동 — 13편부터 그걸 그대로 다룬다.
요약 — 12편 좌표
여기까지 정리. Node 내장 http 만으로 서버 — createServer((req, res) => ...) + listen(port). req 에서 method·url·headers, 본문은 stream chunk 로 모은다. res.writeHead(status, headers) + res.end(body) 가 응답 끝. 라우팅은 switch 로 직접, 본문 크기 제한도 직접. 작은 마이크로서비스·헬스체크 엔드포인트엔 충분. 다음 편부터 Express 가 이걸 어떻게 단순화하는지 본다.
다음 편 예고 — Express 입문
설치·기본 라우팅·응답 보내기. 13편.