← 목록으로 돌아가기

Next.js Middleware 완전 해부: Edge Runtime 제약·실행 타이밍·프로덕션 패턴 실전 가이드

Next.js

Next.js Middleware가 Edge Runtime에서 요청을 가로채고 헤더를 주입하는 다이어그램

요청이 라우트에 닿기 전에 통제권을 갖는다는 것의 의미

우리 프론트엔드 개발자들이 Next.js 기반 서비스를 프로덕션에서 운영하다 보면 반드시 직면하는 문제들이 있습니다. 인증되지 않은 사용자가 /dashboard에 직접 URL을 입력해 접근하는 순간, 일본에서 접속한 사용자에게 한국어 랜딩 페이지를 노출하는 상황, A/B 실험 코호트를 클라이언트 JavaScript 실행 이후에 분기하면서 레이아웃 시프트가 발생하는 경우. 이 문제들의 공통점은 "요청이 라우트에 닿기 전에 처리했다면 훨씬 깔끔했을 것"이라는 점입니다.

Next.js Middleware는 바로 그 지점을 담당합니다. middleware.ts는 모든 요청이 실제 페이지·API·정적 파일에 닿기 전에 실행되는 레이어입니다. 이 Middleware는 기본적으로 Node.js 런타임이 아닌 Edge Runtime 위에서 동작하며, 그 덕분에 사용자와 지리적으로 가장 가까운 엣지 노드에서 응답할 수 있습니다. 빠른 응답 속도를 얻는 대신, 적지 않은 런타임 제약이 따라옵니다.

이 글은 Middleware의 내부 실행 메커니즘부터 Edge Runtime 제약, matcher 정밀 제어, 헤더·쿠키·redirect·rewrite 패턴, 인증 게이트로 쓸 때의 함정, Geo/A-B 분기 실전 구현, 그리고 프로덕션에서 반복적으로 발견되는 실수 8가지까지 실무 관점에서 완전히 해부합니다. Edge Runtime과 Cloudflare Workers 비교Next.js Server Actions 실전 패턴과 함께 읽으면 Next.js 엣지 레이어 전반의 그림이 완성됩니다.


1. Next.js Middleware는 무엇이고 어디서 실행되는가

Middleware는 Next.js 요청 파이프라인에서 가장 먼저 개입하는 레이어입니다. middleware.ts 파일을 프로젝트 루트(또는 src/ 디렉토리 바로 안)에 배치하면 Next.js가 자동으로 인식하고 모든 요청에 대해 실행합니다.

Next.js 공식 Middleware 문서에 따르면, Middleware는 next.config.jsheadersredirects 규칙이 처리된 이후, 파일시스템 라우팅이 시작되기 이전에 실행됩니다. 전체 실행 순서는 다음과 같습니다.

  1. next.config.jsheaders 규칙
  2. next.config.jsredirects 규칙
  3. Middleware — rewrite, redirect, 헤더 주입, 또는 응답 직접 반환
  4. next.config.jsbeforeFiles rewrite
  5. 파일시스템 라우트(public/, _next/static/, pages/, app/)
  6. next.config.jsafterFiles rewrite
  7. 동적 라우트(/blog/[slug])
  8. next.config.jsfallback rewrite

이 순서에서 핵심은 Middleware가 React Server Component 렌더링보다 훨씬 앞에 위치한다는 점입니다. Middleware에서 redirect()를 반환하면 RSC 트리 실행 자체가 발생하지 않습니다. 덕분에 인증 실패 시 불필요한 데이터 페칭과 렌더링 비용을 원천 차단합니다.

Middleware가 실행되는 물리적 위치는 Vercel Edge Network의 엣지 노드입니다. Vercel에 배포할 경우 전 세계 수십 개 리전의 POP(Point of Presence)에서 실행되며, 사용자 요청이 원본 서버로 전달되기 전에 지리적으로 가장 가까운 노드에서 먼저 처리됩니다. Vercel에 따르면 엣지 노드 간 평균 레이턴시는 35ms 이하이며, Middleware 자체 실행 시간이 짧다면 TTFB에 사실상 영향을 주지 않습니다.


2. Edge Runtime의 제약 — Node API 부재·번들 크기·CPU 한도

