[Node.js / Express 5] Express API 개발 실습

1. GitHub 이슈 기반 워크플로우

기능을 개발하기 전에 GitHub Issue를 먼저 만드는 것이 팀 협업의 기본 흐름이다. 이슈 → 브랜치 → 커밋 → PR 순서다.

Git Flow vs GitHub Flow

협업 브랜치 전략은 크게 두 가지로 나뉜다.

전략 브랜치 구성 적합한 상황
Git Flow main, develop, feature, release, hotfix 대규모 팀, 명확한 릴리스 주기가 있는 프로젝트
GitHub Flow main + feature 브랜치 빠른 배포, 소규모 팀, 대부분의 웹 서비스

Git Flow는 main, develop, feature, release, hotfix 브랜치를 모두 관리하는 복잡한 모델이다. GitHub Flow는 main과 feature 브랜치만 사용하는 단순한 모델로, 대부분의 웹 서비스 개발에서 GitHub Flow를 사용한다.

브랜치 네이밍 컨벤션

접두사 용도 예시
feature/ 신규 기능 개발 feature/user-signup
bugfix/ 버그 수정 bugfix/login-error
hotfix/ 긴급 수정 hotfix/security-patch
refactor/ 리팩토링 refactor/user-service
docs/ 문서 작업 docs/api-guide

이슈 생성 단계

  1. Repository → Issues 탭 → New issue
  2. 제목과 내용 작성 (Assignee, Labels 설정)
  3. 이슈 페이지에서 "Create a branch" 클릭
  4. 브랜치 네이밍: feature/chapter-05, fix/login-bug 등 prefix 사용

로컬에서 브랜치 받기

git fetch origin
git switch feature/chapter-05

Issue Template

Settings → General → Features → Issues → Set up templates에서 팀 공통 양식을 만들 수 있다. 아래는 PR 템플릿 예시다. PR과 이슈를 연결하면 PR 머지 시 이슈가 자동으로 닫힌다.

## 작업 내용

## 관련 이슈
Closes #

## 체크리스트
- [ ] 코드 작성 완료
- [ ] 테스트 완료

2. Postman 이해하기

Postman은 GUI 기반 API 테스트 도구다. curl로도 API를 테스트할 수 있지만 Postman은 요청 내역 저장, 환경 변수 관리, 팀 공유 기능이 있어 편리하다.

역할
Params Query String key-value 입력 (?id=1)
Authorization 인증 토큰 설정 (Bearer 토큰 자동 Header에 추가)
Headers Content-Type, Authorization 등 직접 지정
Body POST/PUT/PATCH의 요청 데이터 (JSON 선택 후 입력)
Scripts 요청 전/후 자동화 스크립트 (토큰 파싱 등)

터미널에서 테스트하기

Postman 없이 터미널에서 curl로 API를 바로 테스트할 수 있다.

curl -X POST http://localhost:3000/api/users/signup \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "name": "테스트", "password": "1234"}'

3. 프로젝트 설정

패키지 설치

npm install cors dotenv http-status-codes mysql2
  • cors: 다른 도메인에서 오는 요청을 허용 (프론트엔드 개발 시 필수)
  • dotenv: .env 파일의 환경 변수를 process.env로 로드
  • http-status-codes: 상태 코드를 숫자 대신 의미 있는 상수로 사용
  • mysql2: MySQL 드라이버 (Promise 지원)

CORS와 Same-Origin Policy

Same-Origin Policy(SOP)는 브라우저 보안 정책이다. 브라우저는 보안상 다른 출처(origin)의 리소스 요청을 차단한다. 출처는 프로토콜 + 호스트 + 포트의 조합이다.

CORS는 서버가 특정 출처의 요청을 허용한다고 응답 헤더로 명시하면 브라우저가 요청을 허용하는 방식이다. 프론트엔드(예: localhost:5173)와 백엔드(localhost:3000)가 포트가 다르면 서로 다른 출처이므로 반드시 설정해야 한다.

import cors from 'cors';

app.use(cors({
  origin: ['http://localhost:5173', 'https://myapp.com'],
  credentials: true,
}));

.env 파일

PORT=3000
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=password
DB_NAME=myapp_dev

4. MySQL Connection Pool

DB 연결은 매번 새로 만들면 비용이 크다. Connection Pool은 미리 여러 연결을 만들어두고 재사용한다.

매 요청마다 DB 연결을 새로 생성하면 TCP 핸드셰이크와 인증 과정에 시간이 걸린다. Pool은 미리 여러 연결을 만들어 두고 요청이 오면 빌려주고, 완료되면 반환받는 방식이다.

const pool = mysql.createPool({
  host: process.env.DB_HOST,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  connectionLimit: 10,
  waitForConnections: true,
  queueLimit: 0,
});
  • connectionLimit: 동시에 유지할 최대 연결 수
  • waitForConnections: 연결이 모두 사용 중일 때 대기 여부 (false면 즉시 에러)
  • queueLimit: 대기 큐의 최대 크기 (0이면 무제한)

연결 사용 패턴

import mysql from 'mysql2/promise'
import 'dotenv/config'

export const pool = mysql.createPool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  waitForConnections: true,
  connectionLimit: 10,
})

Pool에서 연결을 가져와 쿼리하고 반드시 반환해야 한다.

