Skip to main content

이제 서버는 단순히 길만 안내하는 수준을 넘어선다. 누가 왔는지, 무엇을 들고 왔는지, 어떤 규칙으로 말하고 있는지까지 읽어야 한다. 여기서부터 비로소 "아, 내가 API를 만들고 있구나" 하는 감각이 온다.

라우팅이 손님을 어느 창구로 보낼지 정하는 일이라면, 이번에는 그 손님이 손에 쥔 서류를 펼쳐 보는 단계다. URL에 적힌 값, 쿼리스트링, 헤더, 본문까지 잘 읽어야 서버가 똑똑하게 반응한다.

요청 안에는 정보가 여러 칸으로 나뉘어 들어온다

Express의 req 객체에는 요청 정보가 한 덩어리로 뭉쳐 있지 않다. 위치에 따라 나뉘어 들어온다.1

예를 들어 이런 요청을 보자.

POST /todos/3?preview=true
Content-Type: application/json
X-Client-Name: thunder-client

{
  "title": "우유 사기",
  "done": false
}

이 요청을 Express는 대략 이렇게 읽는다.

  • req.params.id → '3'

  • req.query.preview → 'true'

  • req.get('Content-Type') → 'application/json'

  • req.body.title → '우유 사기'

  • req.body.done → false

같은 요청인데도 정보가 놓인 자리가 전부 다르다. 이 구분이 처음에는 조금 낯설지만, 익숙해지면 오히려 머리가 맑아진다.

주소에 박혀 있는 값은 params, 물음표 뒤 옵션은 query, 숨은 메모는 header, 실제 내용물은 body다. 택배 상자로 치면 송장 번호, 배송 메모, 스티커, 상자 안 물건이 각각 따로 있는 셈이다.

express.json()은 JSON 통역사다

API에서 가장 자주 다루는 본문 형식은 JSON이다. 문제는 서버가 본문을 저절로 읽어 주지는 않는다는 점이다. Express에서는 express.json() 미들웨어를 먼저 등록해야 JSON 본문을 파싱해서 req.body에 넣어 준다.2

const express = require('express')
const app = express()

app.use(express.json())

이 한 줄은 "JSON으로 온 요청은 내가 읽을 수 있는 자바스크립트 객체로 바꿔 줘"라는 뜻에 가깝다.

중요한 점도 있다. express.json()은 아무 요청이나 다 읽지 않는다. 보통 Content-Type이 application/json인 요청을 대상으로 동작한다.2 그래서 클라이언트가 JSON 본문을 보내면서도 헤더를 제대로 붙이지 않으면 req.body가 기대와 다르게 비어 보일 수 있다.

또 하나 기억할 것이 있다. req.body는 사용자가 보낸 입력값이므로 무조건 믿으면 안 된다. Express 공식 문서도 req.body의 구조와 값은 신뢰되지 않은 입력이라고 분명히 말한다.2 즉, 값이 있는지, 타입이 맞는지 확인하는 습관이 필요하다.

필요하면 본문 크기 제한도 바꿀 수 있다.

app.use(express.json({ limit: '1mb' }))

기본값은 크지 않다. 너무 큰 JSON을 받으려 하면 거절될 수 있다.2 초보 단계에서는 기본값으로 충분하지만, "왜 큰 본문이 갑자기 안 되지?" 같은 의문이 생기면 이 옵션을 떠올리면 된다.

JavaScript로 먼저 보는 진짜 API 입력과 출력

말만 하면 늘 쉬워 보인다. 코드를 보자. 아래 예제는 req.params, req.query, req.body, req.get(), res.json()을 한 번에 묶어 놓은 작은 할 일 API다.

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

app.use(express.json())

let nextId = 3
const todos = [
  { id: 1, title: 'Express body 배우기', done: false },
  { id: 2, title: '헤더와 친해지기', done: true },
]

app.get('/todos/:id', (req, res) => {
  const id = Number(req.params.id)
  const preview = req.query.preview === 'true'

  if (Number.isNaN(id)) {
    return res.status(400).json({
      message: 'id는 숫자여야 합니다.',
    })
  }

  const todo = todos.find((item) => item.id === id)

  if (!todo) {
    return res.status(404).json({
      message: '할 일을 찾을 수 없습니다.',
    })
  }

  const data = preview
    ? { id: todo.id, title: todo.title }
    : todo

  res.json({
    message: '조회 성공',
    data,
    request: {
      params: req.params,
      query: req.query,
    },
  })
})