Edge Runtime은 Chromium V8 엔진 기반의 경량 JavaScript 런타임입니다. 브라우저의 Service Worker와 유사한 샌드박스 환경으로 이해하면 됩니다. Next.js Edge Runtime API 레퍼런스는 지원하는 API와 지원하지 않는 API를 명시합니다.

분류Edge Runtime 지원 OEdge Runtime 지원 X
네트워크fetch, Request, Response, Headers, WebSocketNode.js net, http 직접 소켓
암호화crypto.subtle (Web Crypto API), CryptoKeyNode.js crypto.createHash, createCipher
인코딩TextEncoder, TextDecoder, atob, btoaNode.js Buffer 클래스
스트림ReadableStream, WritableStream, TransformStreamNode.js Stream 모듈
파일시스템fs, path, os 모든 파일 I/O
동적 평가eval, new Function(code)
패키지ES Module + Node API 미사용 패키지CommonJS 전용, native addon

번들 크기 제한도 설계에 직접 영향을 줍니다. Vercel 기준 Middleware 번들의 최대 크기는 1MB(압축 전)입니다. Next.js는 빌드 시 Middleware 번들 크기를 자동 검사하며, 초과할 경우 빌드 자체를 실패시킵니다. 이 제한 때문에 Prisma Client처럼 쿼리 엔진 바이너리를 내부에 포함하는 ORM은 Middleware에서 직접 사용할 수 없습니다.

CPU 실행 시간 제한도 중요합니다. Vercel Edge Functions 기준 단일 요청당 최대 CPU 시간은 50ms입니다. 이것은 벽시계 시간(wall-clock time)이 아닌 순수 JavaScript 실행 시간이므로, await fetch(...)처럼 I/O를 기다리는 동안은 카운트되지 않습니다. 그러나 대량의 JSON 파싱, 반복적인 암호화 연산, 복잡한 정규식 처리가 누적되면 한도를 초과할 수 있습니다.


3. 실행 순서: Middleware → Routing → RSC → Streaming, 어디까지 가로챌 수 있나

Middleware가 요청 흐름에서 실제로 무엇을 할 수 있고 무엇을 할 수 없는지를 명확히 이해해야 올바른 아키텍처 결정을 내릴 수 있습니다.

클라이언트가 GET /dashboard/settings를 요청하면, Vercel Edge 노드가 수신 즉시 Middleware 함수를 실행합니다. Middleware는 세 가지 방향 중 하나를 선택합니다.

NextResponse.redirect() — 클라이언트에게 301/302/307/308 응답을 반환해 다른 URL로 이동시킵니다. RSC 렌더링은 전혀 발생하지 않습니다. 미인증 사용자를 로그인 페이지로 보내는 가장 명확한 패턴입니다.

NextResponse.rewrite() — 클라이언트 URL은 그대로 유지하지만, 실제로 처리할 라우트를 다른 경로로 전환합니다. 클라이언트는 URL 변경을 인지하지 못하고, 서버는 rewrite된 경로의 RSC 렌더링을 시작합니다. A/B 실험에서 variant 사용자를 다른 페이지로 보낼 때 활용합니다.

NextResponse.next() — 요청을 그대로 통과시킵니다. 이 시점에 요청 헤더를 추가·수정해 다운스트림으로 전달할 수 있습니다. 이후 라우팅 → RSC 렌더링 → HTTP Streaming 응답이 정상 진행됩니다.

반면 Middleware가 가로챌 수 없는 영역도 분명합니다. RSC 렌더링이 시작된 이후 생성되는 스트리밍 청크는 Middleware가 개입할 수 없습니다. 응답 본문을 변환하거나 스트리밍 내용을 수정하는 것은 불가능합니다. Server Actions의 POST 요청도 Middleware를 거치지만, Server Action 함수 본체 실행 자체는 Node.js 런타임에서 이루어집니다. Middleware에서 Server Action을 가로채 인증을 완결하는 것은 위험한 설계입니다. Server Action 내부에서 반드시 별도의 검증을 수행해야 합니다.


4. matcherrequest.nextUrl 정밀 제어

Middleware는 기본적으로 프로젝트의 모든 경로에서 실행됩니다. 이미지 최적화 요청(_next/image), 정적 파일 서빙(_next/static), 파비콘까지 포함됩니다. 불필요한 실행은 엣지 컴퓨트 비용을 높이고 레이턴시를 추가합니다. matcher 설정은 선택이 아닌 필수입니다.

