Study/Node.JS

[Node.js] Express 5 — 이벤트 루프부터 프로젝트 세팅까지

the.Dev.Cat 2026. 3. 12. 00:01

Node.js를 처음 제대로 공부하면서 정리해두려고 한다.

2달 안에 풀스택 개인 프로젝트를 시작하는 게 목표다. Node.js → Express → NestJS 순서로 밟아가기로 했는데, 그 첫 걸음을 이 시리즈로 시작하려고 한다.


브라우저 밖의 JavaScript

프론트엔드만 해오다가 Node.js를 처음 공부하려니 가장 먼저 드는 생각이 하나 있었다. "JavaScript는 원래 브라우저에서 작동하는 거 아닌가?"

맞는 것 같다. 그런데 Node.js가 그 제약을 없앴다. Chrome이 쓰는 V8 엔진을 가져와서 서버에서도 작동하도록 만든 것이 Node.js다. JavaScript를 브라우저 밖, 즉 서버에서도 실행할 수 있게 된 것이다.

그래서 Node.js를 배우면서 가장 먼저 짚어야 하는 것이 이벤트 루프다. 브라우저에서도 이벤트 루프가 있긴 하지만, 서버에서는 그 개념이 더 중요하게 느껴졌다.


이벤트 루프: 단일 스레드인데 어떻게 여러 요청을?

브라우저에서 이벤트 루프를 처음 배울 때는 솔직히 그냥 "비동기가 나중에 실행된다"는 정도로만 이해했다. 클릭 이벤트, setTimeout 같은 것들. 그게 전부인 줄 알았다.

서버 맥락에서 이벤트 루프를 다시 보니 처음 생각과는 많이 달랐다.

Node.js는 싱글 스레드다. 하지만 수많은 클라이언트 요청을 동시에 처리할 수 있다. 어떤 원리로 그렇게 작동할까? DB 쿼리나 파일 읽기 같은 I/O 작업을 기다리는 동안 이벤트 루프가 다른 요청을 처리하기 때문이다.

console.log('1. 시작');

setTimeout(() => {
  console.log('2. setTimeout 콜백');
}, 0);

Promise.resolve().then(() => {
  console.log('3. Promise 콜백');
});

console.log('4. 끝');

// 출력:
// 1. 시작
// 4. 끝
// 3. Promise 콜백  ← Microtask가 Timer보다 먼저
// 2. setTimeout 콜백

이 순서는 브라우저에서도 동일하다. Promise(Microtask)가 setTimeout(Macrotask)보다 먼저 처리된다는 규칙 자체는 같았다.

차이가 있다면 서버에서는 이게 실제 요청 처리 성능에 직결된다는 점이다. 이벤트 루프를 막으면 — 즉 메인 스레드에서 무거운 계산을 돌리면 — 그동안 다른 모든 클라이언트의 요청이 멈춰버린다.

// 이러면 진짜 안 된다
app.get('/heavy', (req, res) => {
  let sum = 0;
  for (let i = 0; i < 1_000_000_000; i++) {
    sum += i;
  }
  res.json({ sum });
  // 이 요청 처리하는 동안 다른 모든 요청 대기
});

DB 쿼리, 파일 읽기, HTTP 호출 같은 I/O는 괜찮다. Node.js가 내부적으로 libuv를 통해 OS에 위임하고, 기다리는 동안 다른 일을 한다. CPU를 직접 쓰는 계산 작업이 문제다. 그건 Worker Thread로 분리해야 한다는 것이다.

실무에서 당장 CPU 집약 작업을 만날 일이 많지는 않겠지만, 알아두는 것과 모르는 것은 다르다.


Express를 배우는 이유

Node.js에는 기본으로 http 모듈이 있다. 이걸로 서버를 만들 수 있긴 하다. 그런데 직접 써보려고 코드를 보니 왜 Express가 필요한지 이해가 됐다.

// http 모듈로 Todo API 만들기 — 120줄짜리 지옥
const server = http.createServer((req, res) => {
  const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
  const pathname = url.pathname;

  if (pathname === '/api/todos' && req.method === 'GET') {
    res.writeHead(200);
    res.end(JSON.stringify({ data: todos }));
    return;
  }

  // URL 파라미터를 정규식으로 직접 추출
  const match = pathname.match(/^\/api\/todos\/(\d+)$/);
  if (match && req.method === 'GET') {
    // ...
  }

  // POST면 body를 스트림으로 수동으로 읽어야 한다
  if (pathname === '/api/todos' && req.method === 'POST') {
    let body = '';
    req.on('data', (chunk) => { body += chunk.toString(); });
    req.on('end', () => {
      const parsed = JSON.parse(body);
      // ...
    });
  }
});

