Next.js Middleware와 Edge Runtime: 엣지에서 요청 처리하기

프론트엔드

Next.jsMiddlewareEdge Runtime인증성능 최적화

이 글은 누구를 위한 것인가

  • Next.js 앱에서 요청 레벨 로직(인증, 리다이렉트, i18n)을 구현하려는 팀
  • Edge Runtime의 제약과 장점을 이해하고 적용하려는 개발자
  • A/B 테스트나 Rate Limiting을 엣지에서 처리하려는 팀

들어가며

Next.js Middleware는 요청이 라우트 핸들러에 도달하기 전에 엣지에서 실행된다. Node.js 런타임이 아닌 V8 기반 Edge Runtime이므로 응답 지연이 낮고 전 세계 엣지 노드에서 실행된다.

이 글은 bluefoxdev.kr의 Next.js Middleware 가이드 를 참고하여 작성했습니다.


1. Edge Runtime 이해

[Edge Runtime vs Node.js Runtime]

Edge Runtime:
  기반: V8 (Cloudflare Workers, Vercel Edge)
  콜드 스타트: ~0ms (항상 웜)
  실행 위치: CDN 엣지 노드 (사용자 근처)
  메모리: 128MB 제한
  실행시간: 25ms 제한 (Vercel)
  
  사용 가능:
    fetch, Web Crypto API, ReadableStream
    TextEncoder/Decoder
    URL, URLSearchParams
    Headers, Request, Response
  
  사용 불가:
    fs (파일시스템)
    Node.js 내장 모듈 (path, crypto, os 등)
    대부분의 npm 패키지 (Node.js 의존)

Node.js Runtime:
  기반: Node.js
  콜드 스타트: 수백ms ~ 수초
  실행 위치: 중앙 서버
  메모리: 무제한에 가까움
  npm 패키지: 모두 사용 가능

[Middleware 실행 시점]
  Request → CDN → Middleware → Cache Check → Route Handler

  Middleware가 실행되는 경우:
    - 페이지 요청 (RSC, SSR, SSG)
    - API Route 요청
    - 정적 파일 (config.matcher로 제외 가능)

[matcher 패턴]
  특정 경로만 미들웨어 적용:
  '/dashboard/:path*'   → /dashboard 하위 전체
  '/((?!_next|api).*)'  → _next, api 제외한 모든 경로

2. Middleware 구현 패턴

// middleware.ts (루트에 위치)
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname, searchParams } = request.nextUrl;
  
  // 1. 인증 체크
  const token = request.cookies.get('auth-token')?.value;
  
  if (pathname.startsWith('/dashboard') && !token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('redirect', pathname);
    return NextResponse.redirect(loginUrl);
  }
  
  // 2. 헤더 추가 (RSC에서 읽을 수 있도록)
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-pathname', pathname);
  requestHeaders.set('x-user-agent', request.headers.get('user-agent') ?? '');
  
  return NextResponse.next({
    request: { headers: requestHeaders },
  });
}

export const config = {
  matcher: [
    // _next/static, _next/image, favicon 제외
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};
// JWT 검증 (Edge Runtime에서 Web Crypto API 사용)
import { jwtVerify } from 'jose';  // Edge 호환 JWT 라이브러리

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);

async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET, {
      algorithms: ['HS256'],
    });
    return payload as { userId: string; role: string };
  } catch {
    return null;
  }
}

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;
  
  // 보호된 경로
  if (request.nextUrl.pathname.startsWith('/admin')) {
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
    
    const payload = await verifyToken(token);
    
    if (!payload || payload.role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', request.url));
    }
    
    // 사용자 정보를 헤더로 전달
    const response = NextResponse.next();
    response.headers.set('x-user-id', payload.userId);
    response.headers.set('x-user-role', payload.role);
    return response;
  }
  
  return NextResponse.next();
}
// A/B 테스트 (엣지에서 버킷 배정)
import { NextRequest, NextResponse } from 'next/server';

const EXPERIMENT_COOKIE = 'ab-bucket';
const VARIANTS = ['control', 'treatment-a', 'treatment-b'] as const;

function getBucket(userId: string, experimentId: string): number {
  // 간단한 해시 기반 버킷 배정 (결정론적)
  let hash = 0;
  const str = `${userId}-${experimentId}`;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) - hash) + str.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash) % VARIANTS.length;
}

export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  
  // 기존 버킷 쿠키 확인
  let bucket = request.cookies.get(EXPERIMENT_COOKIE)?.value;
  
  if (!bucket) {
    const userId = request.cookies.get('user-id')?.value ?? 
                   crypto.randomUUID();
    const bucketIdx = getBucket(userId, 'homepage-v2');
    bucket = VARIANTS[bucketIdx];
    
    response.cookies.set(EXPERIMENT_COOKIE, bucket, {
      maxAge: 60 * 60 * 24 * 30,  // 30일 고정
      sameSite: 'lax',
    });
  }
  
  // variant를 헤더로 전달
  response.headers.set('x-ab-variant', bucket);
  
  // /home 경로를 variant에 따라 다른 페이지로
  if (request.nextUrl.pathname === '/home') {
    if (bucket === 'treatment-a') {
      return NextResponse.rewrite(new URL('/home-v2', request.url));
    }
    if (bucket === 'treatment-b') {
      return NextResponse.rewrite(new URL('/home-v3', request.url));
    }
  }
  
  return response;
}
// Rate Limiting (Upstash Redis + Edge)
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, '10 s'),  // 10초에 20회
  analytics: true,
});

export async function middleware(request: NextRequest) {
  // API 경로만 Rate Limiting
  if (!request.nextUrl.pathname.startsWith('/api/')) {
    return NextResponse.next();
  }
  
  const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? '127.0.0.1';
  const { success, limit, remaining, reset } = await ratelimit.limit(ip);
  
  const response = success
    ? NextResponse.next()
    : NextResponse.json(
        { error: 'Too Many Requests' },
        { status: 429 }
      );
  
  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;
}

마무리

Next.js Middleware는 인증 체크, i18n 라우팅, A/B 테스트 버킷 배정처럼 모든 요청에 공통으로 필요한 로직을 엣지에서 0ms 지연으로 처리한다. Edge Runtime 제약(25ms 실행 제한, Node.js 모듈 불가)으로 인해 jose 같은 Edge 호환 라이브러리를 사용해야 한다. Rate Limiting처럼 상태가 필요한 경우는 Upstash Redis 같은 Edge 호환 KV 스토어를 활용한다.