Skip to main content

지난 장에서는 Express 서버를 열고 Hello World를 보내 봤다. 이제 서버는 겨우 첫 인사를 배운 상태다. 아직은 누가 오든 똑같이 "안녕하세요"만 말하는 친절한 로봇에 가깝다.

하지만 실제 서버는 훨씬 바쁘다. 어떤 요청이 왔는지 보고, 어디로 보내야 할지 결정해야 한다. GET /todos인지, POST /todos인지, GET /users/3인지, 아니면 /search?q=node인지에 따라 해야 할 일이 전부 다르다. Express에서 이 길찾기 규칙을 정하는 일이 바로 라우팅이다.1

쉽게 말해, 라우팅은 서버의 교통정리다. 신호등이 없으면 교차로가 난장판이 되듯, 라우팅이 없으면 서버도 금방 혼란에 빠진다.

라우팅은 결국 메서드 + 경로를 보는 일

Express는 요청을 볼 때 보통 두 가지를 함께 본다. 하나는 HTTP 메서드이고, 다른 하나는 URL 경로다.1

예를 들어 아래 두 요청은 주소는 같아 보여도 완전히 다른 일이다.

GET /todos
POST /todos

첫 번째는 "할 일 목록 보여 줘"에 가깝고, 두 번째는 "할 일 하나 새로 만들어 줘"에 가깝다. 주소가 같더라도 메서드가 다르면 역할도 달라진다.

그래서 Express 코드는 보통 이렇게 생긴다.

app.get('/todos', (req, res) => {
  res.send('할 일 목록 보기')
})

app.post('/todos', (req, res) => {
  res.send('할 일 새로 만들기')
})

이 코드를 읽으면 거의 문장처럼 이해된다.

  • GET /todos가 오면 목록을 보여 준다.

  • POST /todos가 오면 새 데이터를 만든다.

  • 같은 /todos라도 메서드가 다르면 다른 핸들러가 실행된다.1

서버는 주소만 보는 게 아니라, 어떤 방식으로 온 요청인지까지 함께 본다는 점이 중요하다.

GET, POST, PUT, DELETE를 먼저 감으로 익히기

이 장에서는 가장 자주 만나는 네 가지 메서드를 중심으로 보자.

  • GET: 읽기

  • POST: 새로 만들기

  • PUT: 수정하기

  • DELETE: 삭제하기

엄밀한 규칙은 더 깊게 들어가면 조금 더 복잡하지만, 입문 단계에서는 이렇게 기억해도 충분하다. 웹 API에서는 보통 이런 식으로 역할을 나눈다.2

예를 들어 할 일 앱이라면 아래처럼 생각할 수 있다.

app.get('/todos', (req, res) => {
  res.send('전체 할 일 조회')
})

app.post('/todos', (req, res) => {
  res.send('새 할 일 생성')
})

app.put('/todos/1', (req, res) => {
  res.send('1번 할 일 수정')
})

app.delete('/todos/1', (req, res) => {
  res.send('1번 할 일 삭제')
})

이쯤 되면 Express가 꽤 예의 바른 안내원처럼 느껴진다.

"어떤 손님이죠?"

"GET 손님이요."

"아, 그럼 조회 창구로 가세요."

"DELETE 손님이요."

"그럼 삭제 창구로 가셔야죠."

경로는 고정될 수도 있고, 비어 있는 칸이 있을 수도 있다

경로는 단순히 문자열일 수도 있다.

app.get('/', (req, res) => {
  res.send('홈')
})

app.get('/about', (req, res) => {
  res.send('소개 페이지')
})

이런 경로는 완전히 고정되어 있다. /about이면 소개 페이지고, /면 홈이다.

그런데 실제 서버에서는 고정된 주소만으로는 부족하다. 사용자마다 ID가 다르고, 게시글마다 번호가 다르기 때문이다. 이럴 때 쓰는 것이 라우트 파라미터다.1