라우팅을 if/else로 처리하고, URL 파라미터를 정규식으로 뽑아야 하고, JSON body를 스트림으로 수동으로 조립해야 한다. CRUD 5개 만드는 데 120줄이나 코드를 작성해야 했다.

Express를 쓰면 같은 기능이 30줄로 줄어든다.

import express from 'express';

const app = express();
app.use(express.json()); // JSON 파싱 한 줄

app.get('/api/todos', (req, res) => {
  res.json({ data: todos });
});

app.get('/api/todos/:id', (req, res) => {
  const todo = todos.find((t) => t.id === parseInt(req.params.id));
  if (!todo) return res.status(404).json({ error: 'Not found' });
  res.json({ data: todo });
});

app.post('/api/todos', (req, res) => {
  const newTodo = { id: todos.length + 1, ...req.body };
  todos.push(newTodo);
  res.status(201).json({ data: newTodo });
});

req.params.id로 URL 파라미터를 바로 꺼내고, req.body로 JSON body를 바로 쓴다. 이것이 Express가 해주는 것들이다.


Express 5에서 달라진 것

이번에 학습하면서는 Express 5를 쓴다. Express 4와 비교했을 때 여러 변경점이 있는데, 그중 하나가 특히 눈에 들어왔다.

async 에러 자동 catch.

Express 4에서는 async 핸들러에서 에러가 나면 직접 처리해줘야 했다.

// Express 4
router.get('/:id', async (req, res, next) => {
  try {
    const todo = await prisma.todo.findUniqueOrThrow({
      where: { id: Number(req.params.id) },
    });
    res.json({ data: todo });
  } catch (err) {
    next(err); // 이걸 빠뜨리면 서버 크래시
  }
});

next(err)를 빠뜨리면 에러가 에러 핸들러로 전달되지 않고 그냥 서버가 멈춘다. 이것이 Express 4에서 가장 흔한 버그 중 하나였다고 한다.

Express 5에서는 async 핸들러에서 throw가 나면 자동으로 에러 미들웨어로 전달된다.

// Express 5
router.get('/:id', async (req, res) => {
  const todo = await prisma.todo.findUniqueOrThrow({
    where: { id: Number(req.params.id) },
  });
  res.json({ data: todo });
  // 에러 나면 자동으로 에러 핸들러로 감
});

try/catch도 없고, asyncHandler 래퍼도 없다. 이것 하나만으로도 Express 5를 써야 할 이유가 충분했다.


프로젝트 세팅

pnpm 선택

패키지 매니저는 pnpm을 쓴다. npm도 되고 yarn도 되는데, pnpm을 선택한 이유가 있다.

npm의 node_modules는 flat 구조라서 package.json에 없는 패키지도 쓸 수 있다. 유령 의존성(Phantom Dependency)이라고 부르는 문제인데, 다른 패키지가 의존하는 걸 내가 직접 import해서 쓰다가 그 패키지 버전이 바뀌면 조용히 깨진다. pnpm은 심볼릭 링크 기반으로 이걸 원천 차단한다.

설치 속도도 가장 빠르고, 디스크 사용량도 하드 링크 방식으로 줄인다. 모노레포 지원도 성숙하다. 선택할 이유가 충분했다.

mkdir express-api
cd express-api
pnpm init

# 런타임 의존성
pnpm add express cors helmet morgan dotenv zod

# 개발 의존성
pnpm add -D typescript tsx @types/express @types/cors @types/morgan @types/node

tsx는 TypeScript 실행기인데, ts-node 대신 쓴다. esbuild 기반이라 훨씬 빠르고, watch 모드가 내장돼 있다. ts-node처럼 tsconfig 경로 설정 같은 것도 따로 잡아주지 않아도 된다.

TypeScript 설정

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "rootDir": ".",
    "declaration": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "types": ["vitest/globals"]
  },
  "include": ["src", "tests"],
  "exclude": ["node_modules", "dist"]
}

moduleResolution: "bundler"는 tsx/esbuild가 파일 확장자를 알아서 해석하게 해준다. strict: true는 기본이다. TypeScript를 쓰면서 strict를 끄는 건 안전벨트를 매고 잠그지 않는 것과 같다.

types: ["vitest/globals"]는 나중에 추가할 Vitest 전용 설정이다. describe, it, expect를 import 없이 쓰기 위해서다.

ESLint 9 Flat Config

