Skip to main content

지금까지 우리는 Express에서 라우트를 만들고, URL을 읽고, body와 header를 꺼내 쓰는 방법을 배웠다. 그런데 이런 생각이 들었을 수 있다.

"그래서 이 요청은 도착하자마자 바로 라우트로 가는 걸까?"

정답은 반쯤만 그렇다. 실제로는 요청이 라우트에 도착하기 전에 여러 단계를 지나갈 수 있다. 누가 왔는지 기록하고, JSON인지 확인하고, 필요한 데이터를 미리 가공하고, 조건이 안 맞으면 중간에서 멈춰 세울 수도 있다. 이 중간 단계를 담당하는 것이 바로 미들웨어다.1

이름부터 수상하다. 미들웨어. 말 그대로 가운데에 끼어드는 소프트웨어다. 서버 입구에서 "잠깐만요, 본문 검사하고 들어가세요", "신분증 보여 주세요", "사진 파일은 저쪽으로 가세요"라고 말하는 안내 직원에 가깝다.

Express를 이해하는 데서 미들웨어는 정말 중요하다. 조금 과장하면, 라우트는 무대 위 배우이고 미들웨어는 조명, 음향, 무대 전환 스태프다. 평소에는 잘 안 보이지만, 없으면 공연이 엉망이 된다.

미들웨어는 정확히 무엇일까

Express 공식 문서는 Express 앱을 사실상 미들웨어 함수 호출의 연속으로 볼 수 있다고 설명한다.1 즉, Express 서버는 "요청 하나 들어옴 → 이것도 실행 → 저것도 실행 → 마지막으로 라우트 실행" 같은 흐름으로 움직인다.

미들웨어 함수는 보통 이런 모양이다.

function myMiddleware(req, res, next) {
  // 뭔가 한다
  next()
}

여기서 세 가지가 핵심이다.

  • req: 들어온 요청 정보

  • res: 클라이언트에게 돌려줄 응답 정보

  • next: 다음 미들웨어로 넘기는 함수

미들웨어는 다음 중 하나를 할 수 있다.1

  • 코드를 실행한다.

  • req나 res를 바꾼다.

  • 응답을 보내고 요청 흐름을 끝낸다.

  • next()로 다음 미들웨어에게 바통을 넘긴다.

여기서 가장 중요한 규칙 하나를 먼저 외우자.

현재 미들웨어가 응답을 끝내지 않았다면, 보통 next()를 호출해야 한다.

이걸 빼먹으면 요청이 다음으로 가지 못하고 중간에서 멈춘다. 서버 입장에서는 "손님을 안내하던 직원이 갑자기 멍하니 서 있는 상태"가 된다. 브라우저는 기다리고, 개발자는 식은땀을 흘린다.1

요청은 미들웨어 줄을 서서 지나간다

미들웨어는 등록한 순서대로 실행된다.1 이 순서가 정말 중요하다. Express는 똑똑하지만, 여러분의 의도를 텔레파시로 읽지는 못한다.

예를 들어 이런 코드가 있다고 해 보자.

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

app.use((req, res, next) => {
  console.log('1번 미들웨어')
  next()
})

app.use((req, res, next) => {
  console.log('2번 미들웨어')
  next()
})

app.get('/', (req, res) => {
  console.log('라우트 실행')
  res.send('Hello')
})

GET / 요청이 오면 실행 순서는 이렇다.

  1. 1번 미들웨어

  2. 2번 미들웨어

  3. / 라우트 핸들러

즉, 요청은 위에서 아래로 흐른다. 먼저 적은 코드가 먼저 실행된다. 그래서 보통 로깅, 본문 파싱, 공통 검사 같은 것은 라우트보다 위에 둔다.

app.use()는 "일단 여기 들르세요"에 가깝다

미들웨어를 등록할 때 가장 자주 보는 것이 app.use()다.

app.use((req, res, next) => {
  console.log('모든 요청에서 실행')
  next()
})

이렇게 쓰면 모든 요청이 이 미들웨어를 지나간다. GET /, POST /todos, DELETE /users/3 전부 해당된다.

특정 경로 아래에서만 실행되게 할 수도 있다.

app.use('/admin', (req, res, next) => {
  console.log('admin 경로에서만 실행')
  next()
})

이 경우 /admin, /admin/users, /admin/settings 같은 요청에서만 동작한다.1

