본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 10장: 미들웨어와 Proxy 고급 패턴
2026년 2월 7일·웹 개발·

10장: 미들웨어와 Proxy 고급 패턴

Next.js의 미들웨어 진화를 다룹니다. middleware.ts에서 proxy.ts로의 전환, 인증, i18n, A/B 테스팅, 속도 제한 등 고급 패턴을 살펴봅니다.

17분1,401자11개 섹션
nextjsreacttypescriptfrontend
공유
nextjs-app-router10 / 13
12345678910111213
이전9장: Turbopack - 차세대 번들러의 시대다음11장: 이미지, 폰트, 메타데이터 최적화

9장에서 Turbopack의 아키텍처와 성능을 다루었습니다. 이번 장에서는 요청이 라우트에 도달하기 전에 실행되는 미들웨어를 살펴봅니다. Next.js 16에서 도입된 proxy.ts가 기존 middleware.ts를 어떻게 발전시켰는지, 그리고 인증, i18n, 기능 플래그 등 실전 패턴을 다룹니다.

미들웨어의 진화: Edge에서 Node.js로

middleware.ts (기존)

Next.js 12에서 도입된 middleware.ts는 Edge Runtime에서 실행됩니다. Edge Runtime은 가벼운 JavaScript 실행 환경으로, CDN 엣지 노드에서 실행되어 지연 시간이 짧습니다. 하지만 Node.js API에 접근할 수 없다는 제약이 있습니다.

middleware.ts - Edge Runtime
typescript
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*'],
};

proxy.ts (Next.js 16)

Next.js 16에서 도입된 proxy.ts는 Node.js Runtime에서 실행됩니다. 이로써 미들웨어 레이어에서 전체 Node.js API를 활용할 수 있게 되었습니다.

proxy.ts - Node.js Runtime
typescript
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();
}

Edge vs Node.js 비교

특성middleware.ts (Edge)proxy.ts (Node.js)
런타임Edge RuntimeNode.js Runtime
Node.js API제한적전체 지원
외부 라이브러리Edge 호환만 가능모든 npm 패키지
Cold Start빠름 (수 ms)상대적으로 느림
배포 위치CDN 엣지서버 리전
적합한 용도단순 리디렉트, 헤더 조작DB 조회, 복잡한 인증
Info

proxy.ts는 middleware.ts를 대체하는 것이 아니라, Node.js Runtime이 필요한 경우의 대안입니다. 단순한 리디렉트나 헤더 조작은 여전히 middleware.ts가 적합합니다. 두 파일이 동시에 존재하면 proxy.ts가 우선합니다.

핵심 제약: 프로젝트당 하나의 파일

미들웨어 파일은 프로젝트 루트(또는 src/)에 단 하나만 존재할 수 있습니다. 여러 관심사를 처리해야 한다면, 하나의 파일 안에서 조합(Composition)해야 합니다.

project-root/
  src/
    proxy.ts        # 또는 middleware.ts (하나만 가능)
    app/
      ...

이 제약은 미들웨어 설계에서 가장 중요한 고려사항입니다. 뒤에서 다룰 조합 패턴이 이 문제를 해결합니다.

Matcher 설정

미들웨어가 실행될 경로를 config.matcher로 지정합니다. 지정하지 않으면 모든 요청에 대해 실행됩니다.

matcher 설정 패턴
typescript
export const config = {
  matcher: [
    // 정적 파일과 API 라우트를 제외한 모든 경로
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
 
    // 특정 경로만 포함
    '/dashboard/:path*',
    '/admin/:path*',
 
    // 정규식 매칭
    '/((?!public/).*)',
  ],
};
Tip

matcher에서 정적 파일(_next/static, _next/image, favicon.ico)을 제외하지 않으면, 모든 정적 자산 요청에 미들웨어가 실행되어 성능이 저하됩니다. 반드시 제외 패턴을 포함하세요.

인증 패턴

세션 기반 인증

가장 일반적인 패턴으로, 쿠키에 저장된 세션 토큰을 검증합니다.

proxy.ts - 세션 인증
typescript
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;
}

역할 기반 접근 제어 (RBAC)

역할 기반 접근 제어
typescript
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();
}

i18n(국제화) 패턴

Accept-Language 기반 로케일 감지

proxy.ts - i18n 라우팅
typescript
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 테스팅

미들웨어에서 기능 플래그와 A/B 테스팅을 구현하면, 서버 컴포넌트 코드 변경 없이 사용자 경험을 분기할 수 있습니다.

proxy.ts - A/B 테스팅
typescript
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를 렌더링합니다.