// middleware.ts — matcher 정규식 + nextUrl 정밀 제어
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname, searchParams } = request.nextUrl;

  // 유지보수 모드 헤더로 일시 차단
  const maintenanceMode = process.env.MAINTENANCE_MODE === 'true';
  if (maintenanceMode && !pathname.startsWith('/maintenance')) {
    const isAdmin = request.cookies.get('admin-bypass')?.value === process.env.ADMIN_BYPASS_TOKEN;
    if (!isAdmin) {
      return NextResponse.rewrite(new URL('/maintenance', request.url));
    }
  }

  // 로그인 페이지 접근 시 이미 인증된 사용자 대시보드로 이동
  if (pathname === '/login') {
    const sessionToken = request.cookies.get('session-token');
    if (sessionToken) {
      const from = searchParams.get('from');
      const destination = from && from.startsWith('/') ? from : '/dashboard';
      return NextResponse.redirect(new URL(destination, request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    /*
     * 다음 경로는 Middleware 실행에서 제외:
     * - _next/static  (빌드 산출물 정적 에셋)
     * - _next/image   (이미지 최적화 엔드포인트)
     * - favicon.ico   (파비콘)
     * - 이미지 파일 확장자 (.png, .jpg, .jpeg, .gif, .svg, .webp)
     * - api/health    (로드밸런서 헬스 체크)
     * - api/webhook   (외부 서비스 웹훅, 사전 인증 불필요)
     */
    '/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:png|jpg|jpeg|gif|svg|webp|ico)$|api/health|api/webhook).*)',
  ],
};

matcher빌드 타임에 정적으로 분석됩니다. 변수 참조나 동적 표현식은 허용되지 않습니다. 이 제약은 Next.js가 어떤 경로에 Middleware를 적용할지 컴파일 타임에 확정하기 위함입니다.

request.nextUrl은 현재 요청 URL의 파싱된 객체입니다. pathname, searchParams, hostname, port, origin을 읽을 수 있습니다. RSC 네비게이션 요청의 경우 _next/data/[buildId]/page.json URL이 정규화되어 실제 페이지 경로처럼 보이는 점에 주의합니다. matcher에서 _next/data를 제외하지 않으면, RSC 데이터 요청이 인증 redirect로 인해 JSON 대신 HTML 응답을 받아 클라이언트 라우터가 오작동합니다.


5. 헤더·쿠키·redirect·rewrite·next() 응답 패턴

Middleware의 응답 패턴들은 단독으로 쓰기보다 조합하는 경우가 많습니다. 인증 검증 후 사용자 컨텍스트를 헤더로 주입하는 실전 패턴을 살펴봅니다.

// middleware.ts — JWT 검증(Web Crypto API) + 헤더 주입 + 조건부 redirect
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// Web Crypto API 기반 HMAC-SHA256 JWT 검증
// https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API
async function verifyJwtEdge(token: string, secret: string): Promise<{
  valid: boolean;
  payload?: Record<string, unknown>;
}> {
  try {
    const encoder = new TextEncoder();
    const parts = token.split('.');
    if (parts.length !== 3) return { valid: false };

    const key = await crypto.subtle.importKey(
      'raw',
      encoder.encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['verify']
    );

    const signatureInput = encoder.encode(parts[0] + '.' + parts[1]);
    const signatureBytes = Uint8Array.from(
      atob(parts[2].replace(/-/g, '+').replace(/_/g, '/')),
      (c) => c.charCodeAt(0)
    );

    const isValid = await crypto.subtle.verify('HMAC', key, signatureBytes, signatureInput);
    if (!isValid) return { valid: false };

    const payload = JSON.parse(atob(parts[1])) as Record<string, unknown>;
    const now = Math.floor(Date.now() / 1000);
    if (typeof payload.exp === 'number' && payload.exp < now) {
      return { valid: false };
    }

    return { valid: true, payload };
  } catch {
    return { valid: false };
  }
}

const PROTECTED_PREFIXES = ['/dashboard', '/admin', '/api/private'];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const isProtected = PROTECTED_PREFIXES.some((prefix) => pathname.startsWith(prefix));

  if (!isProtected) return NextResponse.next();

  const cookieToken = request.cookies.get('auth-token')?.value;
  const bearerToken = request.headers.get('authorization')?.replace(/^Bearer\s+/, '');
  const token = cookieToken ?? bearerToken;

  if (!token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('from', pathname);
    return NextResponse.redirect(loginUrl);
  }

  const { valid, payload } = await verifyJwtEdge(token, process.env.JWT_SECRET!);

  if (!valid) {
    const response = NextResponse.redirect(new URL('/login?reason=session_expired', request.url));
    response.cookies.delete('auth-token');
    return response;
  }

  // 외부에서 주입 가능한 x-user-* 헤더를 먼저 제거 (보안)
  const requestHeaders = new Headers(request.headers);
  requestHeaders.delete('x-user-id');
  requestHeaders.delete('x-user-role');
  requestHeaders.delete('x-user-email');

  requestHeaders.set('x-user-id', String(payload?.sub ?? ''));
  requestHeaders.set('x-user-role', String(payload?.role ?? 'user'));
  requestHeaders.set('x-user-email', String(payload?.email ?? ''));

  return NextResponse.next({
    request: { headers: requestHeaders },
  });
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon\\.ico|.*\\.png$).*)'],
};

