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로 전환할 수 있습니다.

nodejsexpressrest-apijavascripttesting