미들웨어를 처음 배울 때는 app.use()를 "입구에 설치한 공통 장치"처럼 이해하면 편하다.

가장 먼저 친해질 예제: 로깅 미들웨어

미들웨어의 첫 번째 대표 선수는 로깅이다. 누가 어떤 주소로, 어떤 메서드로 요청했는지 남기면 서버가 갑자기 훨씬 살아 있는 것처럼 느껴진다.

먼저 JavaScript 버전부터 보자.

JavaScript로 만드는 첫 미들웨어

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

function logger(req, res, next) {
  const now = new Date().toISOString()
  console.log(`[${now}] ${req.method} ${req.originalUrl}`)
  next()
}

app.use(logger)

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

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

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

이제 브라우저에서 /나 /about으로 들어가면 콘솔에는 이런 로그가 찍힌다.

[2026-03-29T10:00:00.000Z] GET /
[2026-03-29T10:00:05.000Z] GET /about

이 미들웨어는 응답을 보내지 않는다. 대신 기록만 남기고 next()로 다음 단계에 넘긴다. 이런 식으로 미들웨어는 주인공처럼 앞에 나서지 않고도 아주 유용한 일을 한다.

req에 값을 붙일 수도 있다

미들웨어의 재미있는 점은 요청 객체에 데이터를 추가할 수 있다는 것이다.1

app.use((req, res, next) => {
  req.requestTime = Date.now()
  next()
})

app.get('/', (req, res) => {
  res.json({
    message: '안녕하세요',
    requestTime: req.requestTime,
  })
})

이 패턴은 실전에서 자주 쓴다. 예를 들면 인증 미들웨어가 req.user를 넣어 주고, 이후 라우트에서 그 값을 읽는 식이다.

다만 초보 단계에서는 이렇게 기억하면 충분하다.

"미들웨어는 요청을 그냥 통과시키는 게 아니라, 다음 단계가 쓰기 좋게 미리 손봐 줄 수 있다."

next()를 빼먹으면 왜 문제가 될까

아래 코드를 보자.

app.use((req, res, next) => {
  console.log('여기까지는 왔다')
})

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

로그는 찍힌다. 그런데 브라우저는 응답을 못 받는다. 이유는 간단하다. 미들웨어가 응답도 안 보내고, next()도 안 불렀기 때문이다.1

이런 경우 요청 흐름은 여기서 멈춘다.

반대로 이런 코드는 괜찮다.

app.use((req, res, next) => {
  if (req.query.block === 'true') {
    return res.status(403).json({ message: '이 요청은 차단되었습니다.' })
  }

  next()
})

여기서는 두 갈래다. 차단할 때는 직접 응답을 보내고 끝낸다. 차단하지 않을 때는 next()로 넘긴다. 즉, 끝내든지 넘기든지 둘 중 하나는 해야 한다고 생각하면 된다.

미들웨어 체인을 눈으로 보기

조금 더 실감 나는 예제를 보자. 한 요청이 여러 미들웨어를 지나는 모습이다.

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

app.use((req, res, next) => {
  console.log('1단계: 요청 도착')
  next()
})

app.use((req, res, next) => {
  req.nickname = '감자개발자'
  console.log('2단계: req에 nickname 추가')
  next()
})

app.use((req, res, next) => {
  console.log('3단계: 라우트 직전 검사 완료')
  next()
})

app.get('/hello', (req, res) => {
  res.json({
    message: `${req.nickname}님, 반갑습니다!`,
  })
})

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

GET /hello 요청이 오면 콘솔은 대략 이렇게 흘러간다.

1단계: 요청 도착
2단계: req에 nickname 추가
3단계: 라우트 직전 검사 완료

그리고 응답은 이렇다.

{
  "message": "감자개발자님, 반갑습니다!"
}

이 장면을 이해하면 미들웨어가 갑자기 쉬워진다. 요청은 하나지만, 그 요청이 여러 사람의 손을 거쳐 마지막 응답으로 완성되는 것이다.

express.json()도 사실은 미들웨어다

이전 장에서 사용한 express.json()도 사실은 내장 미들웨어다.2

app.use(express.json())

이 코드는 "JSON 본문이 들어오면 자바스크립트 객체로 바꿔서 req.body에 넣어 줘"라는 뜻이다.2

즉, 여러분은 이미 미들웨어를 쓰고 있었다. 다만 이름을 크게 의식하지 않았을 뿐이다.

이 미들웨어가 없으면 이런 라우트는 곤란해진다.

