Next.js의 미들웨어 진화를 다룹니다. middleware.ts에서 proxy.ts로의 전환, 인증, i18n, A/B 테스팅, 속도 제한 등 고급 패턴을 살펴봅니다.
9장에서 Turbopack의 아키텍처와 성능을 다루었습니다. 이번 장에서는 요청이 라우트에 도달하기 전에 실행되는 미들웨어를 살펴봅니다. Next.js 16에서 도입된 proxy.ts가 기존 middleware.ts를 어떻게 발전시켰는지, 그리고 인증, i18n, 기능 플래그 등 실전 패턴을 다룹니다.
Next.js 12에서 도입된 middleware.ts는 Edge Runtime에서 실행됩니다. Edge Runtime은 가벼운 JavaScript 실행 환경으로, CDN 엣지 노드에서 실행되어 지연 시간이 짧습니다. 하지만 Node.js API에 접근할 수 없다는 제약이 있습니다.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Edge Runtime: Node.js API 사용 불가
// fs, crypto.subtle 일부, net 등 접근 불가
const token = request.cookies.get('session');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*'],
};Next.js 16에서 도입된 proxy.ts는 Node.js Runtime에서 실행됩니다. 이로써 미들웨어 레이어에서 전체 Node.js API를 활용할 수 있게 되었습니다.
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { verify } from 'jsonwebtoken'; // Node.js 라이브러리 직접 사용
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
export async function middleware(request: NextRequest) {
// Node.js Runtime: 전체 Node.js API 사용 가능
const token = request.cookies.get('session')?.value;
if (token) {
try {
// jsonwebtoken 같은 Node.js 라이브러리 직접 사용
const decoded = verify(token, process.env.JWT_SECRET!);
// Redis 연결도 가능
const session = await redis.get(`session:${decoded.sub}`);
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}
} catch {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return NextResponse.next();
}| 특성 | middleware.ts (Edge) | proxy.ts (Node.js) |
|---|---|---|
| 런타임 | Edge Runtime | Node.js Runtime |
| Node.js API | 제한적 | 전체 지원 |
| 외부 라이브러리 | Edge 호환만 가능 | 모든 npm 패키지 |
| Cold Start | 빠름 (수 ms) | 상대적으로 느림 |
| 배포 위치 | CDN 엣지 | 서버 리전 |
| 적합한 용도 | 단순 리디렉트, 헤더 조작 | DB 조회, 복잡한 인증 |
proxy.ts는 middleware.ts를 대체하는 것이 아니라, Node.js Runtime이 필요한 경우의 대안입니다. 단순한 리디렉트나 헤더 조작은 여전히 middleware.ts가 적합합니다. 두 파일이 동시에 존재하면 proxy.ts가 우선합니다.
미들웨어 파일은 프로젝트 루트(또는 src/)에 단 하나만 존재할 수 있습니다. 여러 관심사를 처리해야 한다면, 하나의 파일 안에서 조합(Composition)해야 합니다.
project-root/
src/
proxy.ts # 또는 middleware.ts (하나만 가능)
app/
...
이 제약은 미들웨어 설계에서 가장 중요한 고려사항입니다. 뒤에서 다룰 조합 패턴이 이 문제를 해결합니다.
미들웨어가 실행될 경로를 config.matcher로 지정합니다. 지정하지 않으면 모든 요청에 대해 실행됩니다.
export const config = {
matcher: [
// 정적 파일과 API 라우트를 제외한 모든 경로
'/((?!api|_next/static|_next/image|favicon.ico).*)',
// 특정 경로만 포함
'/dashboard/:path*',
'/admin/:path*',
// 정규식 매칭
'/((?!public/).*)',
],
};matcher에서 정적 파일(_next/static, _next/image, favicon.ico)을 제외하지 않으면, 모든 정적 자산 요청에 미들웨어가 실행되어 성능이 저하됩니다. 반드시 제외 패턴을 포함하세요.
가장 일반적인 패턴으로, 쿠키에 저장된 세션 토큰을 검증합니다.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getSession } from '@/lib/auth';
// 인증이 필요 없는 경로
const PUBLIC_PATHS = ['/login', '/signup', '/forgot-password', '/api/auth'];
// 인증된 사용자가 접근하면 리디렉트할 경로
const AUTH_PAGES = ['/login', '/signup'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 공개 경로는 통과
if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) {
const session = await getSession(request);
// 이미 로그인된 사용자가 로그인 페이지에 접근하면 대시보드로
if (session && AUTH_PAGES.some(path => pathname.startsWith(path))) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
// 보호된 경로: 세션 확인
const session = await getSession(request);
if (!session) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// 세션 정보를 헤더로 전달 (서버 컴포넌트에서 활용)
const response = NextResponse.next();
response.headers.set('x-user-id', session.userId);
response.headers.set('x-user-role', session.role);
return response;
}const ROLE_ROUTES: Record<string, string[]> = {
admin: ['/admin'],
editor: ['/admin', '/editor'],
user: ['/dashboard'],
};
function checkRoleAccess(
pathname: string,
role: string
): boolean {
const allowedPaths = ROLE_ROUTES[role] ?? [];
return allowedPaths.some(path => pathname.startsWith(path));
}
export async function middleware(request: NextRequest) {
const session = await getSession(request);
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}
const { pathname } = request.nextUrl;
// 관리자 경로 접근 제어
if (pathname.startsWith('/admin') || pathname.startsWith('/editor')) {
if (!checkRoleAccess(pathname, session.role)) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
}
return NextResponse.next();
}import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { match } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
const SUPPORTED_LOCALES = ['ko', 'en', 'ja'];
const DEFAULT_LOCALE = 'ko';
function getPreferredLocale(request: NextRequest): string {
// 1. 쿠키에 저장된 선호 언어 확인
const cookieLocale = request.cookies.get('locale')?.value;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
return cookieLocale;
}
// 2. Accept-Language 헤더에서 감지
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const negotiator = new Negotiator({ headers });
const languages = negotiator.languages();
try {
return match(languages, SUPPORTED_LOCALES, DEFAULT_LOCALE);
} catch {
return DEFAULT_LOCALE;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 이미 로케일 접두사가 있는지 확인
const hasLocalePrefix = SUPPORTED_LOCALES.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (hasLocalePrefix) {
return NextResponse.next();
}
// 로케일 감지 후 리디렉트
const locale = getPreferredLocale(request);
const redirectUrl = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.redirect(redirectUrl);
}미들웨어에서 기능 플래그와 A/B 테스팅을 구현하면, 서버 컴포넌트 코드 변경 없이 사용자 경험을 분기할 수 있습니다.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const EXPERIMENTS = {
'new-pricing': {
variants: ['control', 'variant-a', 'variant-b'],
// 각 variant의 가중치 (합계 100)
weights: [34, 33, 33],
},
'new-checkout': {
variants: ['control', 'variant-a'],
weights: [50, 50],
},
};
function assignVariant(
experimentId: string,
existingVariant?: string
): string {
const experiment = EXPERIMENTS[experimentId as keyof typeof EXPERIMENTS];
if (!experiment) return 'control';
// 기존 할당이 유효하면 유지
if (existingVariant && experiment.variants.includes(existingVariant)) {
return existingVariant;
}
// 가중치 기반 랜덤 할당
const random = Math.random() * 100;
let cumulative = 0;
for (let i = 0; i < experiment.variants.length; i++) {
cumulative += experiment.weights[i];
if (random < cumulative) {
return experiment.variants[i];
}
}
return experiment.variants[0];
}
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
// 각 실험에 대해 variant 할당
for (const experimentId of Object.keys(EXPERIMENTS)) {
const cookieName = `exp-${experimentId}`;
const existingVariant = request.cookies.get(cookieName)?.value;
const variant = assignVariant(experimentId, existingVariant);
// 쿠키에 variant 저장 (30일)
if (!existingVariant) {
response.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30,
path: '/',
});
}
// 서버 컴포넌트에서 참조할 수 있도록 헤더에 추가
response.headers.set(`x-experiment-${experimentId}`, variant);
}
return response;
}서버 컴포넌트에서 실험 variant를 읽어 다른 UI를 렌더링합니다.
import { headers } from 'next/headers';
export default async function PricingPage() {
const headersList = await headers();
const variant = headersList.get('x-experiment-new-pricing') ?? 'control';
switch (variant) {
case 'variant-a':
return <NewPricingA />;
case 'variant-b':
return <NewPricingB />;
default:
return <CurrentPricing />;
}
}proxy.ts는 Node.js Runtime에서 실행되므로, Redis와 직접 연동하여 속도 제한을 구현할 수 있습니다.
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(60, '1 m'), // 1분당 60요청
analytics: true,
prefix: 'app:ratelimit',
});
export async function middleware(request: NextRequest) {
// API 라우트에만 속도 제한 적용
if (!request.nextUrl.pathname.startsWith('/api')) {
return NextResponse.next();
}
// IP 기반 식별
const ip = request.headers.get('x-forwarded-for')
?? request.headers.get('x-real-ip')
?? '127.0.0.1';
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'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());
return response;
}import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Vercel, AWS CloudFront 등 CDN이 주입하는 지리 정보 헤더
function getGeoInfo(request: NextRequest) {
return {
country: request.headers.get('x-vercel-ip-country')
?? request.headers.get('cloudfront-viewer-country')
?? 'KR',
city: request.headers.get('x-vercel-ip-city') ?? '',
region: request.headers.get('x-vercel-ip-country-region') ?? '',
};
}
export async function middleware(request: NextRequest) {
const geo = getGeoInfo(request);
// 국가별 콘텐츠 분기
if (request.nextUrl.pathname === '/store') {
const storeMap: Record<string, string> = {
KR: '/store/kr',
JP: '/store/jp',
US: '/store/us',
};
const targetPath = storeMap[geo.country] ?? '/store/global';
return NextResponse.rewrite(new URL(targetPath, request.url));
}
// 지리 정보를 서버 컴포넌트에 전달
const response = NextResponse.next();
response.headers.set('x-geo-country', geo.country);
response.headers.set('x-geo-city', geo.city);
return response;
}rewrite는 URL을 변경하지 않고 다른 페이지를 렌더링합니다. 사용자 브라우저의 주소창에는 /store가 표시되지만, 실제로는 /store/kr 페이지가 렌더링됩니다. redirect와의 차이를 명확히 이해하는 것이 중요합니다.
미들웨어에서 요청과 응답 헤더를 조작하는 패턴입니다.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
export async function middleware(request: NextRequest) {
// 요청 헤더 추가 (서버 컴포넌트에서 headers()로 읽기 가능)
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-request-id', uuidv4());
requestHeaders.set('x-pathname', request.nextUrl.pathname);
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
});
// 보안 헤더 추가
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(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
return response;
}프로젝트당 하나의 미들웨어 파일만 허용되므로, 여러 관심사를 깔끔하게 조합하는 패턴이 필수적입니다.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 미들웨어 함수 타입 정의
type MiddlewareFunction = (
request: NextRequest,
response: NextResponse
) => Promise<NextResponse | null>;
// 개별 미들웨어 구현
async function withRateLimit(
request: NextRequest,
response: NextResponse
): Promise<NextResponse | null> {
if (!request.nextUrl.pathname.startsWith('/api')) {
return null; // 다음 미들웨어로 전달
}
const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
// 속도 제한 로직...
return null;
}
async function withAuth(
request: NextRequest,
response: NextResponse
): Promise<NextResponse | null> {
const protectedPaths = ['/dashboard', '/admin', '/settings'];
const isProtected = protectedPaths.some(path =>
request.nextUrl.pathname.startsWith(path)
);
if (!isProtected) return null;
const session = request.cookies.get('session')?.value;
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}
response.headers.set('x-user-session', session);
return null;
}
async function withI18n(
request: NextRequest,
response: NextResponse
): Promise<NextResponse | null> {
const locale = request.cookies.get('locale')?.value ?? 'ko';
response.headers.set('x-locale', locale);
return null;
}
async function withSecurityHeaders(
_request: NextRequest,
response: NextResponse
): Promise<NextResponse | null> {
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
return null;
}
// 체인 실행기
async function runMiddlewareChain(
request: NextRequest,
middlewares: MiddlewareFunction[]
): Promise<NextResponse> {
const response = NextResponse.next();
for (const mw of middlewares) {
const result = await mw(request, response);
if (result) return result; // 조기 반환 (리디렉트 등)
}
return response;
}
// 미들웨어 체인 구성
const middlewareChain: MiddlewareFunction[] = [
withRateLimit,
withAuth,
withI18n,
withSecurityHeaders,
];
export async function middleware(request: NextRequest) {
return runMiddlewareChain(request, middlewareChain);
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};이 패턴의 장점은 각 관심사가 독립된 함수로 분리되어, 테스트와 유지보수가 용이하다는 것입니다. 새로운 관심사를 추가할 때는 함수를 작성하고 체인 배열에 추가하면 됩니다.
proxy.ts는 Node.js Runtime에서 실행되어, 전체 Node.js API와 npm 패키지를 미들웨어에서 사용할 수 있습니다. 기존 middleware.ts(Edge Runtime)와 선택적으로 사용합니다.headers()로 읽을 수 있어, 인증 정보, 실험 variant, 지리 정보 등을 전달하는 데 활용할 수 있습니다.matcher 설정으로 미들웨어 실행 범위를 제한하여 성능을 최적화합니다. 정적 파일은 반드시 제외해야 합니다.다음 장에서는 이미지, 폰트, 메타데이터 최적화를 다룹니다. next/image, next/font, generateMetadata를 활용하여 Core Web Vitals를 최적화하는 전략을 살펴봅니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Next.js의 이미지, 폰트, 메타데이터 최적화를 다룹니다. next/image, next/font, generateMetadata, OG 이미지 생성, Core Web Vitals 전략을 살펴봅니다.
Next.js의 차세대 번들러 Turbopack을 다룹니다. Rust 기반 아키텍처, 성능 벤치마크, FS 캐싱, Webpack 마이그레이션, 설정 방법을 살펴봅니다.
Next.js의 프로덕션 배포를 다룹니다. instrumentation.ts, OpenTelemetry, 보안 헤더, Docker 배포, Build Adapters, 셀프 호스팅 전략을 살펴봅니다.