ESLint 9부터 설정 방식이 바뀌었다. .eslintrc 파일 대신 eslint.config.js를 쓴다.

pnpm add -D eslint @eslint/js typescript-eslint eslint-config-prettier
// eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettier from 'eslint-config-prettier';

export default tseslint.config(
  { ignores: ['dist/', 'node_modules/', 'src/generated/'] },
  js.configs.recommended,
  ...tseslint.configs.recommended,
  ...tseslint.configs.strict,
  {
    rules: {
      '@typescript-eslint/no-unused-vars': [
        'error',
        { argsIgnorePattern: '^_' },
      ],
      '@typescript-eslint/consistent-type-imports': 'error',
    },
  },
  prettier,
);

prettier가 마지막에 오는 게 중요하다. Prettier와 충돌하는 ESLint 포맷 규칙을 꺼야 하는데, 설정이 나중에 올수록 우선순위가 높기 때문이다.

consistent-type-imports는 type import를 강제한다. import type { Foo } from './foo'처럼 타입만 가져올 때는 type 키워드를 써야 한다는 규칙인데, 번들러가 tree shaking할 때 도움이 된다.

Docker로 PostgreSQL 띄우기

DB를 로컬에 직접 설치하지 않고 Docker를 쓴다. OS마다 설치 방법이 다르고, 팀원끼리 버전이 달라지면 골치가 아프다. docker compose up 한 줄로 끝나는 것이 낫다.

# docker-compose.yml
services:
  postgres:
    image: postgres:17-alpine
    container_name: express-postgres
    environment:
      POSTGRES_USER: express_user
      POSTGRES_PASSWORD: express_pass
      POSTGRES_DB: express_db
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U express_user -d express_db']
      interval: 5s
      timeout: 5s
      retries: 5

  postgres-test:
    image: postgres:17-alpine
    container_name: express-postgres-test
    environment:
      POSTGRES_USER: test_user
      POSTGRES_PASSWORD: test_pass
      POSTGRES_DB: test_db
    ports:
      - '5434:5432'

volumes:
  postgres_data:

개발용 DB와 테스트용 DB를 분리한다. 테스트용 DB에는 볼륨을 붙이지 않았다. 테스트가 남긴 데이터가 다음 테스트에 영향을 주면 안 되기 때문이다.

환경 변수: .env / .env.test / .env.example

환경 변수 파일도 세 개로 나눈다.

# .env — 개발용, Git에 커밋하지 않는다
NODE_ENV=development
PORT=3000
DATABASE_URL="postgresql://express_user:express_pass@localhost:5432/express_db?schema=public"
JWT_SECRET="super-secret-jwt-key-that-is-at-least-32-chars-long"
# .env.test — 테스트용, Git에 커밋한다 (민감 정보 없음)
NODE_ENV=test
PORT=3000
DATABASE_URL="postgresql://test_user:test_pass@localhost:5434/test_db?schema=public"
JWT_SECRET="test-secret-key-that-is-at-least-32-characters"

.gitignore에는 .env만 넣는다. .env.test는 테스트 전용 DB 정보라 커밋해도 된다.

여기서 한 발 더 나아가서, 환경 변수를 Zod로 검증한다. 서버가 뜰 때 필요한 환경 변수가 빠져있으면 런타임에 알 수 없는 에러가 날 수 있다. 그보다는 서버 시작 시점에 명시적으로 터지는 것이 훨씬 낫다.

// src/config/env.ts
import dotenv from 'dotenv';
import { z } from 'zod';

const envFile = process.env.NODE_ENV === 'test' ? '.env.test' : '.env';
dotenv.config({ path: envFile });

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
});

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('Invalid environment variables:');
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;

env.PORTnumber 타입으로 추론된다. process.env.PORT는 항상 string | undefined인데, Zod의 z.coerce.number()가 변환과 타입 추론을 동시에 해결해준다.

app.ts와 server.ts를 왜 나누는가

프론트엔드 할 때는 이런 고민을 별로 안 했는데, 백엔드에서는 이게 꽤 중요한 것 같았다.

// src/app.ts — Express 앱 설정
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import { env } from './config/env';

const app = express();

app.use(helmet());
app.use(cors());
if (env.NODE_ENV !== 'test') {
  app.use(morgan('dev'));
}
app.use(express.json());

app.get('/health', (_req, res) => {
  res.json({ status: 'ok' });
});

app.use((_req, res) => {
  res.status(404).json({ status: 'error', message: 'Not Found' });
});

