Next.js 미들웨어는 요청 완료 전에 실행되어 Edge Runtime에서 동작하므로, 인증 체크, 지역 기반 리다이렉트, A/B 테스트, 속도 제한에 최적입니다.
Next.js 미들웨어란?
미들웨어는 요청이 페이지에 도달하기 전에 실행되는 함수입니다.
// Request flow in Next.js
//
// Client Request
// │
// ▼
// ┌─────────────┐
// │ Middleware │ ← Runs here (Edge Runtime)
// └─────────────┘
// │
// ▼
// ┌─────────────┐
// │ Page/API │ ← Your application code
// └─────────────┘
// │
// ▼
// Client Response기본 미들웨어 설정
프로젝트 루트에 middleware.ts 파일을 생성합니다.
// middleware.ts (project root)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Log every request
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
// Continue to the next handler
return NextResponse.next();
}
// Only run on specific paths
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};Matcher 구성
matcher는 어떤 라우트에서 미들웨어가 실행될지 결정합니다.
// Match specific routes
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
};
// Match all routes except static files and API
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
// Multiple matchers with different patterns
export const config = {
matcher: [
'/dashboard/:path*', // All dashboard routes
'/admin/:path*', // All admin routes
'/api/protected/:path*', // Protected API routes
'/profile', // Exact match
],
};
// Conditional matching with has/missing (Next.js 13.1+)
export const config = {
matcher: [
{
source: '/api/:path*',
has: [
{ type: 'header', key: 'authorization' },
],
},
],
};인증 미들웨어
인증이 필요한 라우트를 보호하는 가장 일반적인 용도입니다.
JWT 토큰 검증
미들웨어에서 JWT를 직접 검증합니다.
// middleware.ts — JWT Authentication
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET!
);
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip public paths
if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) {
return NextResponse.next();
}
// Get token from cookie or Authorization header
const token =
request.cookies.get('auth-token')?.value ||
request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
// Redirect to login with return URL
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
try {
// Verify JWT token
const { payload } = await jwtVerify(token, JWT_SECRET);
// Add user info to headers for downstream use
const response = NextResponse.next();
response.headers.set('x-user-id', payload.sub as string);
response.headers.set('x-user-role', payload.role as string);
return response;
} catch (error) {
// Token expired or invalid — redirect to login
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
const response = NextResponse.redirect(loginUrl);
response.cookies.delete('auth-token');
return response;
}
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*', '/api/protected/:path*'],
};역할 기반 접근 제어
사용자 역할에 기반한 접근 제어를 시행합니다.
// Role-based access control in middleware
const ROUTE_PERMISSIONS: Record<string, string[]> = {
'/admin': ['admin'],
'/dashboard': ['admin', 'editor', 'viewer'],
'/editor': ['admin', 'editor'],
'/api/admin': ['admin'],
};
function hasPermission(pathname: string, role: string): boolean {
for (const [route, roles] of Object.entries(ROUTE_PERMISSIONS)) {
if (pathname.startsWith(route)) {
return roles.includes(role);
}
}
return true; // No restriction for unmatched routes
}
export async function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
if (!token) return NextResponse.redirect(new URL('/login', request.url));
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
const role = payload.role as string;
const { pathname } = request.nextUrl;
if (!hasPermission(pathname, role)) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}리다이렉트 패턴
다양한 조건에 기반한 리다이렉트에 적합합니다.
지역 기반 리다이렉트
지리적 위치 데이터로 사용자를 리다이렉트합니다.
// Geo-based redirect (Vercel Edge)
export function middleware(request: NextRequest) {
const country = request.geo?.country || 'US';
const city = request.geo?.city || 'Unknown';
// Redirect EU users to GDPR-compliant page
const EU_COUNTRIES = [
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI',
'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU',
'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
];
if (EU_COUNTRIES.includes(country) &&
request.nextUrl.pathname === '/') {
return NextResponse.redirect(new URL('/eu', request.url));
}
// Add geo headers for downstream use
const response = NextResponse.next();
response.headers.set('x-country', country);
response.headers.set('x-city', city);
return response;
}로케일 감지 및 리다이렉트
사용자 언어 설정을 감지하여 리다이렉트합니다.
// Locale detection middleware
const SUPPORTED_LOCALES = ['en', 'fr', 'de', 'es', 'ja', 'ko', 'zh'];
const DEFAULT_LOCALE = 'en';
function getPreferredLocale(request: NextRequest): string {
// Check cookie first (user preference)
const cookieLocale = request.cookies.get('locale')?.value;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
return cookieLocale;
}
// Parse Accept-Language header
const acceptLang = request.headers.get('accept-language') || '';
const preferred = acceptLang
.split(',')
.map(lang => lang.split(';')[0].trim().substring(0, 2))
.find(lang => SUPPORTED_LOCALES.includes(lang));
return preferred || DEFAULT_LOCALE;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if locale is already in the path
const hasLocale = SUPPORTED_LOCALES.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (hasLocale) return NextResponse.next();
// Redirect to locale-prefixed path
const locale = getPreferredLocale(request);
const url = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.redirect(url);
}속도 제한
엣지에서 속도 제한을 구현합니다.
// Simple rate limiting with in-memory store
// Note: This works per-edge-node; use Redis for distributed rate limiting
const rateLimit = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT = 100; // requests
const RATE_WINDOW = 60 * 1000; // per minute
function isRateLimited(ip: string): boolean {
const now = Date.now();
const record = rateLimit.get(ip);
if (!record || now > record.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + RATE_WINDOW });
return false;
}
record.count++;
return record.count > RATE_LIMIT;
}
export function middleware(request: NextRequest) {
// Only rate-limit API routes
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ||
request.ip ||
'127.0.0.1';
if (isRateLimited(ip)) {
return new NextResponse(
JSON.stringify({ error: 'Too many requests' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': '60',
},
}
);
}
return NextResponse.next();
}슬라이딩 윈도우
슬라이딩 윈도우 알고리즘을 사용합니다.
// Rate limiting with Upstash Redis (production-ready)
// npm install @upstash/ratelimit @upstash/redis
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
analytics: true,
prefix: 'api-ratelimit',
});
export async function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
const ip = request.ip ?? '127.0.0.1';
const { success, limit, reset, remaining } = await ratelimit.limit(ip);
const response = success
? NextResponse.next()
: new NextResponse(JSON.stringify({ error: 'Rate limit exceeded' }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
});
// Always set rate limit headers
response.headers.set('X-RateLimit-Limit', limit.toString());
response.headers.set('X-RateLimit-Remaining', remaining.toString());
response.headers.set('X-RateLimit-Reset', reset.toString());
return response;
}응답 헤더 및 보안
보안 헤더를 설정하기에 최적의 장소입니다.
보안 헤더
// Security headers middleware
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Prevent XSS attacks
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
);
// Prevent clickjacking
response.headers.set('X-Frame-Options', 'DENY');
// Prevent MIME type sniffing
response.headers.set('X-Content-Type-Options', 'nosniff');
// Enable HSTS
response.headers.set(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
// Referrer policy
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions policy
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
return response;
}CORS 헤더
미들웨어 수준에서 CORS를 처리합니다.
// CORS middleware for API routes
const ALLOWED_ORIGINS = [
'https://myapp.com',
'https://staging.myapp.com',
process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean);
export function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
const origin = request.headers.get('origin') || '';
const isAllowed = ALLOWED_ORIGINS.includes(origin);
// Handle preflight OPTIONS request
if (request.method === 'OPTIONS') {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': isAllowed ? origin : '',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
},
});
}
const response = NextResponse.next();
if (isAllowed) {
response.headers.set('Access-Control-Allow-Origin', origin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
}
return response;
}A/B 테스트
깜빡임 없는 서버 사이드 A/B 테스트를 구현합니다.
// A/B Testing middleware
export function middleware(request: NextRequest) {
// Only A/B test the homepage
if (request.nextUrl.pathname !== '/') {
return NextResponse.next();
}
// Check if user already has a variant assigned
const variant = request.cookies.get('ab-variant')?.value;
if (variant) {
// Rewrite to the variant page without changing URL
return NextResponse.rewrite(
new URL(`/variants/${variant}`, request.url)
);
}
// Assign a random variant (50/50 split)
const newVariant = Math.random() < 0.5 ? 'control' : 'experiment';
const response = NextResponse.rewrite(
new URL(`/variants/${newVariant}`, request.url)
);
// Persist variant in cookie for 30 days
response.cookies.set('ab-variant', newVariant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: 'lax',
});
return response;
}URL 리라이트
브라우저 URL을 변경하지 않고 리라이트합니다.
// Multi-tenant rewriting
// app.mysite.com → /tenants/app
// docs.mysite.com → /tenants/docs
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const subdomain = hostname.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'mysite') {
return NextResponse.rewrite(
new URL(`/tenants/${subdomain}${request.nextUrl.pathname}`, request.url)
);
}
return NextResponse.next();
}
// Proxy API requests to external service
// /api/external/* → https://api.external-service.com/*
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/external/')) {
const path = request.nextUrl.pathname.replace('/api/external', '');
return NextResponse.rewrite(
new URL(`https://api.external-service.com${path}`)
);
}
return NextResponse.next();
}미들웨어 체이닝
여러 미들웨어 함수를 조합합니다.
// Composing multiple middleware functions
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
type MiddlewareFn = (
request: NextRequest,
response: NextResponse
) => NextResponse | Promise<NextResponse>;
function chain(
functions: MiddlewareFn[],
request: NextRequest
): Promise<NextResponse> {
return functions.reduce(
async (promise, fn) => {
const response = await promise;
return fn(request, response);
},
Promise.resolve(NextResponse.next())
);
}
// Individual middleware functions
const withLogging: MiddlewareFn = (request, response) => {
console.log(`[${request.method}] ${request.nextUrl.pathname}`);
return response;
};
const withSecurityHeaders: MiddlewareFn = (request, response) => {
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
return response;
};
const withAuth: MiddlewareFn = (request, response) => {
const token = request.cookies.get('auth-token')?.value;
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return response;
};
// Compose all middleware
export function middleware(request: NextRequest) {
return chain([withLogging, withSecurityHeaders, withAuth], request);
}Edge Runtime 제한
Edge Runtime에는 몇 가지 제한이 있습니다.
- 파일 시스템 접근 불가
- 네이티브 Node.js 모듈 불가
- Web API만 사용 가능
- 번들 크기 제한 1MB
- 데이터베이스 연결 불가
- 실행 시간 제한
// What you CAN use in Edge Runtime:
// ✅ fetch, Request, Response, Headers, URL
// ✅ TextEncoder, TextDecoder
// ✅ crypto.subtle (Web Crypto API)
// ✅ structuredClone
// ✅ atob, btoa
// ✅ URLPattern
// ✅ EdgeDB, Upstash, PlanetScale drivers
// What you CANNOT use:
// ❌ fs (file system)
// ❌ path, os, child_process
// ❌ pg, mysql2, mongoose (traditional DB drivers)
// ❌ bcrypt (use bcryptjs or Web Crypto instead)
// ❌ Large npm packages that exceed 1MB bundle미들웨어 디버깅
디버깅 기법을 소개합니다.
// Debugging middleware
export function middleware(request: NextRequest) {
// Log request details
console.log({
method: request.method,
url: request.url,
pathname: request.nextUrl.pathname,
cookies: Object.fromEntries(request.cookies.getAll().map(c => [c.name, c.value])),
headers: Object.fromEntries(request.headers),
geo: request.geo,
ip: request.ip,
});
// Return debug info in response headers (dev only)
const response = NextResponse.next();
if (process.env.NODE_ENV === 'development') {
response.headers.set('x-middleware-pathname', request.nextUrl.pathname);
response.headers.set('x-middleware-matched', 'true');
}
return response;
}모범 사례
- 미들웨어를 가볍게 유지.
- 정적 파일 제외.
- DB 호출 피하기.
- 캐시 헤더 설정.
- A/B 테스트에 쿠키 사용.
- 에러 핸들링 구현.
- 다양한 시나리오 테스트.
- 환경 변수 사용.
결론
Next.js 미들웨어는 엣지에서 동작하는 강력한 도구입니다. 인증, 리다이렉트, 속도 제한 등의 패턴을 마스터하세요.
FAQ
미들웨어는 모든 요청에서 실행되나요?
기본적으로 그렇지만 matcher로 제한해야 합니다.
미들웨어에서 데이터베이스를 사용할 수 있나요?
직접은 불가합니다. fetch로 API 라우트를 호출하세요.
redirect와 rewrite의 차이는?
redirect는 URL을 변경하고, rewrite는 URL을 변경하지 않고 콘텐츠를 변경합니다.
미들웨어 에러 처리는?
try-catch로 감싸고 에러 시 적절한 응답을 반환합니다.