Skip to main content

흩어져 있던 조각, 이제 서버가 된다

라우팅을 배웠고, req.params와 req.query를 다뤘고, body도 읽어 봤고, 미들웨어도 만져 봤다. 그런데 초보자 입장에서는 이런 생각이 들기 쉽다.

"그래서 이걸 다 합치면 진짜 서버가 되나요?"

대답은 아주 시원하게 "네"다. 바로 지금부터다.

REST API는 어렵게 들리지만, 핵심은 단순하다. URL은 다룰 대상, 즉 자원을 가리키고, HTTP 메서드는 그 자원에 대해 무엇을 할지 말해 준다. 할 일 목록이라면 todos가 자원이고, GET, POST, PATCH, DELETE가 행동 버튼이 된다.1

이번 장에서 만들 서버는 아래 다섯 가지 요청을 처리한다.

  • GET /api/todos : 할 일 목록 조회

  • GET /api/todos/:id : 특정 할 일 하나 조회

  • POST /api/todos : 새 할 일 생성

  • PATCH /api/todos/:id : 기존 할 일 수정

  • DELETE /api/todos/:id : 할 일 삭제

이 다섯 개가 바로 CRUD다. Create, Read, Update, Delete. 이름은 거창하지만, 결국 "만들고, 보고, 고치고, 지운다"는 뜻이다. 메모장 앱이든 쇼핑몰이든 게시판이든, 서버의 기본 체력은 거의 여기서 시작한다.

작지만 제대로 된 REST API 만들기: 할 일 서버 프로젝트

오늘 만들 API의 약속

서버가 제멋대로 말하면 클라이언트는 힘들어진다. 그래서 응답 모양을 일정하게 맞춰 두면 아주 편하다. 실제 서비스에서는 성공 여부, 메시지, 데이터 구조를 일정하게 유지하는 경우가 많다.3

이번 장에서는 응답을 이런 식으로 맞춘다.

{
  "success": true,
  "message": "할 일 목록 조회 성공",
  "data": {
    "items": []
  }
}

할 일 한 개의 모양은 이렇게 잡겠다.

{
  "id": 1,
  "title": "우유 사기",
  "completed": false,
  "createdAt": "2026-03-29T10:00:00.000Z",
  "updatedAt": "2026-03-29T10:00:00.000Z"
}

데이터 저장은 데이터베이스 대신 메모리 배열을 사용한다. 즉, 서버를 껐다 켜면 데이터가 사라진다. 실무용 저장소는 아니지만, 흐름을 이해하기에는 가장 가볍고 선명한 방법이다.2

JavaScript 버전: 먼저 완성부터 해보자

프로젝트 폴더에서 Express가 설치되어 있다고 가정하고, app.js 파일을 만든다.

const express = require('express');
const app = express();
const PORT = 3000;

app.use(express.json());

let todos = [
  {
    id: 1,
    title: 'Express로 CRUD 만들기',
    completed: false,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  },
];

let nextId = 2;

function sendSuccess(res, status, message, data) {
  const response = { success: true, message };

  if (data !== undefined) {
    response.data = data;
  }

  return res.status(status).json(response);
}

function sendError(res, status, message) {
  return res.status(status).json({
    success: false,
    message,
  });
}

function findTodoById(id) {
  return todos.find((todo) => todo.id === id);
}

app.get('/', (req, res) => {
  sendSuccess(res, 200, '할 일 API 서버가 실행 중입니다.');
});

app.get('/api/todos', (req, res) => {
  const { completed, q } = req.query;
  let result = [...todos];

  if (completed === 'true') {
    result = result.filter((todo) => todo.completed === true);
  }

  if (completed === 'false') {
    result = result.filter((todo) => todo.completed === false);
  }

  if (q) {
    const keyword = String(q).toLowerCase();
    result = result.filter((todo) =>
      todo.title.toLowerCase().includes(keyword)
    );
  }

  sendSuccess(res, 200, '할 일 목록 조회 성공', {
    items: result,
    count: result.length,
  });
});

app.get('/api/todos/:id', (req, res) => {
  const id = Number(req.params.id);

  if (!Number.isInteger(id)) {
    return sendError(res, 400, '올바른 id가 아닙니다.');
  }

  const todo = findTodoById(id);

  if (!todo) {
    return sendError(res, 404, '할 일을 찾을 수 없습니다.');
  }

  sendSuccess(res, 200, '할 일 조회 성공', { item: todo });
});

