미들웨어란 무엇인가
Next.js 미들웨어는 요청이 서버에 도달하기 전에 실행되는 코드입니다. 사용자의 브라우저가 페이지를 요청하면, 그 요청이 실제 페이지 컴포넌트나 API 라우트에 도달하기 전에 미들웨어가 먼저 가로채서 처리하죠. 인증 확인, 리다이렉트, 헤더 수정, URL 재작성 등 다양한 작업을 수행할 수 있습니다.
미들웨어가 특별한 이유는 Edge Runtime에서 실행된다는 점입니다. 이건 전통적인 Node.js 서버가 아니라, Vercel이나 Cloudflare 같은 CDN 엣지 네트워크에서 사용자와 가장 가까운 위치에서 실행된다는 의미거든요. 덕분에 지연 시간이 매우 짧고, 전 세계 어디서든 빠른 응답이 가능합니다.
요청 라이프사이클에서 미들웨어의 위치를 이해하는 것이 중요합니다.
순서는 이렇습니다:
- 사용자가 URL을 요청합니다
- 미들웨어가 실행됩니다 (Edge Runtime)
- Next.js 라우팅이 수행됩니다
- 데이터 페칭 (Server Components, getServerSideProps 등)
- 페이지가 렌더링되어 응답합니다
하지만 Edge Runtime에는 중요한 제약사항이 있습니다. Node.js의 전체 API를 사용할 수 없어요. fs 모듈로 파일을 읽거나, 데이터베이스에 직접 연결하거나, 무거운 npm 패키지를 사용하는 것이 불가능합니다. 미들웨어는 가볍고 빠르게 실행되어야 하죠. 복잡한 비즈니스 로직은 서버 컴포넌트나 API 라우트에서 처리하고, 미들웨어는 요청의 사전 필터링과 라우팅 결정에 집중해야 합니다.
기본 설정과 matcher 구성
Next.js 미들웨어를 시작하려면 프로젝트 루트(또는 src 폴더를 사용한다면 src 폴더)에 middleware.ts 파일을 생성하면 됩니다. Next.js는 이 파일을 자동으로 인식합니다.
// middleware.ts (프로젝트 루트)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 모든 매칭된 요청에 대해 실행됩니다
console.log('요청 경로:', request.nextUrl.pathname);
return NextResponse.next();
}
// matcher 설정: 어떤 경로에서 미들웨어를 실행할지 지정
export const config = {
matcher: [
// api, _next/static, _next/image, favicon.ico 등을 제외한 모든 경로
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
],
};
matcher 설정은 미들웨어 성능에 결정적인 영향을 미칩니다 (이거 정말 중요합니다). matcher를 설정하지 않으면 모든 요청에 대해 미들웨어가 실행되는데, 정적 파일 — 이미지, CSS, JS 번들 등 — 에 대해서도 불필요하게 실행되어 성능이 저하됩니다.
자, 그럼 matcher에서 자주 사용하는 패턴들을 살펴보겠습니다:
export const config = {
matcher: [
// 특정 경로만 매칭
'/dashboard/:path*',
'/admin/:path*',
// 여러 경로를 개별적으로 지정
'/profile',
'/settings',
// 정규식을 활용한 패턴
'/((?!api|_next/static|_next/image|favicon.ico|.*\.png$|.*\.jpg$).*)',
// API 라우트 중 특정 경로만
'/api/protected/:path*',
],
};
여기서 잠깐, matcher는 빌드 타임에 정적으로 분석되므로 변수나 동적 값을 사용할 수 없습니다. 반드시 리터럴 문자열이어야 하죠. 만약 더 복잡한 조건부 로직이 필요하다면, 넓은 범위로 matcher를 설정하고 미들웨어 함수 내부에서 조건 분기를 처리하는 방식을 사용하세요.
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// matcher로 잡기 어려운 복잡한 조건은 함수 내부에서 처리
if (pathname.startsWith('/admin')) {
return handleAdmin(request);
}
if (pathname.startsWith('/api/webhook')) {
// 웹훅은 미들웨어 처리 없이 통과
return NextResponse.next();
}
return handleDefault(request);
}
NextRequest와 NextResponse API 활용법
Next.js 미들웨어에서 사용하는 NextRequest와 NextResponse는 Web API의 Request와 Response를 확장한 것입니다. 표준 Web API에 Next.js 특화 기능이 추가되어 있어서 편리하게 사용할 수 있죠.
NextRequest 주요 기능
export function middleware(request: NextRequest) {
// URL 정보 접근
const url = request.nextUrl; // NextURL 인스턴스
console.log(url.pathname); // '/dashboard/settings'
console.log(url.searchParams.get('page')); // 쿼리 파라미터
// 헤더 읽기
const authHeader = request.headers.get('authorization');
const userAgent = request.headers.get('user-agent');
const acceptLanguage = request.headers.get('accept-language');
// 쿠키 읽기 (Next.js 확장 API)
const sessionToken = request.cookies.get('session-token')?.value;
const allCookies = request.cookies.getAll();
// 지리적 위치 정보 (Vercel 배포 시)
const country = request.geo?.country;
const city = request.geo?.city;
// 클라이언트 IP
const ip = request.headers.get('x-forwarded-for') ?? request.ip;
return NextResponse.next();
}
NextResponse 주요 기능
export function middleware(request: NextRequest) {
// 1. 다음 미들웨어/페이지로 계속 진행
const response = NextResponse.next();
// 2. 응답 헤더 설정
response.headers.set('x-custom-header', 'hello');
response.headers.set('x-request-id', crypto.randomUUID());
// 3. 쿠키 설정
response.cookies.set('visited', 'true', {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7일
});
// 4. 쿠키 삭제
response.cookies.delete('old-cookie');
return response;
}
리다이렉트와 리라이트는 미들웨어에서 가장 많이 사용하는 패턴입니다:
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 리다이렉트: URL이 바뀌고 브라우저가 새 URL로 이동
if (pathname === '/old-page') {
return NextResponse.redirect(new URL('/new-page', request.url));
}
// 리라이트: URL은 그대로인데 내부적으로 다른 페이지를 렌더링
// 사용자에게는 /dashboard가 보이지만, 실제로는 /app/dashboard를 렌더링
if (pathname === '/dashboard') {
return NextResponse.rewrite(new URL('/app/dashboard', request.url));
}
// 요청 헤더를 수정하여 다음 단계로 전달
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-pathname', pathname);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
인증 미들웨어 구현
인증은 미들웨어의 가장 대표적인 활용 사례입니다.
Edge Runtime에서 JWT를 검증하려면 Node.js의 jsonwebtoken 라이브러리 대신 Web Crypto API를 활용하는 jose 라이브러리를 사용해야 합니다. 사실 이 부분에서 처음 삽질을 좀 하는 분들이 많은데, Edge Runtime이 Node.js API를 지원하지 않기 때문에 발생하는 문제입니다.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify, type JWTPayload } from 'jose';
// JWT 비밀키를 TextEncoder로 변환 (Edge에서는 이 방식 필요)
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET ?? 'fallback-secret-for-dev'
);
// 공개 경로 정의 (인증 불필요)
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/'];
const STATIC_PATHS = ['/api/auth', '/api/public'];
interface CustomJWTPayload extends JWTPayload {
role?: 'user' | 'admin' | 'editor';
userId?: string;
}
async function verifyToken(token: string): Promise<CustomJWTPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as CustomJWTPayload;
} catch {
// 토큰이 만료되었거나 유효하지 않음
return null;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 공개 경로는 인증 없이 통과
if (PUBLIC_PATHS.some(path => pathname === path) ||
STATIC_PATHS.some(path => pathname.startsWith(path))) {
return NextResponse.next();
}
// 쿠키 또는 Authorization 헤더에서 토큰 추출
const token = request.cookies.get('auth-token')?.value
?? request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
// 토큰이 없으면 로그인 페이지로 리다이렉트
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// JWT 검증
const payload = await verifyToken(token);
if (!payload) {
// 유효하지 않은 토큰 - 쿠키 삭제 후 로그인으로
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('auth-token');
return response;
}
// 역할 기반 접근 제어 (RBAC)
if (pathname.startsWith('/admin') && payload.role !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
if (pathname.startsWith('/editor') &&
!['admin', 'editor'].includes(payload.role ?? '')) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
// 인증 정보를 요청 헤더에 추가하여 서버 컴포넌트에서 활용
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.userId ?? '');
requestHeaders.set('x-user-role', payload.role ?? 'user');
return NextResponse.next({
request: { headers: requestHeaders },
});
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\.png$|.*\.svg$).*)'],
};
CVE-2025-29927: 미들웨어만으로는 부족한 이유
2025년 3월에 공개된 CVE-2025-29927 취약점은 Next.js 미들웨어 보안에 대한 중요한 교훈을 남겼습니다. 솔직히 이 취약점을 처음 봤을 때 꽤 충격적이었는데요. x-middleware-subrequest 헤더를 조작하면 미들웨어를 완전히 우회할 수 있었거든요. 공격자가 특정 헤더를 요청에 포함시키면, Next.js가 해당 요청을 내부 서브리퀘스트로 오인하여 미들웨어 실행을 건너뛰었습니다.
이 취약점은 Next.js 14.2.25와 15.2.3에서 패치되었지만, 핵심 교훈은 명확합니다.
미들웨어를 유일한 보안 계층으로 사용하지 마세요.
반드시 심층 방어(Defense-in-Depth) 전략을 적용해야 합니다:
- 미들웨어에서 1차 인증 검사를 수행합니다
- Server Component나 API Route에서 2차 인증 검사를 반드시 수행합니다
- Server Action에서도 별도의 권한 검증을 합니다
- 데이터베이스 쿼리 레벨에서도 사용자 권한을 확인합니다
// app/dashboard/page.tsx - 서버 컴포넌트에서 2차 인증 검사
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { verifySession } from '@/lib/auth';
export default async function DashboardPage() {
const cookieStore = await cookies();
const token = cookieStore.get('auth-token')?.value;
// 미들웨어를 통과했더라도 여기서 다시 검증
const session = await verifySession(token);
if (!session) {
redirect('/login');
}
return <div>안전한 대시보드 콘텐츠</div>;
}
국제화(i18n) 미들웨어 패턴
다국어 웹사이트를 만들 때 미들웨어는 사용자의 선호 언어를 감지하고 적절한 로케일로 라우팅하는 핵심 역할을 합니다. Next.js 13 이후 App Router에서는 내장 i18n 설정이 제거되었기 때문에, 미들웨어에서 직접 구현하거나 next-intl 같은 라이브러리를 활용해야 하죠.
직접 구현하기
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const SUPPORTED_LOCALES = ['ko', 'en', 'ja', 'zh'] as const;
const DEFAULT_LOCALE = 'ko';
type Locale = typeof SUPPORTED_LOCALES[number];
function getPreferredLocale(request: NextRequest): Locale {
// 1순위: 쿠키에 저장된 사용자 선택 언어
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale as Locale)) {
return cookieLocale as Locale;
}
// 2순위: Accept-Language 헤더 파싱
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
// Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
const languages = acceptLanguage
.split(',')
.map(lang => {
const [code, quality] = lang.trim().split(';q=');
return {
code: code.split('-')[0].toLowerCase(), // 'ko-KR' -> 'ko'
quality: quality ? parseFloat(quality) : 1.0,
};
})
.sort((a, b) => b.quality - a.quality);
for (const lang of languages) {
if (SUPPORTED_LOCALES.includes(lang.code as Locale)) {
return lang.code as Locale;
}
}
}
// 3순위: 기본 언어
return DEFAULT_LOCALE;
}
function hasLocalePrefix(pathname: string): boolean {
return SUPPORTED_LOCALES.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
}
function getLocaleFromPath(pathname: string): Locale | null {
const segment = pathname.split('/')[1];
if (SUPPORTED_LOCALES.includes(segment as Locale)) {
return segment as Locale;
}
return null;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 이미 로케일 접두사가 있는 경우 통과
if (hasLocalePrefix(pathname)) {
const locale = getLocaleFromPath(pathname);
const response = NextResponse.next();
// 현재 로케일을 헤더에 설정하여 서버 컴포넌트에서 사용
response.headers.set('x-locale', locale ?? DEFAULT_LOCALE);
return response;
}
// 로케일 접두사가 없는 경우 감지된 로케일로 리라이트
const locale = getPreferredLocale(request);
const newUrl = new URL(`/${locale}${pathname}`, request.url);
// searchParams 유지
newUrl.search = request.nextUrl.search;
// 리라이트: URL은 그대로 유지하면서 내부적으로 로케일 경로를 사용
const response = NextResponse.rewrite(newUrl);
response.headers.set('x-locale', locale);
return response;
}
next-intl과 함께 사용하기
next-intl은 Next.js App Router와 잘 통합되는 인기 있는 국제화 라이브러리입니다 (개인적으로는 이게 제일 편합니다). 미들웨어 설정이 매우 간단하죠:
// middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/', '/(ko|en|ja|zh)/:path*'],
};
// i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['ko', 'en', 'ja', 'zh'],
defaultLocale: 'ko',
localePrefix: 'as-needed', // 기본 로케일은 접두사 생략
localeDetection: true, // Accept-Language 기반 자동 감지
});
next-intl의 미들웨어를 다른 미들웨어 로직과 결합해야 하는 경우가 많은데, 이 부분은 뒤에서 미들웨어 체이닝 패턴에서 자세히 다루겠습니다.
속도 제한(Rate Limiting) 미들웨어
API 남용을 방지하기 위한 속도 제한은 미들웨어에서 구현하기 좋은 패턴입니다. Edge Runtime에서는 전통적인 인메모리 저장소를 사용할 수 없으므로 — 각 엣지 노드가 별도의 인스턴스이기 때문이죠 — Upstash Redis 같은 Edge 호환 저장소를 활용합니다.
Upstash Redis를 활용한 슬라이딩 윈도우 속도 제한
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// Upstash Redis는 HTTP 기반이므로 Edge Runtime에서 동작
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// 슬라이딩 윈도우: 60초 동안 최대 30회 요청
export const apiRateLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(30, '60 s'),
analytics: true, // Upstash 대시보드에서 분석 가능
prefix: 'ratelimit:api',
});
// 인증 엔드포인트는 더 엄격하게
export const authRateLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '60 s'), // 분당 5회
prefix: 'ratelimit:auth',
});
// 일반 페이지 요청은 느슨하게
export const pageRateLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '60 s'), // 분당 100회
prefix: 'ratelimit:page',
});
// middleware.ts에서 속도 제한 적용
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { apiRateLimiter, authRateLimiter } from '@/lib/rate-limit';
function getClientIp(request: NextRequest): string {
// 프록시/로드밸런서 뒤에 있을 때 실제 IP 추출
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
return request.ip ?? '127.0.0.1';
}
async function handleRateLimit(request: NextRequest): Promise<NextResponse | null> {
const { pathname } = request.nextUrl;
const ip = getClientIp(request);
// 엔드포인트별 다른 제한 적용
let limiter = apiRateLimiter;
let identifier = ip;
if (pathname.startsWith('/api/auth')) {
limiter = authRateLimiter;
// 인증 엔드포인트는 IP + 경로로 식별
identifier = `${ip}:${pathname}`;
}
const { success, limit, remaining, reset } = await limiter.limit(identifier);
if (!success) {
// 429 Too Many Requests 응답
return new NextResponse(
JSON.stringify({
error: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.',
retryAfter: Math.ceil((reset - Date.now()) / 1000),
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': reset.toString(),
'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
},
}
);
}
// 속도 제한 통과 - 관련 헤더를 응답에 추가
const response = NextResponse.next();
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 null; // null 반환은 "통과"를 의미
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// API 경로에 대해서만 속도 제한 적용
if (pathname.startsWith('/api')) {
const rateLimitResponse = await handleRateLimit(request);
if (rateLimitResponse) {
return rateLimitResponse; // 429 응답 반환
}
}
return NextResponse.next();
}
Upstash의 @upstash/ratelimit 라이브러리는 슬라이딩 윈도우 외에도 고정 윈도우(fixedWindow)와 토큰 버킷(tokenBucket) 알고리즘을 지원합니다. 제가 실제 프로젝트에서 써본 경험으로는 슬라이딩 윈도우가 가장 일반적이고 공평한 제한을 제공합니다.
A/B 테스트와 기능 플래그
A/B 테스트는 미들웨어의 또 다른 강력한 활용 사례입니다. 사용자를 다른 변형(variant) 페이지로 라우팅하되, URL은 동일하게 유지할 수 있죠.
핵심은 쿠키 기반 버킷 할당입니다. 사용자가 처음 방문할 때 변형을 할당하고, 이후 방문에서는 같은 변형을 계속 보여줘야 일관된 경험을 제공할 수 있습니다.
// lib/ab-test.ts
export interface Experiment {
name: string;
variants: string[];
// 각 변형의 트래픽 비율 (합이 1이 되어야 함)
weights?: number[];
}
export const EXPERIMENTS: Record<string, Experiment> = {
'homepage-hero': {
name: 'homepage-hero',
variants: ['control', 'variant-a', 'variant-b'],
weights: [0.34, 0.33, 0.33],
},
'pricing-layout': {
name: 'pricing-layout',
variants: ['control', 'variant-a'],
weights: [0.5, 0.5],
},
};
export function assignVariant(experiment: Experiment): string {
const weights = experiment.weights ??
experiment.variants.map(() => 1 / experiment.variants.length);
const random = Math.random();
let cumulative = 0;
for (let i = 0; i < weights.length; i++) {
cumulative += weights[i];
if (random < cumulative) {
return experiment.variants[i];
}
}
return experiment.variants[experiment.variants.length - 1];
}
// middleware.ts에서 A/B 테스트 적용
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { EXPERIMENTS, assignVariant } from '@/lib/ab-test';
function handleABTest(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
let response: NextResponse;
// 홈페이지 히어로 실험
if (pathname === '/') {
const experiment = EXPERIMENTS['homepage-hero'];
const cookieName = `ab-${experiment.name}`;
// 기존 쿠키에서 변형 확인 (일관된 경험 유지)
let variant = request.cookies.get(cookieName)?.value;
if (!variant || !experiment.variants.includes(variant)) {
// 새 방문자: 변형 할당
variant = assignVariant(experiment);
}
// URL 리라이트로 변형 페이지 렌더링
// / -> /_variants/homepage-hero/control 또는 variant-a 등
const variantUrl = new URL(
`/_variants/${experiment.name}/${variant}`,
request.url
);
response = NextResponse.rewrite(variantUrl);
// 쿠키에 변형 저장 (30일)
response.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: false, // 클라이언트에서 분석 도구가 읽을 수 있도록
sameSite: 'lax',
});
// 분석을 위한 헤더 추가
response.headers.set('x-experiment', experiment.name);
response.headers.set('x-variant', variant);
return response;
}
// 가격 페이지 실험
if (pathname === '/pricing') {
const experiment = EXPERIMENTS['pricing-layout'];
const cookieName = `ab-${experiment.name}`;
let variant = request.cookies.get(cookieName)?.value;
if (!variant || !experiment.variants.includes(variant)) {
variant = assignVariant(experiment);
}
const variantUrl = new URL(
`/_variants/${experiment.name}/${variant}`,
request.url
);
response = NextResponse.rewrite(variantUrl);
response.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30,
httpOnly: false,
sameSite: 'lax',
});
return response;
}
return NextResponse.next();
}
실험 변형 페이지의 파일 구조는 다음과 같이 구성합니다:
app/
├── _variants/
│ ├── homepage-hero/
│ │ ├── control/
│ │ │ └── page.tsx // 기존 디자인
│ │ ├── variant-a/
│ │ │ └── page.tsx // 새 히어로 배너
│ │ └── variant-b/
│ │ └── page.tsx // 비디오 히어로
│ └── pricing-layout/
│ ├── control/
│ │ └── page.tsx
│ └── variant-a/
│ └── page.tsx
├── page.tsx // 원본 홈페이지 (폴백)
└── pricing/
└── page.tsx // 원본 가격 페이지 (폴백)
기능 플래그(Feature Flag)도 유사한 패턴으로 구현할 수 있습니다. 외부 서비스(LaunchDarkly, Statsig 등)에서 플래그 값을 가져와 쿠키에 캐싱하고, 미들웨어에서 해당 쿠키를 읽어 라우팅하는 방식입니다. 다만 외부 API 호출은 미들웨어 성능에 영향을 줄 수 있으므로, 결과를 쿠키에 캐싱하여 매 요청마다 API를 호출하지 않도록 주의해야 합니다.
미들웨어 체이닝 패턴
자, 여기서 중요한 사실 하나. Next.js는 프로젝트당 하나의 middleware.ts 파일만 허용합니다. 하지만 실제 프로덕션 환경에서는 인증, 국제화, 속도 제한, A/B 테스트 등 여러 관심사를 동시에 처리해야 하죠.
이를 우아하게 관리하기 위한 체이닝 패턴들을 살펴보겠습니다.
패턴 1: 순차 실행 팩토리
// lib/middleware/chain.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 미들웨어 함수의 타입 정의
export type MiddlewareFunction = (
request: NextRequest,
response: NextResponse
) => Promise<NextResponse> | NextResponse;
// 여러 미들웨어를 순차적으로 실행하는 체인 생성
export function createMiddlewareChain(middlewares: MiddlewareFunction[]) {
return async function chainedMiddleware(
request: NextRequest
): Promise<NextResponse> {
// 초기 응답 생성
let response = NextResponse.next();
for (const middleware of middlewares) {
const result = await middleware(request, response);
// 리다이렉트나 에러 응답이면 즉시 반환 (체인 중단)
if (result.status !== 200 || result.headers.get('Location')) {
return result;
}
// 이전 미들웨어가 설정한 헤더/쿠키를 유지하면서 진행
response = result;
}
return response;
};
}
// lib/middleware/auth.middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import type { MiddlewareFunction } from './chain';
import { jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const PUBLIC_PATHS = ['/login', '/register', '/'];
export const authMiddleware: MiddlewareFunction = async (request, response) => {
const { pathname } = request.nextUrl;
// 공개 경로는 통과
if (PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith('/api/public'))) {
return response;
}
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);
// 사용자 정보를 헤더에 추가
response.headers.set('x-user-id', (payload.sub as string) ?? '');
return response;
} catch {
const redirectResponse = NextResponse.redirect(new URL('/login', request.url));
redirectResponse.cookies.delete('auth-token');
return redirectResponse;
}
};
// lib/middleware/i18n.middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import type { MiddlewareFunction } from './chain';
const LOCALES = ['ko', 'en', 'ja'];
const DEFAULT_LOCALE = 'ko';
export const i18nMiddleware: MiddlewareFunction = (request, response) => {
const { pathname } = request.nextUrl;
// 이미 로케일 접두사가 있으면 통과
if (LOCALES.some(l => pathname.startsWith(`/${l}/`) || pathname === `/${l}`)) {
return response;
}
// 로케일 감지 및 리라이트
const locale = request.cookies.get('NEXT_LOCALE')?.value ?? DEFAULT_LOCALE;
const newUrl = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.rewrite(newUrl);
};
// middleware.ts - 모든 미들웨어를 합치는 진입점
import { createMiddlewareChain } from '@/lib/middleware/chain';
import { authMiddleware } from '@/lib/middleware/auth.middleware';
import { i18nMiddleware } from '@/lib/middleware/i18n.middleware';
import { rateLimitMiddleware } from '@/lib/middleware/rate-limit.middleware';
// 실행 순서: 속도 제한 → 인증 → 국제화
const middleware = createMiddlewareChain([
rateLimitMiddleware, // 1. 속도 제한 (가장 먼저, 비용이 저렴)
authMiddleware, // 2. 인증 (JWT 검증)
i18nMiddleware, // 3. 국제화 (URL 리라이트)
]);
export default middleware;
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\.(?:png|jpg|svg|ico)$).*)'],
};
패턴 2: 고차 함수 래퍼
더 유연한 제어가 필요할 때는 고차 함수 패턴을 사용할 수 있습니다:
// lib/middleware/with-auth.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
type MiddlewareHandler = (request: NextRequest) => Promise<NextResponse> | NextResponse;
export function withAuth(
handler: MiddlewareHandler,
options?: { publicPaths?: string[] }
): MiddlewareHandler {
const publicPaths = options?.publicPaths ?? ['/login', '/register'];
return async (request: NextRequest) => {
const { pathname } = request.nextUrl;
// 공개 경로는 바로 다음 핸들러로
if (publicPaths.some(p => pathname.startsWith(p))) {
return handler(request);
}
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// 인증 통과 후 다음 핸들러 실행
return handler(request);
};
}
export function withRateLimit(
handler: MiddlewareHandler,
options?: { maxRequests?: number }
): MiddlewareHandler {
return async (request: NextRequest) => {
// 속도 제한 로직...
// 통과하면 다음 핸들러 호출
return handler(request);
};
}
// 사용 예: 안쪽에서 바깥쪽으로 읽기
// 실행 순서: withRateLimit → withAuth → 핵심 로직
export default withRateLimit(
withAuth(
async (request) => {
// 핵심 미들웨어 로직
return NextResponse.next();
},
{ publicPaths: ['/login', '/register', '/'] }
),
{ maxRequests: 100 }
);
패턴 3: 경로 기반 분기
경로에 따라 완전히 다른 미들웨어 로직을 적용해야 할 때 유용한 패턴입니다:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 경로별 핸들러 매핑
const routeHandlers: Array<{
match: (pathname: string) => boolean;
handler: (request: NextRequest) => Promise<NextResponse> | NextResponse;
}> = [
{
match: (path) => path.startsWith('/api'),
handler: handleApiRoute,
},
{
match: (path) => path.startsWith('/admin'),
handler: handleAdminRoute,
},
{
match: (path) => path.startsWith('/dashboard'),
handler: handleDashboardRoute,
},
{
// 기본 핸들러 (항상 매칭됨)
match: () => true,
handler: handleDefaultRoute,
},
];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
for (const route of routeHandlers) {
if (route.match(pathname)) {
return route.handler(request);
}
}
return NextResponse.next();
}
async function handleApiRoute(request: NextRequest): Promise<NextResponse> {
// API: 속도 제한 + CORS 헤더
// ...
return NextResponse.next();
}
async function handleAdminRoute(request: NextRequest): Promise<NextResponse> {
// 관리자: 엄격한 인증 + 역할 검사
// ...
return NextResponse.next();
}
async function handleDashboardRoute(request: NextRequest): Promise<NextResponse> {
// 대시보드: 일반 인증
// ...
return NextResponse.next();
}
async function handleDefaultRoute(request: NextRequest): Promise<NextResponse> {
// 기본: i18n + A/B 테스트
// ...
return NextResponse.next();
}
성능 최적화와 주의사항
미들웨어는 모든 매칭된 요청에 대해 실행되므로, 성능이 사용자 경험에 직접적인 영향을 미칩니다.
몇 가지 중요한 최적화 원칙과 흔한 실수를 살펴보겠습니다.
가볍게 유지하기
미들웨어에서 무거운 작업을 하면 안 됩니다 (이거 정말 중요합니다). Edge Runtime의 실행 시간 제한은 플랫폼마다 다르지만, 일반적으로 수십 밀리초 안에 완료되어야 해요.
- 하지 말 것: 복잡한 데이터 변환, 큰 JSON 파싱, 외부 API 여러 개 호출
- 해야 할 것: 간단한 조건 검사, 쿠키/헤더 읽기, JWT 서명 검증, 리다이렉트/리라이트
// 나쁜 예: 미들웨어에서 무거운 작업
export async function middleware(request: NextRequest) {
// 여러 외부 API를 호출하면 응답 시간이 급격히 늘어남
const userData = await fetch('https://api.example.com/user');
const permissions = await fetch('https://api.example.com/permissions');
const features = await fetch('https://api.example.com/features');
// ...
}
// 좋은 예: 미들웨어는 가볍게, 무거운 작업은 서버 컴포넌트에서
export async function middleware(request: NextRequest) {
// JWT 검증만 수행 (빠르고 가벼움)
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
// 상세한 권한 확인은 서버 컴포넌트에서 처리
return NextResponse.next();
}
matcher 범위를 최소화하기
필요한 경로에서만 미들웨어가 실행되도록 matcher를 정확하게 설정하세요:
// 나쁜 예: 너무 넓은 범위
export const config = {
matcher: '/:path*', // 모든 경로 - 정적 파일까지 실행됨
};
// 좋은 예: 필요한 경로만 정확히 지정
export const config = {
matcher: [
'/dashboard/:path*',
'/api/protected/:path*',
'/admin/:path*',
],
};
무한 리다이렉트 루프 방지
미들웨어에서 가장 흔한 실수 중 하나가 무한 리다이렉트 루프입니다. 솔직히 저도 처음에 많이 겪었어요:
// 위험: 무한 루프 발생 가능
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
if (!token) {
// /login으로 리다이렉트하면, /login도 미들웨어를 통과하므로
// 토큰이 없어서 다시 /login으로 리다이렉트... 무한 반복!
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
// 안전: 리다이렉트 대상 경로를 제외
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 로그인 페이지 자체는 미들웨어 처리에서 제외
if (pathname === '/login' || pathname === '/register') {
return NextResponse.next();
}
const token = request.cookies.get('auth-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
정적 파일 제외
이미지, 폰트, 파비콘 등 정적 파일에 대해 미들웨어가 실행되면 불필요한 성능 오버헤드가 발생합니다:
export const config = {
matcher: [
/*
* 다음으로 시작하는 경로를 제외한 모든 경로 매칭:
* - api (API 라우트) - 필요에 따라 포함/제외
* - _next/static (정적 파일)
* - _next/image (이미지 최적화)
* - favicon.ico (파비콘)
* - sitemap.xml, robots.txt (SEO 파일)
* - 이미지 확장자 파일들
*/
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\.(?:png|jpg|jpeg|gif|webp|svg|ico)$).*)',
],
};
응답 본문 수정 불가
미들웨어에서는 응답 본문(body)을 읽거나 수정할 수 없습니다. 미들웨어가 할 수 있는 것은 헤더와 쿠키 수정, 리다이렉트, 리라이트뿐이죠. HTML이나 JSON 응답 내용을 변경해야 한다면 서버 컴포넌트나 API 라우트에서 처리하세요.
환경 변수 관리
Edge Runtime에서 사용하는 환경 변수는 빌드 타임에 번들에 포함되어야 합니다. NEXT_PUBLIC_ 접두사 없이도 미들웨어에서 process.env로 접근할 수 있지만, 반드시 배포 환경에서 올바르게 설정되어 있는지 확인하세요.
실전 프로덕션 미들웨어 예제
자, 이제 지금까지 배운 모든 패턴을 결합한 프로덕션 수준의 미들웨어를 작성해 보겠습니다. 처음엔 좀 복잡해 보일 수 있는데, 차근차근 따라가면 괜찮습니다.
인증, 국제화, 속도 제한을 하나의 미들웨어에서 체계적으로 관리하는 완성된 예제입니다.
// lib/middleware/types.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export type MiddlewareResult = {
response: NextResponse;
// true면 체인을 중단하고 이 응답을 즉시 반환
shouldStop: boolean;
};
export type MiddlewareHandler = (
request: NextRequest,
currentResponse: NextResponse
) => Promise<MiddlewareResult>;
// lib/middleware/create-chain.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import type { MiddlewareHandler } from './types';
export function createChain(handlers: MiddlewareHandler[]) {
return async (request: NextRequest): Promise<NextResponse> => {
let response = NextResponse.next();
for (const handler of handlers) {
try {
const result = await handler(request, response);
response = result.response;
if (result.shouldStop) {
return response;
}
} catch (error) {
console.error('[Middleware Error]', error);
// 미들웨어 에러 시 요청을 차단하지 않고 통과시킴
// 로그만 남기고 다음 핸들러로 진행
continue;
}
}
return response;
};
}
// lib/middleware/handlers/rate-limit.handler.ts
import type { MiddlewareHandler } from '../types';
import { NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const rateLimiters = {
api: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(30, '60 s'),
prefix: 'rl:api',
}),
auth: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '60 s'),
prefix: 'rl:auth',
}),
};
export const rateLimitHandler: MiddlewareHandler = async (request, response) => {
const { pathname } = request.nextUrl;
// API 경로가 아니면 속도 제한 건너뛰기
if (!pathname.startsWith('/api')) {
return { response, shouldStop: false };
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
?? request.ip ?? 'unknown';
const limiter = pathname.startsWith('/api/auth')
? rateLimiters.auth
: rateLimiters.api;
const { success, limit, remaining, reset } = await limiter.limit(ip);
if (!success) {
const errorResponse = new NextResponse(
JSON.stringify({ error: '요청 한도를 초과했습니다.' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': '0',
'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
},
}
);
return { response: errorResponse, shouldStop: true };
}
// 속도 제한 정보를 헤더에 추가
response.headers.set('X-RateLimit-Limit', limit.toString());
response.headers.set('X-RateLimit-Remaining', remaining.toString());
return { response, shouldStop: false };
};
// lib/middleware/handlers/auth.handler.ts
import type { MiddlewareHandler } from '../types';
import { NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const PUBLIC_PATHS = new Set(['/', '/login', '/register', '/forgot-password']);
const PUBLIC_PREFIXES = ['/api/public', '/api/auth', '/api/webhook'];
function isPublicPath(pathname: string): boolean {
if (PUBLIC_PATHS.has(pathname)) return true;
return PUBLIC_PREFIXES.some(prefix => pathname.startsWith(prefix));
}
export const authHandler: MiddlewareHandler = async (request, response) => {
const { pathname } = request.nextUrl;
if (isPublicPath(pathname)) {
return { response, shouldStop: false };
}
const token = request.cookies.get('auth-token')?.value
?? request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('from', pathname);
return {
response: NextResponse.redirect(loginUrl),
shouldStop: true,
};
}
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
// 관리자 경로 권한 확인
if (pathname.startsWith('/admin') && payload.role !== 'admin') {
return {
response: NextResponse.redirect(new URL('/unauthorized', request.url)),
shouldStop: true,
};
}
// 인증 정보를 헤더로 전달
response.headers.set('x-user-id', (payload.sub as string) ?? '');
response.headers.set('x-user-role', (payload.role as string) ?? 'user');
return { response, shouldStop: false };
} catch {
const loginUrl = new URL('/login', request.url);
const redirectResponse = NextResponse.redirect(loginUrl);
redirectResponse.cookies.delete('auth-token');
return { response: redirectResponse, shouldStop: true };
}
};
// lib/middleware/handlers/i18n.handler.ts
import type { MiddlewareHandler } from '../types';
import { NextResponse } from 'next/server';
const LOCALES = ['ko', 'en', 'ja', 'zh'] as const;
const DEFAULT_LOCALE = 'ko';
function detectLocale(request: Request, cookieLocale?: string): string {
// 쿠키 우선
if (cookieLocale && LOCALES.includes(cookieLocale as typeof LOCALES[number])) {
return cookieLocale;
}
// Accept-Language 헤더 파싱
const acceptLang = request.headers.get('accept-language') ?? '';
const preferred = acceptLang
.split(',')
.map(part => part.trim().split(';')[0].split('-')[0].toLowerCase())
.find(lang => LOCALES.includes(lang as typeof LOCALES[number]));
return preferred ?? DEFAULT_LOCALE;
}
export const i18nHandler: MiddlewareHandler = async (request, response) => {
const { pathname } = request.nextUrl;
// API 경로는 국제화 처리 불필요
if (pathname.startsWith('/api')) {
return { response, shouldStop: false };
}
// 이미 로케일 접두사가 있으면 통과
const hasLocale = LOCALES.some(
l => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
);
if (hasLocale) {
const locale = pathname.split('/')[1];
response.headers.set('x-locale', locale);
return { response, shouldStop: false };
}
// 로케일 감지 및 리라이트
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
const locale = detectLocale(request, cookieLocale);
const newUrl = new URL(`/${locale}${pathname}`, request.url);
newUrl.search = request.nextUrl.search;
const rewriteResponse = NextResponse.rewrite(newUrl);
rewriteResponse.headers.set('x-locale', locale);
// 이전 핸들러가 설정한 헤더 유지
response.headers.forEach((value, key) => {
if (!rewriteResponse.headers.has(key)) {
rewriteResponse.headers.set(key, value);
}
});
return { response: rewriteResponse, shouldStop: false };
};
// lib/middleware/handlers/security.handler.ts
import type { MiddlewareHandler } from '../types';
export const securityHandler: MiddlewareHandler = async (request, response) => {
// 보안 헤더 추가
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set('X-DNS-Prefetch-Control', 'on');
response.headers.set(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
response.headers.set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
// 요청 추적 ID 부여
const requestId = crypto.randomUUID();
response.headers.set('X-Request-Id', requestId);
return { response, shouldStop: false };
};
// middleware.ts - 최종 통합
import { createChain } from '@/lib/middleware/create-chain';
import { securityHandler } from '@/lib/middleware/handlers/security.handler';
import { rateLimitHandler } from '@/lib/middleware/handlers/rate-limit.handler';
import { authHandler } from '@/lib/middleware/handlers/auth.handler';
import { i18nHandler } from '@/lib/middleware/handlers/i18n.handler';
/*
* 미들웨어 실행 순서:
* 1. 보안 헤더 (항상 실행, 가장 가벼움)
* 2. 속도 제한 (API만 해당, 인증 전에 실행하여 비용 절감)
* 3. 인증 (JWT 검증)
* 4. 국제화 (URL 리라이트)
*
* 각 핸들러가 shouldStop: true를 반환하면
* 이후 핸들러는 실행되지 않고 즉시 응답합니다.
*/
const middleware = createChain([
securityHandler,
rateLimitHandler,
authHandler,
i18nHandler,
]);
export default middleware;
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff|woff2|ttf|eot)$).*)',
],
};
이 프로덕션 미들웨어의 핵심 설계 원칙을 정리하면 다음과 같습니다:
- 관심사 분리: 각 핸들러가 하나의 책임만 담당합니다. 새로운 기능을 추가하거나 기존 기능을 수정할 때 다른 핸들러에 영향을 주지 않아요.
- 실행 순서 최적화: 가벼운 작업(보안 헤더)을 먼저, 비용이 큰 작업(외부 API 호출인 속도 제한)을 그 다음, 그리고 인증과 라우팅 순서로 실행합니다. 속도 제한에 걸리면 JWT 검증도 하지 않아 불필요한 연산을 줄이죠.
- 조기 중단:
shouldStop: true를 통해 불필요한 후속 처리를 건너뜁니다. 429 응답이나 로그인 리다이렉트 시 이후 핸들러가 실행될 필요가 없습니다. - 에러 복원력: 개별 핸들러의 에러가 전체 미들웨어를 중단시키지 않습니다. 에러 로그를 남기고 다음 핸들러로 넘어가요.
- 테스트 용이성: 각 핸들러를 독립적으로 단위 테스트할 수 있습니다.
디버깅 팁
개발 환경에서 미들웨어 동작을 확인하려면 다음 방법들을 활용하세요:
// 개발 환경에서만 미들웨어 로깅 활성화
export const loggingHandler: MiddlewareHandler = async (request, response) => {
if (process.env.NODE_ENV === 'development') {
const start = Date.now();
console.log(`[MW] ${request.method} ${request.nextUrl.pathname}`);
// 다음 핸들러 실행 후 시간 측정은 불가하지만
// 현재 핸들러까지의 진입 시점은 확인 가능
console.log(`[MW] Headers:`, Object.fromEntries(request.headers));
console.log(`[MW] Cookies:`, request.cookies.getAll());
}
return { response, shouldStop: false };
};
또한 브라우저의 개발자 도구 네트워크 탭에서 응답 헤더를 확인하면, 미들웨어가 설정한 X-Request-Id, X-RateLimit-Remaining 같은 커스텀 헤더들을 통해 미들웨어가 정상적으로 동작하고 있는지 확인할 수 있습니다.
마무리
Next.js 미들웨어는 강력하지만 올바르게 사용해야 합니다.
핵심을 정리하면:
- 미들웨어는 가볍게 유지하세요. Edge Runtime의 제약을 이해하고, 무거운 로직은 서버 컴포넌트나 API 라우트로 위임하세요.
- matcher로 범위를 좁히세요. 불필요한 경로에서 미들웨어가 실행되지 않도록 정확한 matcher를 설정하세요.
- 보안은 다층으로 구축하세요. CVE-2025-29927 사례에서 보듯이, 미들웨어만 신뢰하지 말고 서버 컴포넌트, API 라우트, 데이터베이스 레벨에서도 검증하세요.
- 체이닝 패턴으로 관심사를 분리하세요. 하나의 거대한 미들웨어 함수 대신, 각 관심사별 핸들러로 분리하면 유지보수가 훨씬 편해집니다.
- 에러 처리를 잊지 마세요. 미들웨어 에러가 사이트 전체를 다운시키지 않도록 적절한 에러 핸들링을 구현하세요.
제가 실제 프로젝트에서 경험한 바로는, 이 가이드에서 다룬 패턴들을 적용하면 인증부터 국제화, 속도 제한, A/B 테스트까지 견고하고 확장 가능한 미들웨어 아키텍처를 구축할 수 있습니다. Server Actions의 보안 패턴과 함께 사용하면 더욱 탄탄한 Next.js 애플리케이션을 만들 수 있어요.