Express.js 기초: 라우팅, 미들웨어, REST API 구축, 요청·응답 처리, 보안 설정

이 글의 핵심

Express에서 가장 많이 헤매는 부분은 미들웨어가 등록 순서대로 실행되고 next()를 호출하지 않으면 요청이 멈춘다는 점입니다. 에러 처리 미들웨어가 인자 네 개로 구분되는 이유, 커스텀 에러 클래스로 응답을 일관되게 만드는 방법을 짚고, helmet 같은 기본 보안 설정까지 붙여 실사용 가능한 API 뼈대를 완성합니다.

들어가며

Express.js란?

Express.js는 Node.js의 http 모듈 위에 라우팅과 미들웨어를 얹은 최소한의 웹 프레임워크입니다. 요청이 들어오면 주소(경로)와 HTTP 메서드에 따라 교통 표지판·우편 집배처럼 “이 편지는 이 핸들러로”라고 연결해 줍니다. 그 사이사이에 미들웨어를 끼우는데, 이는 검문소나 공장의 필터처럼 로그인·JSON 파싱·CORS 같은 공통 작업을 통과시키는 단계라고 이해하시면 됩니다.

특징:

  • 간결한 API: 최소한의 코드로 서버 구축

  • 미들웨어: 요청 처리 파이프라인

  • 라우팅: URL 패턴 매칭

  • 템플릿 엔진: EJS, Pug 등 지원

  • 생태계: 인증·로깅·보안 등 이미 만들어진 미들웨어가 많음 사용 사례:

  • REST API 서버

  • 웹 애플리케이션

  • 마이크로서비스

  • 프록시 서버


설치와 기본 서버 구조

설치

# 프로젝트 초기화
npm init -y
# Express 설치
npm install express
# 개발 도구 설치
npm install --save-dev nodemon

Hello World

// app.js
const express = require('express');
const app = express();
app.get('/', (req, res) => {
    res.send('Hello, Express!');
});
const PORT = 3000;
app.listen(PORT, () => {
    console.log(`서버 실행 중: http://localhost:${PORT}`);
});

실행:

node app.js
# 또는
nodemon app.js

설치 전에 알아 둘 점이 있습니다. 2025년부터 npm install express의 기본 버전은 Express 5입니다. 이 글의 대부분 코드는 4와 5에서 똑같이 동작하지만, 경로 패턴 문법(아래 “경로 매개변수” 참고)과 async 핸들러의 에러 처리 방식이 달라졌습니다. 기존 Express 4 프로젝트를 유지보수한다면 package.json에서 버전을 확인하고, 새 프로젝트라면 Express 5를 기준으로 작성하는 것이 좋습니다. 또 Node.js 18.11부터는 node --watch app.js로 nodemon 없이도 파일 변경 시 재시작할 수 있습니다.

기본 구조

const express = require('express');
const app = express();
// 미들웨어
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 라우트
app.get('/', (req, res) => {
    res.send('홈 페이지');
});
app.get('/about', (req, res) => {
    res.send('소개 페이지');
});
// 404 처리
app.use((req, res) => {
    res.status(404).send('페이지를 찾을 수 없습니다');
});
// 에러 처리
app.use((err, req, res, next) => {
    console.error(err.stack);
    res.status(500).send('서버 에러');
});
// 서버 시작
app.listen(3000, () => {
    console.log('서버 실행 중: http://localhost:3000');
});

이 구조에서 등록 순서가 그대로 실행 순서라는 점이 Express를 이해하는 핵심입니다. Express는 요청이 들어오면 app.use/app.get으로 등록된 목록을 위에서부터 차례로 검사하고, 경로와 메서드가 맞는 첫 번째 함수를 실행합니다. 그래서 404 처리기는 모든 라우트 뒤에 있어야 하고(앞에 두면 모든 요청이 404가 됩니다), 에러 처리기는 그보다도 뒤에 있어야 합니다. 에러 처리기는 인자가 정확히 네 개여야 Express가 에러 처리용으로 인식합니다. next를 쓰지 않는다고 매개변수에서 지우면 인자가 세 개인 일반 미들웨어로 취급되어 에러가 생겨도 호출되지 않습니다. ESLint의 unused-vars 규칙 때문에 next를 지웠다가 에러 응답이 사라지는 일이 실제로 자주 생기므로, _next처럼 이름을 바꿔 남겨 두세요.


라우팅: 메서드, 경로 매개변수, 쿼리, 라우터 분리

app.get('/users', …)처럼 경로 + 메서드가 맞을 때만 실행되는 함수를 붙입니다. 우편물 주소가 동네·번지·호수까지 정확히 맞아야 배달되듯, 클라이언트가 GET /api/users/42처럼 요청하면 :id에 42가 들어가 해당 핸들러 한 곳으로만 전달됩니다.

HTTP 메서드

const express = require('express');
const app = express();
// GET: 데이터 조회
app.get('/users', (req, res) => {
    res.json({ users: ['홍길동', '김철수'] });
});
// POST: 데이터 생성
app.post('/users', (req, res) => {
    const user = req.body;
    res.status(201).json({ message: '사용자 생성됨', user });
});
// PUT: 데이터 전체 수정
app.put('/users/:id', (req, res) => {
    const { id } = req.params;
    const user = req.body;
    res.json({ message: `사용자 ${id} 수정됨`, user });
});
// PATCH: 데이터 일부 수정
app.patch('/users/:id', (req, res) => {
    const { id } = req.params;
    const updates = req.body;
    res.json({ message: `사용자 ${id} 일부 수정됨`, updates });
});
// DELETE: 데이터 삭제
app.delete('/users/:id', (req, res) => {
    const { id } = req.params;
    res.json({ message: `사용자 ${id} 삭제됨` });
});
// ALL: 모든 메서드
app.all('/secret', (req, res) => {
    res.send('비밀 페이지');
});