app.post('/api/todos', (req, res) => {
  const { title } = req.body;

  if (typeof title !== 'string' || title.trim() === '') {
    return sendError(res, 400, 'title은 비어 있지 않은 문자열이어야 합니다.');
  }

  const now = new Date().toISOString();

  const newTodo = {
    id: nextId++,
    title: title.trim(),
    completed: false,
    createdAt: now,
    updatedAt: now,
  };

  todos.push(newTodo);

  sendSuccess(res, 201, '할 일 생성 성공', { item: newTodo });
});

app.patch('/api/todos/:id', (req, res) => {
  const id = Number(req.params.id);

  if (!Number.isInteger(id)) {
    return sendError(res, 400, '올바른 id가 아닙니다.');
  }

  const todo = findTodoById(id);

  if (!todo) {
    return sendError(res, 404, '수정할 할 일을 찾을 수 없습니다.');
  }

  const { title, completed } = req.body;

  if (title !== undefined) {
    if (typeof title !== 'string' || title.trim() === '') {
      return sendError(res, 400, 'title은 비어 있지 않은 문자열이어야 합니다.');
    }

    todo.title = title.trim();
  }

  if (completed !== undefined) {
    if (typeof completed !== 'boolean') {
      return sendError(res, 400, 'completed는 boolean 값이어야 합니다.');
    }

    todo.completed = completed;
  }

  todo.updatedAt = new Date().toISOString();

  sendSuccess(res, 200, '할 일 수정 성공', { item: todo });
});

app.delete('/api/todos/:id', (req, res) => {
  const id = Number(req.params.id);

  if (!Number.isInteger(id)) {
    return sendError(res, 400, '올바른 id가 아닙니다.');
  }

  const index = todos.findIndex((todo) => todo.id === id);

  if (index === -1) {
    return sendError(res, 404, '삭제할 할 일을 찾을 수 없습니다.');
  }

  const deletedTodo = todos[index];
  todos.splice(index, 1);

  sendSuccess(res, 200, '할 일 삭제 성공', { item: deletedTodo });
});

app.use((req, res) => {
  sendError(res, 404, '요청한 경로를 찾을 수 없습니다.');
});

app.listen(PORT, () => {
  console.log(`서버 실행 중: http://localhost:${PORT}`);
});

서버를 실행한다.

node app.js

브라우저나 API 도구에서 http://localhost:3000으로 들어가면 서버가 살아 있다는 인사를 볼 수 있다.

이 코드가 꽤 괜찮은 이유

이 코드는 단순해 보이지만, 실전 감각이 몇 가지 숨어 있다.

먼저 app.use(express.json())가 있다. 이 한 줄이 없으면 req.body는 비어 있는 상자처럼 행동한다. 클라이언트가 JSON을 보내도 서버는 "누가 왔는지 모르겠는데요?" 하고 멀뚱멀뚱 서 있게 된다.

그리고 응답 형식을 sendSuccess, sendError로 통일했다. 서버를 만들 때 초반에는 그냥 res.json(...)을 여기저기 써도 되지만, 엔드포인트가 늘어나면 금세 말투가 들쭉날쭉해진다. 어떤 응답은 message가 있고, 어떤 응답은 없고, 어떤 응답은 todo, 어떤 응답은 item, 어떤 응답은 배열만 덜렁 보내는 식이 된다. 그러면 프런트엔드가 고생한다. 응답 형식을 고정하면 나중에 훨씬 편하다.3

또 GET /api/todos에 completed와 q 쿼리를 붙였다. 실제 서비스는 목록만 던져 주지 않고, 완료 여부나 검색어, 페이지 같은 조건을 자주 받는다. 여기서는 아주 가볍게 두 가지만 붙여서 "목록 조회도 생각보다 똑똑해질 수 있구나"라는 감각을 익히면 충분하다.3

무엇보다 중요한 건 상태 코드다. 생성은 201, 잘못된 입력은 400, 없는 데이터는 404로 나눴다. 서버가 숫자로도 의사를 표현하기 시작한 것이다. 이것이 API를 읽기 쉽게 만드는 큰 포인트다.

요청 하나씩 뜯어보자

목록 조회: GET /api/todos

가장 기본적인 읽기 작업이다. 그냥 전체를 반환해도 되지만, 여기서는 두 가지 옵션을 추가했다.

/api/todos?completed=true라고 요청하면 완료된 일만 모아서 보여 준다. /api/todos?q=express라고 요청하면 제목에 express가 들어간 항목만 걸러 준다. 초보자 입장에서는 이 부분이 아주 중요하다. "쿼리는 필터"라는 감각이 여기서 몸에 붙는다.

단건 조회: GET /api/todos/:id

req.params.id는 문자열이다. 숫자처럼 보여도 문자열이다. 그래서 Number(req.params.id)로 바꿔 준 뒤 검사했다. 이걸 빼먹으면 1과 '1' 때문에 비교에서 삐끗하기 쉽다.