NextResponse.next({ request: { headers } })로 주입한 헤더는 Route Handler에서 headers() 함수로, Server Component에서도 import { headers } from 'next/headers'로 읽을 수 있습니다. 클라이언트 번들에는 노출되지 않습니다. 보안 측면에서 중요한 점은 외부에서 주입 가능한 헤더를 먼저 삭제한 뒤 신뢰 가능한 값으로 덮어쓰는 것입니다. 이 단계를 생략하면 공격자가 x-user-id: admin 헤더를 직접 요청에 포함해 권한을 우회할 수 있습니다.


6. 인증 게이트로 쓸 때의 함정 — 무거운 검증·DB 호출 금지

Middleware를 인증 게이트로 활용하는 것은 올바른 방향이지만, 경계를 잘못 설정하면 성능과 안정성 모두 망가집니다. 필자가 B2B SaaS 프로젝트에서 겪은 사례를 공유합니다.

초기 아키텍처에서는 보안 팀의 요구에 따라 Middleware가 요청마다 Redis 세션 스토어를 조회하는 방식으로 인증을 구현했습니다. "JWT보다 서버 측 세션이 즉각적인 폐기(revoke)가 가능하다"는 이유였습니다. Edge Runtime에서 Redis 클라이언트(ioredis)를 사용할 수 없으므로, Upstash Redis의 HTTP REST API를 직접 호출하는 방식으로 구현했습니다.

결과는 두 가지 문제로 나타났습니다. 첫째, Edge Function Cold Start에 HTTP 왕복 레이턴시가 더해져 인증이 필요한 모든 요청의 TTFB가 평균 90~140ms 증가했습니다. DAU 50만 명 규모에서 이 증가는 Vercel Edge Function 비용과 사용자 체감 속도 모두에 영향을 미쳤습니다. 둘째, Upstash REST API 호출은 동기적이었고, 일부 엣지 노드에서 간헐적인 타임아웃이 발생할 때 인증 실패 응답이 사용자에게 노출됐습니다.

결국 Middleware는 HMAC-SHA256 서명과 만료 시간만 검증하는 경량 역할로 축소했습니다. 세션 폐기 확인은 Route Handler 레이어에서만 수행하도록 분리했고, Middleware에서 주입한 x-user-id 헤더를 바탕으로 Route Handler가 캐싱된 세션 상태를 확인합니다. TTFB는 초기 수준으로 회복됐습니다.

Middleware에서 해도 되는 것Middleware에서 하면 안 되는 것
JWT 서명·만료 검증 (Web Crypto)DB/Redis 직접 쿼리
쿠키·헤더 기반 조건 분기Prisma, Mongoose, Drizzle 사용
정적 리다이렉트·리라이트fs, path 파일 시스템 접근
Geo 헤더 기반 로케일 분기무거운 외부 API 동기 호출
A/B 쿠키 발급Node.js 전용 암호화 모듈
응답 헤더 추가대용량 JSON 파싱 (수백 KB 이상)

이 경험의 핵심 교훈은 다음과 같습니다. Middleware에서 해도 되는 것과 하면 안 되는 것의 경계는 "Edge Runtime에서 실행 가능한가"가 아니라 "이 작업이 모든 요청에 동기적으로 추가 레이턴시를 발생시키는가"입니다.


