Next.js 15 미들웨어 완벽 가이드 — 인증·리다이렉트·리라이트 실전 패턴

@JavaPark · September 14, 2023 · 14 min read

안녕하세요, 자바파커입니다.

"로그인 체크를 매 페이지 컴포넌트마다 복붙하고 계신가요? 그 코드, 미들웨어 한 파일로 끝납니다."

결론부터 말씀드리면 — Next.js 15 미들웨어는 요청이 라우트에 도달하기 전에 가로채는 관문입니다. 여기서 인증, 리다이렉트, 리라이트, 쿠키 세팅, 봇 차단까지 한 번에 처리하면 각 페이지/레이아웃의 코드가 훨씬 가벼워집니다. 오늘은 2026년 4월 기준 실전에서 바로 쓸 수 있는 다섯 가지 패턴matcher 설정, Edge Runtime 제약까지 정리해드릴게요.


미들웨어란 무엇인가

Next.js 미들웨어는 요청(Request)을 페이지/API 라우트 핸들러보다 먼저 실행하는 함수입니다. 기본적으로 Edge Runtime 위에서 동작하기 때문에 사용자와 물리적으로 가까운 지점(CDN 엣지)에서 실행되고, Node.js 서버 부팅을 기다리지 않아 콜드 스타트가 거의 없습니다.

쉽게 말하면 —

  • 요청을 통과시키거나 (NextResponse.next())
  • 리다이렉트 시키거나 (NextResponse.redirect())
  • 내부적으로 다른 경로로 재작성하거나 (NextResponse.rewrite())
  • 쿠키/헤더를 조작한 뒤 계속 진행시키거나

이렇게 네 갈래로 요청 흐름을 제어할 수 있습니다.

과거 pages/_middleware.ts 또는 per-route 미들웨어 파일은 deprecated 되었습니다. 지금은 프로젝트 루트의 middleware.ts 한 파일만 사용합니다.


파일 위치와 기본 구조 (App Router)

App Router 프로젝트 기준 middleware.tssrc/ 를 쓰면 src/middleware.ts, 아니면 프로젝트 루트에 둡니다. app/ 디렉터리 안에 두는 게 아니라는 점에 주의하세요.

my-app/
├─ app/
│  ├─ layout.tsx
│  └─ page.tsx
├─ middleware.ts   ← 여기!
├─ next.config.ts
└─ package.json

가장 기본적인 스켈레톤은 이렇게 생겼습니다.

// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"

export function middleware(request: NextRequest) {
  // 여기서 요청을 가로채서 원하는 작업 수행
  return NextResponse.next()
}

// 어떤 경로에서 미들웨어를 실행할지 지정
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}
  • middleware 함수는 default export 없이 named export 여야 합니다.
  • NextRequest는 표준 Request의 확장으로 cookies, nextUrl, geo, ip 등 Next 고유 속성을 추가로 가집니다.
  • config.matcher빌드 타임에 정적으로 분석되기 때문에 환경변수나 런타임 값으로 만들 수 없습니다.

실전 패턴 1 — 인증 가드 + 로그인 페이지 리다이렉트

가장 많이 쓰는 케이스입니다. /dashboard, /settings 같은 보호된 경로에 접근할 때 세션 쿠키를 검사하고, 없으면 로그인 페이지로 보냅니다.

// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"

const PROTECTED = ["/dashboard", "/settings", "/billing"]

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl
  const needsAuth = PROTECTED.some(p => pathname.startsWith(p))
  if (!needsAuth) return NextResponse.next()

  const token = req.cookies.get("session")?.value
  if (!token) {
    const loginUrl = new URL("/login", req.url)
    loginUrl.searchParams.set("from", pathname) // 로그인 후 원래 경로로 복귀
    return NextResponse.redirect(loginUrl)
  }

  // 필요하면 JWT 검증 (Edge 호환 라이브러리: jose 추천)
  return NextResponse.next()
}

export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*", "/billing/:path*"],
}

포인트:

  • JWT 검증은 jose 를 쓰세요. jsonwebtoken은 Node.js crypto에 의존해서 Edge에서 돌지 않습니다.
  • DB 조회가 필요한 무거운 인증은 미들웨어에서 하지 말고, 토큰 존재/만료만 체크하고 실제 권한은 Server Component에서 확인하는 게 빠릅니다.

실전 패턴 2 — 지역 기반 i18n 리다이렉트

접속자의 국가 코드(request.geo?.country)나 Accept-Language 헤더를 보고 / 요청을 /ko, /en, /ja 등으로 보냅니다.

// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"

const LOCALES = ["ko", "en", "ja"] as const
const DEFAULT_LOCALE = "en"

function pickLocale(req: NextRequest) {
  const country = req.geo?.country?.toLowerCase()
  if (country === "kr") return "ko"
  if (country === "jp") return "ja"

  const accept = req.headers.get("accept-language") ?? ""
  const preferred = accept.split(",")[0]?.split("-")[0]
  return LOCALES.includes(preferred as any) ? preferred : DEFAULT_LOCALE
}

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl
  const hasLocale = LOCALES.some(
    l => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
  )
  if (hasLocale) return NextResponse.next()

  const locale = pickLocale(req)
  return NextResponse.redirect(new URL(`/${locale}${pathname}`, req.url))
}

export const config = {
  matcher: ["/((?!_next|api|.*\\..*).*)"],
}

.*\\..* 패턴으로 favicon.ico, robots.txt 같은 정적 파일 확장자 요청은 스킵합니다.


실전 패턴 3 — A/B 테스트 쿠키 세팅 + 리라이트

동일한 URL에 접근해도 사용자를 A/B 버킷에 나누어 다른 경로를 내부적으로 렌더링하고 싶을 때의 패턴입니다.

// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"

export function middleware(req: NextRequest) {
  if (req.nextUrl.pathname !== "/pricing") return NextResponse.next()

  let bucket = req.cookies.get("ab-pricing")?.value
  if (bucket !== "a" && bucket !== "b") {
    bucket = Math.random() < 0.5 ? "a" : "b"
  }

  const url = req.nextUrl.clone()
  url.pathname = bucket === "b" ? "/pricing-b" : "/pricing-a"

  const res = NextResponse.rewrite(url)
  res.cookies.set("ab-pricing", bucket, {
    httpOnly: true,
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 30,
  })
  return res
}

export const config = {
  matcher: ["/pricing"],
}

rewriteURL은 그대로 유지한 채 내부 경로만 바꾸는 기법이라 사용자 주소창에는 /pricing으로 보입니다. SEO에도 안전합니다.


실전 패턴 4 — 봇 차단 / 간단한 Rate Limit

Edge에서 돌아가는 KV 스토어(Upstash Redis, Vercel KV 등)와 조합하면 초당 N회 제한을 걸 수 있습니다. 간단한 데모용 코드입니다.

// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(30, "10 s"), // 10초당 30회
})

const BAD_UA = /(curl|wget|python-requests|scrapy)/i

export async function middleware(req: NextRequest) {
  const ua = req.headers.get("user-agent") ?? ""
  if (BAD_UA.test(ua)) {
    return new NextResponse("Forbidden", { status: 403 })
  }

  const ip = req.ip ?? req.headers.get("x-forwarded-for") ?? "anon"
  const { success, remaining } = await ratelimit.limit(ip)

  if (!success) {
    return new NextResponse("Too Many Requests", { status: 429 })
  }

  const res = NextResponse.next()
  res.headers.set("X-RateLimit-Remaining", String(remaining))
  return res
}

export const config = {
  matcher: ["/api/:path*"],
}

운영 환경에서는 화이트리스트, 로그인 사용자 예외, 경로별 한도 차등 등을 추가해서 씁니다.


실전 패턴 5 — 구 URL → 신 URL 리라이트/리다이렉트

블로그 구조 개편 등으로 경로가 바뀐 경우, next.config.tsredirects()로 대체할 수도 있지만 동적 규칙이 필요하면 미들웨어가 유리합니다.

// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"

// 예: /posts/123 → /blog/123 로 영구 리다이렉트
export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl
  const match = pathname.match(/^\/posts\/(\d+)$/)
  if (!match) return NextResponse.next()

  const url = req.nextUrl.clone()
  url.pathname = `/blog/${match[1]}`
  return NextResponse.redirect(url, 308) // 301 대신 308(Permanent) 권장
}

export const config = {
  matcher: ["/posts/:id*"],
}

308은 301과 달리 메서드/바디를 보존하기 때문에 POST 엔드포인트 이전 시에도 안전합니다.


matcher 설정 깊이 파기

미들웨어가 모든 요청에 실행되면 정적 에셋, 이미지 최적화 요청까지 거쳐가서 불필요한 지연이 생깁니다. matcher로 꼭 필요한 경로만 태웁니다.

패턴 예시:

export const config = {
  matcher: [
    // 1) 특정 경로만
    "/dashboard/:path*",

    // 2) 정적 파일/내부 경로 제외 (가장 흔함)
    "/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",

    // 3) 헤더 조건까지 조합 (Next 13.5+ 확장 문법)
    {
      source: "/((?!api/).*)",
      has: [{ type: "header", key: "x-admin-preview", value: "true" }],
    },
  ],
}

주의사항:

  • matcher는 빌드 타임 상수 여야 합니다. 배열 요소에 변수를 넣지 마세요.
  • 정규식은 path-to-regexp 문법을 따릅니다. 부정 전방탐색 (?!...)이 핵심 도구입니다.

Edge Runtime 제약 — 뭘 못 쓰나

미들웨어는 기본이 Edge Runtime이라 V8 Isolates 위에서 돕니다. 덕분에 빠르지만 쓸 수 없는 것도 많습니다.

  • fs, path, child_process, netNode 내장 모듈 다수 불가
  • eval, new Function 같은 동적 코드 평가 금지
  • 네이티브 바이너리에 의존하는 라이브러리 (예: bcrypt, sharp) 불가
  • 파일 시스템 접근이 필요한 ORM 초기화도 불가 (Prisma 엣지 클라이언트는 별도)

대체재:

목적 쓰지 말 것 쓸 것
JWT 서명/검증 jsonwebtoken jose
해시 bcrypt Web Crypto API (crypto.subtle)
UUID uuid v3 crypto.randomUUID()
로깅 DB 쓰기 Node drive 직결 HTTP 기반 logger, Upstash

Next.js 15.2 이상에서는 Node.js 런타임 미들웨어(export const config = { runtime: 'nodejs' })가 실험 기능으로 열려 있습니다. 네이티브 모듈이 꼭 필요하면 고려해볼 수 있지만, Edge의 속도 이점이 사라진다는 점은 감안하세요.


꼭 알아둘 주의점

  1. 응답 본문은 수정 불가. 미들웨어는 HTML 내용을 바꾸는 도구가 아닙니다. 본문 치환이 필요하면 Route Handler나 Server Component에서 하세요.
  2. 쿠키/헤더 조작은 NextResponse 객체에만 적용해야 반영됩니다. req.cookies.set()서버 내부용일 뿐, 브라우저에 Set-Cookie로 나가지 않습니다.
  3. 미들웨어 체이닝은 공식 지원되지 않습니다. 한 파일에서 여러 관심사를 함수로 쪼개 호출하는 패턴을 쓰세요.
  4. 실행 시간은 짧게. Edge에서 수백 ms를 쓰면 TTFB가 크게 악화됩니다. 무거운 로직은 라우트 핸들러로 넘기세요.
  5. dev 와 prod 매칭이 다를 수 있습니다. 배포 전 반드시 next build && next start로 matcher 결과를 확인하세요.

FAQ

Q1. 미들웨어 체이닝이 되나요?

공식적으로는 파일 하나만 허용됩니다. 대신 각 관심사(auth, i18n, ab-test)를 함수로 분리해서 middleware.ts 안에서 순차 호출하는 패턴이 일반적입니다. 커뮤니티 라이브러리로 next-middleware-chain, nextjs-middleware-pipeline 같은 것도 있지만 의존성이 한 겹 더 생기므로 저는 그냥 함수 합성으로 씁니다.

Q2. 성능에 영향이 얼마나 가나요?

matcher를 잘 건 경우 요청당 1~5ms 수준입니다. 다만 await 호출(DB, 외부 API)이 들어가면 그 지연이 모든 페이지 TTFB에 누적되기 때문에, 가능하면 동기 로직 + 캐싱된 토큰 검증 정도로 제한하세요.

Q3. API 라우트에도 미들웨어가 실행되나요?

네, matcher에 포함되어 있다면 Route Handler(app/api/.../route.ts) 실행 전에 돕니다. 인증/레이트리밋을 API에 걸 때 아주 유용합니다.


관련 포스팅


참고 자료


여기까지 Next.js 15 미들웨어의 다섯 가지 실전 패턴과 Edge Runtime 제약을 정리했습니다. 인증, i18n, A/B 테스트, rate limit, URL 마이그레이션 — 네 줄짜리 middleware.ts 하나로 앱 전체의 관문을 제어할 수 있다는 점이 핵심입니다.

여러분은 미들웨어를 어떤 용도로 가장 많이 쓰시나요? 댓글로 공유해주시면, 다른 분들께도 좋은 레퍼런스가 됩니다. 궁금한 점이나 막히는 부분도 편하게 남겨주세요.

@JavaPark
AI 시대의 개발자 도구, 실전 경험을 공유합니다