const conn = await pool.getConnection()
try {
  const [rows] = await conn.query('SELECT * FROM users WHERE id = ?', [userId])
  return rows[0]
} finally {
  conn.release()
}

5. API 개발 실습: 회원가입

실제 요청 흐름을 코드로 따라가 본다.

회원 테이블 생성

먼저 MySQL에서 회원 테이블을 생성한다.

CREATE TABLE member (
  id          INT           NOT NULL AUTO_INCREMENT,
  email       VARCHAR(50)   NOT NULL UNIQUE,
  name        VARCHAR(50)   NOT NULL,
  password    VARCHAR(255)  NOT NULL,
  created_at  TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at  TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id)
);

요청 흐름

Client  (HTTP 요청 / 응답)
  |
  | req.body 전달
  v
Controller  (요청 파싱, 응답 반환)
  |
  | DTO 변환
  v
DTO  (허용된 필드만 추출)
  |
  | 비즈니스 로직 호출
  v
Service  (이메일 중복 검증 등)
  |
  | DB 조회 / 저장 요청
  v
Repository  (SQL 쿼리 실행)
  |
  | SQL
  v
DB  (데이터 저장소)

라우터 분리

Express 라우터를 도메인별로 분리하면 app.js가 깔끔하게 유지된다.

import express from 'express';
import { signUp } from './user.controller.js';

const router = express.Router();

router.post('/signup', signUp);

export default router;
import userRouter from './routes/user.route.js';

app.use('/api/users', userRouter);

Controller

import { StatusCodes } from 'http-status-codes'
import { bodyToUser } from '../dtos/user.dto.js'
import { userSignUp } from '../services/user.service.js'

export const handleUserSignUp = async (req, res) => {
  const userDto = bodyToUser(req.body)
  const result = await userSignUp(userDto)
  res.status(StatusCodes.OK).json({ result: 'success', data: result })
}

DTO

export const bodyToUser = (body) => ({
  email: body.email,
  password: body.password,
  name: body.name,
  gender: body.gender,
  birth: body.birth,
  address: body.address,
  preferences: body.preferences ?? [],
})

Service

import { addUser, getUser } from '../repositories/user.repository.js'

export const userSignUp = async (dto) => {
  const existingUser = await getUser(dto.email)
  if (existingUser) {
    throw new Error('이미 가입된 이메일이다.')
  }
  const userId = await addUser(dto)
  return { userId }
}

Repository

import { pool } from '../db.config.js'

export const getUser = async (email) => {
  const conn = await pool.getConnection()
  try {
    const [rows] = await conn.query(
      'SELECT id FROM users WHERE email = ?',
      [email]
    )
    return rows[0] ?? null
  } finally {
    conn.release()
  }
}

export const addUser = async (dto) => {
  const conn = await pool.getConnection()
  try {
    const [result] = await conn.query(
      'INSERT INTO users (email, password, name) VALUES (?, ?, ?)',
      [dto.email, dto.password, dto.name]
    )
    return result.insertId
  } finally {
    conn.release()
  }
}

6. 에러 처리 전략

try/catch 없이 async 함수에서 오류가 발생하면 서버가 HTML 에러 페이지를 반환한다. Express 5부터 async 함수의 오류가 자동으로 에러 핸들러로 전달된다.

BusinessError 클래스 패턴

비즈니스 오류(이메일 중복 등)와 서버 내부 오류를 분리하면 클라이언트에 더 명확한 응답을 줄 수 있다. 커스텀 에러 클래스로 상태 코드를 함께 전달하는 방식을 주로 사용한다.

export class BusinessError extends Error {
  constructor(message, statusCode = 400) {
    super(message);
    this.statusCode = statusCode;
  }
}
import { StatusCodes } from 'http-status-codes';
import { BusinessError } from '../errors/business.error.js';

if (existingUser) {
  throw new BusinessError('이미 존재하는 이메일이다.', StatusCodes.CONFLICT);
}

전역 에러 핸들러

app.use((err, req, res, next) => {
  console.error(err.message)
  const statusCode = err.statusCode ?? StatusCodes.INTERNAL_SERVER_ERROR
  res.status(statusCode).json({
    result: 'fail',
    error: err.message,
  })
})

err.statusCode가 있으면 해당 값을 사용하고, 없으면 500으로 응답한다. BusinessError를 던지면 자동으로 올바른 상태 코드로 응답하게 된다.

핵심 키워드

환경 변수

.env 파일로 관리하는 설정값. 코드에 직접 쓰지 않고 process.env로 읽는다.

CORS (Cross-Origin Resource Sharing)

다른 도메인에서의 HTTP 요청을 허용하는 정책. Same-Origin Policy를 서버 측에서 명시적으로 완화한다. cors 미들웨어로 설정한다.

DB Connection Pool

미리 만들어둔 DB 연결 집합. 매번 새 연결 대신 재사용해 성능을 높인다. TCP 핸드셰이크 비용을 줄이는 것이 핵심이다.

async/await

비동기 코드를 동기처럼 작성하는 문법. await는 Promise가 완료될 때까지 실행을 일시 정지한다.

try/catch/finally

try: 정상 처리, catch: 예외 처리, finally: 성공/실패 무관하게 항상 실행 (conn.release() 위치).