7. Geo / A-B / Feature flag 분기 실전

Middleware의 가장 강력한 활용 사례는 서버에서 사용자를 분류하고 그 분류를 요청 흐름에 즉시 반영하는 것입니다. 클라이언트 JavaScript 실행 전에 이미 올바른 콘텐츠를 향해 라우팅이 결정되므로 레이아웃 시프트가 원천 차단됩니다.

// middleware.ts — Geo 리다이렉트 + A/B 코호트 쿠키 + 캐싱 헤더 제어
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// Edge에서 동작하는 결정론적 코호트 해시 (외부 라이브러리 없이)
function hashToCohort(seed: string, buckets: number): number {
  let hash = 5381;
  for (let i = 0; i < seed.length; i++) {
    hash = ((hash << 5) + hash) ^ seed.charCodeAt(i);
    hash = hash >>> 0;
  }
  return hash % buckets;
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const response = NextResponse.next();

  // ---- 1. Geo 기반 로케일 리다이렉트 ----
  const country = request.geo?.country;
  const alreadyLocalized =
    pathname.startsWith('/ko') ||
    pathname.startsWith('/en') ||
    pathname.startsWith('/ja');

  if (pathname === '/' && !alreadyLocalized) {
    if (country === 'JP') {
      return NextResponse.redirect(new URL('/ja', request.url));
    }
    if (country && country !== 'KR') {
      const acceptLang = request.headers.get('accept-language') ?? '';
      if (acceptLang.startsWith('en')) {
        return NextResponse.redirect(new URL('/en', request.url));
      }
    }
  }

  // ---- 2. A/B 실험 코호트 쿠키 발급 ----
  const EXPERIMENT_ID = 'checkout-redesign-q2-2026';
  const EXPERIMENT_PATHS = ['/checkout', '/cart'];
  const isExperimentPath = EXPERIMENT_PATHS.some((p) => pathname.startsWith(p));

  if (isExperimentPath) {
    const existingCohort = request.cookies.get('ab_' + EXPERIMENT_ID)?.value;

    if (!existingCohort) {
      const anonId =
        request.cookies.get('anon-id')?.value ?? crypto.randomUUID();
      const cohortIndex = hashToCohort(anonId + ':' + EXPERIMENT_ID, 2);
      const cohort = cohortIndex === 0 ? 'control' : 'variant';

      response.cookies.set('ab_' + EXPERIMENT_ID, cohort, {
        maxAge: 60 * 60 * 24 * 30,
        httpOnly: true,
        sameSite: 'lax',
        secure: process.env.NODE_ENV === 'production',
        path: '/',
      });

      if (cohort === 'variant' && pathname.startsWith('/checkout')) {
        return NextResponse.rewrite(
          new URL(pathname.replace('/checkout', '/checkout-v2'), request.url),
          { headers: response.headers }
        );
      }
    } else if (existingCohort === 'variant' && pathname.startsWith('/checkout')) {
      return NextResponse.rewrite(
        new URL(pathname.replace('/checkout', '/checkout-v2'), request.url)
      );
    }
  }

  // ---- 3. 경로별 캐싱 헤더 제어 ----
  if (pathname.startsWith('/dashboard') || pathname.startsWith('/account')) {
    response.headers.set('Cache-Control', 'private, no-store, must-revalidate');
    response.headers.set('Vercel-CDN-Cache-Control', 'no-store');
  } else if (pathname === '/' || pathname.startsWith('/blog')) {
    const hasSession = request.cookies.has('session-token');
    if (!hasSession) {
      response.headers.set(
        'Cache-Control',
        'public, s-maxage=300, stale-while-revalidate=600'
      );
    }
  }

  return response;
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:png|jpg|jpeg|gif|svg|webp)$|api/health).*)',
  ],
};

Feature flag 서비스(LaunchDarkly, Statsig, Unleash)의 Edge 호환 SDK를 Middleware에 통합할 때 주의점이 있습니다. 외부 API 동기 호출은 레이턴시를 증가시킵니다. 플래그 설정을 Vercel Edge Config나 암호화된 쿠키에 미리 캐싱해두고, Middleware에서는 캐시만 읽는 설계가 TTFB 측면에서 유리합니다. Edge Config는 전 세계 엣지 노드에 복제되어 1ms 이하의 읽기 레이턴시를 제공합니다.


