Edge Functions & Middleware 패턴 — 엣지에서 무엇을 처리해야 하는가

프론트엔드

Edge FunctionsMiddlewareNext.jsVercelCloudflare Workers

이 글은 누구를 위한 것인가

  • Next.js Middleware를 써보고 싶은데 무엇을 처리해야 할지 모르는 팀
  • API 서버 왕복을 줄여 응답 속도를 높이려는 프론트엔드 엔지니어
  • A/B 테스트나 지역화를 서버 코드 변경 없이 구현하고 싶은 개발자

엣지란 무엇인가

기존 웹 서비스는 사용자 → CDN(정적 파일) → 원본 서버(동적 처리) 구조다. 엣지는 CDN 노드에서 JavaScript를 실행할 수 있게 해 원본 서버 왕복 없이 동적 처리가 가능하다.

[사용자]
    │ 수십~수백ms
    ▼
[원본 서버] ← 기존 방식

[사용자]
    │ 수~수십ms
    ▼
[엣지 노드] → 많은 처리를 여기서
    │ (필요한 경우만)
    ▼
[원본 서버]

엣지 실행 환경의 제약: Node.js API 일부 미지원, 실행 시간 제한, 콜드 스타트 없음(항상 warm), DB 직접 연결 불가(TCP 제한).


1. 무엇을 엣지에서 처리해야 하는가

적합한 작업

작업이유
인증 토큰 검증모든 요청에 필요, 원본 서버 왕복 불필요
A/B 테스트 라우팅쿠키 기반 분기, DB 불필요
지역 기반 리다이렉트IP → 국가 코드 매핑
응답 헤더 수정CSP, CORS, 캐시 헤더 추가
요청 리라이트URL 구조 변경, 파라미터 추가
속도 제한 (Rate Limiting)IP 기반 카운팅 (KV 스토어 필요)

적합하지 않은 작업

작업이유
DB 쿼리TCP 연결 제약 (HTTP API 경유는 가능)
파일 시스템 접근지원 안 함
CPU 집약적 작업실행 시간 제한
세션 상태 유지무상태(stateless) 환경

2. Next.js Middleware 실전 패턴

인증 게이트웨이

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyJWT } from '@/lib/auth';

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

  // 공개 경로는 통과
  const publicPaths = ['/login', '/signup', '/api/auth'];
  if (publicPaths.some(path => pathname.startsWith(path))) {
    return NextResponse.next();
  }

  // 토큰 검증 (DB 조회 없이 JWT 서명만 검증)
  const token = request.cookies.get('auth_token')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  try {
    const payload = await verifyJWT(token);  // 엣지 호환 Web Crypto API 사용

    // 검증된 사용자 정보를 헤더로 전달 (원본 서버에서 재사용)
    const response = NextResponse.next();
    response.headers.set('x-user-id', payload.userId);
    response.headers.set('x-user-role', payload.role);
    return response;

  } catch {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

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

A/B 테스트 라우팅

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

  if (pathname === '/') {
    // 기존 쿠키로 버킷 유지 (일관된 경험)
    const existingBucket = request.cookies.get('ab_home')?.value;

    if (existingBucket) {
      return rewriteToVariant(request, existingBucket);
    }

    // 새로 배정 (50:50)
    const bucket = Math.random() < 0.5 ? 'control' : 'variant';
    const response = rewriteToVariant(request, bucket);

    // 30일간 버킷 유지
    response.cookies.set('ab_home', bucket, {
      maxAge: 60 * 60 * 24 * 30,
      httpOnly: true,
      sameSite: 'lax',
    });

    return response;
  }

  return NextResponse.next();
}

function rewriteToVariant(request: NextRequest, bucket: string): NextResponse {
  const url = request.nextUrl.clone();
  url.pathname = bucket === 'variant' ? '/home-v2' : '/';
  return NextResponse.rewrite(url);
}

지역 기반 리다이렉트

export function middleware(request: NextRequest) {
  const country = request.geo?.country ?? 'US';
  const { pathname } = request.nextUrl;

  // 이미 지역 경로면 스킵
  if (pathname.startsWith('/kr') || pathname.startsWith('/us')) {
    return NextResponse.next();
  }

  // 한국 사용자는 /kr 경로로
  if (country === 'KR' && !pathname.startsWith('/kr')) {
    return NextResponse.redirect(
      new URL(`/kr${pathname}`, request.url)
    );
  }

  return NextResponse.next();
}

3. 응답 헤더 수정 패턴

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

  // 보안 헤더 일괄 추가
  const securityHeaders = {
    'X-Frame-Options': 'DENY',
    'X-Content-Type-Options': 'nosniff',
    'Referrer-Policy': 'strict-origin-when-cross-origin',
    'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
    'Content-Security-Policy': [
      "default-src 'self'",
      "script-src 'self' 'unsafe-inline' https://www.googletagmanager.com",
      "img-src 'self' data: https:",
      "font-src 'self'",
    ].join('; '),
  };

  Object.entries(securityHeaders).forEach(([key, value]) => {
    response.headers.set(key, value);
  });

  return response;
}

4. Cloudflare Workers: 더 많은 제어

Vercel Edge보다 더 세밀한 제어가 필요하면 Cloudflare Workers를 사용한다.

KV 기반 속도 제한

// Cloudflare Worker
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown';
    const key = `rate_limit:${ip}`;

    const current = parseInt(await env.KV.get(key) ?? '0');

    if (current >= 100) {
      return new Response('Too Many Requests', {
        status: 429,
        headers: { 'Retry-After': '60' },
      });
    }

    await env.KV.put(key, String(current + 1), {
      expirationTtl: 60,  // 1분 윈도우
    });

    return fetch(request);
  }
};

5. 성능 고려사항

Middleware 실행 비용

모든 요청에 Middleware가 실행된다. 불필요한 요청을 matcher로 필터링한다.

export const config = {
  matcher: [
    // 정적 파일과 이미지 제외
    '/((?!_next/static|_next/image|favicon.ico|.*\\.png|.*\\.jpg).*)',
    // API 라우트만
    '/api/:path*',
  ],
};

JWT vs 세션 토큰

엣지에서 토큰 검증을 하려면 JWT(자기 검증형) 이 적합하다. 세션 토큰은 DB나 Redis 조회가 필요해 엣지에서 검증이 어렵다.

// 엣지 호환 JWT 검증 (Web Crypto API)
async function verifyJWT(token: string): Promise<JWTPayload> {
  const [header, payload, signature] = token.split('.');

  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(process.env.JWT_SECRET!),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify']
  );

  const valid = await crypto.subtle.verify(
    'HMAC',
    key,
    base64urlDecode(signature),
    new TextEncoder().encode(`${header}.${payload}`)
  );

  if (!valid) throw new Error('Invalid signature');

  return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
}

맺으며

엣지 함수는 "빠르게 실행되어야 하고 DB 조회가 필요 없는 로직"을 위한 레이어다. 인증 토큰 검증, A/B 테스트 라우팅, 보안 헤더 추가 — 이런 작업들을 원본 서버 왕복 없이 처리하면 전체 응답 시간이 줄어든다.

주의할 점은 엣지에 너무 많은 로직을 넣으려는 욕구다. DB 연결이 필요하거나, CPU 작업이 무거운 로직은 원본 서버가 담당해야 한다. 엣지는 빠른 경량 처리에 집중한다.