브라우저에서만 동작하던 JavaScript를 서버에서도 실행할 수 있게 해주는 것이 Node.js입니다. Google V8 엔진 위에서 동작하며, 빠른 I/O와 이벤트 기반 아키텍처로 API 서버, 실시간 서비스에 적합합니다.
Node.js 기본 — HTTP 서버
// server.js
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: '안녕하세요!' }));
});
server.listen(3000, () => {
console.log('서버 실행 중: http://localhost:3000');
});
파일 시스템 다루기
const fs = require('fs/promises');
const path = require('path');
async function readConfig() {
const filePath = path.join(__dirname, 'config.json');
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content);
}
async function writeLog(message) {
const logPath = path.join(__dirname, 'logs', 'app.log');
const line = `[${new Date().toISOString()}] ${message}\n`;
await fs.appendFile(logPath, line);
}
Express로 REST API 만들기
npm init -y
npm install express
// app.js
const express = require('express');
const app = express();
app.use(express.json()); // JSON 요청 파싱
// 인메모리 데이터 (실제로는 DB 사용)
let posts = [
{ id: 1, title: 'Node.js 입문', content: '...' },
{ id: 2, title: 'Express 가이드', content: '...' },
];
// GET — 목록 조회
app.get('/api/posts', (req, res) => {
res.json(posts);
});
// GET — 단건 조회
app.get('/api/posts/:id', (req, res) => {
const post = posts.find(p => p.id === Number(req.params.id));
if (!post) return res.status(404).json({ error: '없는 글입니다' });
res.json(post);
});
// POST — 생성
app.post('/api/posts', (req, res) => {
const { title, content } = req.body;
if (!title) return res.status(400).json({ error: '제목은 필수입니다' });
const newPost = { id: Date.now(), title, content };
posts.push(newPost);
res.status(201).json(newPost);
});
// PUT — 수정
app.put('/api/posts/:id', (req, res) => {
const idx = posts.findIndex(p => p.id === Number(req.params.id));
if (idx === -1) return res.status(404).json({ error: '없는 글입니다' });
posts[idx] = { ...posts[idx], ...req.body };
res.json(posts[idx]);
});
// DELETE — 삭제
app.delete('/api/posts/:id', (req, res) => {
posts = posts.filter(p => p.id !== Number(req.params.id));
res.status(204).send();
});
app.listen(3000, () => console.log('🚀 서버 시작: http://localhost:3000'));
미들웨어 패턴
// 로깅 미들웨어
app.use((req, res, next) => {
console.log(`${req.method} ${req.url} — ${new Date().toISOString()}`);
next(); // 다음 미들웨어로 전달
});
// 인증 미들웨어
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: '로그인이 필요합니다' });
// 토큰 검증 로직...
next();
}
// 특정 라우트에만 적용
app.delete('/api/posts/:id', requireAuth, (req, res) => {
// 인증된 사용자만 삭제 가능
});
환경 변수 관리
npm install dotenv
// .env 파일
PORT=3000
DB_URL=mongodb://localhost:27017/mydb
JWT_SECRET=my-super-secret-key
// app.js
require('dotenv').config();
const PORT = process.env.PORT || 3000;
app.listen(PORT);
에러 처리 — Express 글로벌 에러 핸들러
API 서버에서 에러를 제대로 처리하지 않으면 서버 전체가 멈출 수 있습니다. Express에서는 4개의 인수를 받는 미들웨어를 가장 마지막에 등록하면 글로벌 에러 핸들러가 됩니다.
// 에러가 발생하면 next(err)로 넘기기
app.get('/api/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
res.json(user);
} catch (err) {
next(err); // 글로벌 에러 핸들러로 전달
}
});
// 글로벌 에러 핸들러 (반드시 마지막에 등록)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message || '서버 오류가 발생했습니다'
});
});
Node.js 이벤트 루프 이해하기
Node.js가 빠른 이유는 논블로킹 I/O와 이벤트 루프 덕분입니다. 파일 읽기, 데이터베이스 쿼리처럼 시간이 걸리는 작업을 기다리지 않고 다음 요청을 처리합니다. 이 방식 덕분에 싱글 스레드임에도 수천 개의 동시 연결을 처리할 수 있습니다.
- Call Stack — 현재 실행 중인 코드의 위치를 추적합니다.
- Web APIs / libuv — setTimeout, 파일 I/O, 네트워크 요청 등 비동기 작업을 처리합니다.
- Callback Queue — 완료된 비동기 작업의 콜백이 대기하는 큐입니다.
- Event Loop — Call Stack이 비면 Callback Queue에서 작업을 가져와 실행합니다.
이 구조 때문에 CPU를 많이 사용하는 계산 작업(예: 대용량 이미지 처리, 암호화)은 Node.js 단독으로는 적합하지 않습니다. 이런 경우에는 worker_threads 모듈이나 별도 마이크로서비스를 활용하는 것이 좋습니다.
npm — 패키지 관리 핵심
npm(Node Package Manager)은 세계 최대의 소프트웨어 레지스트리입니다. package.json은 프로젝트의 설정 파일이자 의존성 목록으로, 팀원들이 동일한 환경을 구성하는 기준이 됩니다.
npm install 패키지명— 프로덕션 의존성 설치 (dependencies)npm install 패키지명 --save-dev— 개발 전용 의존성 설치 (devDependencies)npm ci— CI/CD 환경에서 package-lock.json 기준으로 정확히 설치npm audit— 보안 취약점 검사npm outdated— 업데이트 가능한 패키지 확인
package-lock.json은 반드시 Git에 커밋해야 합니다. 이 파일이 있어야 팀 전원이 동일한 버전의 패키지를 설치하게 됩니다.
Node.js는 이벤트 루프 기반의 논블로킹 I/O로 고성능 API 서버를 구현할 수 있습니다. Express로 빠르게 REST API를 구성하고, 미들웨어 패턴으로 인증·로깅·에러 처리를 분리하세요. 실제 서비스라면 글로벌 에러 핸들러를 반드시 등록하고, 데이터베이스(MongoDB, PostgreSQL)와 연동해 데이터를 영속적으로 저장해야 합니다.
클러스터 모드 — 다중 코어 활용
Node.js는 기본적으로 단일 스레드입니다. CPU가 여러 코어를 가져도 하나만 사용합니다. cluster 모듈로 코어 수만큼 프로세스를 생성해 성능을 높일 수 있습니다.
import cluster from 'cluster';
import os from 'os';
if (cluster.isPrimary) {
const numCPUs = os.cpus().length;
console.log(`마스터 프로세스 시작. 워커 ${numCPUs}개 생성`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker) => {
console.log(`워커 ${worker.process.pid} 종료. 재시작...`);
cluster.fork(); // 자동 재시작
});
} else {
// 각 워커에서 서버 시작
const app = express();
app.listen(3000, () => {
console.log(`워커 ${process.pid} 포트 3000에서 실행 중`);
});
}
실무에서는 PM2의 cluster 모드가 더 편리합니다. pm2 start app.js -i max 명령 하나로 모든 코어를 활용하고, 무중단 재시작과 모니터링까지 제공합니다.
Node.js 보안 필수 체크리스트
Express 앱을 프로덕션에 배포할 때 반드시 적용해야 할 보안 설정입니다.
import express from 'express';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import cors from 'cors';
const app = express();
// 1. 보안 헤더 설정 (XSS, clickjacking 방어)
app.use(helmet());
// 2. CORS — 허용할 출처를 명시적으로 지정
app.use(cors({
origin: ['https://myapp.com', 'https://www.myapp.com'],
credentials: true,
}));
// 3. Rate Limiting — 분당 100요청으로 제한
app.use(rateLimit({
windowMs: 60 * 1000,
max: 100,
message: '요청이 너무 많습니다. 잠시 후 다시 시도하세요.',
}));
// 4. 요청 크기 제한 (10MB)
app.use(express.json({ limit: '10mb' }));
// 5. 환경 변수로 시크릿 관리 (코드에 직접 하드코딩 금지)
const JWT_SECRET = process.env.JWT_SECRET; // .env 파일에서 로드
Node.js 스트림 — 대용량 파일 처리
대용량 파일을 통째로 메모리에 읽으면 서버가 다운될 수 있습니다. 스트림을 사용하면 청크 단위로 처리해 메모리 부담을 최소화합니다.
import fs from 'fs';
import zlib from 'zlib';
import { pipeline } from 'stream/promises';
// 1GB 파일도 메모리 걱정 없이 압축
await pipeline(
fs.createReadStream('big-file.csv'),
zlib.createGzip(),
fs.createWriteStream('big-file.csv.gz')
);
// HTTP 응답으로 파일 스트리밍
app.get('/download/:file', (req, res) => {
const filePath = `/data/${req.params.file}`;
res.setHeader('Content-Disposition', 'attachment');
fs.createReadStream(filePath).pipe(res);
});
파일 다운로드, CSV 처리, 로그 분석 등 대용량 데이터를 다루는 API라면 스트림을 기본으로 사용해야 합니다. pipeline은 파이프라인의 에러 처리와 정리를 자동으로 해줘서 메모리 누수를 방지합니다.
Node.js 서비스를 프로덕션에서 안정적으로 운영하기
Node.js 애플리케이션을 개발 환경에서 잘 돌리는 것과 프로덕션에서 안정적으로 운영하는 것은 다른 차원의 문제입니다. 가장 먼저 신경 써야 할 것은 프로세스 관리입니다. 개발 시 node server.js로 실행하면 서버가 크래시될 때 자동으로 재시작되지 않습니다. PM2나 Forever 같은 프로세스 매니저를 사용하면 크래시 시 자동 재시작, 로그 관리, 프로세스 모니터링을 한 번에 처리할 수 있습니다. 두 번째로 중요한 것은 구조화된 로깅입니다. console.log 대신 Winston이나 Pino 같은 로깅 라이브러리를 사용해 JSON 포맷으로 로그를 출력하면, Datadog이나 CloudWatch 같은 로그 수집 시스템과 연동해 검색과 분석이 용이해집니다.
메모리 누수는 장기 운영 환경에서 자주 등장하는 문제입니다. Node.js 프로세스의 메모리 사용량이 시간이 지날수록 계속 늘어난다면 메모리 누수를 의심해야 합니다. 이벤트 리스너를 제거하지 않거나, 전역 변수에 데이터를 무제한으로 쌓거나, 클로저가 예상보다 큰 범위를 참조하는 것이 주요 원인입니다. Node.js의 --inspect 플래그와 Chrome DevTools 메모리 프로파일러를 조합하면 어느 객체가 메모리를 잡고 있는지 추적할 수 있습니다. 가비지 컬렉션이 정상적으로 동작하는 환경이라도 메모리 상한을 --max-old-space-size로 설정해두는 것이 서버 전체 메모리 고갈을 막는 안전망이 됩니다.