8. 모니터링: Vercel Speed Insights·로그 sampling

Middleware는 모든 요청에 개입하므로, Middleware 자체의 성능 이상이 서비스 전체 응답 시간에 즉각 반영됩니다. 모니터링 없이 운영하는 것은 위험합니다.

Vercel 환경에서는 Speed Insights 대시보드의 Edge Function Duration 지표에서 Middleware 실행 시간을 확인할 수 있습니다. P95 Duration이 30ms를 초과하기 시작하면 로직 최적화가 필요합니다. P99가 50ms에 근접하면 CPU 한도 위반 위험 신호입니다.

로그 sampling은 Middleware에서 특히 중요합니다. 초당 수만 건의 요청이 Middleware를 거치는 환경에서 모든 요청에 대해 외부 로깅 API를 동기 호출하는 것은 레이턴시와 비용 모두 감당하기 어렵습니다. NextFetchEvent.waitUntil()을 활용해 로그를 비동기로 배출하고, 샘플링 비율을 적용하는 패턴이 실무 표준입니다. event.waitUntil()에 전달된 Promise는 클라이언트에게 응답이 반환된 이후에도 계속 실행됩니다. 덕분에 로그 전송이 사용자 응답 시간에 영향을 주지 않습니다. 단, Vercel Edge Function은 응답 완료 후 최대 30초간 백그라운드 작업을 허용합니다. 이 한도를 초과하는 작업은 강제 종료됩니다.


9. Edge ↔ Node Runtime 경계 설계: middleware는 가볍게, 헤비 로직은 Route Handler로

Middleware와 Route Handler, Server Component의 역할을 명확히 분리하는 것이 안정적인 Next.js 아키텍처의 핵심입니다. Next.js PPR(Partial Prerendering) 아키텍처와 결합하면 Middleware → 정적 셸 즉시 반환 → 동적 콘텐츠 스트리밍의 흐름이 완성됩니다.

설계 원칙을 한 문장으로 정리하면 이렇습니다. Middleware는 요청을 분류하고 신호를 주입하는 곳이며, Route Handler가 그 신호를 바탕으로 판단을 완결합니다.

Middleware에서 x-user-id, x-user-role 헤더를 주입했다면, Route Handler나 Server Component에서는 해당 헤더를 신뢰하고 재검증 없이 활용합니다. 단, 이 신뢰는 내부 헤더에만 적용됩니다. 앞서 언급했듯이 외부에서 임의 주입 가능한 헤더는 Middleware 진입 시 먼저 삭제하고 검증된 값으로 교체해야 합니다.

2026년 기준 Next.js 15 기반 프로젝트의 레이어별 책임 분배를 정리하면 다음과 같습니다.

레이어런타임주요 책임사용 가능 도구
MiddlewareEdge (V8)JWT 서명 검증, Geo 분기, A/B 쿠키 발급, 정적 리다이렉트Web Crypto, fetch, cookies, headers
Route HandlerNode.js세션 DB 조회, 외부 API, 파일 처리, 복잡한 권한 검사Prisma, Drizzle, Redis, fs
Server ComponentNode.js데이터 페칭 + 렌더링, RSC payload 생성모든 Node.js API
Server ActionNode.js폼 처리, DB 뮤테이션, 캐시 무효화모든 Node.js API

참고로 Next.js 16에서는 middleware.ts 컨벤션이 proxy.ts로 리네이밍되었고, 기본 런타임도 Edge에서 Node.js로 변경되었습니다. Edge Runtime 실행이 필요할 경우 별도 설정이 필요합니다. 2026년 3월 기준 Next.js 15.x 프로젝트에서는 이 글에서 다룬 패턴이 그대로 적용됩니다.


10. 흔히 보는 실수 8가지와 회피 패턴

실무에서 반복적으로 목격하는 Middleware 관련 실수들을 정리합니다. 코드 리뷰와 프로덕션 사고 분석에서 수집한 패턴들입니다.

실수 1: _next/data 경로를 matcher에서 제외하지 않음. RSC 클라이언트 네비게이션은 _next/data/[buildId]/page.json 형태로 JSON 데이터를 요청합니다. 이 요청이 Middleware의 인증 로직에 걸려 HTML 로그인 페이지로 리다이렉트되면, 클라이언트 라우터가 JSON을 기대했다가 HTML을 받아 네비게이션 자체가 실패합니다. _next/static만 제외하고 _next/data를 놓치는 경우가 많습니다.