생성: POST /api/todos

생성 요청은 req.body.title을 받는다. 여기서 trim()이 아주 중요하다. 공백만 가득한 문자열도 문자열이기 때문이다. 즉, 그냥 타입만 검사하면 ' ' 같은 유령 할 일이 탄생한다. 제목 없는 할 일은 마치 제목 없는 공포 영화 예고편 같다. 불안하기만 하고 정체가 없다.

수정: PATCH /api/todos/:id

수정은 PUT 대신 PATCH를 사용했다. 이유는 간단하다. PATCH는 일부 필드만 바꾸기 좋다. 예를 들어 제목은 그대로 두고 completed만 true로 바꾸는 식이다. 할 일 완료 체크 같은 동작과 잘 어울린다.

수정할 때는 바뀐 시점을 알 수 있도록 updatedAt도 함께 갱신했다. 나중에 목록을 최신 수정 순으로 정렬하거나, "방금 수정됨" 같은 표시를 붙일 때 이런 값이 도움 된다.

삭제: DELETE /api/todos/:id

삭제는 찾고, 지우고, 끝이다. 다만 지우기 전에 없는 id인지 먼저 확인한다. 존재하지 않는 항목을 삭제하려고 할 때도 조용히 성공해 버리면 디버깅이 힘들어진다. 서버는 친절해야 하지만, 애매하게 친절하면 안 된다.

직접 호출해 보기

Postman, Insomnia, Hoppscotch, 혹은 curl로 테스트할 수 있다. 손으로 몇 번 쳐 보면 서버가 훨씬 친해진다.

새 할 일 만들기:

curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"장보기"}'

목록 조회:

curl http://localhost:3000/api/todos

완료 상태 수정:

curl -X PATCH http://localhost:3000/api/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"completed":true}'

검색해서 조회:

curl "http://localhost:3000/api/todos?q=express"

삭제:

curl -X DELETE http://localhost:3000/api/todos/1

응답 예시는 이런 느낌이다.

{
  "success": true,
  "message": "할 일 생성 성공",
  "data": {
    "item": {
      "id": 2,
      "title": "장보기",
      "completed": false,
      "createdAt": "2026-03-29T10:30:00.000Z",
      "updatedAt": "2026-03-29T10:30:00.000Z"
    }
  }
}

초보자가 자주 넘어지는 지점

첫째, express.json()을 빼먹는 실수다. 그러면 POST와 PATCH가 거의 반쯤 봉인된다.

둘째, req.params.id를 숫자라고 착각하는 실수다. URL에서 꺼낸 값은 기본적으로 문자열이다.

셋째, 빈 문자열 검사를 느슨하게 하는 실수다. title이 있다는 것과, 쓸 만한 제목이라는 것은 다른 문제다.

넷째, 수정 요청에서 모든 필드를 덮어써 버리는 실수다. 부분 수정이라면 전달된 필드만 바꾸는 편이 안전하다.

다섯째, 메모리 저장을 데이터베이스처럼 믿는 실수다. 지금은 학습용이다. 서버를 재시작하면 목록도 같이 증발한다.2

TypeScript 버전도 같이 보면 더 재밌다

같은 서버를 TypeScript로 옮기면 무엇이 좋아질까. 가장 먼저, 할 일 객체가 어떤 모양이어야 하는지 코드가 스스로 말해 준다. completed에 문자열이 들어가려 할 때, 실행 전에 눈치를 챌 가능성이 훨씬 커진다.

아래는 같은 기능을 TypeScript로 옮긴 버전이다. 구조는 거의 똑같고, 타입만 더해졌다.

src/app.ts

import express, { Request, Response } from 'express';

const app = express();
const PORT = 3000;

app.use(express.json());

interface Todo {
  id: number;
  title: string;
  completed: boolean;
  createdAt: string;
  updatedAt: string;
}

let todos: Todo[] = [
  {
    id: 1,
    title: 'TypeScript로 옮겨 보기',
    completed: false,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  },
];

let nextId = 2;

function sendSuccess<T>(res: Response, status: number, message: string, data?: T) {
  const response: { success: true; message: string; data?: T } = {
    success: true,
    message,
  };

  if (data !== undefined) {
    response.data = data;
  }

  return res.status(status).json(response);
}

function sendError(res: Response, status: number, message: string) {
  return res.status(status).json({
    success: false,
    message,
  });
}

function parseId(rawId: string): number | null {
  const id = Number(rawId);
  return Number.isInteger(id) ? id : null;
}

function findTodoById(id: number): Todo | undefined {
  return todos.find((todo) => todo.id === id);
}