app.post('/todos', (req, res) => {
  console.log(req.body)
  res.json({ received: req.body })
})

express.json()이 없다면 JSON 요청을 보냈을 때 req.body가 기대처럼 채워지지 않을 수 있다. 또 Express 공식 문서는 req.body가 사용자 입력이라서 신뢰되지 않은 값이라고 분명히 설명한다. 즉, 파싱되었다고 해서 자동으로 안전한 것은 아니다.2

JavaScript 예제: 로깅 + JSON 파싱 + 라우트

이번에는 실제로 많이 쓰는 조합을 하나로 묶어 보자.

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

app.use((req, res, next) => {
  console.log(`${req.method} ${req.originalUrl}`)
  next()
})

app.use(express.json())

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

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

  res.status(201).json({
    message: '할 일이 생성되었습니다.',
    data: {
      id: 1,
      title: title.trim(),
      done,
    },
  })
})

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

이 코드에서 요청 흐름은 이렇게 된다.

  1. 로깅 미들웨어가 요청 정보를 출력한다.

  2. express.json()이 JSON 본문을 읽어 req.body를 만든다.

  3. /todos 라우트가 req.body를 사용해 응답을 만든다.

이 구조가 익숙해지면 Express 코드가 전보다 훨씬 질서 있게 보이기 시작한다.

정적 파일 제공도 미들웨어다

이번 장의 또 다른 주인공은 express.static()이다. 이름 그대로 정적 파일을 제공하는 내장 미들웨어다.3

정적 파일은 서버가 가공하지 않고 그대로 보내는 파일이다. 예를 들면 HTML, CSS, JavaScript 파일, 이미지가 여기에 해당한다.

가장 기본적인 사용법은 이렇다.

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

app.use(express.static(path.join(__dirname, 'public')))

이렇게 하면 public 폴더 안의 파일들이 URL로 접근 가능해진다.3

예를 들어 폴더 구조가 아래와 같다고 하자.

project/
  public/
    hello.html
    css/
      style.css
    images/
      cat.png
  app.js

그러면 이런 식으로 접근할 수 있다.

  • /hello.html

  • /css/style.css

  • /images/cat.png

여기서 중요한 포인트가 하나 있다. URL에는 public이라는 폴더 이름이 들어가지 않는다. Express는 정적 파일 디렉터리를 기준점으로 삼아 파일을 찾는다.3

JavaScript 예제: 작은 정적 사이트 붙이기

이번에는 API 서버에 정적 파일도 붙여 보자.

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

app.use((req, res, next) => {
  console.log(`${req.method} ${req.originalUrl}`)
  next()
})

app.use(express.json())
app.use(express.static(path.join(__dirname, 'public')))

app.get('/api/health', (req, res) => {
  res.json({
    ok: true,
    time: new Date().toISOString(),
  })
})

app.post('/api/echo', (req, res) => {
  res.json({
    message: '받은 JSON을 그대로 돌려드립니다.',
    body: req.body,
  })
})

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

이제 이 서버는 두 가지 일을 동시에 한다.

  • /api/...로 시작하는 요청은 API처럼 처리한다.

  • public 폴더의 파일은 정적으로 바로 제공한다.

서버가 갑자기 다재다능해졌다. JSON도 받고, HTML도 내보내고, CSS도 건네준다. 이제 제법 "서버답다."

가상 경로를 붙일 수도 있다

정적 파일을 꼭 루트에 바로 노출할 필요는 없다. 예를 들어 /static 아래로만 보이게 만들 수도 있다.3

app.use('/static', express.static(path.join(__dirname, 'public')))

그러면 public/images/cat.png 파일은 /static/images/cat.png로 접근한다.

이 방식은 URL 구조를 정리할 때 꽤 유용하다. "정적 자원은 전부 /static 아래에 모아 두자" 같은 규칙을 만들기 좋다.

라우터 단위 미들웨어도 있다

모든 요청에 같은 미들웨어를 적용할 필요는 없다. 어떤 기능 묶음에만 적용하고 싶을 때가 있다. 그럴 때 express.Router()와 라우터 미들웨어를 쓴다.1

예를 들어 관리자 기능에만 검사 로직을 붙이고 싶다고 하자.

const express = require('express')
const app = express()
const adminRouter = express.Router()

