기술 포스트

Middleware로 할 수 있는 것과 하면 안 되는 것


Article

이 글은 Next.js 15 이전 기준이다.

Next.js 16에서 middleware.ts는 deprecated 됐고 proxy.ts로 이름이 바뀌었다. 내보내는 함수 이름도 proxy이고, 기본 런타임이 Edge가 아니라 Node.js다. 아래에서 "Edge Runtime의 벽"이라 부른 제약 중 일부는 이제 해당하지 않는다. 옛 코드는 npx @next/codemod@canary middleware-to-proxy . 로 옮긴다.

최신 기준으로 정리한 강의는 이쪽이다. proxy.ts 와 라우트 핸들러, 그리고 배포

다만 이 글의 결론은 지금도 유효하다. 거기에 너무 많은 걸 넣지 말라는 것. 공식 문서도 이 기능을 최후의 수단으로만 쓰라고 권한다.

Middleware에 너무 많은 걸 넣었다

Next.js의 Middleware를 처음 발견했을 때 만능 도구처럼 보였다. 모든 요청을 가로챌 수 있고 리다이렉트할 수 있고 헤더도 수정할 수 있으니, 그러면 인증도 여기서 하고 로깅도 여기서 하고 데이터 검증도 여기서 하면 되겠다고 생각했다.

그렇게 로직을 몰아넣었다가 Edge Runtime의 벽에 부딪혔다.

Middleware의 기본 동작

middleware.ts 파일 하나로 동작한다. 프로젝트 루트나 src/에 둔다.

// 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();
}

// 어떤 경로에서 실행할지 지정
export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*'],
};

실행 시점은 요청이 들어올 때, 라우트 핸들러나 페이지가 실행되기 전이다. 실행 환경은 Edge Runtime이다. 일반 Node.js가 아니라는 것, 이 글의 대부분이 여기서 파생된다.

할 수 있는 것들

1. 인증 체크 & 리다이렉트

가장 흔한 사용 사례다.

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;
  const { pathname } = request.nextUrl;

  // 인증이 필요한 경로
  if (pathname.startsWith('/dashboard')) {
    if (!token) {
      const loginUrl = new URL('/login', request.url);
      loginUrl.searchParams.set('from', pathname);
      return NextResponse.redirect(loginUrl);
    }
  }

  // 이미 로그인한 사용자가 로그인 페이지 접근 시
  if (pathname === '/login' && token) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return NextResponse.next();
}

여기서 확인하는 건 토큰의 존재 여부까지다. 유효성 검증, 그러니까 DB 조회 같은 건 Server Component나 API Route에서 한다.

2. 리다이렉트 & 리라이트

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

  // 오래된 URL 리다이렉트
  if (pathname === '/old-blog') {
    return NextResponse.redirect(new URL('/blog', request.url));
  }

  // URL은 유지하면서 다른 페이지 렌더링 (리라이트)
  if (pathname === '/docs') {
    return NextResponse.rewrite(new URL('/documentation/latest', request.url));
  }

  return NextResponse.next();
}

3. 헤더 추가/수정

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

  // 보안 헤더 추가
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');

  // 요청 헤더에 정보 추가 (Server Component에서 읽기 위해)
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-pathname', request.nextUrl.pathname);

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

4. i18n 라우팅

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

  // 이미 로케일이 있으면 패스
  if (pathname.startsWith('/ko') || pathname.startsWith('/en')) {
    return NextResponse.next();
  }

  // Accept-Language 헤더에서 선호 언어 감지
  const locale = request.headers.get('accept-language')?.includes('ko')
    ? 'ko'
    : 'en';

  return NextResponse.redirect(
    new URL(`/${locale}${pathname}`, request.url)
  );
}

5. A/B 테스트

export function middleware(request: NextRequest) {
  const bucket = request.cookies.get('ab-bucket')?.value;
  const response = NextResponse.next();

  if (!bucket) {
    // 50/50 분배
    const newBucket = Math.random() < 0.5 ? 'A' : 'B';
    response.cookies.set('ab-bucket', newBucket);
  }

  return response;
}

