architecture2025-04-05·9 min·129/348

Vercel에서 Next.js middleware 설정

Next.js middleware를 Vercel에서 설정하여 라우팅, 인증, 리다이렉트를 처리하는 방법을 알아봅니다.

Vercel에서 Next.js middleware 설정

Introduction

Next.js middleware는 요청이 완료되기 전에 실행되는 코드로, 인증, 라우팅, 리다이렉트 등의 기능을 구현할 수 있습니다. Vercel 환경에서 Next.js middleware를 설정하고 활용하는 방법을 알아보겠습니다.

Environment

  • Vercel 프로젝트 (Next.js 14+)
  • Edge Runtime
  • JWT 인증
  • TypeScript

Problem

middleware 파일이 올바르게 설정되지 않으면 예상치 못한 동작이 발생합니다:

// middleware.js (잘못된 위치)
export function middleware(request) {
  // 이 코드는 middleware 폴더에 있어야 함
}

// 에러: Middleware must be placed in the root of the project

Analysis

Next.js middleware의 특징:

  1. Edge Runtime에서 실행: 빠른 실행 시간
  2. 요청/응답 조작: 헤더, 쿠키, 리다이렉트 가능
  3. .matcher로 라우팅 지정: 특정 경로에서만 실행
  4. 제한된 API: 전체 Node.js API 사용 불가

Solution

1. 기본 middleware 설정

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

export function middleware(request: NextRequest) {
  // 요청 로깅
  console.log(`${request.method} ${request.nextUrl.pathname}`);
  
  return NextResponse.next();
}

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

2. 인증 middleware

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';

const protectedRoutes = ['/dashboard', '/profile', '/settings'];
const authRoutes = ['/login', '/register'];

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value;
  const { pathname } = request.nextUrl;
  
  // 보호된 라우트 체크
  if (protectedRoutes.some(route => pathname.startsWith(route))) {
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
    
    try {
      await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET));
      return NextResponse.next();
    } catch (error) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }
  
  // 인증 라우트 체크 (이미 로그인된 경우 대시보드로 리다이렉트)
  if (authRoutes.some(route => pathname.startsWith(route))) {
    if (token) {
      try {
        await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET));
        return NextResponse.redirect(new URL('/dashboard', request.url));
      } catch (error) {
        // 토큰이 유효하지 않으면 로그인 페이지 유지
      }
    }
  }
  
  return NextResponse.next();
}

3. 리다이렉트 middleware

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

const redirects = [
  { source: '/old-blog/:slug', destination: '/blog/:slug', permanent: true },
  { source: '/docs/:path*', destination: '/documentation/:path*', permanent: false },
  { source: '/app/:path*', destination: '/dashboard/:path*', permanent: true },
];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  for (const redirect of redirects) {
    const pattern = new RegExp(
      `^${redirect.source.replace(/:\w+/g, '([^/]+)')}$`
    );
    
    const match = pathname.match(pattern);
    if (match) {
      let destination = redirect.destination;
      
      // 패라미터 교체
      match.slice(1).forEach((param, index) => {
        destination = destination.replace(`:${index + 1}`, param);
      });
      
      const response = NextResponse.redirect(
        new URL(destination, request.url),
        redirect.permanent ? 308 : 307
      );
      
      return response;
    }
  }
  
  return NextResponse.next();
}

4. 헤더 조작 middleware

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

export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  
  // 보안 헤더 추가
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'origin-when-cross-origin');
  response.headers.set(
    'Strict-Transport-Security',
    'max-age=63072000; includeSubDomains; preload'
  );
  
  // CORS 헤더
  const origin = request.headers.get('origin');
  if (origin) {
    response.headers.set('Access-Control-Allow-Origin', origin);
    response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  }
  
  // 커스텀 헤더
  response.headers.set('X-Request-Id', crypto.randomUUID());
  response.headers.set('X-Server-Timing', Date.now().toString());
  
  return response;
}

export const config = {
  matcher: '/api/:path*',
};

5. Geo 기반 middleware

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

export function middleware(request: NextRequest) {
  const country = request.geo?.country || 'US';
  const city = request.geo?.city || 'Unknown';
  
  // 국가별 라우팅
  const countryRoutes: Record = {
    'KR': '/ko',
    'JP': '/ja',
    'DE': '/de',
    'FR': '/fr',
  };
  
  const locale = countryRoutes[country] || '/en';
  const { pathname } = request.nextUrl;
  
  // 이미 로케일이 포함된 경로는 무시
  if (pathname.match(/^\/(ko|ja|de|fr|en)/)) {
    return NextResponse.next();
  }
  
  // 리다이렉트
  return NextResponse.redirect(
    new URL(`${locale}${pathname}`, request.url)
  );
}

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

6. Rate Limiting middleware

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

const rateLimit = new Map();

const WINDOW_SIZE = 60 * 1000; // 1분
const MAX_REQUESTS = 100;

export function middleware(request: NextRequest) {
  const ip = request.ip || request.headers.get('x-forwarded-for') || 'unknown';
  const now = Date.now();
  
  const record = rateLimit.get(ip);
  
  if (!record || now - record.timestamp > WINDOW_SIZE) {
    rateLimit.set(ip, { count: 1, timestamp: now });
    return NextResponse.next();
  }
  
  if (record.count >= MAX_REQUESTS) {
    return NextResponse.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }
  
  record.count++;
  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

7. 쿠키 조작 middleware

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

export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  
  // 세션 쿠키 확인
  const sessionId = request.cookies.get('session_id')?.value;
  
  if (!sessionId) {
    // 새 세션 생성
    const newSessionId = crypto.randomUUID();
    response.cookies.set('session_id', newSessionId, {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: 60 * 60 * 24 * 30 // 30일
    });
  }
  
  // 언어 쿠키
  const locale = request.cookies.get('locale')?.value || 'en';
  response.headers.set('Accept-Language', locale);
  
  return response;
}

Lessons Learned

  1. Edge Runtime: middleware는 Edge Runtime에서 실행되므로 제한된 API만 사용할 수 있습니다.
  2. matcher 설정: matcher를 올바르게 설정하여 불필요한 middleware 실행을 방지해야 합니다.
  3. 인증 처리: middleware에서 인증을 처리하면 모든 라우트에서 일관된 인증 로직을 적용할 수 있습니다.
  4. 성능 고려: middleware는 모든 요청에서 실행되므로 경량으로 유지해야 합니다.
  5. 에러 핸들링: middleware에서 에러가 발생하면 전체 애플리케이션에 영향을 미칠 수 있습니다.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.