app.post('/todos', (req, res) => {
  const { title, done = false } = req.body
  const clientName = req.get('X-Client-Name') || 'unknown'

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

  if (typeof done !== 'boolean') {
    return res.status(400).json({
      message: 'done은 true 또는 false여야 합니다.',
    })
  }

  const newTodo = {
    id: nextId++,
    title: title.trim(),
    done,
  }

  todos.push(newTodo)

  res.status(201).json({
    message: '할 일이 생성되었습니다.',
    data: newTodo,
    meta: {
      clientName,
      contentType: req.get('Content-Type') || null,
    },
  })
})

app.get('/request-info/:id', (req, res) => {
  res.json({
    params: req.params,
    query: req.query,
    headers: {
      authorization: req.get('Authorization') || null,
      'user-agent': req.get('User-Agent') || null,
      'x-client-name': req.get('X-Client-Name') || null,
    },
  })
})

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

이 코드의 포인트를 천천히 뜯어보면 꽤 많은 것이 보인다.

GET /todos/:id는 주소에서 id를 꺼낸다. 이것이 req.params다.

?preview=true는 쿼리스트링이므로 req.query.preview로 읽는다.

POST /todos에서는 본문으로 들어온 title, done을 req.body에서 읽는다.

X-Client-Name 같은 헤더 값은 req.get()으로 꺼낸다.

그리고 응답은 전부 res.json()으로 보내고 있다. 이 순간부터 서버는 정말 API스러워진다.

req.body는 보통 생성과 수정에서 빛난다

본문은 주로 POST, PUT, PATCH에서 많이 쓴다. 이유는 간단하다. 생성하거나 수정할 내용이 URL에 다 들어갈 수는 없기 때문이다.

새 할 일을 만들 때 URL은 보통 이렇게 간단하다.

POST /todos

진짜 내용은 본문으로 들어간다.

{
  "title": "산책 가기",
  "done": false
}

Express 안에서는 이렇게 읽는다.

app.post('/todos', (req, res) => {
  console.log(req.body)
  console.log(req.body.title)
  console.log(req.body.done)

  res.json({ message: '확인 완료' })
})

이때 많이 하는 실수가 하나 있다. req.body.title이 당연히 문자열일 거라고 믿는 것이다. 하지만 사용자는 빈 값도 보낼 수 있고, 숫자도 보낼 수 있고, 심지어 배열을 보낼 수도 있다. 서버는 낙천적인 성격보다 꼼꼼한 성격이 오래 간다.

그래서 이렇게 검사하는 습관이 좋다.

if (typeof req.body.title !== 'string' || req.body.title.trim() === '') {
  return res.status(400).json({ message: 'title이 올바르지 않습니다.' })
}

req.params와 req.query는 여전히 중요하다

이번 장의 주인공은 body지만, 실제 API에서는 params와 query가 함께 움직이는 경우가 많다.1

예를 들어 GET /todos/1?preview=true를 보자.

여기서 1은 어떤 대상을 조회할지 정하는 값이다. 그래서 req.params.id다.

반면 preview=true는 어떻게 보여 줄지 정하는 옵션이다. 그래서 req.query.preview다.

둘의 역할이 다르다는 점이 중요하다. params는 대상을 찍고, query는 조회 방식을 조절한다.

그리고 입문 단계에서 꼭 기억할 사실이 하나 더 있다. req.params와 req.query의 값은 대부분 문자열처럼 다뤄야 한다. id가 숫자처럼 보여도 먼저 문자열로 들어오고, preview=true도 불리언이 아니라 문자열 'true'로 들어온다고 생각하는 편이 안전하다.

그래서 이런 코드가 자연스럽다.

const id = Number(req.params.id)
const preview = req.query.preview === 'true'

서버 개발은 결국 "겉모습 말고 실제 타입을 확인하라"는 교훈을 여러 번 만나게 된다.

header는 요청의 숨은 메모다

헤더는 본문처럼 눈에 확 들어오지 않지만, 실전에서는 엄청 자주 쓴다. Content-Type, Authorization, User-Agent, 커스텀 헤더 같은 값들이 전부 여기에 들어 있다.3