하면 안 되는 것들

1. 무거운 인증 검증

// ❌ Middleware에서 하면 안 되는 것
export async function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value;

  // DB 조회 → Edge Runtime에서 대부분의 ORM이 작동하지 않음
  const user = await prisma.user.findUnique({ where: { token } });

  // JWT 검증 → 일부 crypto API가 Edge에서 지원 안 됨
  const decoded = jwt.verify(token, process.env.SECRET);
}

Edge Runtime은 Node.js API를 일부만 지원한다. fs, net, child_process는 못 쓰고 일부 crypto 함수도 없다. Prisma나 TypeORM 같은 ORM은 대부분 작동하지 않는다.

그래서 Middleware에서는 토큰이 있는지만 보고, 상세 검증은 아래로 내린다.

// middleware.ts — 가볍게
export function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value;
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

// app/(protected)/layout.tsx — 상세 검증
export default async function ProtectedLayout({ children }) {
  const session = await verifySession(); // DB 조회, JWT 검증 등
  if (!session) redirect('/login');
  return <>{children}</>;
}

2. 데이터 가져오기 (fetch 남용)

// ❌ Middleware에서 외부 API 호출
export async function middleware(request: NextRequest) {
  const config = await fetch('https://api.example.com/config');
  const features = await fetch('https://api.example.com/features');
  // ...
}

이게 위험한 건 실행 횟수 때문이다. 모든 요청마다 실행된다. 이미지, CSS, JS 요청에도. 외부 API가 느려지면 사이트 전체가 같이 느려진다. matcher를 걸어도 불필요한 fetch는 그대로 성능을 잡아먹는다.

3. 복잡한 비즈니스 로직

// ❌ Middleware에서 비즈니스 로직 처리
export async function middleware(request: NextRequest) {
  const user = getUserFromToken(request);

  // 구독 상태 확인
  if (user.subscription === 'free' && request.nextUrl.pathname.startsWith('/premium')) {
    return NextResponse.redirect(new URL('/upgrade', request.url));
  }

  // 사용량 제한
  if (user.apiCalls > user.limit) {
    return NextResponse.json({ error: 'Rate limited' }, { status: 429 });
  }
}

구독 상태나 사용량 제한은 Server Component나 Route Handler의 일이다. Middleware가 내려야 할 결정은 라우팅 수준까지다.

역할 분담 정리

역할 Middleware Server Component Route Handler
토큰 존재 확인 ✅ - -
토큰 유효성 검증 ❌ ✅ ✅
리다이렉트 ✅ ✅ (redirect) ✅
헤더 추가 ✅ - ✅
DB 조회 ❌ ✅ ✅
외부 API 호출 ⚠️ (최소한) ✅ ✅
쿠키 설정 ✅ ✅ ✅
i18n 감지 ✅ - -
비즈니스 로직 ❌ ✅ ✅

matcher 설정 팁

config.matcher를 제대로 설정하지 않으면 정적 파일 요청까지 포함해서 모든 요청에 Middleware가 실행된다.

export const config = {
  matcher: [
    // 정적 파일과 내부 Next.js 경로 제외
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
};

또는 필요한 경로만 명시적으로 지정한다.

export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*', '/login', '/signup'],
};

경험상 명시적으로 지정하는 쪽이 낫다. "왜 이 요청에 Middleware가 실행되지?"라는 질문 자체가 줄어든다.

정리

Middleware는 문지기에 가깝다. 출입증이 있는지 확인하고 방향을 안내하는 정도. 출입증이 진짜인지 감별하거나, 방문자의 업무를 대신 처리하거나, 신상을 조회하는 건 다른 자리에서 할 일이다.

처음에 이 경계를 몰라서 한참 헤맸다. 로직을 몰아넣을수록 편해지는 게 아니라 Edge Runtime 에러 메시지를 읽는 시간만 길어졌다.

Share
  • Next.js
  • Middleware
  • Edge Runtime
  • 인증
  • 라우팅
Middleware로 할 수 있는 것과 하면 안 되는 것 — 디코드랩(DCODELAB)