export default app;
// src/server.ts — 서버 시작
import app from './app';
import { env } from './config/env';

const server = app.listen(env.PORT, () => {
  console.log(`Server running on http://localhost:${env.PORT}`);
  console.log(`Environment: ${env.NODE_ENV}`);
});

const shutdown = () => {
  server.close(() => {
    process.exit(0);
  });
};

process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

나누는 이유가 있다. 테스트할 때 Supertest는 app을 직접 import해서 HTTP 요청을 시뮬레이션한다. 서버가 실제로 listen하지 않아도 된다. app.tsserver.ts가 붙어있으면 테스트 파일이 app을 import할 때마다 서버가 실제로 뜨고, 포트 충돌이 발생한다.

분리하면 테스트에서는 app만 가져다 쓰고, server.ts는 실제 서버를 띄울 때만 실행된다.


Vitest + Supertest

테스트 환경은 Vitest를 쓴다. Jest 호환 API를 제공하면서 ESM을 네이티브로 지원한다. Jest로 ESM 환경 세팅하는 게 복잡하지만, Vitest는 설정 없이 바로 된다.

pnpm add -D vitest @vitest/coverage-v8 supertest @types/supertest
// vitest.config.ts
import dotenv from 'dotenv';
import { defineConfig } from 'vitest/config';

dotenv.config({ path: '.env.test' });

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['tests/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      include: ['src/**/*.ts'],
      exclude: ['src/generated/**', 'src/server.ts'],
    },
    setupFiles: ['tests/setup.ts'],
    testTimeout: 10000,
    fileParallelism: false,
  },
});

fileParallelism: false가 있다. 테스트 파일 여러 개가 동시에 실행되면 같은 DB를 공유하기 때문에 충돌이 날 수 있어서, 순차 실행으로 설정한다.

환경이 잘 돌아가는지 확인하려고 가장 단순한 테스트부터 만들었다.

// src/utils/sum.ts
export function sum(a: number, b: number): number {
  return a + b;
}
// tests/unit/sum.test.ts
import { sum } from '../../src/utils/sum';

describe('sum', () => {
  it('두 수를 더한다', () => {
    expect(sum(1, 2)).toBe(3);
  });

  it('음수를 더한다', () => {
    expect(sum(-1, -2)).toBe(-3);
  });

  it('0을 더한다', () => {
    expect(sum(5, 0)).toBe(5);
  });
});

그리고 Supertest로 /health 엔드포인트도 테스트했다.

// tests/e2e/health.test.ts
import request from 'supertest';
import app from '../../src/app';

describe('GET /health', () => {
  it('200과 { status: "ok" }를 반환한다', async () => {
    const response = await request(app)
      .get('/health')
      .expect(200);

    expect(response.body).toEqual({ status: 'ok' });
  });
});

describe('404 처리', () => {
  it('존재하지 않는 경로에 404를 반환한다', async () => {
    const response = await request(app)
      .get('/nonexistent')
      .expect(404);

    expect(response.body).toEqual({ status: 'error', message: 'Not Found' });
  });
});

Supertest가 흥미로웠던 부분이, request(app)에 Express 앱을 넘기면 실제 포트를 열지 않고도 HTTP 요청을 테스트할 수 있다는 점이다. 내부적으로 임시 서버에 바인딩해서 요청을 처리하고 응답을 돌려준다. app.ts/server.ts 분리가 여기서 제 역할을 한다.


세팅 마무리

pnpm dev로 서버가 뜨고, pnpm test로 테스트가 통과하면 준비가 된 것이다.

docker compose up -d   # PostgreSQL 시작
pnpm dev               # 개발 서버
pnpm test:unit         # sum 테스트
pnpm test:e2e          # /health 테스트

디렉토리 구조는 이렇게 됐다.

express-api/
├── src/
│   ├── app.ts
│   ├── server.ts
│   ├── config/
│   │   └── env.ts
│   └── utils/
│       └── sum.ts
├── tests/
│   ├── setup.ts
│   ├── unit/
│   │   └── sum.test.ts
│   └── e2e/
│       └── health.test.ts
├── docker-compose.yml
├── .env
├── .env.test
├── .env.example
├── .gitignore
├── .prettierrc
├── eslint.config.js
├── tsconfig.json
├── vitest.config.ts
└── package.json

설정 파일이 적지 않다. 그런데 한 번 잡아두면 나중에 안 건드리는 것들이라, 초반에 제대로 해두는 것이 맞다.

다음은 Prisma로 DB 스키마를 정의하고 User-Todo 관계를 모델링하는 내용이다.