라우트 파라미터는 URL 속 빈칸이다

라우트 파라미터는 경로 안에 변하는 값을 넣기 위한 자리다. Express에서는 콜론(:)으로 표시한다.1

app.get('/users/:id', (req, res) => {
  res.send(`사용자 ID는 ${req.params.id}입니다.`)
})

이제 요청 주소가 아래처럼 오면,

/users/10
/users/25
/users/999

Express는 :id 자리에 들어온 값을 req.params에 담아 준다.1

req.params.id

즉, /users/10이면 req.params.id는 '10'이 된다.

여기서 중요한 점이 하나 있다. 파라미터 값은 숫자처럼 보여도 일단 문자열로 들어온다고 생각하는 편이 안전하다.1

그래서 숫자로 써야 할 때는 변환해 주는 습관이 좋다.

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

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

  res.send(`숫자로 바꾼 사용자 ID: ${id}`)
})

서버는 생각보다 꼼꼼해야 한다. 겉으로 숫자처럼 보인다고 바로 믿으면 나중에 뒤통수를 맞는다.

파라미터는 여러 개도 받을 수 있다

사용자와 그 사용자의 게시글을 함께 표현하고 싶다면 이렇게 할 수 있다.

app.get('/users/:userId/posts/:postId', (req, res) => {
  res.json({
    userId: req.params.userId,
    postId: req.params.postId,
  })
})

요청이 아래처럼 오면,

/users/7/posts/42

응답은 이런 식이 된다.

{
  "userId": "7",
  "postId": "42"
}

URL만 봐도 "7번 사용자의 42번 게시글이구나" 하고 읽히기 때문에, 라우트 파라미터는 API 주소를 깔끔하게 만드는 데 아주 유용하다.

쿼리스트링은 주소 뒤에 붙는 추가 조건이다

이번에는 이런 주소를 보자.

/search?q=node&page=2

여기서 /search는 경로이고, ?q=node&page=2는 쿼리스트링이다. 쿼리스트링은 추가 조건이나 옵션을 보낼 때 많이 쓴다.3

Express에서는 이것을 req.query로 꺼낸다.3

app.get('/search', (req, res) => {
  res.json({
    q: req.query.q,
    page: req.query.page,
  })
})

요청이 /search?q=node&page=2라면 대략 이런 값이 들어온다.

req.query.q    // 'node'
req.query.page // '2'

여기서도 값은 보통 문자열처럼 다뤄야 한다. page가 2처럼 보여도 바로 숫자는 아니다.

그리고 아주 중요한 사실이 하나 있다. 쿼리스트링은 라우트 경로 자체의 일부가 아니다. 그래서 app.get('/search')는 /search?q=node도 처리한다.1

즉, Express는 먼저 /search라는 길을 찾고, 그다음에 req.query에서 부가 정보를 꺼내는 식으로 동작한다고 생각하면 이해하기 쉽다.

라우트 파라미터와 쿼리스트링은 언제 다르게 쓸까

둘 다 값을 전달하지만 성격은 다르다.

라우트 파라미터는 보통 "꼭 필요한 식별자"에 가깝다. 예를 들어 /todos/3에서 3이 없으면 어떤 할 일인지 알 수 없다.

쿼리스트링은 보통 "있으면 좋은 조건"에 가깝다. 예를 들어 /todos?done=true는 목록을 가져오되 완료된 것만 필터링해 달라는 의미다.

비교하면 감이 빨리 온다.

  • /todos/3 → 3번 할 일 하나를 가리킨다.

  • /todos?page=2 → 할 일 목록에서 2페이지를 보고 싶다.

  • /todos?done=true&keyword=node → 완료된 항목 중 제목에 node가 들어간 것만 보고 싶다.

짧게 정리하면 이렇다.

대상 하나를 찍을 때는 파라미터, 목록에 조건을 붙일 때는 쿼리스트링.

