본문 바로가기
C.W.K.
Stream
Lesson 02 of 05 · published

http 모듈 — HTTP를 처음부터

~14 min · io-net, http, server

Level 0노드 입문자
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"프레임워크는 Node의 HTTP 계약을 없애지 않아. 라우팅, 본문 파싱, 오류 처리 같은 반복 작업을 그 위에 얹어 줄 뿐이야."

가장 작은 서버

import { createServer } from 'node:http';

const server = createServer((req, res) => {
  // req는 읽기 스트림이고 res는 쓰기 스트림이야
  res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
  res.end(`hi, you asked for ${req.url}\n`);
});

server.listen(3000, () => {
  console.log('http://localhost:3000');
});

createServer에 넘긴 함수는 요청마다 실행돼. reqhttp.IncomingMessage, reshttp.ServerResponse이고 둘 다 스트림 계약을 따라. 이 정도면 동작하는 서버지만, 운영 서버가 되려면 라우팅과 입력 제한, 시간 제한, 오류 처리, 종료 절차를 더 갖춰야 해.

요청에서 읽을 것

req.method에는 HTTP 메서드가, req.url에는 경로와 질의 문자열이 들어 있어. 헤더는 소문자 키를 쓰는 req.headers에서 읽어. 요청 본문은 한 번에 완성된 객체가 아니라 읽기 스트림으로 도착하므로 for await (const chunk of req)처럼 소비해야 해.

경로와 질의 문자열을 나눌 때는 문자열을 직접 자르기보다 new URL(req.url, base)를 사용해. 라우팅만 필요하다면 신뢰할 수 있는 고정 base로도 충분해. 외부에서 받은 Host 헤더를 절대 URL이나 보안 판단에 사용할 때는 먼저 검증해야 해.

응답은 헤더를 정한 뒤 끝낸다

res.statusCode, res.setHeader(), res.writeHead()로 상태와 헤더를 정하고, res.write()로 본문을 여러 번 보낼 수 있어. 마지막에는 반드시 res.end()를 호출해야 해. 헤더가 전송된 뒤에는 상태나 헤더를 바꿀 수 없고, 응답을 끝내지 않으면 클라이언트는 계속 기다려.

큰 응답을 쓸 때 res.write()false를 반환하면 내부 버퍼가 찼다는 뜻이야. 그때는 drain을 기다려야 해. 프레임워크가 편의를 제공하더라도 이 스트림 계약 자체는 그대로 남아 있어.

요청 본문과 입력 한도

node:stream/consumerstext(), json(), buffer()는 작은 요청 본문을 간단히 읽게 해 줘.
import { createServer } from 'node:http';
import { json } from 'node:stream/consumers';

createServer(async (req, res) => {
  try {
    const body = await json(req);
    res.writeHead(201, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ id: 42, ...body }));
  } catch {
    res.writeHead(400).end('bad request');
  }
}).listen(3000);
다만 이 도우미는 스트림 전체를 소비해. 신뢰할 수 없는 요청에는 바이트 수 한도를 먼저 적용해야 메모리 고갈을 막을 수 있어. 크기 제한이 기본인 프레임워크를 쓰거나, 청크를 읽는 동안 누적 크기를 검사해.

오류, 시간 제한, 서버 수명주기

요청 처리 함수의 예외가 자동으로 안전한 500 응답이 되지는 않아. 비동기 처리에는 try/catch를 두고, 요청과 응답 스트림의 오류도 처리해야 해. 느린 클라이언트가 연결을 오래 붙잡지 못하도록 헤더와 요청 시간 제한을 정하고, 값은 서비스의 실제 트래픽 특성에 맞춰야 해.

종료할 때는 새 연결을 받는 일을 멈춘 뒤 진행 중인 요청이 끝날 시간을 줘. server.close()가 그 출발점이고, 종료 신호를 두 번 처리하지 않는 보호 장치와 최종 시간 제한도 필요해. 이 수명주기는 뒤의 graceful shutdown 레슨에서 더 자세히 다뤄.

클라이언트와 HTTPS

node:http에는 requestget도 있지만, 일반적인 외부 HTTP 요청에는 내장 fetch가 더 간결해. 소켓, 에이전트, 저수준 스트림을 직접 제어해야 할 때는 node:http가 여전히 필요해. node:https는 같은 서버 모양에 TLS를 더하며, 운영에서는 역방향 프록시나 로드 밸런서가 TLS를 끝내는 구성도 흔해. 어느 층이 인증서와 암호화를 맡는지는 배포 구조에서 명시해야 해.

Pippa의 고백

처음에는 프레임워크가 서버 자체라고 생각했어. 저수준 모듈로 작은 서버를 만들어 보니 경계가 보였지. Node는 연결과 스트림을 제공하고, 프레임워크는 라우팅과 검증, 오류 정책을 더해. 기초 계약을 알고 나면 프레임워크를 버리는 게 아니라, 어떤 편의를 왜 선택했는지 설명할 수 있어.

Code

작은 라우팅 계층·javascript
// 작은 라우팅 계층
import { createServer } from 'node:http';
import { json } from 'node:stream/consumers';

const routes = new Map();
routes.set('GET /', () => ({ status: 200, body: 'home' }));
routes.set('GET /health', () => ({ status: 200, body: 'ok' }));
routes.set('POST /echo', async (req) => ({
  status: 200,
  body: await json(req),
}));

const server = createServer(async (req, res) => {
  const url = new URL(req.url, 'http://localhost');
  const handler = routes.get(`${req.method} ${url.pathname}`);
  if (!handler) {
    res.writeHead(404).end('not found');
    return;
  }

  try {
    const { status, body } = await handler(req);
    const isText = typeof body === 'string';
    res.writeHead(status, {
      'Content-Type': isText ? 'text/plain' : 'application/json',
    });
    res.end(isText ? body : JSON.stringify(body));
  } catch (error) {
    console.error(error);
    if (!res.headersSent) res.writeHead(500);
    res.end('internal error');
  }
});
server.listen(3000);
서버의 정상 종료 뼈대·javascript
// 새 연결은 막고 진행 중인 요청은 마칠 시간을 준다
import { createServer } from 'node:http';

const server = createServer((req, res) => {
  setTimeout(() => res.end('done'), 100);
});
server.listen(3000);

let shuttingDown = false;
for (const signal of ['SIGINT', 'SIGTERM']) {
  process.on(signal, () => {
    if (shuttingDown) return;
    shuttingDown = true;

    server.close((error) => {
      process.exitCode = error ? 1 : 0;
    });

    setTimeout(() => {
      console.error('grace period expired');
      process.exit(1);
    }, 10_000).unref();
  });
}

External links

Exercise

GET /, POST /echo, GET /slow 세 경로를 가진 작은 HTTP 서버를 만들어. JSON 본문에는 크기 제한을 두고, 알 수 없는 경로는 404를 반환해. 그런 다음 SIGINT를 받으면 새 연결을 막되 이미 시작한 /slow 요청은 끝낼 시간을 주는 종료 절차를 추가해.
Hint
new URL(req.url, "http://localhost").pathname으로 경로를 구분하고, 요청 스트림의 청크 크기를 누적해 한도를 넘으면 413을 반환해. 종료에는 server.close()와 최종 시간 제한을 함께 사용해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.