안녕하세요, 자바파커입니다.
"로그인 체크를 매 페이지 컴포넌트마다 복붙하고 계신가요? 그 코드, 미들웨어 한 파일로 끝납니다."
결론부터 말씀드리면 — 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.ts는 src/ 를 쓰면 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.jscrypto에 의존해서 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"],
}rewrite는 URL은 그대로 유지한 채 내부 경로만 바꾸는 기법이라 사용자 주소창에는 /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.ts의 redirects()로 대체할 수도 있지만 동적 규칙이 필요하면 미들웨어가 유리합니다.
// 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,net등 Node 내장 모듈 다수 불가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의 속도 이점이 사라진다는 점은 감안하세요.
꼭 알아둘 주의점
- 응답 본문은 수정 불가. 미들웨어는 HTML 내용을 바꾸는 도구가 아닙니다. 본문 치환이 필요하면 Route Handler나 Server Component에서 하세요.
- 쿠키/헤더 조작은
NextResponse객체에만 적용해야 반영됩니다.req.cookies.set()은 서버 내부용일 뿐, 브라우저에 Set-Cookie로 나가지 않습니다. - 미들웨어 체이닝은 공식 지원되지 않습니다. 한 파일에서 여러 관심사를 함수로 쪼개 호출하는 패턴을 쓰세요.
- 실행 시간은 짧게. Edge에서 수백 ms를 쓰면 TTFB가 크게 악화됩니다. 무거운 로직은 라우트 핸들러로 넘기세요.
- 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 미들웨어로 공통 로그 남기기 — 로깅 특화 — 본 글이 "미들웨어 자체 사용법"에 집중했다면, 로깅 파이프라인 구축은 이 글에서 다룹니다.
참고 자료
- Next.js 공식 문서 — Middleware
- Next.js 공식 문서 — Edge Runtime
- Next.js 공식 문서 — NextResponse API
- jose — Edge 호환 JWT 라이브러리
- Upstash Ratelimit
여기까지 Next.js 15 미들웨어의 다섯 가지 실전 패턴과 Edge Runtime 제약을 정리했습니다. 인증, i18n, A/B 테스트, rate limit, URL 마이그레이션 — 네 줄짜리 middleware.ts 하나로 앱 전체의 관문을 제어할 수 있다는 점이 핵심입니다.
여러분은 미들웨어를 어떤 용도로 가장 많이 쓰시나요? 댓글로 공유해주시면, 다른 분들께도 좋은 레퍼런스가 됩니다. 궁금한 점이나 막히는 부분도 편하게 남겨주세요.