같은 주소라도 요청 방식이 다르면 다른 길이다

이제 감을 실전 형태로 묶어 보자.

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

app.use(express.json())

app.get('/todos', (req, res) => {
  res.send('할 일 목록 조회')
})

app.post('/todos', (req, res) => {
  res.send('할 일 생성')
})

app.put('/todos/:id', (req, res) => {
  res.send(`${req.params.id}번 할 일 수정`)
})

app.delete('/todos/:id', (req, res) => {
  res.send(`${req.params.id}번 할 일 삭제`)
})

여기서는 같은 /todos라는 길을 중심으로 여러 작업이 나뉜다.

GET /todos는 목록을 조회한다.

POST /todos는 새 항목을 만든다.

PUT /todos/:id는 특정 항목을 수정한다.

DELETE /todos/:id는 특정 항목을 삭제한다.

이 패턴은 아주 자주 쓰인다. 서버가 정리되어 보이고, 클라이언트도 예측하기 쉬워진다.

자주 하는 실수: 라우트 순서가 중요하다

Express는 라우트를 위에서 아래로 검사한다. 그래서 더 구체적인 경로를 먼저 쓰는 것이 중요하다.24

예를 들어 아래 코드를 보자.

app.get('/users/:id', (req, res) => {
  res.send(`사용자 ${req.params.id}`)
})

app.get('/users/new', (req, res) => {
  res.send('새 사용자 만들기 페이지')
})

이 경우 /users/new로 요청해도 첫 번째 라우트가 먼저 잡아먹을 수 있다. Express는 new를 :id 값으로 이해해 버리기 때문이다.2

그래서 순서를 이렇게 바꾸는 편이 안전하다.

app.get('/users/new', (req, res) => {
  res.send('새 사용자 만들기 페이지')
})

app.get('/users/:id', (req, res) => {
  res.send(`사용자 ${req.params.id}`)
})

정적 경로가 동적 경로보다 먼저 오는 쪽이 더 읽기 쉽고, 버그도 줄어든다.

이제 작은 서버를 하나 제대로 만들어 보자

이번에는 메서드, 파라미터, 쿼리, 404 처리까지 한 번에 들어 있는 작은 예제를 만들어 보자. 메모리 배열을 쓰는 아주 단순한 예제라서 데이터베이스가 없어도 바로 이해할 수 있다.

JavaScript 버전부터 보자.

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

app.use(express.json())

let nextId = 4
let todos = [
  { id: 1, title: 'Express 라우팅 배우기', done: false },
  { id: 2, title: '점심 메뉴 고민하기', done: true },
  { id: 3, title: '커피와 친해지기', done: false },
]

app.get('/todos', (req, res) => {
  const done = req.query.done
  const keyword = req.query.keyword

  let result = todos

  if (done !== undefined) {
    const doneValue = done === 'true'
    result = result.filter((todo) => todo.done === doneValue)
  }

  if (keyword) {
    result = result.filter((todo) => todo.title.includes(keyword))
  }

  res.json(result)
})

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

  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: '할 일을 찾을 수 없습니다.' })
  }

  res.json(todo)
})

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

  if (!title) {
    return res.status(400).json({ message: 'title이 필요합니다.' })
  }

  const newTodo = {
    id: nextId++,
    title,
    done: false,
  }

  todos.push(newTodo)
  res.status(201).json(newTodo)
})

app.put('/todos/:id', (req, res) => {
  const id = Number(req.params.id)
  const { title, done } = req.body

  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: '수정할 할 일이 없습니다.' })
  }

  if (title === undefined || done === undefined) {
    return res.status(400).json({ message: 'title과 done을 함께 보내 주세요.' })
  }

  todo.title = title
  todo.done = done

  res.json(todo)
})

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

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

  const index = todos.findIndex((item) => item.id === id)

  if (index === -1) {
    return res.status(404).json({ message: '삭제할 할 일이 없습니다.' })
  }

  todos.splice(index, 1)
  res.status(204).end()
})