Express에서는 보통 두 방식으로 헤더를 읽는다.

req.headers['content-type']
req.get('Content-Type')

입문 단계에서는 req.get()이 읽기 편하다. 그리고 헤더 이름은 대소문자를 구분하지 않는다. Content-Type, content-type, CONTENT-TYPE는 같은 헤더를 가리킨다.3

예를 들어 인증 헤더가 있는지 확인하는 코드는 이렇게 쓸 수 있다.

app.get('/secret', (req, res) => {
  const authorization = req.get('Authorization')

  if (!authorization) {
    return res.status(401).json({
      message: 'Authorization 헤더가 필요합니다.',
    })
  }

  res.json({
    message: '헤더 확인 완료',
    authorization,
  })
})

물론 지금은 토큰 검증까지 하지는 않는다. 하지만 "헤더에서 규칙 정보를 읽는다"는 감각만 잡아도 API 이해도가 크게 올라간다.

res.json()은 API의 기본 말투다

웹 페이지를 만들 때는 HTML을 보내기도 하지만, API는 보통 JSON으로 말한다. Express의 res.json()은 자바스크립트 객체나 배열을 JSON으로 바꿔 응답하고, 응답의 Content-Type도 JSON으로 맞춰 준다.4

res.json({ message: '안녕하세요' })

상태 코드와 함께 보내면 더 실전 같다.

res.status(201).json({
  message: '생성 완료',
  data: newTodo,
})

이 패턴은 꽤 자주 보게 된다.

  • 성공 조회 → 200

  • 새로 생성 → 201

  • 잘못된 입력 → 400

  • 인증 없음 → 401

  • 데이터 없음 → 404

중요한 것은 숫자를 외우는 것보다, 상황에 맞는 응답을 JSON으로 일관되게 보내는 습관이다. 그래야 프런트엔드나 다른 클라이언트가 서버의 말을 예측하기 쉬워진다.

예를 들어 성공 응답은 이런 모양으로 맞출 수 있다.

{
  "message": "조회 성공",
  "data": {
    "id": 1,
    "title": "Express body 배우기",
    "done": false
  }
}

에러 응답도 비슷하게 맞추면 사용하기 좋다.

{
  "message": "id는 숫자여야 합니다."
}

응답 모양이 들쭉날쭉하면 서버는 일은 하는데 말투가 제멋대로인 동료처럼 느껴진다. 꽤 피곤하다.

직접 보내 보면 감이 훨씬 빨리 온다

아래처럼 요청을 보내 보면 머리보다 손이 먼저 이해한다.

할 일 생성 요청이다.

POST /todos
Content-Type: application/json
X-Client-Name: test-client

{
  "title": "저녁 메뉴 정하기",
  "done": false
}

응답은 대략 이렇게 온다.

{
  "message": "할 일이 생성되었습니다.",
  "data": {
    "id": 3,
    "title": "저녁 메뉴 정하기",
    "done": false
  },
  "meta": {
    "clientName": "test-client",
    "contentType": "application/json"
  }
}

이번에는 조회 요청이다.

GET /todos/1?preview=true

응답은 축약 모드이므로 이렇게 올 수 있다.

{
  "message": "조회 성공",
  "data": {
    "id": 1,
    "title": "Express body 배우기"
  },
  "request": {
    "params": {
      "id": "1"
    },
    "query": {
      "preview": "true"
    }
  }
}

이 장면이 바로 API의 맛이다. 클라이언트는 값을 보내고, 서버는 읽고, 검사하고, JSON으로 다시 답한다.

자주 하는 실수는 거의 정해져 있다

처음에는 실수가 비슷한 패턴으로 반복된다. 미리 알고 있으면 덜 아프다.

  • app.use(express.json())를 빼먹는다. 그러면 JSON 본문이 들어와도 req.body가 기대처럼 안 보일 수 있다.

  • Content-Type: application/json을 안 붙인다. 그러면 서버가 JSON으로 해석하지 못할 수 있다.2

  • req.params.id나 req.query.page를 숫자라고 믿는다. 실제로는 문자열처럼 들어온다.

  • req.body를 무조건 믿는다. 값이 없거나 타입이 이상할 수 있으니 항상 검사해야 한다.2

