Node.js HTTP 서버
http 모듈이란?
Node.js의 http 모듈은 Express 같은 프레임워크 없이도 HTTP 서버와 클라이언트를 만들 수 있게 해줍니다. Express를 배우기 전에 http 모듈을 이해하면 프레임워크 내부 동작 방식을 이해하는 데 큰 도움이 됩니다.
기본 서버 구현
const http = require('http');
const server = http.createServer((req, res) => {
// req: IncomingMessage (요청 객체)
// res: ServerResponse (응답 객체)
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('<h1>Hello, Node.js!</h1>');
});
server.listen(3000, 'localhost', () => {
console.log('서버 시작: http://localhost:3000');
});
// 에러 처리
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error('포트 3000이 이미 사용 중입니다');
} else {
console.error('서버 에러:', err.message);
}
});
req (IncomingMessage) 객체
요청에 관한 모든 정보가 담겨 있습니다.
const http = require('http');
const server = http.createServer((req, res) => {
console.log('=== 요청 정보 ===');
console.log('메서드:', req.method); // GET, POST, PUT, DELETE
console.log('URL:', req.url); // /users?page=1
console.log('HTTP 버전:', req.httpVersion); // 1.1
console.log('헤더:', req.headers);
// 자주 쓰는 헤더
console.log('Content-Type:', req.headers['content-type']);
console.log('Authorization:', req.headers['authorization']);
console.log('User-Agent:', req.headers['user-agent']);
console.log('IP 주소:', req.socket.remoteAddress);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
});
server.listen(3000);
요청 바디(Body) 읽기
POST/PUT 요청의 바디는 스트림으로 전달됩니다:
const http = require('http');
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
// 너무 큰 요청 차단 (보안)
if (body.length > 1e6) {
req.destroy();
reject(new Error('요청 크기 초과'));
}
});
req.on('end', () => {
try {
resolve(JSON.parse(body));
} catch {
resolve(body); // JSON이 아닌 경우 문자열 반환
}
});
req.on('error', reject);
});
}
const server = http.createServer(async (req, res) => {
if (req.method === 'POST') {
const body = await parseBody(req);
console.log('요청 바디:', body);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ received: body }));
} else {
res.writeHead(405);
res.end('Method Not Allowed');
}
});
server.listen(3000);
res (ServerResponse) 객체
응답을 클라이언트에 보내는 데 사용합니다.
const http = require('http');
const server = http.createServer((req, res) => {
// 상태 코드 + 헤더 설정
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'X-Custom-Header': 'my-value',
'Cache-Control': 'no-cache',
});
// 또는 개별 설정
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.setHeader('X-Request-Id', '12345');
// 헤더 읽기
console.log(res.getHeader('Content-Type'));
// 헤더 제거
res.removeHeader('X-Request-Id');
// 응답 전송 (헤더 + 바디를 한 번에)
res.end(JSON.stringify({ message: 'success' }));
// 또는 나눠서 쓰기
// res.write('첫 번째 청크');
// res.write('두 번째 청크');
// res.end();
});
server.listen(3000);
URL 파싱과 라우팅 직접 구현
const http = require('http');
const { URL } = require('url');
// 간단한 라우터 구현
const routes = new Map();
function get(path, handler) {
routes.set(`GET:${path}`, handler);
}
function post(path, handler) {
routes.set(`POST:${path}`, handler);
}
// 라우트 등록
get('/', (req, res) => {
res.end(JSON.stringify({ message: '홈페이지' }));
});
get('/users', (req, res, params, query) => {
const page = parseInt(query.get('page') || '1');
const limit = parseInt(query.get('limit') || '10');
res.end(JSON.stringify({ page, limit, users: [] }));
});
get('/users/:id', (req, res, params) => {
res.end(JSON.stringify({ id: params.id }));
});
post('/users', async (req, res) => {
let body = '';
for await (const chunk of req) {
body += chunk;
}
const user = JSON.parse(body);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ created: user }));
});
// 동적 라우트 매칭 함수
function matchRoute(method, pathname) {
const key = `${method}:${pathname}`;
// 정확한 매칭
if (routes.has(key)) {
return { handler: routes.get(key), params: {} };
}
// 동적 세그먼트 매칭 (:id 등)
for (const [routeKey, handler] of routes) {
const [routeMethod, routePath] = routeKey.split(':');
if (routeMethod !== method) continue;
const routeSegments = routePath.split('/');
const pathSegments = pathname.split('/');
if (routeSegments.length !== pathSegments.length) continue;
const params = {};
const matched = routeSegments.every((seg, i) => {
if (seg.startsWith(':')) {
params[seg.slice(1)] = pathSegments[i];
return true;
}
return seg === pathSegments[i];
});
if (matched) return { handler, params };
}
return null;
}
const server = http.createServer(async (req, res) => {
const baseUrl = `http://${req.headers.host}`;
const url = new URL(req.url, baseUrl);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
const match = matchRoute(req.method, url.pathname);
if (!match) {
res.writeHead(404);
res.end(JSON.stringify({ error: '404 Not Found' }));
return;
}
try {
await match.handler(req, res, match.params, url.searchParams);
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: '서버 오류', message: err.message }));
}
});
server.listen(3000, () => {
console.log('라우터 서버: http://localhost:3000');
console.log('테스트: curl http://localhost:3000/users?page=2&limit=5');
console.log('테스트: curl http://localhost:3000/users/42');
});
쿼리 스트링 파싱
const { URL, URLSearchParams } = require('url');
const querystring = require('querystring'); // 구형 방식
// 권장: URL 클래스 사용
const url = new URL('http://example.com/search?q=node.js&page=1&tags=js&tags=backend');
console.log(url.pathname); // /search
console.log(url.searchParams.get('q')); // node.js
console.log(url.searchParams.get('page')); // 1
console.log(url.searchParams.getAll('tags')); // ['js', 'backend']
console.log(url.searchParams.has('q')); // true
// 순회
for (const [key, value] of url.searchParams) {
console.log(`${key}: ${value}`);
}
// URLSearchParams 단독 사용
const params = new URLSearchParams('a=1&b=2&c=3');
params.set('d', '4');
params.delete('a');
console.log(params.toString()); // b=2&c=3&d=4
정적 파일 서빙
const http = require('http');
const fs = require('fs');
const path = require('path');
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
};
const PUBLIC_DIR = path.join(__dirname, 'public');
async function serveStaticFile(req, res) {
// 경로 순회 공격 방지 (../ 등)
const urlPath = decodeURIComponent(req.url.split('?')[0]);
const safePath = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
let filePath = path.join(PUBLIC_DIR, safePath);
// 디렉토리 접근 시 index.html 서빙
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
try {
const stat = await fs.promises.stat(filePath);
// ETag 캐싱
const etag = `"${stat.mtime.getTime()}-${stat.size}"`;
if (req.headers['if-none-match'] === etag) {
res.writeHead(304);
res.end();
return;
}
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': stat.size,
'ETag': etag,
'Cache-Control': 'public, max-age=3600',
});
const stream = fs.createReadStream(filePath);
stream.pipe(res);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found');
}
}
const server = http.createServer((req, res) => {
serveStaticFile(req, res);
});
server.listen(8080, () => {
console.log('정적 파일 서버: http://localhost:8080');
});
미들웨어 개념 이해
Express 이전 단계로, 미들웨어의 핵심 아이디어를 직접 구현해 봅니다.
const http = require('http');
// 미들웨어: (req, res, next) => void 형태의 함수
function logger(req, res, next) {
const start = Date.now();
const original = res.end.bind(res);
res.end = function (...args) {
const duration = Date.now() - start;
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url} - ${res.statusCode} (${duration}ms)`);
return original(...args);
};
next();
}
function cors(req, res, next) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
next();
}
function jsonBodyParser(req, res, next) {
if (req.headers['content-type']?.includes('application/json')) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
req.body = JSON.parse(body);
} catch {
req.body = null;
}
next();
});
} else {
next();
}
}
// 미들웨어 체인 실행기
function applyMiddleware(middlewares, req, res, finalHandler) {
let index = 0;
function next() {
if (index < middlewares.length) {
middlewares[index++](req, res, next);
} else {
finalHandler(req, res);
}
}
next();
}
const middlewares = [logger, cors, jsonBodyParser];
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
applyMiddleware(middlewares, req, res, (req, res) => {
if (req.url === '/api/data' && req.method === 'POST') {
res.writeHead(200);
res.end(JSON.stringify({ echo: req.body }));
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not Found' }));
}
});
});
server.listen(3000, () => {
console.log('미들웨어 서버: http://localhost:3000');
});
고수 팁
HTTP 서버 우아한 종료(Graceful Shutdown)
const http = require('http');
const server = http.createServer((req, res) => {
res.end('OK');
});
server.listen(3000);
// SIGTERM: 프로세스 종료 신호 (PM2, Docker 등)
process.on('SIGTERM', () => {
console.log('종료 신호 수신, 새 연결 거부 중...');
server.close(() => {
console.log('모든 연결 종료, 프로세스 종료');
process.exit(0);
});
// 강제 종료 타임아웃 (30초)
setTimeout(() => {
console.error('강제 종료');
process.exit(1);
}, 30_000);
});
keep-alive 연결 관리
const http = require('http');
const server = http.createServer((req, res) => {
res.end('OK');
});
// keep-alive 타임아웃 설정
server.keepAliveTimeout = 65_000; // 65초
server.headersTimeout = 66_000; // 66초 (keep-alive보다 길게)
server.listen(3000);