adminRouter.use((req, res, next) => {
  const isAdmin = req.get('x-admin') === 'true'

  if (!isAdmin) {
    return res.status(403).json({
      message: '관리자만 접근할 수 있습니다.',
    })
  }

  next()
})

adminRouter.get('/dashboard', (req, res) => {
  res.json({ message: '관리자 대시보드입니다.' })
})

app.use('/admin', adminRouter)

이제 /admin/dashboard는 헤더 검사를 통과해야만 접근할 수 있다. 반면 일반 라우트는 이 검사를 거치지 않는다.

이 방식의 장점은 분명하다. 필요한 곳에만 필요한 미들웨어를 붙일 수 있다. 코드도 덜 지저분하고, 역할도 더 분명해진다.

미들웨어는 배열이나 여러 함수로 묶어 쓸 수도 있다

Express는 한 라우트에 여러 미들웨어를 나란히 붙일 수 있다.1

function logRequest(req, res, next) {
  console.log('요청 로그')
  next()
}

function checkToken(req, res, next) {
  if (!req.get('Authorization')) {
    return res.status(401).json({ message: '토큰이 필요합니다.' })
  }
  next()
}

app.get('/profile', logRequest, checkToken, (req, res) => {
  res.json({ message: '내 프로필입니다.' })
})

이렇게 하면 /profile 요청은 로그를 남기고, 토큰도 검사한 뒤에야 최종 응답으로 간다.

한 함수에 이것저것 다 넣기보다, 역할을 나눠 붙이는 편이 훨씬 읽기 좋다. 미들웨어를 잘 쓰는 코드는 대체로 "작은 부품을 조립한 코드"처럼 보인다.

에러 처리 미들웨어는 모양부터 다르다

보통 미들웨어는 (req, res, next) 형태다. 그런데 에러 처리 미들웨어는 인자가 네 개다.1

app.use((err, req, res, next) => {
  console.error(err.stack)
  res.status(500).json({
    message: '서버에서 문제가 발생했습니다.',
  })
})

이 네 개짜리 모양이 중요한 이유는 Express가 이 함수를 에러 전용 미들웨어로 구분하기 때문이다.1

예를 들어 라우트 안에서 이런 식으로 넘길 수 있다.

app.get('/boom', (req, res, next) => {
  const error = new Error('폭발!')
  next(error)
})

그러면 Express는 일반 미들웨어를 건너뛰고 에러 처리 미들웨어로 이동한다. 지금은 맛보기만 해도 충분하다. 다음 장에서 더 본격적으로 다루겠지만, 미들웨어 시스템 안에 에러 처리까지 포함된다는 점은 미리 알아 두면 좋다.

TypeScript 버전으로 보면 흐름이 더 또렷해진다

이제 같은 개념을 TypeScript로 옮겨 보자. TypeScript를 쓰면 req에 무엇이 들어 있는지 더 분명하게 적을 수 있다. 특히 커스텀 속성을 붙일 때 타입을 챙겨야 한다.

먼저 간단한 로깅 + JSON 파싱 + 정적 파일 제공 예제를 보자.

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

const app = express()
const PORT = 3000

function logger(req: Request, res: Response, next: NextFunction) {
  console.log(`${req.method} ${req.originalUrl}`)
  next()
}

app.use(logger)
app.use(express.json())
app.use('/static', express.static(path.join(__dirname, 'public')))

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

app.post(
  '/api/todos',
  (req: Request<{}, any, CreateTodoBody>, res: Response) => {
    const { title, done = false } = req.body

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

    return res.status(201).json({
      message: '할 일이 생성되었습니다.',
      data: {
        id: 1,
        title: title.trim(),
        done,
      },
    })
  }
)

app.get('/api/health', (req: Request, res: Response) => {
  res.json({ ok: true })
})

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

이 버전의 핵심은 CreateTodoBody 타입이다. 이 라우트가 어떤 본문을 기대하는지 코드만 봐도 읽힌다.

TypeScript에서 req에 값을 붙이려면 타입도 알려 줘야 한다

JavaScript에서는 그냥 req.requestTime = Date.now()를 해도 넘어간다. TypeScript는 "그런 속성 원래 없는데요?"라고 묻는다. 아주 성실한 친구다.

그래서 이런 식으로 확장된 요청 타입을 만들어 쓸 수 있다.

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

const app = express()

type RequestWithTime = Request & {
  requestTime?: number
}

function addRequestTime(
  req: RequestWithTime,
  res: Response,
  next: NextFunction
) {
  req.requestTime = Date.now()
  next()
}