경로 매개변수 (Route Parameters)

// 단일 매개변수
app.get('/users/:id', (req, res) => {
    const { id } = req.params;
    res.send(`사용자 ID: ${id}`);
});
// GET /users/123 → id: "123"
// 여러 매개변수
app.get('/users/:userId/posts/:postId', (req, res) => {
    const { userId, postId } = req.params;
    res.json({ userId, postId });
});
// GET /users/123/posts/456 → { userId: "123", postId: "456" }
// 정규식 패턴 (Express 4 문법, Express 5에서는 에러)
app.get('/files/:filename(\\d+\\.txt)', (req, res) => {
    const { filename } = req.params;
    res.send(`파일: ${filename}`);
});
// GET /files/123.txt ✅
// GET /files/abc.txt ❌
// 선택적 매개변수 (Express 4 문법, Express 5에서는 '/users{/:id}')
app.get('/users/:id?', (req, res) => {
    if (req.params.id) {
        res.send(`사용자 ID: ${req.params.id}`);
    } else {
        res.send('모든 사용자');
    }
});

Express 5는 내부 경로 매칭 라이브러리(path-to-regexp)를 새 버전으로 바꾸면서 문법을 단순화했습니다. 매개변수 안에 정규식을 넣는 :filename(\\d+) 형태와 ?로 끝나는 선택적 매개변수는 더 이상 지원되지 않아, 이 코드를 Express 5에서 실행하면 서버가 뜨는 순간 경로 파싱 에러가 납니다. 선택적 부분은 /users{/:id}처럼 중괄호로 감싸고, 형식 검증은 핸들러 안에서 /^\d+\.txt$/.test(filename)처럼 직접 하거나 검증 미들웨어로 옮기는 것이 새 방식입니다. 와일드카드도 * 대신 /*splat처럼 이름을 붙여야 합니다. 정규식을 경로에서 뺀 이유 중 하나는 복잡한 정규식이 요청마다 실행되면서 생기는 ReDoS(정규식 서비스 거부) 위험을 줄이기 위해서입니다.

req.params의 값은 항상 문자열이라는 점도 중요합니다. /users/123의 id는 숫자 123이 아니라 "123"이므로, 아래 CRUD 예제처럼 parseInt로 바꾼 뒤 ===로 비교해야 합니다. 변환 없이 users.find(u => u.id === req.params.id)로 비교하면 항상 undefined가 나와 “분명 있는 사용자인데 404가 난다”는 버그가 됩니다.

쿼리 문자열 (Query String)

app.get('/search', (req, res) => {
    const { q, page, limit } = req.query;
    
    res.json({
        query: q,
        page: parseInt(page) || 1,
        limit: parseInt(limit) || 10
    });
});
// GET /search?q=nodejs&page=2&limit=20
// { query: "nodejs", page: 2, limit: 20 }

쿼리 값은 문자열일 거라고 가정하기 쉽지만, 클라이언트가 ?q=a&q=b처럼 같은 키를 두 번 보내면 req.query.q는 배열 ["a", "b"]가 되고, Express 4의 기본 파서(qs)는 ?user[name]=x를 객체로 만들기도 합니다. 문자열 메서드(q.toLowerCase())를 바로 호출하는 코드는 이런 요청에 TypeError로 죽고, 객체가 그대로 DB 쿼리로 들어가면 NoSQL 인젝션의 통로가 됩니다. Express 5는 기본 쿼리 파서를 단순 모드로 바꿔 중첩 객체를 만들지 않지만, 배열 문제는 그대로이므로 typeof q === 'string' 확인이나 검증 라이브러리로 형태를 강제해야 합니다. limit도 상한을 두지 않으면 ?limit=1000000으로 한 번에 모든 데이터를 가져가는 요청을 막을 수 없습니다.

라우터 분리

// routes/users.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
    res.json({ users: [] });
});
router.get('/:id', (req, res) => {
    res.json({ id: req.params.id });
});
router.post('/', (req, res) => {
    res.status(201).json({ message: '생성됨' });
});
module.exports = router;
// app.js
const express = require('express');
const usersRouter = require('./routes/users');
const postsRouter = require('./routes/posts');
const app = express();
app.use('/api/users', usersRouter);
app.use('/api/posts', postsRouter);
app.listen(3000);

미들웨어의 종류와 직접 만들기

미들웨어란?

미들웨어는 요청과 응답 사이에서 실행되는 함수입니다. 들어온 요청이 라우트 핸들러에 닿기 전에 검문·신분 확인·본문(JSON) 해석 같은 공통 절차를 거치게 할 때 씁니다. next()를 호출해야만 다음 검문소로 통과하며, 호출하지 않으면 그 자리에서 응답으로 끝납니다.

function myMiddleware(req, res, next) {
    console.log('미들웨어 실행');
    next();  // 다음 미들웨어로 이동
}
app.use(myMiddleware);

실행 흐름:

요청 → 미들웨어1 → 미들웨어2 → 라우트 핸들러 → 응답

애플리케이션 레벨 미들웨어

const express = require('express');
const app = express();
// 모든 요청에 실행
app.use((req, res, next) => {
    console.log(`${req.method} ${req.url}`);
    console.log('시간:', new Date().toISOString());
    next();
});
// 특정 경로에만 실행
app.use('/api', (req, res, next) => {
    console.log('API 요청');
    next();
});
// 여러 미들웨어 체이닝
app.get('/users',
    (req, res, next) => {
        console.log('미들웨어 1');
        next();
    },
    (req, res, next) => {
        console.log('미들웨어 2');
        next();
    },
    (req, res) => {
        res.send('사용자 목록');
    }
);

내장 미들웨어

// JSON 파싱
app.use(express.json());
// URL-encoded 파싱
app.use(express.urlencoded({ extended: true }));
// 정적 파일 서빙
app.use(express.static('public'));
// public/style.css → http://localhost:3000/style.css
// 여러 정적 폴더
app.use('/static', express.static('public'));
app.use('/uploads', express.static('uploads'));

서드파티 미들웨어

npm install cors morgan helmet compression
const cors = require('cors');
const morgan = require('morgan');
const helmet = require('helmet');
const compression = require('compression');
// CORS (Cross-Origin Resource Sharing)
app.use(cors());
// HTTP 요청 로깅
app.use(morgan('dev'));
// GET /users 200 15.234 ms - 123
// 보안 헤더
app.use(helmet());
// 응답 압축
app.use(compression());

cors()를 인자 없이 쓰면 모든 출처의 요청을 허용합니다(Access-Control-Allow-Origin: *). 개발 중에는 편하지만 운영에서는 아래 “보안” 절처럼 허용할 도메인을 명시해야 합니다. 또 CORS는 브라우저가 다른 출처 스크립트의 응답 읽기를 막는 장치일 뿐 서버를 보호하지 않는다는 점을 오해하기 쉽습니다. curl이나 서버 간 요청은 CORS와 무관하게 API를 호출할 수 있으므로, 인증과 권한 검사는 CORS와 별개로 반드시 필요합니다.

이 미들웨어들도 순서가 의미를 가집니다. helmet()은 헤더만 설정하므로 앞쪽에 두는 것이 좋고, morgan은 라우트보다 앞에 있어야 모든 요청을 기록합니다. express.static을 로깅보다 앞에 두면 정적 파일 요청이 로그에서 빠지는데, 이를 의도적으로 이용해 로그 양을 줄이기도 합니다.

커스텀 미들웨어

로깅 미들웨어:

function logger(req, res, next) {
    const start = Date.now();
    
    res.on('finish', () => {
        const duration = Date.now() - start;
        console.log(`${req.method} ${req.url} ${res.statusCode} - ${duration}ms`);
    });
    
    next();
}
app.use(logger);

인증 미들웨어:

// 함수 정의 및 구현
function authenticate(req, res, next) {
    const token = req.headers.authorization;
    
    if (!token) {
        return res.status(401).json({ error: '인증 토큰이 필요합니다' });
    }
    
    try {
        // 토큰 검증 (예: JWT)
        const user = verifyToken(token);
        req.user = user;
        next();
    } catch (err) {
        res.status(401).json({ error: '유효하지 않은 토큰' });
    }
}
// 보호된 라우트
app.get('/profile', authenticate, (req, res) => {
    res.json({ user: req.user });
});

이 미들웨어는 두 가지를 보여 줍니다. 하나는 응답을 보낸 경로에서는 next()를 부르지 않는다는 규칙입니다. return res.status(401)...로 함수를 끝내 다음 핸들러로 넘어가지 않게 합니다. 다른 하나는 req.user = user처럼 요청 객체에 값을 붙여 다음 단계에 전달하는 관용구입니다. 뒤의 핸들러는 토큰을 다시 검증하지 않고 req.user를 믿고 씁니다. 실제 Authorization 헤더는 Bearer eyJ... 형태라 접두어를 떼야 하는데, 아래 “인증 시스템” 예제가 그 형태를 보여 줍니다. 인증 실패(401)와 권한 부족(403)을 구분해 응답하면 클라이언트가 “다시 로그인”과 “접근 불가”를 다르게 처리할 수 있습니다. 권한 확인 미들웨어:

function authorize(...roles) {
    return (req, res, next) => {
        if (!req.user) {
            return res.status(401).json({ error: '인증 필요' });
        }
        
        if (!roles.includes(req.user.role)) {
            return res.status(403).json({ error: '권한 없음' });
        }
        
        next();
    };
}
// 사용
app.delete('/users/:id', 
    authenticate,
    authorize('admin'),
    (req, res) => {
        res.json({ message: '사용자 삭제됨' });
    }
);

CRUD REST API와 상태 코드

CRUD 구현

const express = require('express');
const app = express();
app.use(express.json());
// 임시 데이터베이스
let users = [
    { id: 1, name: '홍길동', email: '[email protected]' },
    { id: 2, name: '김철수', email: '[email protected]' }
];
let nextId = 3;
// CREATE: 사용자 생성
app.post('/api/users', (req, res) => {
    const { name, email } = req.body;
    
    // 유효성 검사
    if (!name || !email) {
        return res.status(400).json({ error: '이름과 이메일이 필요합니다' });
    }
    
    const user = { id: nextId++, name, email };
    users.push(user);
    
    res.status(201).json(user);
});
// READ: 모든 사용자 조회
app.get('/api/users', (req, res) => {
    const { page = 1, limit = 10 } = req.query;
    
    const startIndex = (page - 1) * limit;
    const endIndex = page * limit;
    
    const paginatedUsers = users.slice(startIndex, endIndex);
    
    res.json({
        users: paginatedUsers,
        total: users.length,
        page: parseInt(page),
        totalPages: Math.ceil(users.length / limit)
    });
});
// READ: 특정 사용자 조회
app.get('/api/users/:id', (req, res) => {
    const id = parseInt(req.params.id);
    const user = users.find(u => u.id === id);
    
    if (!user) {
        return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
    }
    
    res.json(user);
});
// UPDATE: 사용자 수정
app.put('/api/users/:id', (req, res) => {
    const id = parseInt(req.params.id);
    const { name, email } = req.body;
    
    const userIndex = users.findIndex(u => u.id === id);
    
    if (userIndex === -1) {
        return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
    }
    
    users[userIndex] = { id, name, email };
    res.json(users[userIndex]);
});
// DELETE: 사용자 삭제
app.delete('/api/users/:id', (req, res) => {
    const id = parseInt(req.params.id);
    const userIndex = users.findIndex(u => u.id === id);
    
    if (userIndex === -1) {
        return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
    }
    
    users.splice(userIndex, 1);
    res.status(204).send();  // No Content
});
app.listen(3000);

이 예제는 메모리 배열을 쓰므로 서버를 재시작하면 데이터가 사라지고, PM2 클러스터 모드처럼 프로세스를 여러 개 띄우면 프로세스마다 다른 배열을 갖게 되어 방금 만든 사용자가 다음 요청에서 404가 나기도 합니다. 개념을 익힌 뒤에는 데이터베이스 연동으로 저장소를 옮겨야 합니다.

PUT과 PATCH의 차이도 이 코드에서 볼 수 있습니다. PUT 핸들러는 { id, name, email }로 객체를 통째로 교체하므로, 클라이언트가 name만 보내면 email이 undefined로 지워집니다. 이것이 PUT의 정의(전체 교체)에는 맞지만, 일부만 바꾸는 요청이라면 기존 객체에 받은 필드만 합치는 PATCH로 제공하는 것이 맞습니다. 또 req.body를 검증 없이 그대로 저장하면 클라이언트가 isAdmin: true 같은 필드를 끼워 넣을 수 있으므로(mass assignment), 위처럼 필요한 필드만 구조 분해해 쓰는 습관이 안전합니다.

HTTP 상태 코드

코드의미사용 예
200OK성공
201Created리소스 생성
204No Content삭제 성공
400Bad Request잘못된 요청
401Unauthorized인증 필요
403Forbidden권한 없음
404Not Found리소스 없음
500Internal Server Error서버 에러

Request와 Response 객체

Request 객체

app.get('/demo', (req, res) => {
    // URL 매개변수
    console.log(req.params);
    
    // 쿼리 문자열
    console.log(req.query);
    
    // 요청 본문
    console.log(req.body);
    
    // 헤더
    console.log(req.headers);
    console.log(req.get('User-Agent'));
    
    // HTTP 메서드
    console.log(req.method);
    
    // URL
    console.log(req.url);
    console.log(req.path);
    console.log(req.originalUrl);
    
    // IP 주소
    console.log(req.ip);
    
    // 쿠키 (cookie-parser 필요)
    console.log(req.cookies);
    
    res.send('완료');
});

Response 객체

app.get('/response-demo', (req, res) => {
    // 텍스트 응답
    res.send('Hello');
    
    // JSON 응답
    res.json({ message: 'Success' });
    
    // 상태 코드 + JSON
    res.status(201).json({ created: true });
    
    // 파일 전송
    res.sendFile('/path/to/file.pdf');
    
    // 파일 다운로드
    res.download('/path/to/file.pdf', 'custom-name.pdf');
    
    // 리다이렉트
    res.redirect('/new-url');
    res.redirect(301, '/permanent-url');
    
    // 헤더 설정
    res.set('Content-Type', 'text/html');
    res.set({
        'Content-Type': 'application/json',
        'X-Custom-Header': 'value'
    });
    
    // 쿠키 설정
    res.cookie('name', 'value', { maxAge: 900000, httpOnly: true });
    
    // 쿠키 삭제
    res.clearCookie('name');
    
    // 렌더링 (템플릿 엔진)
    res.render('index', { title: '홈' });
});

이 코드는 API 목록을 한곳에 모아 보여 주기 위한 것이라 그대로 실행하면 에러가 납니다. 한 요청에는 응답을 한 번만 보낼 수 있으므로 첫 res.send('Hello') 이후의 호출은 Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client를 일으킵니다. 실제 핸들러에서는 이 중 하나만 골라 쓰고, 응답을 보낸 뒤에는 return으로 함수를 끝내야 합니다. res.sendFile은 절대 경로를 요구하므로 path.join(__dirname, 'files', name)처럼 만들어야 하고, 사용자 입력으로 파일 이름을 받는다면 root 옵션을 줘서 지정한 폴더 밖으로 나가지 못하게 하는 것이 경로 조작 공격을 막는 방법입니다.


에러 처리 미들웨어와 커스텀 에러

에러 처리 미들웨어

// 비동기 에러 래퍼
function asyncHandler(fn) {
    return (req, res, next) => {
        Promise.resolve(fn(req, res, next)).catch(next);
    };
}
// 사용
app.get('/users/:id', asyncHandler(async (req, res) => {
    const user = await User.findById(req.params.id);
    
    if (!user) {
        throw new Error('사용자를 찾을 수 없습니다');
    }
    
    res.json(user);
}));
// 에러 처리 미들웨어 (맨 마지막에 배치)
app.use((err, req, res, next) => {
    console.error(err.stack);
    
    const statusCode = err.statusCode || 500;
    const message = err.message || '서버 에러';
    
    res.status(statusCode).json({
        error: {
            message,
            ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
        }
    });
});

asyncHandler가 필요한 이유는 Express 4가 async 함수가 반환한 Promise를 보지 않기 때문입니다. async 핸들러 안에서 throw하거나 await한 Promise가 reject되면, Express 4는 그 사실을 모르므로 에러 미들웨어가 호출되지 않고 요청은 응답 없이 멈춰 있다가 클라이언트 타임아웃으로 끝납니다. 동시에 Node는 UnhandledPromiseRejection을 기록하고, Node 15 이후 기본 설정에서는 프로세스가 종료됩니다. 요청 하나의 버그가 서버 전체를 죽이는 셈입니다. 래퍼는 이 Promise에 .catch(next)를 붙여 에러를 에러 미들웨어로 보내 줍니다.

Express 5는 이 문제를 프레임워크 차원에서 해결해, 핸들러가 반환한 Promise가 reject되면 자동으로 next(err)를 호출합니다. Express 5를 쓴다면 asyncHandler나 아래 예제의 try/catch + next(err)는 필요 없고, 그냥 throw하면 됩니다. 에러 응답에서 err.message를 그대로 내보내는 부분은 운영에서 주의가 필요합니다. DB 드라이버의 에러 메시지에는 테이블 이름이나 쿼리 일부가 들어 있을 수 있으므로, 500 에러라면 클라이언트에는 일반 메시지만 보내고 상세 내용은 로그에만 남기는 것이 안전합니다. 아래 AppError의 isOperational 플래그가 바로 “사용자에게 보여 줘도 되는 예상된 에러인가”를 구분하기 위한 것입니다.

커스텀 에러 클래스

// errors.js
class AppError extends Error {
    constructor(message, statusCode) {
        super(message);
        this.statusCode = statusCode;
        this.isOperational = true;
        Error.captureStackTrace(this, this.constructor);
    }
}
class NotFoundError extends AppError {
    constructor(message = '리소스를 찾을 수 없습니다') {
        super(message, 404);
    }
}
class ValidationError extends AppError {
    constructor(message = '유효하지 않은 입력') {
        super(message, 400);
    }
}
module.exports = { AppError, NotFoundError, ValidationError };
// 사용
const { NotFoundError, ValidationError } = require('./errors');
app.get('/users/:id', async (req, res, next) => {
    try {
        const user = await User.findById(req.params.id);
        
        if (!user) {
            throw new NotFoundError('사용자를 찾을 수 없습니다');
        }
        
        res.json(user);
    } catch (err) {
        next(err);
    }
});

블로그 API, 파일 업로드, 인증 예제

블로그 API

// app.js
const express = require('express');
const app = express();
app.use(express.json());
// 데이터
let posts = [
    { id: 1, title: '첫 글', content: '내용', author: '홍길동', createdAt: new Date() }
];
let nextId = 2;
// 모든 글 조회
app.get('/api/posts', (req, res) => {
    const { author, sort = 'desc' } = req.query;
    
    let result = [...posts];
    
    // 필터링
    if (author) {
        result = result.filter(p => p.author === author);
    }
    
    // 정렬
    result.sort((a, b) => {
        return sort === 'asc' 
            ? a.createdAt - b.createdAt 
            : b.createdAt - a.createdAt;
    });
    
    res.json({ posts: result, total: result.length });
});
// 특정 글 조회
app.get('/api/posts/:id', (req, res) => {
    const post = posts.find(p => p.id === parseInt(req.params.id));
    
    if (!post) {
        return res.status(404).json({ error: '글을 찾을 수 없습니다' });
    }
    
    res.json(post);
});
// 글 작성
app.post('/api/posts', (req, res) => {
    const { title, content, author } = req.body;
    
    if (!title || !content || !author) {
        return res.status(400).json({ 
            error: '제목, 내용, 작성자가 필요합니다' 
        });
    }
    
    const post = {
        id: nextId++,
        title,
        content,
        author,
        createdAt: new Date()
    };
    
    posts.push(post);
    res.status(201).json(post);
});
// 글 수정
app.put('/api/posts/:id', (req, res) => {
    const id = parseInt(req.params.id);
    const { title, content } = req.body;
    
    const postIndex = posts.findIndex(p => p.id === id);
    
    if (postIndex === -1) {
        return res.status(404).json({ error: '글을 찾을 수 없습니다' });
    }
    
    posts[postIndex] = {
        ...posts[postIndex],
        title,
        content,
        updatedAt: new Date()
    };
    
    res.json(posts[postIndex]);
});
// 글 삭제
app.delete('/api/posts/:id', (req, res) => {
    const id = parseInt(req.params.id);
    const postIndex = posts.findIndex(p => p.id === id);
    
    if (postIndex === -1) {
        return res.status(404).json({ error: '글을 찾을 수 없습니다' });
    }
    
    posts.splice(postIndex, 1);
    res.status(204).send();
});
app.listen(3000, () => {
    console.log('블로그 API 서버 실행 중: http://localhost:3000');
});

multer로 파일 업로드

npm install multer
const express = require('express');
const multer = require('multer');
const path = require('path');
const app = express();
// 저장 설정
const storage = multer.diskStorage({
    destination: (req, file, cb) => {
        cb(null, 'uploads/');
    },
    filename: (req, file, cb) => {
        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
        cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
    }
});
// 파일 필터
const fileFilter = (req, file, cb) => {
    const allowedTypes = /jpeg|jpg|png|gif/;
    const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
    const mimetype = allowedTypes.test(file.mimetype);
    
    if (extname && mimetype) {
        cb(null, true);
    } else {
        cb(new Error('이미지 파일만 업로드 가능합니다'));
    }
};
const upload = multer({
    storage,
    fileFilter,
    limits: { fileSize: 5 * 1024 * 1024 }  // 5MB
});
// 단일 파일 업로드
app.post('/upload', upload.single('image'), (req, res) => {
    if (!req.file) {
        return res.status(400).json({ error: '파일이 필요합니다' });
    }
    
    res.json({
        message: '업로드 성공',
        file: {
            filename: req.file.filename,
            size: req.file.size,
            path: req.file.path
        }
    });
});
// 여러 파일 업로드
app.post('/upload-multiple', upload.array('images', 5), (req, res) => {
    if (!req.files || req.files.length === 0) {
        return res.status(400).json({ error: '파일이 필요합니다' });
    }
    
    res.json({
        message: `${req.files.length}개 파일 업로드 성공`,
        files: req.files.map(f => ({
            filename: f.filename,
            size: f.size
        }))
    });
});
// 에러 처리
app.use((err, req, res, next) => {
    if (err instanceof multer.MulterError) {
        if (err.code === 'LIMIT_FILE_SIZE') {
            return res.status(400).json({ error: '파일 크기가 너무 큽니다' });
        }
    }
    
    res.status(500).json({ error: err.message });
});
app.listen(3000);

업로드 예제에서 흔히 놓치는 부분이 몇 가지 있습니다. fileFilter가 확인하는 확장자와 mimetype은 모두 클라이언트가 보낸 값이라 위조할 수 있습니다. 실행 파일의 이름을 photo.png로 바꾸고 Content-Type: image/png로 보내면 이 필터를 통과하므로, 보안이 중요하다면 파일 앞부분의 매직 바이트를 검사하는 라이브러리(file-type 등)로 실제 형식을 확인해야 합니다. destination의 uploads/ 폴더는 multer가 자동으로 만들어 주지 않아서, 폴더가 없으면 ENOENT: no such file or directory 에러가 납니다. 또 마지막 에러 처리기는 fileFilter가 던진 “이미지 파일만…” 에러도 500으로 응답하는데, 이것은 서버 문제가 아니라 잘못된 요청이므로 400이 맞습니다. 업로드한 파일을 express.static으로 그대로 공개한다면, 사용자가 올린 HTML이나 SVG가 같은 도메인에서 실행되는 XSS 위험도 함께 고려해야 합니다.

인증 시스템

npm install bcrypt jsonwebtoken
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const JWT_SECRET = 'your-secret-key';
const users = [];
// 회원가입
app.post('/auth/register', async (req, res) => {
    try {
        const { email, password, name } = req.body;
        
        // 유효성 검사
        if (!email || !password || !name) {
            return res.status(400).json({ error: '모든 필드가 필요합니다' });
        }
        
        // 중복 확인
        if (users.find(u => u.email === email)) {
            return res.status(400).json({ error: '이미 존재하는 이메일입니다' });
        }
        
        // 비밀번호 해싱
        const hashedPassword = await bcrypt.hash(password, 10);
        
        const user = {
            id: users.length + 1,
            email,
            password: hashedPassword,
            name,
            createdAt: new Date()
        };
        
        users.push(user);
        
        // 비밀번호 제외하고 응답
        const { password: _, ...userWithoutPassword } = user;
        res.status(201).json(userWithoutPassword);
        
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
// 로그인
app.post('/auth/login', async (req, res) => {
    try {
        const { email, password } = req.body;
        
        // 사용자 찾기
        const user = users.find(u => u.email === email);
        
        if (!user) {
            return res.status(401).json({ error: '이메일 또는 비밀번호가 잘못되었습니다' });
        }
        
        // 비밀번호 확인
        const isValid = await bcrypt.compare(password, user.password);
        
        if (!isValid) {
            return res.status(401).json({ error: '이메일 또는 비밀번호가 잘못되었습니다' });
        }
        
        // JWT 토큰 생성
        const token = jwt.sign(
            { id: user.id, email: user.email },
            JWT_SECRET,
            { expiresIn: '1h' }
        );
        
        res.json({ token, user: { id: user.id, email: user.email, name: user.name } });
        
    } catch (err) {
        res.status(500).json({ error: err.message });
    }
});
// 인증 미들웨어
function authenticate(req, res, next) {
    const authHeader = req.headers.authorization;
    
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return res.status(401).json({ error: '인증 토큰이 필요합니다' });
    }
    
    const token = authHeader.substring(7);
    
    try {
        const decoded = jwt.verify(token, JWT_SECRET);
        req.user = decoded;
        next();
    } catch (err) {
        res.status(401).json({ error: '유효하지 않은 토큰' });
    }
}
// 보호된 라우트
app.get('/auth/profile', authenticate, (req, res) => {
    const user = users.find(u => u.id === req.user.id);
    
    if (!user) {
        return res.status(404).json({ error: '사용자를 찾을 수 없습니다' });
    }
    
    const { password, ...userWithoutPassword } = user;
    res.json(userWithoutPassword);
});
app.listen(3000);

학습용 예제라 운영 전에 반드시 바꿔야 할 부분이 있습니다. JWT_SECRET을 코드에 적어 두면 저장소에 접근할 수 있는 누구나 임의의 토큰을 만들 수 있으므로 process.env.JWT_SECRET처럼 환경 변수에서 읽고, 값이 없으면 서버가 시작되지 않게 해야 합니다. 로그인 실패 시 “이메일 또는 비밀번호가 잘못되었습니다”로 메시지를 통일한 것은 어떤 이메일이 가입되어 있는지 알려 주지 않기 위한 올바른 선택이지만, 사용자가 없을 때는 bcrypt.compare를 건너뛰어 응답이 훨씬 빨리 오므로 응답 시간 차이로 가입 여부가 드러날 수 있습니다. 사용자가 없을 때도 더미 해시와 비교해 시간을 맞추는 방법이 있습니다. bcrypt.hash의 두 번째 인자 10은 비용 계수로, 1 올릴 때마다 해싱 시간이 두 배가 됩니다. 로그인 요청이 CPU를 많이 쓰는 이유가 이것이며, 로그인 엔드포인트에는 아래 보안 절의 rate limit을 따로 거는 것이 좋습니다.


EJS 템플릿 엔진

설치 및 설정

npm install ejs
const express = require('express');
const app = express();
// 뷰 엔진 설정
app.set('view engine', 'ejs');
app.set('views', './views');
app.get('/', (req, res) => {
    res.render('index', {
        title: '홈 페이지',
        message: 'Express + EJS'
    });
});
app.listen(3000);

EJS 템플릿

<!-- views/index.ejs -->
<!DOCTYPE html>
<html>
<head>
    <title><%= title %></title>
</head>
<body>
    <h1><%= message %></h1>
    
    <% if (users && users.length > 0) { %>
        <ul>
            <% users.forEach(user => { %>
                <li><%= user.name %> (<%= user.email %>)</li>
            <% }); %>
        </ul>
    <% } else { %>
        <p>사용자가 없습니다.</p>
    <% } %>
</body>
</html>
app.get('/users', (req, res) => {
    const users = [
        { name: '홍길동', email: '[email protected]' },
        { name: '김철수', email: '[email protected]' }
    ];
    
    res.render('index', {
        title: '사용자 목록',
        message: '등록된 사용자',
        users
    });
});

helmet과 입력 검증으로 보안 설정

기본 보안 설정

npm install helmet cors express-rate-limit
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const app = express();
// Helmet: 보안 헤더 설정
app.use(helmet());
// CORS 설정
app.use(cors({
    origin: 'https://yourdomain.com',
    credentials: true
}));
// Rate Limiting
const limiter = rateLimit({
    windowMs: 15 * 60 * 1000,  // 15분
    max: 100,  // 최대 100개 요청
    message: '너무 많은 요청이 발생했습니다'
});
app.use('/api/', limiter);
// 요청 본문 크기 제한 (대용량 본문으로 메모리를 소모시키는 공격 방지)
app.use(express.json({ limit: '10kb' }));
// 입력 형식 검증 (SQL Injection의 근본 대책은 파라미터 바인딩)
app.get('/users/:id', (req, res) => {
    const id = parseInt(req.params.id);
    
    if (isNaN(id)) {
        return res.status(400).json({ error: '유효하지 않은 ID' });
    }
    
    // ...
});
app.listen(3000);

주석을 바로잡은 부분이 있습니다. express.json({ limit })은 XSS와 관계가 없고, 수 MB짜리 JSON을 보내 서버 메모리와 CPU를 소모시키는 공격을 막는 설정입니다(기본값은 100kb). XSS는 사용자 입력을 HTML에 출력할 때 이스케이프하는 것(EJS의 <%= %>가 기본으로 해 줍니다)과 helmet의 Content-Security-Policy로 막습니다. ID를 숫자로 검증하는 것도 좋은 습관이지만, SQL 인젝션의 근본 대책은 문자열을 이어 붙여 쿼리를 만들지 않고 db.query('SELECT * FROM users WHERE id = ?', [id])처럼 파라미터 바인딩을 쓰는 것입니다. 검증은 한 겹의 방어일 뿐, 바인딩을 대신할 수 없습니다.

rate limit은 클라이언트 IP를 기준으로 요청 수를 셉니다. Nginx 같은 리버스 프록시 뒤에서 trust proxy를 설정하지 않으면 Express가 보는 req.ip가 모두 프록시 주소(127.0.0.1)가 되어, 모든 사용자가 하나의 카운터를 공유하다 정상 사용자까지 한꺼번에 429를 받게 됩니다. 최신 express-rate-limit은 이 상황을 감지하면 ERR_ERL_UNEXPECTED_X_FORWARDED_FOR 경고를 출력합니다. 반대로 프록시가 없는데 trust proxy를 켜면 클라이언트가 X-Forwarded-For 헤더를 위조해 제한을 우회할 수 있으므로, 실제 프록시 단계 수에 맞춰(app.set('trust proxy', 1)) 설정해야 합니다. 또 메모리 저장소 기반 limiter는 프로세스마다 카운터가 따로 있으므로, 여러 인스턴스를 운영한다면 Redis 같은 공유 저장소를 써야 제한이 의도대로 동작합니다.

입력 검증

npm install express-validator
const { body, validationResult } = require('express-validator');
app.post('/api/users',
    // 검증 규칙
    body('email').isEmail().normalizeEmail(),
    body('password').isLength({ min: 8 }),
    body('name').trim().notEmpty(),
    
    // 핸들러
    (req, res) => {
        const errors = validationResult(req);
        
        if (!errors.isEmpty()) {
            return res.status(400).json({ errors: errors.array() });
        }
        
        // 유효한 데이터 처리
        res.json({ message: '사용자 생성됨' });
    }
);

환경 설정, PM2, Nginx로 배포하기

환경 설정

// config.js
module.exports = {
    port: process.env.PORT || 3000,
    nodeEnv: process.env.NODE_ENV || 'development',
    isDevelopment: process.env.NODE_ENV === 'development',
    isProduction: process.env.NODE_ENV === 'production'
};

프로덕션 설정

const express = require('express');
const config = require('./config');
const app = express();
// 프로덕션 전용 설정
if (config.isProduction) {
    // 신뢰할 수 있는 프록시 설정
    app.set('trust proxy', 1);
    
    // 압축
    const compression = require('compression');
    app.use(compression());
    
    // 로깅
    const morgan = require('morgan');
    app.use(morgan('combined'));
}
// 개발 전용 설정
if (config.isDevelopment) {
    const morgan = require('morgan');
    app.use(morgan('dev'));
}
app.listen(config.port);

PM2로 배포

# PM2 설치
npm install -g pm2
# 앱 시작
pm2 start app.js --name "my-app"
# 클러스터 모드 (멀티 코어 활용)
pm2 start app.js -i max
# 상태 확인
pm2 status
pm2 logs
pm2 monit
# 재시작
pm2 restart my-app
# 중지
pm2 stop my-app
# 삭제
pm2 delete my-app
# 부팅 시 자동 시작
pm2 startup
pm2 save

Nginx 리버스 프록시

# /etc/nginx/sites-available/myapp
server {
    listen 80;
    server_name yourdomain.com;
    
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

헤더 중복 전송, 미들웨어 순서, next() 누락

Cannot set headers after they are sent

원인: 응답을 두 번 보냄

// ❌ 잘못된 코드
app.get('/users/:id', (req, res) => {
    const user = users.find(u => u.id === parseInt(req.params.id));
    
    if (!user) {
        res.status(404).json({ error: '없음' });
    }
    
    res.json(user);  // 에러! (404 응답 후 또 응답)
});
// ✅ return 사용
app.get('/users/:id', (req, res) => {
    const user = users.find(u => u.id === parseInt(req.params.id));
    
    if (!user) {
        return res.status(404).json({ error: '없음' });
    }
    
    res.json(user);
});

미들웨어 순서가 틀렸을 때

// ❌ 잘못된 순서
app.get('/users', (req, res) => {
    res.json({ users: [] });
});
app.use(express.json());  // 너무 늦음!
// ✅ 올바른 순서
app.use(express.json());  // 먼저
app.get('/users', (req, res) => {
    console.log(req.body);  // 파싱됨
    res.json({ users: [] });
});

next() 호출 누락

// ❌ next() 없음
app.use((req, res, next) => {
    console.log('미들웨어');
    // next()를 호출하지 않으면 여기서 멈춤
});
// ✅ next() 호출
app.use((req, res, next) => {
    console.log('미들웨어');
    next();  // 다음으로 진행
});

프로젝트 구조, 컨트롤러, API 버전 관리

프로젝트 구조

my-express-app/
├── src/
│   ├── config/
│   │   └── database.js
│   ├── controllers/
│   │   ├── userController.js
│   │   └── postController.js
│   ├── middlewares/
│   │   ├── auth.js
│   │   └── errorHandler.js
│   ├── models/
│   │   ├── User.js
│   │   └── Post.js
│   ├── routes/
│   │   ├── userRoutes.js
│   │   └── postRoutes.js
│   ├── utils/
│   │   └── logger.js
│   └── app.js
├── public/
├── views/
├── .env
├── .gitignore
├── package.json
└── server.js

컨트롤러 패턴

// controllers/userController.js
const User = require('../models/User');
exports.getAllUsers = async (req, res, next) => {
    try {
        const users = await User.find();
        res.json({ users });
    } catch (err) {
        next(err);
    }
};
exports.getUserById = 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);
    }
};
exports.createUser = async (req, res, next) => {
    try {
        const user = await User.create(req.body);
        res.status(201).json(user);
    } catch (err) {
        next(err);
    }
};
// routes/userRoutes.js
// 변수 선언 및 초기화
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);
router.post('/', userController.createUser);
module.exports = router;
// app.js
const userRoutes = require('./routes/userRoutes');
app.use('/api/users', userRoutes);

API 버전 관리

// v1/routes/users.js
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
    res.json({ version: 'v1', users: [] });
});
module.exports = router;
// v2/routes/users.js
// 변수 선언 및 초기화
const express = require('express');
const router = express.Router();
router.get('/', (req, res) => {
    res.json({ version: 'v2', users: [], meta: {} });
});
module.exports = router;
// app.js
// 변수 선언 및 초기화
const v1Users = require('./v1/routes/users');
const v2Users = require('./v2/routes/users');
app.use('/api/v1/users', v1Users);
app.use('/api/v2/users', v2Users);

Express 요약

  1. Express.js: Node.js 웹 프레임워크
  2. 라우팅: HTTP 메서드 + URL 패턴
  3. 미들웨어: 요청 처리 파이프라인
  4. REST API: CRUD 작업 구현
  5. 에러 처리: 에러 미들웨어, 비동기 래퍼
  6. 보안: Helmet, CORS, Rate Limiting
  7. 배포: PM2, Nginx

Express vs 다른 프레임워크

프레임워크특징사용 시기
Express간결, 생태계 큼일반적인 웹 앱
Fastify빠름, 스키마 검증고성능 API
Koa최신 문법, 가벼움모던 프로젝트
NestJSTypeScript, 구조화대규모 엔터프라이즈

다음 단계

추천 학습 자료

공식 문서:


자주 묻는 질문 (FAQ)

Q. Cannot set headers after they are sent 오류는 왜 발생하나요?

A. 한 요청에 응답을 두 번 보내려고 할 때 발생합니다. 대표적으로 if (!user) 분기에서 res.status(404).json()을 호출한 뒤 return을 빼먹어 아래의 res.json(user)까지 실행되는 경우입니다. 응답을 보내는 분기마다 return res... 형태로 함수를 끝내고, 미들웨어에서는 응답을 보냈다면 next()를 다시 호출하지 않도록 해야 합니다.


같이 보면 좋은 글