app/pricing/page.tsx
typescript
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 />;
  }
}

속도 제한 (Rate Limiting)

proxy.ts는 Node.js Runtime에서 실행되므로, Redis와 직접 연동하여 속도 제한을 구현할 수 있습니다.

proxy.ts - Upstash Redis 속도 제한
typescript
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;
}

지리적 위치 기반 라우팅

proxy.ts - 지리적 위치 라우팅
typescript
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;
}
Info

rewrite는 URL을 변경하지 않고 다른 페이지를 렌더링합니다. 사용자 브라우저의 주소창에는 /store가 표시되지만, 실제로는 /store/kr 페이지가 렌더링됩니다. redirect와의 차이를 명확히 이해하는 것이 중요합니다.

Request/Response 헤더 조작

미들웨어에서 요청과 응답 헤더를 조작하는 패턴입니다.

proxy.ts - 헤더 조작
typescript
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;
}

조합 패턴: 여러 관심사 통합

프로젝트당 하나의 미들웨어 파일만 허용되므로, 여러 관심사를 깔끔하게 조합하는 패턴이 필수적입니다.

체인 패턴

proxy.ts - 미들웨어 체인
typescript
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).*)'],
};

이 패턴의 장점은 각 관심사가 독립된 함수로 분리되어, 테스트와 유지보수가 용이하다는 것입니다. 새로운 관심사를 추가할 때는 함수를 작성하고 체인 배열에 추가하면 됩니다.

핵심 요약

  • Next.js 16의 proxy.ts는 Node.js Runtime에서 실행되어, 전체 Node.js API와 npm 패키지를 미들웨어에서 사용할 수 있습니다. 기존 middleware.ts(Edge Runtime)와 선택적으로 사용합니다.
  • 프로젝트당 하나의 미들웨어 파일만 허용되므로, 체인 패턴으로 인증, i18n, 속도 제한, 보안 헤더 등 여러 관심사를 조합해야 합니다.
  • 미들웨어에서 헤더를 설정하면 서버 컴포넌트의 headers()로 읽을 수 있어, 인증 정보, 실험 variant, 지리 정보 등을 전달하는 데 활용할 수 있습니다.
  • matcher 설정으로 미들웨어 실행 범위를 제한하여 성능을 최적화합니다. 정적 파일은 반드시 제외해야 합니다.
  • A/B 테스팅은 쿠키와 헤더를 조합하여, 서버 컴포넌트 코드 변경 없이 사용자 경험을 분기할 수 있습니다.

다음 장에서는 이미지, 폰트, 메타데이터 최적화를 다룹니다. next/image, next/font, generateMetadata를 활용하여 Core Web Vitals를 최적화하는 전략을 살펴봅니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#nextjs#react#typescript#frontend

관련 글

웹 개발

11장: 이미지, 폰트, 메타데이터 최적화

Next.js의 이미지, 폰트, 메타데이터 최적화를 다룹니다. next/image, next/font, generateMetadata, OG 이미지 생성, Core Web Vitals 전략을 살펴봅니다.

2026년 2월 9일·17분
웹 개발

9장: Turbopack - 차세대 번들러의 시대

Next.js의 차세대 번들러 Turbopack을 다룹니다. Rust 기반 아키텍처, 성능 벤치마크, FS 캐싱, Webpack 마이그레이션, 설정 방법을 살펴봅니다.

2026년 2월 5일·20분
웹 개발

12장: 모니터링, 보안, 프로덕션 배포

Next.js의 프로덕션 배포를 다룹니다. instrumentation.ts, OpenTelemetry, 보안 헤더, Docker 배포, Build Adapters, 셀프 호스팅 전략을 살펴봅니다.

2026년 2월 11일·15분
이전 글9장: Turbopack - 차세대 번들러의 시대
다음 글11장: 이미지, 폰트, 메타데이터 최적화

댓글

목차

약 17분 남음
  • 미들웨어의 진화: Edge에서 Node.js로
    • middleware.ts (기존)
    • proxy.ts (Next.js 16)
    • Edge vs Node.js 비교
  • 핵심 제약: 프로젝트당 하나의 파일
  • Matcher 설정
  • 인증 패턴
    • 세션 기반 인증
    • 역할 기반 접근 제어 (RBAC)
  • i18n(국제화) 패턴
    • Accept-Language 기반 로케일 감지
  • 기능 플래그와 A/B 테스팅
  • 속도 제한 (Rate Limiting)
  • 지리적 위치 기반 라우팅
  • Request/Response 헤더 조작
  • 조합 패턴: 여러 관심사 통합
    • 체인 패턴
  • 핵심 요약