app.use(addRequestTime)

app.get('/', (req: RequestWithTime, res: Response) => {
  res.json({
    message: '안녕하세요',
    requestTime: req.requestTime,
  })
})

TypeScript를 쓰면 미들웨어가 요청 객체를 어떻게 바꾸는지 더 선명하게 표현할 수 있다. 나중에 코드가 커졌을 때 특히 큰 도움이 된다.

실전 감각: 미들웨어 순서를 이렇게 생각하자

초보자에게 가장 자주 생기는 문제는 미들웨어를 "무엇을 쓰느냐"보다 "어디에 놓느냐"다. 순서가 중요하기 때문이다.1

보통은 이런 감각으로 정리하면 된다.

  • 가장 먼저 공통 로깅 같은 미들웨어를 둔다.

  • 그다음 express.json() 같은 파싱 미들웨어를 둔다.

  • 그다음 인증, 권한 검사처럼 일부 요청을 걸러내는 미들웨어를 둔다.

  • 마지막에 라우트를 둔다.

  • 에러 처리 미들웨어는 맨 아래에 둔다.

예를 들어 이런 식이다.

app.use(logger)
app.use(express.json())
app.use(authMiddleware)

app.use('/api/users', userRouter)
app.use('/api/todos', todoRouter)

app.use(errorHandler)

이 구조는 읽기도 좋고, 문제를 찾기도 쉽다. 서버를 설계할 때 "입구 → 통역 → 검사 → 목적지 → 문제 처리" 순서라고 생각하면 감이 빨리 온다.

자주 하는 실수들

미들웨어를 배우는 초반에는 거의 비슷한 실수를 한다. 미리 보면 덜 당황한다.

  • next()를 빼먹는다.

  • express.json()을 라우트 아래에 둔다.

  • 모든 요청에 무거운 미들웨어를 전역으로 붙인다.

  • 응답을 이미 보냈는데도 next()를 또 호출한다.

  • 에러 처리 미들웨어를 일반 미들웨어처럼 세 개 인자로 작성한다.

특히 express.json()의 위치는 정말 자주 틀린다. req.body를 쓰는 라우트보다 먼저 등록되어 있어야 한다. 그래야 라우트가 실행될 때 이미 본문 파싱이 끝나 있다.2

미들웨어를 보는 눈이 생기면 Express가 달라 보인다

이제 Express 코드를 다시 보면 전보다 구조가 보일 것이다.

예전에는 이런 코드가 그냥 "뭔가 많이 붙어 있네"처럼 보였을 수 있다.

app.use(express.json())
app.use('/static', express.static('public'))
app.use(logger)
app.use('/admin', authMiddleware, adminRouter)

하지만 이제는 다르게 읽힌다.

express.json()은 JSON 통역사다.

express.static()은 정적 파일 배달 담당이다.

logger는 출입 기록 담당이다.

authMiddleware는 관리자 구역 경비원이다.

이렇게 이해하면 Express가 갑자기 훨씬 똑똑해 보인다. 사실 Express가 갑자기 똑똑해진 게 아니라, 우리가 그 흐름을 읽을 수 있게 된 것이다.

마무리하며

미들웨어는 Express의 숨은 주인공이다. 요청이 라우트에 닿기 전에 어떤 일이 벌어지는지 이해하게 해 주고, 서버 기능을 작은 조각으로 나눠 조립하게 해 준다.

이번 장에서 꼭 챙겨 가야 할 감각은 세 가지다.

첫째, 미들웨어는 요청과 응답 사이에서 실행되는 함수다.

둘째, 등록한 순서대로 실행되며, 필요하면 next()로 다음 단계에 넘긴다.

셋째, 로깅, JSON 파싱, 정적 파일 제공 같은 흔한 기능도 사실은 미들웨어다.

이 감각이 생기면 Express 코드는 훨씬 덜 마법 같고, 훨씬 더 설계된 시스템처럼 보인다.

그리고 그 순간부터 서버 코드는 재미있어진다. 요청 하나가 들어와 여러 손을 거친 뒤 정확한 응답으로 나가는 모습이 보이기 때문이다. 마치 작은 공장이 돌아가는 걸 지켜보는 기분이다. 꽤 멋지다.

참고

1Using Express middleware

2Express 5.x API Reference

3Serving static files in Express

책 목록

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

전체 목차 보기

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

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

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

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

참여하기