Express를 이용한 Node.js REST API 작성
1. 개요
이 글에서는 Express를 이용해 Order를 관리하는 REST API를 구축하는 방법을 안내합니다 — 프로젝트 초기 설정, 라우트 정의, 미들웨어, 데이터 검증에서 중앙 집중식 오류 처리까지. 읽고 나면 완전한 CRUD API를 직접 구축할 수 있고, Jest와 Supertest로 자동 테스트를 작성할 수 있으며, Express를 처음 사용할 때 흔한 오류들을 알게 됩니다.
글 전체를 관통하는 예제: Customer의 Order(주문)을 관리하는 API로, 생성, 조회, 수정, 삭제를 지원합니다.
2. 프로젝트 준비
새로운 Node.js 프로젝트를 만들고 Express를 설치합니다:
mkdir order-api && cd order-api
npm init -y
npm install express
npm install --save-dev jest supertest
package.json에 type: module을 추가하여 import/export 문법을 사용하고, test 스크립트를 선언합니다:
{
"name": "order-api",
"type": "module",
"scripts": {
"start": "node src/server.js",
"test": "node --experimental-vm-modules node_modules/.bin/jest"
}
}
참고: ES Module을 사용하는 Jest는
--experimental-vm-modules플래그가 필요합니다. 만약 복잡하게 느껴진다면"type": "module"을 제거하고require/module.exports(CommonJS)를 사용해도 됩니다 — 둘 다 유효하며 팀의 선호에 따라 선택하면 됩니다.
3. Express 기본 서버 만들기
// src/app.js
import express from 'express';
const app = express();
app.use(express.json()); // JSON body를 req.body로 파싱합니다
app.get('/health', (req, res) => {
res.json({ status: 'UP' });
});
export default app;
// src/server.js
import app from './app.js';
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Order API가 포트 ${PORT}에서 실행 중입니다`);
});
express.json()은 기본 제공되는 미들웨어로, Express가Content-Type: application/json을 자동으로req.body객체로 파싱하도록 도와줍니다. 이 줄이 없으면req.body는undefined입니다.app.js를server.js에서 분리해야 테스트할 때 포트를 실제로 열지 않아도 됩니다 — 이것은 Node.js REST API의 중요한 관례입니다.
테스트해보세요:
node src/server.js
curl http://localhost:3000/health
{"status":"UP"}
4. Order 리소스 정의
배열을 이용한 메모리 내 “데이터베이스”를 사용하여 API에 집중하고 실제 DB에 의존하지 않습니다.
// src/orders/orderRepository.js
let orders = [];
let nextId = 1;
export function findAll() {
return orders;
}
export function findById(id) {
return orders.find((o) => o.id === id);
}
export function create(data) {
const order = { id: nextId++, ...data, status: 'PENDING' };
orders.push(order);
return order;
}
export function update(id, data) {
const index = orders.findIndex((o) => o.id === id);
if (index === -1) return null;
orders[index] = { ...orders[index], ...data };
return orders[index];
}
export function remove(id) {
const index = orders.findIndex((o) => o.id === id);
if (index === -1) return false;
orders.splice(index, 1);
return true;
}
이 Repository는 모든 ORM(Sequelize, Prisma 등)에서 볼 수 있는 CRUD 작업을 모방합니다. 차이점은 데이터가 DB가 아닌 RAM에 저장된다는 것뿐입니다.
5. Express Router를 이용한 라우팅
모든 라우트를 app에 붙이는 대신 express.Router()를 사용하여 리소스 별로 모듈화합니다:
// src/orders/orderRouter.js
import { Router } from 'express';
import * as repo from './orderRepository.js';
const router = Router();
router.get('/', (req, res) => {
res.json(repo.findAll());
});
router.get('/:id', (req, res, next) => {
const order = repo.findById(Number(req.params.id));
if (!order) return next({ status: 404, message: 'Order가 존재하지 않습니다' });
res.json(order);
});
router.post('/', (req, res) => {
const order = repo.create(req.body);
res.status(201).json(order);
});
router.put('/:id', (req, res, next) => {
const order = repo.update(Number(req.params.id), req.body);
if (!order) return next({ status: 404, message: 'Order가 존재하지 않습니다' });
res.json(order);
});
router.delete('/:id', (req, res, next) => {
const deleted = repo.remove(Number(req.params.id));
if (!deleted) return next({ status: 404, message: 'Order가 존재하지 않습니다' });
res.status(204).end();
});
export default router;
라우터를 app에 /orders 경로로 붙입니다:
// src/app.js (업데이트)
import orderRouter from './orders/orderRouter.js';
app.use('/orders', orderRouter);
router.get('/:id', ...)에서:id는 라우트 파라미터로req.params.id로 가져옵니다(항상 문자열이므로 비교할 때Number()가 필요합니다).- 각 라우트에서
res.status(404)...를 반복하는 대신next({ status: 404, message: ... })를 호출하여 오류를 오류 처리 미들웨어로 전달합니다(7번 섹션 참고). - DELETE 성공 시
204 No Content를 반환하고 본문을 반송하지 않습니다.
6. 입력 데이터 검증
수작업으로 if/else로 검증하면 경우를 빠뜨리기 쉽습니다. express-validator를 사용하면 더 간결하고 명확합니다:
npm install express-validator
// src/orders/orderValidator.js
import { body, validationResult } from 'express-validator';
export const validateCreateOrder = [
body('customerName').isString().notEmpty().withMessage('customerName은 필수입니다'),
body('amount').isFloat({ gt: 0 }).withMessage('amount은 0보다 커야 합니다'),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
},
];
POST 라우트에 검증을 붙입니다:
// src/orders/orderRouter.js (업데이트)
import { validateCreateOrder } from './orderValidator.js';
router.post('/', validateCreateOrder, (req, res) => {
const order = repo.create(req.body);
res.status(201).json(order);
});
- 각
body(...)는 한 필드를 검사하는 규칙입니다. 오류가 있으면validationResult(req)에 모입니다. - 배열의 마지막 미들웨어가
errors.isEmpty()를 검사합니다 — 오류가 있으면400을 상세 정보와 함께 반환하고next()를 호출하지 않아 요청이 실제 핸들러로 진행하지 않도록 차단합니다.
amount를 빠뜨린 요청을 보내보세요:
curl -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"customerName": "Nam"}'
{"errors":[{"type":"field","msg":"amount은 0보다 커야 합니다","path":"amount",...}]}
7. 중앙 집중식 오류 처리
Express는 오류 처리 미들웨어를 정의할 수 있습니다 — 4개의 파라미터 (err, req, res, next)를 가진 미들웨어로, 모든 라우트 뒤에 배치합니다:
// src/errorHandler.js
export function errorHandler(err, req, res, next) {
const status = err.status || 500;
const message = err.message || '시스템 오류입니다';
if (status === 500) {
console.error(err); // 디버깅을 위해 실제 오류를 로깅하고 스택 트레이스를 클라이언트에 전달하지 않습니다
}
res.status(status).json({ message });
}
// src/app.js (업데이트, 파일 끝에 추가)
import { errorHandler } from './errorHandler.js';
app.use(errorHandler);
- Express는 함수 이름이 아닌 파라미터의 개수(4)로 이 미들웨어가 오류 핸들러임을 인식합니다.
app.use(errorHandler)를 가장 마지막에 모든app.use(router)보다 뒤에 배치해야 합니다 — Express는 선언 순서대로 미들웨어를 처리합니다.- 모든
next(err)또는 라우트에서 throw된 오류는 여기로 전달됩니다.
흔한 오류: 비동기 코드에서
next(err)호출을 잊는 것입니다(예:await db.query(...)가 throw). 이렇게 하면 Express가 오류를 캐치하지 못해 서버가 충돌하거나 요청이 멈춥니다. Express 5(최신 버전)에서는 비동기 핸들러의 throw된 오류가 자동으로 캐치되지만, Express 4에서는 try/catch로 감싸고 수동으로next(err)를 호출해야 합니다.
8. Service 레이어를 추가하여 비즈니스 로직 분리
로직이 더 복잡해질 때(예: 고객의 한도를 초과하면 주문을 생성하지 않음), 모든 것을 라우터에 넣지 마세요. orderService로 분리합니다:
// src/orders/orderService.js
import * as repo from './orderRepository.js';
const MAX_AMOUNT = 100_000_000;
export function createOrder(data) {
if (data.amount > MAX_AMOUNT) {
const err = new Error('한도를 초과했습니다');
err.status = 422;
throw err;
}
return repo.create(data);
}
// src/orders/orderRouter.js (POST 라우트 업데이트)
import * as orderService from './orderService.js';
router.post('/', validateCreateOrder, (req, res, next) => {
try {
const order = orderService.createOrder(req.body);
res.status(201).json(order);
} catch (err) {
next(err);
}
});
라우터는 이제 HTTP(상태 코드, 파라미터 파싱)만 담당하고, orderService가 비즈니스 로직을 담당합니다. 이는 다른 프레임워크의 Controller–Service 레이어 패턴과 같으며, HTTP와 독립적으로 비즈니스 로직을 테스트하는 데 도움이 됩니다.
9. Jest와 Supertest로 테스트 작성
Supertest는 실제 포트를 열지 않고도 app 객체(Express 인스턴스)에서 직접 API를 호출할 수 있습니다 — 이것이 3번 섹션에서 app.js와 server.js를 분리한 이유입니다.
// tests/order.test.js
import request from 'supertest';
import app from '../src/app.js';
describe('Order API', () => {
it('order가 성공적으로 생성되면 201과 id를 반환합니다', async () => {
const response = await request(app)
.post('/orders')
.send({ customerName: 'Nam', amount: 500000 });
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
customerName: 'Nam',
amount: 500000,
status: 'PENDING',
});
expect(response.body.id).toBeDefined();
});
it('amount가 없으면 400을 반환합니다', async () => {
const response = await request(app)
.post('/orders')
.send({ customerName: 'Nam' });
expect(response.status).toBe(400);
});
it('존재하지 않는 order를 가져오면 404를 반환합니다', async () => {
const response = await request(app).get('/orders/9999');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Order가 존재하지 않습니다');
});
});
테스트를 실행합니다:
npm test
PASS tests/order.test.js
Order API
✓ order가 성공적으로 생성되면 201과 id를 반환합니다
✓ amount가 없으면 400을 반환합니다
✓ 존재하지 않는 order를 가져오면 404를 반환합니다
request(app)은 Express app에 직접 연결된 가상의 HTTP 클라이언트를 초기화합니다 — 실제 네트워크를 통한 테스트보다 빠릅니다.toMatchObject는 지정된 필드만 검사하고 다른 필드는 무시합니다(자동 생성되는id같은) — 동적 값이 있는 응답을 테스트하는 데 편합니다.
참고:
orders는 테스트 간에 공유되는 메모리 내 배열이므로 테스트 실행 순서가 서로에게 영향을 미칠 수 있습니다(예: test 1에서 생성된 order가 test 2를 실행할 때 여전히 남아있음). 실제 DB에 연결된 API의 경우beforeEach에서 데이터를 리셋하거나 트랜잭션 롤백을 사용하세요.
10. Express 순수 사용을 피해야 할 때
- 큰 규모의 API, 복잡한 여러 도메인: Express는 구조에 대한 강제 컨벤션이 없습니다. 팀이 스스로 규칙을 정하지 않으면 “스파게티 코드”가 되기 쉽습니다. 명확한 레이어드 아키텍처를 원한다면 NestJS(Spring처럼 DI, 모듈, 데코레이터가 있음)를 고려하세요.
- 강력한 타입 안전성이 필요함: Express + 순수 JavaScript는 컴파일 시점에 타입 오류를 캐치하지 못합니다. 프로젝트가 크면 처음부터 TypeScript를 사용하세요 — “나중에 변환하려고 기다리지 마세요”. 나중의 변환 비용이 훨씬 높습니다.
- 높은 처리량, 복잡한 스트리밍/WebSocket이 필요함: Express는 여전히 작동하지만 Fastify 또는 더 성능에 최적화된 프레임워크(JSON 직렬화 벤치마크가 더 빠름)가 매우 높은 트래픽의 시스템에 더 적합할 수 있습니다.
작은 규모에서 중간 규모의 API, 위 예제처럼 명확한 CRUD에서 Express는 여전히 빠르고 간단한 선택입니다. 커뮤니티가 크고 기존 미들웨어를 찾기 쉽습니다(auth, rate-limit, CORS 등).
11. 결론
이 글을 통해 Express로 완전한 Order REST API를 구축했습니다: Router를 이용한 라우팅, express-validator를 이용한 입력 검증, 4개 파라미터 미들웨어를 이용한 중앙 집중식 오류 처리, 그리고 비즈니스 로직을 서비스 레이어로 분리하여 테스트를 쉽게 했습니다. Jest + Supertest를 이용한 테스트 부분은 API의 동작을 깨뜨릴 걱정 없이 자신 있게 리팩토링할 수 있도록 해줍니다. 이 토대에서 다음을 추가할 수 있습니다: 실제 데이터베이스 연결(Prisma, Mongoose), 인증(JWT), 또는 프로젝트가 커지면서 TypeScript로 전환할 수 있습니다.