app.use((req, res) => {
  res.status(404).json({ message: '없는 주소입니다.' })
})

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

이 코드에는 이번 장의 핵심이 거의 다 들어 있다.

GET /todos는 전체 목록을 보여 준다.

GET /todos/:id는 특정 할 일 하나를 보여 준다.

POST /todos는 새 할 일을 추가한다.

PUT /todos/:id는 특정 할 일을 수정한다.

DELETE /todos/:id는 특정 할 일을 지운다.

그리고 마지막 app.use(...)는 어디에도 매칭되지 않은 요청을 받아 404를 돌려준다.4

이 예제를 어떻게 테스트하면 좋을까

브라우저와 API 도구를 섞어서 써 보면 좋다.

먼저 목록 조회다.

GET http://localhost:3000/todos

완료된 할 일만 보고 싶다면 이렇게 할 수 있다.

GET http://localhost:3000/todos?done=true

제목에 커피가 들어간 것만 보고 싶다면 이렇게 한다.

GET http://localhost:3000/todos?keyword=커피

특정 항목 조회는 이렇게 한다.

GET http://localhost:3000/todos/2

없는 항목을 조회하면 라우트는 맞지만 데이터가 없으므로 404 응답이 온다.

GET http://localhost:3000/todos/999

그리고 아예 존재하지 않는 주소로 가면 마지막 404 처리기가 응답한다.

GET http://localhost:3000/banana-milk

서버 입장에서 보면 둘 다 404지만, 의미는 조금 다르다.

하나는 "그런 데이터가 없어요"이고, 다른 하나는 "그런 주소 자체가 없어요"다.

404 처리는 왜 마지막에 둬야 할까

404 전용 미들웨어는 반드시 가장 아래쪽, 즉 모든 라우트 뒤에 두는 것이 좋다.4

app.use((req, res) => {
  res.status(404).json({ message: '없는 주소입니다.' })
})

이 코드는 "지금까지 위에서 아무도 이 요청을 처리하지 못했으면 내가 받겠다"라는 뜻에 가깝다.

만약 이 코드를 위에 두면 어떻게 될까? 아래에 있는 정상 라우트들까지 도달하지 못하고 전부 404가 되어 버릴 수 있다. 서버가 손님을 보자마자 "몰라요, 집에 가세요"라고 말하는 셈이다.

req.params, req.query, req.body를 한 번에 정리하기

이제 이름이 조금 헷갈릴 수 있으니 한 번 정리해 보자.3

  • req.params: URL 경로 안의 값. 예를 들면 /todos/:id에서 id

  • req.query: ? 뒤에 붙는 값. 예를 들면 ?page=2&done=true

  • req.body: 요청 본문에 들어 있는 값. 주로 POST, PUT에서 많이 사용

예를 들어 아래 요청을 상상해 보자.

PUT /todos/3?preview=true

본문이 이렇게 왔다고 하면,

{
  "title": "책 읽기",
  "done": true
}

서버 안에서는 대략 이렇게 나뉜다.

req.params.id      // '3'
req.query.preview  // 'true'
req.body.title     // '책 읽기'
req.body.done      // true

위치를 구분해서 생각하면 훨씬 덜 헷갈린다.

같은 경로를 조금 더 보기 좋게 묶는 방법도 있다

같은 경로에 대해 여러 메서드를 붙일 일이 많다면 app.route()를 쓸 수도 있다.1

app.route('/books')
  .get((req, res) => {
    res.send('책 목록 조회')
  })
  .post((req, res) => {
    res.send('책 추가')
  })

이 방식은 경로를 한 번만 적어도 되어서, 메서드가 여러 개인 라우트를 정리할 때 편하다. 지금은 필수는 아니지만, "아 이런 스타일도 있구나" 정도로 알고 있으면 나중에 코드가 길어졌을 때 도움이 된다.