이 네 가지는 거의 입문자들의 단골 메뉴다. 한 번쯤은 다 겪는다. 너무 걱정할 필요는 없다. 다만 두 번, 세 번 같은 함정에 빠지지 않으면 된다.

TypeScript 버전은 요청의 모양을 더 선명하게 보여 준다

JavaScript에서는 런타임 검사에 의존하는 부분이 많다. TypeScript를 쓰면 "이 라우트는 어떤 params, 어떤 body, 어떤 query를 기대하는지"를 코드에 더 분명하게 적을 수 있다. Express의 Request 타입은 제네릭을 통해 이런 모양을 표현할 수 있다.5

아래는 같은 예제를 TypeScript로 옮긴 버전이다.

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

const app = express()
const PORT = 3000

app.use(express.json())

type Todo = {
  id: number
  title: string
  done: boolean
}

type TodoParams = {
  id: string
}

type TodoQuery = {
  preview?: string
}

type CreateTodoBody = {
  title?: string
  done?: boolean
}

let nextId = 3
const todos: Todo[] = [
  { id: 1, title: 'Express body 배우기', done: false },
  { id: 2, title: '헤더와 친해지기', done: true },
]

app.get(
  '/todos/:id',
  (req: Request<TodoParams, any, any, TodoQuery>, res: Response) => {
    const id = Number(req.params.id)
    const preview = req.query.preview === 'true'

    if (Number.isNaN(id)) {
      return res.status(400).json({ message: 'id는 숫자여야 합니다.' })
    }

    const todo = todos.find((item) => item.id === id)

    if (!todo) {
      return res.status(404).json({ message: '할 일을 찾을 수 없습니다.' })
    }

    const data = preview
      ? { id: todo.id, title: todo.title }
      : todo

    res.json({
      message: '조회 성공',
      data,
    })
  }
)

app.post(
  '/todos',
  (req: Request<{}, any, CreateTodoBody>, res: Response) => {
    const { title, done = false } = req.body
    const clientName = req.get('X-Client-Name') || 'unknown'

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

    if (typeof done !== 'boolean') {
      return res.status(400).json({
        message: 'done은 true 또는 false여야 합니다.',
      })
    }

    const newTodo: Todo = {
      id: nextId++,
      title: title.trim(),
      done,
    }

    todos.push(newTodo)

    res.status(201).json({
      message: '할 일이 생성되었습니다.',
      data: newTodo,
      meta: {
        clientName,
      },
    })
  }
)

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

TypeScript 버전에서 눈여겨볼 점은 세 가지다.

TodoParams 덕분에 req.params.id가 어떤 이름의 값인지 더 분명해졌다.

TodoQuery 덕분에 req.query.preview라는 옵션이 있다는 사실이 코드에 드러난다.

CreateTodoBody 덕분에 이 요청 본문이 어떤 모양이어야 하는지 한눈에 보인다.

물론 TypeScript를 쓴다고 해서 런타임 검사가 사라지는 것은 아니다. 타입은 개발자를 돕지만, 실제 요청은 언제든 이상한 값으로 들어올 수 있다. 그래서 JavaScript 버전에서 하던 typeof 검사나 빈 값 검사는 여전히 중요하다.

결국 API는 입력을 읽고, 확인하고, JSON으로 답하는 일이다

Express 서버를 만들다 보면 멋진 이름의 기능이 여럿 나오지만, 실전의 핵심은 의외로 소박하다.

클라이언트가 보낸 입력을 읽는다.

어디에 들어 있는지 구분한다. params인지, query인지, header인지, body인지 확인한다.

값이 올바른지 검사한다.

마지막으로 res.json()으로 일관된 응답을 돌려준다.

이 흐름이 손에 익기 시작하면 서버가 갑자기 덜 추상적으로 느껴진다. 이제 요청은 더 이상 막연한 덩어리가 아니다. 해부할 수 있는 구조물이다.

그리고 이 감각이 생기는 순간부터, 서버는 정말 재미있어진다. 손님이 무슨 말을 했는지 알아듣고, 필요한 검사를 하고, 딱 맞는 JSON으로 대답하는 똑똑한 접수대가 되기 때문이다.

참고

1How To Use the req Object in Express

2Express 5.x API Reference

3Express Request Headers

4Returning JSON Responses in Node.js and Express

5How extend express types on Typescript to add custom properties

책 목록

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

전체 목차 보기

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

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

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

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

참여하기