Node.js http 서버와 라우팅 - 프레임워크 없이 GET API 만들기 (Node.js API 4단원)
이 단원에서 배우는 것
드디어 서버를 띄운다. 프레임워크 없이 node:http 하나로 메모 목록을 돌려주는 읽기 전용 API를 만든다. Express 같은 프레임워크도 결국 이 모듈 위에 지어져 있어서, 여기서 요청과 응답이 어떻게 생겼는지 한 번 손으로 다뤄 보면 어떤 프레임워크를 만나도 "이 기능은 밑에서 무엇을 하는지"가 보인다.
createServer와listen으로 서버를 띄우고, 요청(req)과 응답(res) 객체를 읽는다.new URL()로 경로와 쿼리 문자열을 나누고, 메서드와 경로로 처리할 코드를 고르는 라우팅을 만든다.- JSON 응답에 필요한 헤더(
content-type,content-length)와 상태 코드 200·404를 쓴다. - 브라우저 대신 Node.js의
fetch로 서버를 시험한다.
문제 상황
스터디 모임에서 쓸 메모 앱을 만든다. 프론트엔드 담당이 이렇게 요청했다.
GET /memos— 메모 목록을 JSON으로.?q=검색어로 제목 검색,?done=true|false로 완료 여부 거르기.GET /memos/2— 2번 메모 하나. 없으면 404.GET /health— 서버가 살아 있는지 확인하는 주소. 배포 도구가 주기적으로 부른다.
| 요청 | 성공 | 실패 |
|---|---|---|
GET /health | 200 { ok: true } | - |
GET /memos | 200 { items, count } | - |
GET /memos?q=…&done=… | 200 거른 목록 | - |
GET /memos/:id | 200 메모 하나 | 404 메모 없음 |
| 그 밖의 주소 | - | 404 주소 없음 |
아직 저장은 하지 않는다. 메모 세 개를 코드에 적어 두고 읽기만 한다. 예제는 ch04 폴더에 둔다.
완성 코드
서버: server.mjs
// server.mjs — node:http 만으로 만든 첫 메모 API (읽기 전용)
import { createServer } from 'node:http';
const PORT = 3700;
const HOST = '127.0.0.1';
const memos = [
{ id: 1, title: 'Node 설치 확인', done: true },
{ id: 2, title: '모듈 복습', done: false },
{ id: 3, title: 'http 서버 만들기', done: false },
];
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);
}
function handle(req, res) {
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
const path = url.pathname;
if (req.method === 'GET' && path === '/health') {
return sendJson(res, 200, { ok: true });
}
if (req.method === 'GET' && path === '/memos') {
const q = url.searchParams.get('q');
const done = url.searchParams.get('done');
let result = memos;
if (q) result = result.filter((m) => m.title.includes(q));
if (done === 'true' || done === 'false') {
result = result.filter((m) => m.done === (done === 'true'));
}
return sendJson(res, 200, { items: result, count: result.length });
}
const match = path.match(/^\/memos\/(\d+)$/);
if (req.method === 'GET' && match) {
const memo = memos.find((m) => m.id === Number(match[1]));
if (!memo) return sendJson(res, 404, { error: '메모가 없습니다' });
return sendJson(res, 200, memo);
}
sendJson(res, 404, { error: `${req.method} ${path} 는 없는 주소입니다` });
}
const server = createServer(handle);
server.listen(PORT, HOST, () => {
console.log(`메모 API 대기 중: http://${HOST}:${PORT}`);
});
시험용 클라이언트: client.mjs
// client.mjs — 서버에 요청을 보내고 상태 코드와 본문을 출력한다
const BASE = process.argv[2] ?? 'http://127.0.0.1:3700';
const paths = process.argv.slice(3);
for (const path of paths) {
const res = await fetch(BASE + path);
const text = await res.text();
console.log(`GET ${path} -> ${res.status} ${res.headers.get('content-type')}`);
console.log(` ${text}`);
}
줄별 해설
서버의 뼈대: 함수 하나와 포트 하나
const server = createServer(handle);
server.listen(PORT, HOST, () => {
createServer에 넘긴 handle 함수가 요청 하나마다 한 번씩 불린다. 요청이 동시에 100개 오면 100번 불린다. 2단원에서 본 것처럼 이 함수 안에서 오래 걸리는 동기 계산을 하면 나머지 99개가 기다린다.
그림 4-1. 요청 하나가 응답이 되기까지
listen(3700, '127.0.0.1')은 "이 컴퓨터 안에서 오는 3700번 포트 연결만 받겠다"는 뜻이다. 호스트를 빼면 모든 네트워크 카드(0.0.0.0이나 ::)에서 받게 되어, 같은 와이파이에 있는 다른 기기에서도 접속할 수 있다. 연습할 때는 127.0.0.1로 묶어 두는 편이 안전하다. 콜백은 포트를 실제로 잡은 뒤에 불리므로 "대기 중" 메시지는 여기서 찍는다. 포트를 다른 프로그램이 쓰고 있으면 EADDRINUSE 오류가 난다. 그때는 숫자를 바꾸면 된다.
요청에서 필요한 것 꺼내기
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
const path = url.pathname;
req.url에는 /memos?q=%EB%AA%A8%EB%93%88처럼 경로와 쿼리 문자열이 붙은 채로, 그것도 퍼센트 인코딩된 채로 들어 있다. 문자열을 ?로 직접 자르면 인코딩 해제, 같은 이름 여러 개, + 처리 같은 일을 다 손으로 해야 한다. 브라우저에도 있는 표준 URL 클래스에 맡기면 pathname(경로)과 searchParams(쿼리)로 나눠 주고, searchParams.get('q')는 인코딩을 풀어 '모듈'을 준다. new URL은 완전한 주소만 받으므로 두 번째 인자로 기준 주소를 준다. 여기서 호스트 값은 쓰지 않으니 무엇이든 괜찮다.
그림 4-2. req.url 을 URL 로 나누기
req.method는 GET, POST 같은 메서드, req.headers는 요청 헤더다. Node.js는 헤더 이름을 모두 소문자로 바꿔 둔다. req.headers['Content-Type']이 아니라 req.headers['content-type']으로 읽는다.
| 값 | 예 | 주의 |
|---|---|---|
req.method | 'GET' | 대문자 |
req.url | '/memos?q=%EB…' | 인코딩된 그대로 |
req.headers | ['content-type'] | 이름은 모두 소문자 |
searchParams.get() | 'false' | 언제나 문자열, 없으면 null |
라우팅: 메서드와 경로로 갈래 나누기
if (req.method === 'GET' && path === '/health')처럼 조건을 차례로 확인하고, 맞는 곳에서 응답하고 return한다. 아무 조건에도 맞지 않으면 마지막 줄의 404로 떨어진다. 이것이 라우팅의 전부다. 프레임워크의 app.get('/health', ...)은 이 if를 표로 정리해 주는 도구다. 6단원에서 우리도 표로 바꾼다.
const match = path.match(/^\/memos\/(\d+)$/);
/memos/2처럼 경로 안에 값이 들어 있는 경우는 정규식으로 꺼낸다. ^와 $로 처음과 끝을 고정했으므로 /memos/2/extra나 /memos/abc는 맞지 않는다. 꺼낸 값은 문자열 '2'이므로 Number()로 바꿔서 숫자 id와 비교한다. ===로 문자열과 숫자를 비교하면 영원히 false다.
쿼리 문자열 값도 언제나 문자열이다. ?done=false의 값은 불리언 false가 아니라 문자열 'false'이고, 빈 문자열이 아니므로 if (done)은 참이 된다. 그래서 'true', 'false' 두 글자와 정확히 비교했다. 그 밖의 값(?done=yes)은 거르지 않고 무시한다. 5단원부터는 이런 값을 오류로 알려 주는 방법을 쓴다.
응답 보내기: 상태 코드, 헤더, 본문
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);
}
writeHead는 상태 줄과 헤더를, end는 본문을 보내고 응답을 마친다. end를 부르지 않으면 클라이언트는 응답이 끝나기를 계속 기다린다. 헤더 두 개의 의미는 이렇다.
content-type: application/json; charset=utf-8— 본문이 JSON이고 UTF-8로 인코딩되어 있다. 빠뜨리면 받는 쪽이 한글을 깨뜨리거나 JSON으로 해석하지 않을 수 있다.content-length— 본문의 바이트 수. 3단원에서 본 대로 한글은 한 글자에 3바이트라서body.length(글자 수)를 쓰면 틀린다.Buffer.byteLength로 센다.
상태 코드 200은 성공, 404는 "그런 자원이 없다"다. 목록 검색 결과가 0개인 것은 404가 아니라 200에 빈 배열이다. 404는 /memos/99처럼 그 주소가 가리키는 대상이 없을 때 쓴다.
클라이언트: 서버를 두드려 보기
client.mjs는 첫 인자로 받은 서버 주소 뒤에 나머지 경로들을 붙여 차례로 fetch한다. res.status는 상태 코드, res.headers.get()은 응답 헤더, res.text()는 본문 문자열이다. 본문을 객체로 받고 싶으면 res.json()을 쓴다. 한글 경로 /memos?q=모듈은 fetch가 알아서 퍼센트 인코딩해서 보낸다. 브라우저 주소창이나 curl을 써도 되지만, 이 책은 출력을 그대로 원고에 싣기 위해 Node 스크립트를 쓴다.
실제 실행 결과
터미널을 두 개 연다. 첫 번째 터미널에서 ch04 폴더로 가서 node server.mjs로 서버를 띄운다. 서버는 끝나지 않고 요청을 기다린다. 두 번째 터미널에서 클라이언트를 실행한다.
$ node client.mjs http://127.0.0.1:3700 /health /memos /memos?q=모듈 /memos?done=false /memos/2 /memos/99 /memos/ /users
GET /health -> 200 application/json; charset=utf-8
{"ok":true}
GET /memos -> 200 application/json; charset=utf-8
{"items":[{"id":1,"title":"Node 설치 확인","done":true},{"id":2,"title":"모듈 복습","done":false},{"id":3,"title":"http 서버 만들기","done":false}],"count":3}
GET /memos?q=모듈 -> 200 application/json; charset=utf-8
{"items":[{"id":2,"title":"모듈 복습","done":false}],"count":1}
GET /memos?done=false -> 200 application/json; charset=utf-8
{"items":[{"id":2,"title":"모듈 복습","done":false},{"id":3,"title":"http 서버 만들기","done":false}],"count":2}
GET /memos/2 -> 200 application/json; charset=utf-8
{"id":2,"title":"모듈 복습","done":false}
GET /memos/99 -> 404 application/json; charset=utf-8
{"error":"메모가 없습니다"}
GET /memos/ -> 404 application/json; charset=utf-8
{"error":"GET /memos/ 는 없는 주소입니다"}
GET /users -> 404 application/json; charset=utf-8
{"error":"GET /users 는 없는 주소입니다"}
서버 터미널에는 시작 메시지만 찍혀 있다. 요청 기록은 9단원에서 붙인다. 서버는 Ctrl+C로 끈다.
메모 API 대기 중: http://127.0.0.1:3700
결과를 하나씩 보면 이렇다. ?q=모듈은 한글이 인코딩되어 전달됐지만 서버에서 제대로 풀려 1건이 걸렸다. ?done=false는 미완료 두 건만 돌려줬다. /memos/99는 "메모가 없다"는 404, /users는 "주소가 없다"는 404다. 같은 404라도 메시지로 둘을 구분했다. 눈여겨볼 것은 /memos/(끝에 빗금)다. 사람 눈에는 /memos와 같아 보이지만 우리 라우터는 정확히 같은 문자열만 비교하므로 404가 났다(연습 문제 1번).
실무에서 자주 틀리는 것
1. 응답을 두 번 보낸다
조건문 안에서 sendJson을 부르고 return을 빠뜨리면 아래쪽 404까지 이어서 실행된다. 이미 끝난 응답에 또 쓰려고 하면 ERR_HTTP_HEADERS_SENT 오류가 난다. 응답을 보낸 줄에는 항상 return을 붙이는 습관을 들인다.
2. 쿼리 값을 타입이 있는 값으로 착각한다
?page=2의 값은 '2', ?done=false의 값은 'false'다. 없는 쿼리는 null이다. 숫자와 불리언이 필요하면 직접 바꾸고 검사한다.
3. 0.0.0.0으로 열어 둔 개발 서버
카페 와이파이에서 연습용 서버를 모든 주소에 열어 두면 같은 네트워크의 누구나 접속할 수 있다. 외부 공개는 배포할 때 정한다. 개발 중에는 127.0.0.1이다.
4. 한글 본문 길이를 글자 수로 센다
content-length를 body.length로 적으면 한글이 들어간 응답의 뒷부분이 잘린다. 헤더를 손으로 적을 때는 Buffer.byteLength를 쓴다. 적지 않으면 Node.js가 조각 전송(chunked) 방식으로 보내므로, 틀린 값을 적느니 안 적는 편이 낫다.
연습 문제
/memos/처럼 끝에 빗금이 붙은 경로도/memos와 같이 처리하고 싶다. 라우팅 전에 경로를 어떻게 다듬으면 되는가? 루트 경로/는 어떻게 되어야 하는가?- 개발자가
sendJson을 쓰지 않고res.writeHead(200)만 부른 뒤res.end()를 빠뜨렸다. 클라이언트에서는 어떤 일이 생기는가? req.url이/memos?q=%EB%AA%A8%EB%93%88&q=abc일 때url.searchParams.get('q')와url.searchParams.getAll('q')는 각각 무엇을 돌려주는가?
정답과 해설
- 경로가
/하나가 아니고 빗금으로 끝나면 마지막 빗금을 떼어 낸다. 예:const path = url.pathname.length > 1 ? url.pathname.replace(/\/+$/, '') : url.pathname;이렇게 하면/memos/와/memos//는/memos가 되고/는 그대로 남는다(검증 스크립트로 네 경우 확인). 다른 방법은 빗금이 붙은 주소로 오면301로 빗금 없는 주소를 알려 주는 것이다. 어느 쪽이든 팀에서 하나로 정해 문서에 남긴다. - 클라이언트는 아무것도 받지 못한 채 기다린다.
writeHead는 헤더를 준비만 하고, 실제로는 첫 본문 조각이나end()와 함께 보내기 때문이다. 검증 스크립트에서 이렇게 만든 서버에 0.5초 제한을 걸고fetch했더니 응답 헤더조차 오지 않아TimeoutError로 끝났다. 브라우저라면 로딩 표시만 계속 돈다. 결국 클라이언트나 중간 프록시의 시간 제한에 걸려 끊긴다. 모든 갈래가end()로 끝나는지 확인하고, 6단원처럼 응답을 한곳(sendJson)에서만 보내게 만들면 이런 실수가 줄어든다. get('q')는 첫 번째 값만, 인코딩을 푼'모듈'을 돌려준다.getAll('q')는['모듈', 'abc']배열이다(검증 스크립트로 확인). 같은 이름이 여러 번 올 수 있다는 것을 알아 두면, 검색 조건 여러 개를 받는 API를 만들 때 쓸 수 있다.
지금 서버는 읽기만 한다. 다음 단원에서는 POST로 메모를 받는다. 요청 본문을 읽고, 믿을 수 없는 입력을 검사하는 것이 핵심이다.