실수 2: middleware.tsapp/ 폴더 내부에 두는 것. app/middleware.ts는 Middleware로 인식되지 않습니다. 프로젝트 루트 또는 src/ 바로 안에 위치해야 합니다.

실수 3: Node.js 전용 패키지를 import해 빌드 에러 유발. jsonwebtokencrypto 모듈에 의존하기 때문에 Edge Runtime에서 번들링 자체가 실패합니다. jose 라이브러리는 동일한 JWT 기능을 Web Crypto API 기반으로 제공하며 Edge Runtime과 완전히 호환됩니다.

실수 4: NextResponse.redirect()에 상대 경로 전달. Edge Runtime에는 상대 경로를 해석할 기준 베이스 URL이 없습니다. NextResponse.redirect('/login')은 런타임 에러를 발생시킵니다. 반드시 NextResponse.redirect(new URL('/login', request.url))처럼 절대 URL 객체를 전달해야 합니다.

실수 5: response 객체를 만든 뒤 새 NextResponse.next()로 교체. const response = NextResponse.next()로 객체를 생성하고 response.headers.set(...)으로 헤더를 설정했는데, 이후 코드에서 return NextResponse.next()를 반환하면 설정한 헤더가 사라집니다. 처음 생성한 response 객체를 직접 반환해야 합니다.

실수 6: Middleware에서 외부 HTTP API를 동기 호출해 TTFB 증가. 무거운 외부 API 호출은 Route Handler로 내려야 합니다. Middleware에서 꼭 외부 데이터가 필요하다면, Vercel Edge Config나 쿠키에 캐시된 값을 활용합니다.

실수 7: Server Action에 Middleware 인증만 의존하고 내부 검증 생략. Next.js 공식 문서는 각 Server Action 내부에서 독립적으로 인증·권한을 재검증하도록 명시합니다. Middleware matcher 설정 변경이나 로컬 직접 호출로 Middleware를 우회하는 시나리오가 존재합니다.

실수 8: 자체 호스팅 환경에서 request.geo를 신뢰. request.geo는 Vercel Edge Network가 주입하는 전용 헤더에서 파싱된 값입니다. Nginx + Node.js 자체 호스팅 환경에서는 undefined입니다. Cloudflare 프록시 환경이라면 CF-IPCountry 헤더를, 직접 GeoIP가 필요하다면 MaxMind GeoIP 데이터베이스를 Route Handler 레이어에서 사용해야 합니다.


결론: Middleware 프로덕션 체크리스트

Next.js Middleware는 요청 흐름의 최전선에서 강력한 제어권을 제공합니다. Edge Runtime의 물리적 제약을 이해하고 레이어별 책임을 명확히 분리한다면, 레이턴시 페널티 없이 인증·국제화·실험 분기를 동시에 처리하는 아키텍처를 구현할 수 있습니다.

  • matcher 범위 최소화 확인: _next/static, _next/image, _next/data 경로와 정적 파일 확장자를 제외했는지 next build 후 라우트별 Middleware 실행 여부를 검증합니다.
  • Edge Runtime 호환 패키지 검증: next build를 실행해 번들 크기 경고와 Edge 호환 에러가 없는지 확인합니다. jsonwebtokenjose, cryptocrypto.subtle, Node.js BufferTextEncoder로 대체했는지 점검합니다.
  • Middleware ↔ Route Handler 역할 경계 재확인: DB 조회, 외부 API 동기 호출, 복잡한 비즈니스 로직이 Middleware에 남아 있지 않은지 코드 리뷰 체크리스트에 포함합니다.
  • 외부 주입 헤더 삭제 후 재설정 검증: Middleware에서 x-user-* 헤더를 주입하기 전에, 외부에서 전달된 동명 헤더를 삭제하는 코드가 실행되고 있는지 보안 리뷰를 수행합니다.
  • event.waitUntil() 기반 비동기 로그 전송 적용: 모니터링 이벤트를 동기 호출로 전송하지 않고, 샘플링 비율을 설정한 waitUntil() 패턴으로 주 응답 경로와 분리합니다.

참고 자료