app.get('/api/todos', (req: Request, res: Response) => {
  const completed = req.query.completed;
  const q = typeof req.query.q === 'string' ? req.query.q.toLowerCase() : '';

  let result = [...todos];

  if (completed === 'true') {
    result = result.filter((todo) => todo.completed === true);
  }

  if (completed === 'false') {
    result = result.filter((todo) => todo.completed === false);
  }

  if (q) {
    result = result.filter((todo) => todo.title.toLowerCase().includes(q));
  }

  return sendSuccess(res, 200, '할 일 목록 조회 성공', {
    items: result,
    count: result.length,
  });
});

app.get('/api/todos/:id', (req: Request, res: Response) => {
  const id = parseId(req.params.id);

  if (id === null) {
    return sendError(res, 400, '올바른 id가 아닙니다.');
  }

  const todo = findTodoById(id);

  if (!todo) {
    return sendError(res, 404, '할 일을 찾을 수 없습니다.');
  }

  return sendSuccess(res, 200, '할 일 조회 성공', { item: todo });
});

app.post('/api/todos', (req: Request, res: Response) => {
  const { title } = req.body as { title?: string };

  if (typeof title !== 'string' || title.trim() === '') {
    return sendError(res, 400, 'title은 비어 있지 않은 문자열이어야 합니다.');
  }

  const now = new Date().toISOString();

  const newTodo: Todo = {
    id: nextId++,
    title: title.trim(),
    completed: false,
    createdAt: now,
    updatedAt: now,
  };

  todos.push(newTodo);

  return sendSuccess(res, 201, '할 일 생성 성공', { item: newTodo });
});

app.patch('/api/todos/:id', (req: Request, res: Response) => {
  const id = parseId(req.params.id);

  if (id === null) {
    return sendError(res, 400, '올바른 id가 아닙니다.');
  }

  const todo = findTodoById(id);

  if (!todo) {
    return sendError(res, 404, '수정할 할 일을 찾을 수 없습니다.');
  }

  const { title, completed } = req.body as {
    title?: string;
    completed?: boolean;
  };

  if (title !== undefined) {
    if (typeof title !== 'string' || title.trim() === '') {
      return sendError(res, 400, 'title은 비어 있지 않은 문자열이어야 합니다.');
    }

    todo.title = title.trim();
  }

  if (completed !== undefined) {
    if (typeof completed !== 'boolean') {
      return sendError(res, 400, 'completed는 boolean 값이어야 합니다.');
    }

    todo.completed = completed;
  }

  todo.updatedAt = new Date().toISOString();

  return sendSuccess(res, 200, '할 일 수정 성공', { item: todo });
});

app.delete('/api/todos/:id', (req: Request, res: Response) => {
  const id = parseId(req.params.id);

  if (id === null) {
    return sendError(res, 400, '올바른 id가 아닙니다.');
  }

  const index = todos.findIndex((todo) => todo.id === id);

  if (index === -1) {
    return sendError(res, 404, '삭제할 할 일을 찾을 수 없습니다.');
  }

  const deletedTodo = todos[index];
  todos.splice(index, 1);

  return sendSuccess(res, 200, '할 일 삭제 성공', { item: deletedTodo });
});

app.listen(PORT, () => {
  console.log(`서버 실행 중: http://localhost:${PORT}`);
});

TypeScript 버전에서 눈여겨볼 부분은 interface Todo다. 이 한 줄 덕분에 할 일 데이터의 모양이 흐릿하지 않다. 나중에 priority, dueDate, userId 같은 필드를 추가할 때도 훨씬 안전하게 확장할 수 있다.

작은 서버지만, 이미 서버답다

지금 만든 건 장난감처럼 작아 보여도, 서버의 중요한 뼈대는 다 들어 있다. 라우팅이 있고, 요청 본문을 읽고, 입력을 검사하고, 상태 코드를 나누고, 일관된 JSON 응답을 보낸다. 이것만으로도 프런트엔드와 제대로 대화하는 API가 된다.

그리고 더 좋은 점이 하나 있다. 나중에 메모리 배열을 MongoDB나 PostgreSQL로 바꾸더라도, 바깥에서 보는 API 모양은 거의 그대로 유지할 수 있다. 즉, 지금 익히는 것은 임시 코드가 아니라 서버 설계의 기본 자세다.

오늘 만든 할 일 서버는 작다. 하지만 제법 당당하다. Hello World를 지나, 이제는 정말로 "일을 처리하는 서버"가 됐기 때문이다.

참고

1A Simple Todo REST API with Node, Express and MongoDB

2Building a Todo API with Node.js

3Todo List Backend API

책 목록

처음부터 배포까지, Node.js Express 서버 수업

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기