TypeScript 버전으로 보면 이렇게 바뀐다

이제 같은 예제를 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
}

let nextId = 4
let todos: Todo[] = [
  { id: 1, title: 'Express 라우팅 배우기', done: false },
  { id: 2, title: '점심 메뉴 고민하기', done: true },
  { id: 3, title: '커피와 친해지기', done: false },
]

app.get('/todos', (req: Request, res: Response) => {
  const done = req.query.done as string | undefined
  const keyword = req.query.keyword as string | undefined

  let result = todos

  if (done !== undefined) {
    const doneValue = done === 'true'
    result = result.filter((todo) => todo.done === doneValue)
  }

  if (keyword) {
    result = result.filter((todo) => todo.title.includes(keyword))
  }

  res.json(result)
})

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

  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: '할 일을 찾을 수 없습니다.' })
  }

  res.json(todo)
})

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

  if (!title) {
    return res.status(400).json({ message: 'title이 필요합니다.' })
  }

  const newTodo: Todo = {
    id: nextId++,
    title,
    done: false,
  }

  todos.push(newTodo)
  res.status(201).json(newTodo)
})

app.put('/todos/:id', (req: Request, res: Response) => {
  const id = Number(req.params.id)
  const { title, done } = req.body as { title?: string; done?: boolean }

  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: '수정할 할 일이 없습니다.' })
  }

  if (title === undefined || done === undefined) {
    return res.status(400).json({ message: 'title과 done을 함께 보내 주세요.' })
  }

  todo.title = title
  todo.done = done

  res.json(todo)
})

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

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

  const index = todos.findIndex((item) => item.id === id)

  if (index === -1) {
    return res.status(404).json({ message: '삭제할 할 일이 없습니다.' })
  }

  todos.splice(index, 1)
  res.status(204).end()
})

app.use((req: Request, res: Response) => {
  res.status(404).json({ message: '없는 주소입니다.' })
})

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

JavaScript 버전과 비교하면 달라진 점은 크지 않다.

  • Todo 타입이 생겨서 데이터 모양이 더 분명해졌다.

  • req.query, req.body에서 꺼낸 값에 타입 힌트를 조금 더 줬다.

  • 라우팅의 핵심 구조는 그대로다.

즉, TypeScript를 쓴다고 해서 라우팅 개념이 바뀌는 것은 아니다. 길찾기 규칙은 그대로고, 안내 표지판이 조금 더 또렷해지는 느낌에 가깝다.

오늘 챕터에서 꼭 손에 쥐고 가야 할 것

이번 장의 핵심은 아주 명확하다.

서버는 단순히 "요청을 받는다"에서 끝나지 않는다. 어떤 요청이 왔을 때 어디로 보낼지 정하는 일이 서버의 중요한 본업이다.

그 본업을 Express에서는 라우팅으로 풀어낸다.

  • app.get(), app.post(), app.put(), app.delete()로 메서드별 길을 만든다.1

  • req.params로 URL 안의 값을 읽는다.1

  • req.query로 쿼리스트링 값을 읽는다.3

  • 더 구체적인 라우트를 먼저 쓰고, 404 처리는 맨 마지막에 둔다.24

이 정도만 확실히 이해해도 서버가 훨씬 덜 흐릿해진다. 이제 요청이 와도 당황하지 않는다. "아, 너는 어느 길로 가야 하는 손님인지 내가 안다"라고 말할 수 있다.

다음 장에서는 여기서 한 걸음 더 들어가서, 요청 안쪽을 더 자세히 들여다본다. body, header, JSON 응답을 다루면서 서버가 손님이 들고 온 짐을 어떻게 확인하는지도 배워 보자.

참고

1Express routing

2Express Tutorial Part 4: Routes and controllers

3How To Retrieve URL and POST Parameters with Express

4Handle 404 and 500 errors in NodeJS and ExpressJS

책 목록

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

전체 목차 보기

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

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

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

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

참여하기