본문으로 이동
·14분 읽기

Edge Functions: 무엇인지, 언제 사용하고, 언제 사용하지 말아야 하는지

Edge 런타임, V8 Isolate, 콜드 스타트의 진실, 지역 기반 라우팅, A/B 테스팅, Edge 인증, 그리고 일부를 다시 Node.js로 옮긴 이유. Edge 컴퓨팅에 대한 균형 잡힌 시각.

공유:X / TwitterLinkedIn

"Edge"라는 단어는 정말 많이 사용됩니다. Vercel이 말하고, Cloudflare가 말하고, Deno가 말합니다. 웹 성능에 관한 모든 컨퍼런스 발표에서 "엣지에서 실행"이라는 표현이 마법의 주문처럼 앱을 빠르게 만들어준다는 듯이 언급됩니다.

저도 그에 동조했습니다. 미들웨어, API 라우트, 심지어 일부 렌더링 로직까지 엣지 런타임으로 옮겼습니다. 그 중 일부는 훌륭한 선택이었습니다. 나머지는 새벽 2시에 커넥션 풀 오류를 디버깅한 후 조용히 3주 만에 다시 Node.js로 되돌렸습니다.

이 글은 그 이야기의 균형 잡힌 버전입니다 — 엣지가 실제로 무엇인지, 어디서 진정으로 빛나는지, 어디서 절대 빛나지 않는지, 그리고 애플리케이션의 각 부분에 어떤 런타임을 사용할지 어떻게 결정하는지에 대해 다룹니다.

엣지(Edge)란 무엇인가?#

지리부터 시작합시다. 누군가 여러분의 웹사이트를 방문하면, 요청은 디바이스에서 ISP를 거쳐 인터넷을 통해 서버로 이동하고, 처리된 후 응답이 다시 돌아옵니다. 서버가 us-east-1(버지니아)에 있고 사용자가 도쿄에 있다면, 그 왕복은 대략 14,000km에 달합니다. 광섬유를 통한 빛의 속도로 편도 약 70ms입니다. DNS 확인, TLS 핸드셰이크, 처리 시간을 추가하면 사용자가 첫 바이트를 보기까지 200-400ms는 쉽게 걸립니다.

"엣지"란 코드를 전 세계에 분산된 서버에서 실행하는 것을 의미합니다 — 항상 정적 자산을 서빙해온 동일한 CDN 노드이지만, 이제 여러분의 로직도 실행할 수 있습니다. 버지니아에 있는 하나의 오리진 서버 대신, 코드가 전 세계 300개 이상의 위치에서 실행됩니다. 도쿄의 사용자는 도쿄 서버에 접속합니다. 파리의 사용자는 파리 서버에 접속합니다.

지연 시간 계산은 단순하고 설득력이 있습니다:

전통적 방식 (단일 오리진):
  도쿄 → 버지니아: ~140ms 왕복 (물리적 한계만)
  + TLS 핸드셰이크: ~140ms 추가 (또 다른 왕복)
  + 처리: 20-50ms
  합계: ~300-330ms

엣지 (로컬 PoP):
  도쿄 → 도쿄 엣지 노드: ~5ms 왕복
  + TLS 핸드셰이크: ~5ms 추가
  + 처리: 5-20ms
  합계: ~15-30ms

초기 응답에서 10-20배 개선입니다. 실제로 존재하고, 측정 가능하며, 특정 작업에서는 혁신적입니다.

하지만 마케팅이 간과하는 부분이 있습니다: 엣지는 완전한 서버 환경이 아닙니다. 근본적으로 다른 것입니다.

V8 Isolate vs Node.js#

전통적인 Node.js는 완전한 운영 체제 프로세스에서 실행됩니다. 파일 시스템에 접근할 수 있고, TCP 연결을 열 수 있고, 자식 프로세스를 생성할 수 있고, 환경 변수를 스트림으로 읽을 수 있으며, 기본적으로 리눅스 프로세스가 할 수 있는 모든 것을 할 수 있습니다.

엣지 함수는 Node.js에서 실행되지 않습니다. V8 isolate에서 실행됩니다 — Chrome을 구동하는 동일한 JavaScript 엔진이지만, 핵심만 남긴 것입니다. V8 isolate를 경량 샌드박스라고 생각하세요:

typescript
// Node.js에서는 작동하지만 엣지에서는 작동하지 않습니다
import fs from "fs";
import { createConnection } from "net";
import { execSync } from "child_process";
 
const file = fs.readFileSync("/etc/hosts");        // ❌ 파일 시스템 없음
const conn = createConnection({ port: 5432 });     // ❌ 원시 TCP 없음
const result = execSync("ls -la");                  // ❌ 자식 프로세스 없음
process.env.DATABASE_URL;                           // ⚠️  사용 가능하지만 정적, 배포 시 설정됨

엣지에서 사용할 수 있는 것은 Web API 표면 — 브라우저에서 사용 가능한 것과 동일한 API입니다:

typescript
// 엣지에서 모두 작동합니다
const response = await fetch("https://api.example.com/data");
const url = new URL(request.url);
const headers = new Headers({ "Content-Type": "application/json" });
const encoder = new TextEncoder();
const encoded = encoder.encode("hello");
const hash = await crypto.subtle.digest("SHA-256", encoded);
const id = crypto.randomUUID();
 
// Web Streams API
const stream = new ReadableStream({
  start(controller) {
    controller.enqueue("chunk 1");
    controller.enqueue("chunk 2");
    controller.close();
  },
});
 
// Cache API
const cache = caches.default;
await cache.put(request, response.clone());

제약 조건은 실제로 존재하고 엄격합니다:

  • 메모리: isolate당 128MB (Cloudflare Workers), 일부 플랫폼에서 256MB
  • CPU 시간: 실제 CPU 시간 10-50ms (벽시계 시간이 아님 — await fetch()는 카운트되지 않지만, 5MB 페이로드에 대한 JSON.parse()는 카운트됨)
  • 네이티브 모듈 없음: C++ 바인딩이 필요한 모든 것 (bcrypt, sharp, canvas)은 사용 불가
  • 영구 연결 없음: 요청 간에 데이터베이스 연결을 유지할 수 없음
  • 번들 크기 제한: 일반적으로 전체 워커 스크립트에 1-5MB

이것은 CDN 위의 Node.js가 아닙니다. 다른 멘탈 모델을 가진 다른 런타임입니다.

콜드 스타트: 미신과 현실#

엣지 함수가 "제로 콜드 스타트"를 가진다고 들어보셨을 것입니다. 이것은... 대부분 사실이며, 비교는 정말로 극적입니다.

전통적인 컨테이너 기반 서버리스 함수(AWS Lambda, Google Cloud Functions)는 다음과 같이 작동합니다:

  1. 요청 도착
  2. 플랫폼이 컨테이너를 프로비저닝 (사용 가능한 것이 없을 경우)
  3. 컨테이너가 OS를 부팅
  4. 런타임 초기화 (Node.js, Python 등)
  5. 코드 로드 및 초기화
  6. 요청 처리

2-5단계가 콜드 스타트입니다. Node.js Lambda의 경우 일반적으로 200-500ms입니다. Java Lambda의 경우 2-5초가 될 수 있습니다. .NET Lambda의 경우 500ms-1.5초입니다.

V8 isolate는 다르게 작동합니다:

  1. 요청 도착
  2. 플랫폼이 새 V8 isolate를 생성 (또는 웜 상태의 것을 재사용)
  3. 코드 로드 (배포 시 이미 바이트코드로 컴파일됨)
  4. 요청 처리

2-3단계가 5ms 미만입니다. 종종 1ms 미만입니다. isolate는 컨테이너가 아닙니다 — 부팅할 OS도 없고, 초기화할 런타임도 없습니다. V8은 마이크로초 단위로 새로운 isolate를 생성합니다. "제로 콜드 스타트"라는 표현은 마케팅 용어이지만, 현실(5ms 미만 시작)은 대부분의 사용 사례에서 제로에 충분히 가깝습니다.

하지만 엣지에서 콜드 스타트가 여전히 문제가 되는 경우가 있습니다:

큰 번들. 엣지 함수가 2MB의 의존성을 가져오면, 그 코드는 여전히 로드되고 파싱되어야 합니다. 검증 라이브러리와 날짜 포맷팅 라이브러리를 엣지 미들웨어에 번들링했을 때 이것을 어렵게 배웠습니다. 콜드 스타트가 2ms에서 40ms로 올라갔습니다. 여전히 빠르지만 "제로"는 아닙니다.

드문 위치. 엣지 제공자는 수백 개의 PoP을 가지고 있지만, 모든 PoP이 코드를 웜 상태로 유지하는 것은 아닙니다. 나이로비에서 시간당 하나의 요청을 받는다면, 그 isolate는 요청 사이에 재활용됩니다. 다음 요청은 다시 시작 비용을 지불합니다.

요청당 다중 isolate. 엣지 함수가 다른 엣지 함수를 호출하거나 (미들웨어와 API 라우트가 모두 엣지인 경우), 하나의 사용자 요청에 여러 isolate를 시작할 수 있습니다.

실질적인 조언: 엣지 함수 번들을 작게 유지하세요. 필요한 것만 가져오세요. 적극적으로 트리 셰이킹하세요. 번들이 작을수록 콜드 스타트가 빠르고, "제로 콜드 스타트" 약속이 더 잘 유지됩니다.

typescript
// ❌ 엣지에서 이렇게 하지 마세요
import dayjs from "dayjs";
import * as yup from "yup";
import lodash from "lodash";
 
// ✅ 대신 이렇게 하세요 — 내장 API 사용
const date = new Date().toISOString();
const isValid = typeof input === "string" && input.length < 200;
const unique = [...new Set(items)];

엣지 함수에 완벽한 사용 사례#

광범위한 실험 후, 명확한 패턴을 발견했습니다: 엣지 함수는 오리진 서버에 도달하기 전에 요청에 대한 빠른 결정을 내려야 할 때 탁월합니다. 게이트키퍼, 라우터, 변환기이지 — 애플리케이션 서버가 아닙니다.

1. 지리적 위치 기반 리다이렉트#

이것이 킬러 사용 사례입니다. 요청은 가장 가까운 엣지 노드에 도달하며, 노드는 이미 사용자가 어디에 있는지 알고 있습니다. API 호출도 필요 없고, IP 조회 데이터베이스도 필요 없습니다 — 플랫폼이 지리 데이터를 제공합니다:

typescript
// middleware.ts — 모든 요청에서 엣지에서 실행
import { NextRequest, NextResponse } from "next/server";
 
export const config = {
  matcher: ["/", "/shop/:path*"],
};
 
export function middleware(request: NextRequest) {
  const country = request.geo?.country ?? "US";
  const city = request.geo?.city ?? "Unknown";
  const region = request.geo?.region ?? "Unknown";
 
  // 국가별 스토어로 리다이렉트
  if (request.nextUrl.pathname === "/shop") {
    const storeMap: Record<string, string> = {
      DE: "/shop/eu",
      FR: "/shop/eu",
      GB: "/shop/uk",
      JP: "/shop/jp",
      TR: "/shop/tr",
    };
 
    const storePath = storeMap[country] ?? "/shop/us";
    if (request.nextUrl.pathname !== storePath) {
      return NextResponse.redirect(new URL(storePath, request.url));
    }
  }
 
  // 다운스트림 사용을 위한 지리 헤더 추가
  const response = NextResponse.next();
  response.headers.set("x-user-country", country);
  response.headers.set("x-user-city", city);
  response.headers.set("x-user-region", region);
  return response;
}

이것은 사용자 바로 옆에서 5ms 미만으로 실행됩니다. 대안 — IP 조회를 하고 다시 리다이렉트하기 위해 오리진 서버까지 요청을 보내는 것 — 은 오리진에서 먼 사용자에게 100-300ms가 소요됩니다.

2. 클라이언트 깜빡임 없는 A/B 테스팅#

클라이언트 측 A/B 테스팅은 두려운 "원본 콘텐츠의 깜빡임"을 유발합니다 — 사용자가 JavaScript가 버전 B로 교체하기 전에 잠깐 버전 A를 봅니다. 엣지에서는 페이지가 렌더링을 시작하기도 전에 변형을 할당할 수 있습니다:

typescript
import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  // 사용자에게 이미 변형 할당이 있는지 확인
  const existingVariant = request.cookies.get("ab-variant")?.value;
 
  if (existingVariant) {
    // 올바른 변형 페이지로 리라이트
    const url = request.nextUrl.clone();
    url.pathname = `/variants/${existingVariant}${url.pathname}`;
    return NextResponse.rewrite(url);
  }
 
  // 새 변형 할당 (50/50 분할)
  const variant = Math.random() < 0.5 ? "control" : "treatment";
 
  const url = request.nextUrl.clone();
  url.pathname = `/variants/${variant}${url.pathname}`;
 
  const response = NextResponse.rewrite(url);
  response.cookies.set("ab-variant", variant, {
    maxAge: 60 * 60 * 24 * 30, // 30일
    httpOnly: true,
    sameSite: "lax",
  });
 
  return response;
}

리라이트가 네트워크 레벨에서 발생하므로 사용자는 절대 깜빡임을 보지 않습니다. 브라우저는 A/B 테스트였다는 것조차 모릅니다 — 변형 페이지를 직접 받을 뿐입니다.

3. 인증 토큰 검증#

인증이 JWT를 사용하고 (데이터베이스 세션 조회를 하지 않는다면), 엣지는 완벽합니다. JWT 검증은 순수 암호화입니다 — 데이터베이스가 필요 없습니다:

typescript
import { jwtVerify, importSPKI } from "jose";
import { NextRequest, NextResponse } from "next/server";
 
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`;
 
export async function middleware(request: NextRequest) {
  const token = request.cookies.get("session-token")?.value;
 
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  try {
    const key = await importSPKI(PUBLIC_KEY, "RS256");
    const { payload } = await jwtVerify(token, key, {
      algorithms: ["RS256"],
      issuer: "https://auth.example.com",
    });
 
    // 사용자 정보를 헤더로 다운스트림에 전달
    const response = NextResponse.next();
    response.headers.set("x-user-id", payload.sub as string);
    response.headers.set("x-user-role", payload.role as string);
    return response;
  } catch {
    // 토큰 만료 또는 유효하지 않음
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete("session-token");
    return response;
  }
}

이 패턴은 강력합니다: 엣지 미들웨어가 토큰을 검증하고 사용자 정보를 신뢰할 수 있는 헤더로 오리진에 전달합니다. API 라우트는 토큰을 다시 검증할 필요가 없습니다 — request.headers.get("x-user-id")를 읽기만 하면 됩니다.

4. 봇 탐지 및 속도 제한#

엣지 함수는 원치 않는 트래픽이 오리진에 도달하기 전에 차단할 수 있습니다:

typescript
import { NextRequest, NextResponse } from "next/server";
 
// 간단한 인메모리 속도 제한기 (엣지 위치별)
const rateLimitMap = new Map<string, { count: number; timestamp: number }>();
 
export function middleware(request: NextRequest) {
  const ip = request.headers.get("x-forwarded-for")?.split(",").pop()?.trim()
    ?? "unknown";
  const ua = request.headers.get("user-agent") ?? "";
 
  // 알려진 악성 봇 차단
  const badBots = ["AhrefsBot", "SemrushBot", "MJ12bot", "DotBot"];
  if (badBots.some((bot) => ua.includes(bot))) {
    return new NextResponse("Forbidden", { status: 403 });
  }
 
  // 간단한 속도 제한
  const now = Date.now();
  const windowMs = 60_000; // 1분
  const maxRequests = 100;
 
  const entry = rateLimitMap.get(ip);
  if (entry && now - entry.timestamp < windowMs) {
    entry.count++;
    if (entry.count > maxRequests) {
      return new NextResponse("Too Many Requests", {
        status: 429,
        headers: { "Retry-After": "60" },
      });
    }
  } else {
    rateLimitMap.set(ip, { count: 1, timestamp: now });
  }
 
  // 메모리 누수 방지를 위한 주기적 정리
  if (rateLimitMap.size > 10_000) {
    const cutoff = now - windowMs;
    for (const [key, val] of rateLimitMap) {
      if (val.timestamp < cutoff) rateLimitMap.delete(key);
    }
  }
 
  return NextResponse.next();
}

한 가지 주의점: 위의 속도 제한 맵은 isolate별, 위치별입니다. 300개의 엣지 위치가 있다면, 각각 자체 맵을 가집니다. 엄격한 속도 제한을 위해서는 Upstash Redis나 Cloudflare Durable Objects 같은 분산 저장소가 필요합니다. 하지만 대략적인 남용 방지를 위해서는 위치별 제한이 놀라울 정도로 잘 작동합니다.

5. 요청 리라이팅 및 개인화 헤더#

엣지 함수는 요청이 오리진에 도달하기 전에 변환하는 데 탁월합니다:

typescript
import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  const url = request.nextUrl;
 
  // 디바이스 기반 콘텐츠 협상
  const ua = request.headers.get("user-agent") ?? "";
  const isMobile = /mobile|android|iphone/i.test(ua);
  response.headers.set("x-device-type", isMobile ? "mobile" : "desktop");
 
  // 쿠키에서 피처 플래그
  const flags = request.cookies.get("feature-flags")?.value;
  if (flags) {
    response.headers.set("x-feature-flags", flags);
  }
 
  // i18n을 위한 로케일 감지
  const acceptLanguage = request.headers.get("accept-language") ?? "en";
  const preferredLocale = acceptLanguage.split(",")[0]?.split("-")[0] ?? "en";
  const supportedLocales = [
    "en", "tr", "de", "fr", "es", "pt", "ja", "ko", "it",
    "nl", "ru", "pl", "uk", "sv", "cs", "ar", "hi", "zh",
  ];
  const locale = supportedLocales.includes(preferredLocale)
    ? preferredLocale
    : "en";
 
  if (!url.pathname.startsWith(`/${locale}`) && !url.pathname.startsWith("/api")) {
    return NextResponse.redirect(new URL(`/${locale}${url.pathname}`, request.url));
  }
 
  return response;
}

엣지가 실패하는 곳#

이 섹션은 마케팅 페이지가 건너뛰는 부분입니다. 저는 이 모든 벽에 부딪혔습니다.

1. 데이터베이스 연결#

이것이 가장 큰 문제입니다. 전통적인 데이터베이스(PostgreSQL, MySQL)는 영구 TCP 연결을 사용합니다. Node.js 서버는 시작 시 커넥션 풀을 열고 요청 간에 해당 연결을 재사용합니다. 효율적이고, 검증되었으며, 잘 이해되어 있습니다.

엣지 함수는 이것을 할 수 없습니다. 각 isolate는 일시적입니다. 연결을 여는 "시작" 단계가 없습니다. 연결을 열 수 있다 하더라도, isolate는 하나의 요청 후에 재활용될 수 있어 연결 설정 시간이 낭비됩니다.

typescript
// ❌ 이 패턴은 엣지에서 근본적으로 작동하지 않습니다
import { Pool } from "pg";
 
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10, // 10개의 커넥션 풀
});
 
// 각 엣지 호출은:
// 1. 새 Pool을 생성 (호출 간 안정적으로 재사용 불가)
// 2. 데이터베이스에 TCP 연결 열기 (DB는 엣지가 아닌 us-east-1에 있음)
// 3. 데이터베이스와 TLS 핸드셰이크
// 4. 쿼리 실행
// 5. isolate가 재활용되면 연결 폐기
 
// PgBouncer 같은 커넥션 풀링 서비스를 사용하더라도
// 엣지 → 오리진 데이터베이스의 네트워크 지연은 여전히 발생

데이터베이스 왕복 문제는 근본적입니다. 데이터베이스는 한 리전에 있습니다. 엣지 함수는 300개 리전에 있습니다. 엣지에서의 모든 데이터베이스 쿼리는 엣지 위치에서 데이터베이스 리전까지 갔다 와야 합니다. 도쿄의 사용자가 도쿄 엣지 노드에 접속하지만, 데이터베이스가 버지니아에 있는 경우:

도쿄의 엣지 함수
  → 버지니아 PostgreSQL로 쿼리: ~140ms 왕복
  → 두 번째 쿼리: ~140ms 추가
  → 합계: 두 쿼리에 280ms

버지니아의 Node.js 함수 (DB와 같은 리전)
  → PostgreSQL로 쿼리: ~1ms 왕복
  → 두 번째 쿼리: ~1ms 추가
  → 합계: 두 쿼리에 2ms

이 시나리오에서 엣지 함수는 데이터베이스 작업에서 140배 느립니다. 엣지 함수가 더 빨리 시작되었다는 것은 중요하지 않습니다 — 데이터베이스 왕복이 모든 것을 지배합니다.

이것이 HTTP 기반 데이터베이스 프록시가 존재하는 이유입니다(Neon의 서버리스 드라이버, PlanetScale의 fetch 기반 드라이버, Supabase의 REST API). 작동하지만, 여전히 단일 리전의 데이터베이스에 HTTP 요청을 보내고 있습니다. "TCP를 사용할 수 없음" 문제는 해결하지만 "데이터베이스가 멀리 있음" 문제는 해결하지 못합니다.

typescript
// ✅ 엣지에서 작동합니다 (HTTP 기반 데이터베이스 접근)
// 하지만 데이터베이스가 엣지 노드에서 멀면 여전히 느립니다
import { neon } from "@neondatabase/serverless";
 
export const runtime = "edge";
 
export async function GET(request: Request) {
  const sql = neon(process.env.DATABASE_URL!);
  // Neon 데이터베이스에 HTTP 요청을 보냅니다
  // 작동하지만, 지연은 데이터베이스 리전까지의 거리에 따라 달라집니다
  const posts = await sql`SELECT * FROM posts WHERE published = true LIMIT 10`;
  return Response.json(posts);
}

2. 장기 실행 작업#

엣지 함수에는 CPU 시간 제한이 있으며, 일반적으로 실제 컴퓨트 시간 10-50ms입니다. 벽시계 시간은 더 관대하지만(보통 30초), CPU 집약적 작업은 빠르게 한계에 도달합니다:

typescript
// ❌ 엣지에서 CPU 시간 제한을 초과합니다
export const runtime = "edge";
 
export async function POST(request: Request) {
  const data = await request.json();
 
  // 이미지 처리 — CPU 집약적
  // (sharp는 네이티브 모듈이므로 사용할 수도 없음)
  const processed = heavyImageProcessing(data.image);
 
  // PDF 생성 — CPU 집약적 + Node.js API 필요
  const pdf = generatePDF(data.content);
 
  // 대규모 데이터 변환
  const result = data.items // 100,000 항목
    .map(transform)
    .filter(validate)
    .sort(compare)
    .reduce(aggregate, {});
 
  return Response.json(result);
}

함수가 수 밀리초 이상의 CPU 시간이 필요하다면, 리전 Node.js 서버에 속합니다. 마침표.

3. Node.js 전용 의존성#

이것은 사람들을 놀라게 합니다. 놀랄 만큼 많은 npm 패키지가 Node.js 내장 모듈에 의존합니다:

typescript
// ❌ 이 패키지들은 엣지에서 작동하지 않습니다
import bcrypt from "bcrypt";            // 네이티브 C++ 바인딩
import sharp from "sharp";              // 네이티브 C++ 바인딩
import puppeteer from "puppeteer";      // 파일 시스템 + child_process 필요
import nodemailer from "nodemailer";    // net 모듈 필요
import { readFile } from "fs/promises"; // Node.js 파일 시스템 API
import mongoose from "mongoose";         // TCP 연결 + Node.js API
 
// ✅ 엣지 호환 대안
import { hashSync } from "bcryptjs";    // 순수 JS 구현 (더 느림)
// 이미지: 별도의 서비스 또는 API 사용
// 이메일: HTTP 기반 이메일 API 사용 (Resend, SendGrid REST)
// 데이터베이스: HTTP 기반 클라이언트 사용

엣지로 무언가를 옮기기 전에, 모든 의존성을 확인하세요. 의존성 트리 세 단계 깊숙이 묻혀 있는 하나의 require("fs")가 런타임에서 엣지 함수를 크래시시킵니다 — 빌드 타임이 아닙니다. 배포하고, 모든 것이 정상으로 보이다가, 첫 번째 요청이 해당 코드 경로에 도달하면 난해한 오류가 발생합니다.

4. 큰 번들 크기#

엣지 플랫폼은 엄격한 번들 크기 제한이 있습니다:

  • Cloudflare Workers: 1MB (무료), 5MB (유료)
  • Vercel Edge Functions: 4MB (압축)
  • Deno Deploy: 20MB

UI 컴포넌트 라이브러리, 검증 라이브러리, 날짜 라이브러리를 import하면 충분하게 느껴지던 것도 부족해집니다. 한번은 배럴 파일에서 가져오면서 전체 @/components 디렉토리를 끌어들여 엣지 미들웨어가 3.5MB로 부풀어 오른 적이 있습니다.

typescript
// ❌ 배럴 파일 임포트가 너무 많이 끌어올 수 있습니다
import { validateEmail } from "@/lib/utils";
// utils.ts가 20개의 다른 모듈에서 재내보내면, 모두 번들에 포함됩니다
 
// ✅ 소스에서 직접 임포트
import { validateEmail } from "@/lib/validators/email";

5. 스트리밍과 WebSocket#

엣지 함수는 스트리밍 응답(Web Streams API)을 할 수 있지만, 장기 WebSocket 연결은 다른 이야기입니다. 일부 플랫폼이 엣지에서 WebSocket을 지원하지만(Cloudflare Workers, Deno Deploy), 엣지 함수의 일시적 특성은 상태를 유지하는 장기 연결에 적합하지 않습니다.

Next.js 엣지 런타임#

Next.js는 라우트별로 엣지 런타임을 선택할 수 있게 해줍니다. 전부 다 할 필요 없이 — 엣지에서 실행할 라우트를 정확히 선택하면 됩니다.

미들웨어 (항상 엣지)#

Next.js 미들웨어는 항상 엣지에서 실행됩니다. 이것은 설계상 의도된 것입니다 — 미들웨어는 모든 매칭 요청을 가로채므로, 빠르고 전 세계에 분산되어야 합니다:

typescript
// middleware.ts — 항상 엣지에서 실행, 옵트인 불필요
import { NextRequest, NextResponse } from "next/server";
 
export function middleware(request: NextRequest) {
  // 모든 매칭 요청 전에 실행됩니다
  // 빠르게 유지하세요 — 데이터베이스 호출 없음, 무거운 연산 없음
  return NextResponse.next();
}
 
export const config = {
  // 특정 경로에서만 실행
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
  ],
};

엣지의 API 라우트#

모든 라우트 핸들러는 엣지 런타임을 선택할 수 있습니다:

typescript
// app/api/hello/route.ts
export const runtime = "edge"; // 이 한 줄이 런타임을 변경합니다
 
export async function GET(request: Request) {
  return Response.json({
    message: "엣지에서 인사드립니다",
    region: process.env.VERCEL_REGION ?? "unknown",
    timestamp: Date.now(),
  });
}

엣지의 페이지 라우트#

전체 페이지도 엣지에서 렌더링할 수 있지만, 이것을 하기 전에 신중하게 생각하겠습니다:

typescript
// app/dashboard/page.tsx
export const runtime = "edge";
 
export default async function DashboardPage() {
  // 기억하세요: 여기서는 Node.js API가 없습니다
  // 모든 데이터 가져오기는 fetch() 또는 엣지 호환 클라이언트를 사용해야 합니다
  const data = await fetch("https://api.example.com/dashboard", {
    headers: { Authorization: `Bearer ${process.env.API_KEY}` },
    next: { revalidate: 60 },
  }).then((r) => r.json());
 
  return (
    <main>
      <h1>대시보드</h1>
      {/* 데이터 렌더링 */}
    </main>
  );
}

엣지 런타임에서 사용 가능한 것#

사용 가능한 것과 불가능한 것에 대한 실용적 레퍼런스입니다:

typescript
// ✅ 엣지에서 사용 가능
fetch()                          // HTTP 요청
Request / Response               // 웹 표준 요청/응답
Headers                          // HTTP 헤더
URL / URLSearchParams            // URL 파싱
TextEncoder / TextDecoder        // 문자열 인코딩
crypto.subtle                    // 암호화 작업 (서명, 해싱)
crypto.randomUUID()              // UUID 생성
crypto.getRandomValues()         // 암호학적 난수
structuredClone()                // 딥 클로닝
atob() / btoa()                  // Base64 인코딩/디코딩
setTimeout() / setInterval()     // 타이머 (CPU 제한 기억)
console.log()                    // 로깅
ReadableStream / WritableStream  // 스트리밍
AbortController / AbortSignal    // 요청 취소
URLPattern                       // URL 패턴 매칭
 
// ❌ 엣지에서 사용 불가
require()                        // CommonJS (import 사용)
fs / path / os                   // Node.js 내장 모듈
process.exit()                   // 프로세스 제어
Buffer                           // 대신 Uint8Array 사용
__dirname / __filename           // import.meta.url 사용
setImmediate()                   // 웹 표준 아님

엣지에서의 인증: 전체 패턴#

인증에 대해 더 깊이 다루고 싶습니다. 가장 영향력 있는 엣지 사용 사례 중 하나이지만, 잘못하기 쉽기 때문입니다.

작동하는 패턴은: 엣지에서 토큰을 검증하고, 신뢰할 수 있는 클레임을 다운스트림에 전달하며, 미들웨어에서는 절대 데이터베이스를 건드리지 않는 것입니다.

typescript
// lib/edge-auth.ts — 엣지 호환 인증 유틸리티
import { jwtVerify, SignJWT, importSPKI, importPKCS8 } from "jose";
 
const PUBLIC_KEY_PEM = process.env.JWT_PUBLIC_KEY!;
const ISSUER = "https://auth.myapp.com";
const AUDIENCE = "https://myapp.com";
 
export interface TokenPayload {
  sub: string;
  email: string;
  role: "user" | "admin" | "moderator";
  iat: number;
  exp: number;
}
 
export async function verifyToken(token: string): Promise<TokenPayload | null> {
  try {
    const publicKey = await importSPKI(PUBLIC_KEY_PEM, "RS256");
    const { payload } = await jwtVerify(token, publicKey, {
      algorithms: ["RS256"],
      issuer: ISSUER,
      audience: AUDIENCE,
      clockTolerance: 30, // 30초의 시계 오차 허용
    });
 
    return payload as unknown as TokenPayload;
  } catch {
    return null;
  }
}
 
export function isTokenExpiringSoon(payload: TokenPayload): boolean {
  const now = Math.floor(Date.now() / 1000);
  const fiveMinutes = 5 * 60;
  return payload.exp - now < fiveMinutes;
}
typescript
// middleware.ts — 인증 미들웨어
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, isTokenExpiringSoon } from "./lib/edge-auth";
 
const PUBLIC_PATHS = ["/", "/login", "/register", "/api/auth/login"];
const ADMIN_PATHS = ["/admin"];
 
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // 공개 경로는 인증 건너뛰기
  if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith("/api/public"))) {
    return NextResponse.next();
  }
 
  // 토큰 추출
  const token = request.cookies.get("auth-token")?.value;
  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
 
  // 토큰 검증 (순수 암호화 — 데이터베이스 호출 없음)
  const payload = await verifyToken(token);
  if (!payload) {
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete("auth-token");
    return response;
  }
 
  // 역할 기반 접근 제어
  if (ADMIN_PATHS.some((p) => pathname.startsWith(p)) && payload.role !== "admin") {
    return NextResponse.redirect(new URL("/unauthorized", request.url));
  }
 
  // 검증된 사용자 정보를 신뢰할 수 있는 헤더로 오리진에 전달
  const response = NextResponse.next();
  response.headers.set("x-user-id", payload.sub);
  response.headers.set("x-user-email", payload.email);
  response.headers.set("x-user-role", payload.role);
 
  // 토큰 갱신이 필요한 경우 신호
  if (isTokenExpiringSoon(payload)) {
    response.headers.set("x-token-refresh", "true");
  }
 
  return response;
}
typescript
// app/api/profile/route.ts — 오리진 서버가 신뢰할 수 있는 헤더를 읽습니다
export async function GET(request: Request) {
  // 이 헤더는 JWT 검증 후 엣지 미들웨어에 의해 설정되었습니다
  // 자체 인프라에서 왔기 때문에 신뢰할 수 있습니다
  const userId = request.headers.get("x-user-id");
  const userRole = request.headers.get("x-user-role");
 
  if (!userId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  // 이제 데이터베이스에 접근할 수 있습니다 — 오리진 서버에 있으니,
  // 커넥션 풀을 가진 데이터베이스 바로 옆입니다
  const user = await db.user.findUnique({ where: { id: userId } });
 
  return Response.json(user);
}

핵심 인사이트: 엣지는 빠른 부분(암호화 검증)을 하고, 오리진은 느린 부분(데이터베이스 쿼리)을 합니다. 각각이 가장 효율적인 곳에서 실행됩니다.

한 가지 중요한 주의점: 이것은 JWT에서만 작동합니다. 인증 시스템이 모든 요청에서 데이터베이스 조회를 필요로 한다면(세션 ID 쿠키를 사용한 세션 기반 인증처럼), 엣지는 도움이 되지 않습니다 — 여전히 데이터베이스를 호출해야 하며, 이는 오리진 리전으로의 왕복을 의미합니다.

엣지 캐싱#

엣지에서의 캐싱은 흥미로운 부분입니다. 엣지 노드는 응답을 캐시할 수 있으며, 이는 동일한 URL에 대한 후속 요청이 오리진에 도달하지 않고 엣지에서 직접 서빙됨을 의미합니다.

Cache-Control 제대로 하기#

typescript
// app/api/products/route.ts
export const runtime = "edge";
 
export async function GET(request: Request) {
  const url = new URL(request.url);
  const category = url.searchParams.get("category") ?? "all";
 
  const products = await fetch(
    `${process.env.ORIGIN_API}/products?category=${category}`,
  ).then((r) => r.json());
 
  return Response.json(products, {
    headers: {
      // CDN에서 60초간 캐시
      // 최대 5분간 재검증하면서 오래된 것 서빙
      // 클라이언트는 10초간 캐시 가능
      "Cache-Control": "public, s-maxage=60, stale-while-revalidate=300, max-age=10",
 
      // 이 헤더로 변형하여 다른 변형이 다른 캐시 항목을 얻도록
      Vary: "Accept-Language, Accept-Encoding",
 
      // 타겟 무효화를 위한 CDN 전용 캐시 태그
      "Cache-Tag": `products,category-${category}`,
    },
  });
}

stale-while-revalidate 패턴은 엣지에서 특히 강력합니다. 다음과 같이 작동합니다:

  1. 첫 번째 요청: 엣지가 오리진에서 가져오고, 응답을 캐시하고, 반환합니다
  2. 60초 이내 요청: 엣지가 캐시에서 서빙 (0ms 오리진 지연)
  3. 61-360초의 요청: 엣지가 오래된 캐시 버전을 즉시 서빙하지만, 백그라운드에서 오리진으로부터 새 버전을 가져옵니다
  4. 360초 이후: 캐시가 완전히 만료, 다음 요청은 오리진으로

사용자는 거의 항상 캐시된 응답을 받습니다. 신선도 트레이드오프는 명시적이고 조정 가능합니다.

동적 설정을 위한 Edge Config#

Vercel의 Edge Config(그리고 다른 플랫폼의 유사 서비스)는 모든 엣지 위치에 복제되는 키-값 설정을 저장할 수 있게 해줍니다. 재배포 없이 업데이트하려는 피처 플래그, 리다이렉트 규칙, A/B 테스트 설정에 매우 유용합니다:

typescript
import { get } from "@vercel/edge-config";
import { NextRequest, NextResponse } from "next/server";
 
export async function middleware(request: NextRequest) {
  // Edge Config 읽기는 매우 빠릅니다 (~1ms)
  // 데이터가 모든 엣지 위치에 복제되어 있기 때문입니다
  const maintenanceMode = await get<boolean>("maintenance_mode");
 
  if (maintenanceMode) {
    return NextResponse.rewrite(new URL("/maintenance", request.url));
  }
 
  // 피처 플래그
  const features = await get<Record<string, boolean>>("feature_flags");
  if (features?.["new_pricing_page"] && request.nextUrl.pathname === "/pricing") {
    return NextResponse.rewrite(new URL("/pricing-v2", request.url));
  }
 
  // 동적 리다이렉트 (재배포 없이 리다이렉트 업데이트)
  const redirects = await get<Array<{ from: string; to: string; permanent: boolean }>>(
    "redirects",
  );
 
  if (redirects) {
    const match = redirects.find((r) => r.from === request.nextUrl.pathname);
    if (match) {
      return NextResponse.redirect(
        new URL(match.to, request.url),
        match.permanent ? 308 : 307,
      );
    }
  }
 
  return NextResponse.next();
}

이것은 진정한 게임 체인저입니다. Edge Config 이전에는 피처 플래그를 변경하려면 코드 변경과 재배포가 필요했습니다. 이제 대시보드에서 JSON 값을 업데이트하면 몇 초 안에 전 세계로 전파됩니다.

실제 성능 계산#

마케팅 계산 대신 정직한 계산을 해봅시다. 데이터베이스를 쿼리해야 하는 일반적인 API 엔드포인트에 대해 세 가지 아키텍처를 비교하겠습니다:

시나리오: 사용자 프로필 API (2개의 데이터베이스 쿼리)#

아키텍처 A: 전통적인 리전 Node.js

도쿄의 사용자 → 버지니아의 오리진: 140ms
  + DB 쿼리 1 (같은 리전): 2ms
  + DB 쿼리 2 (같은 리전): 2ms
  + 처리: 5ms
  = 합계: ~149ms

아키텍처 B: HTTP 데이터베이스를 사용한 엣지 함수

도쿄의 사용자 → 도쿄의 엣지: 5ms
  + DB 쿼리 1 (버지니아까지 HTTP): 145ms
  + DB 쿼리 2 (버지니아까지 HTTP): 145ms
  + 처리: 3ms
  = 합계: ~298ms ← 리전보다 더 느림

아키텍처 C: 리전 데이터베이스(읽기 복제본)를 사용한 엣지 함수

도쿄의 사용자 → 도쿄의 엣지: 5ms
  + DB 쿼리 1 (도쿄 복제본까지 HTTP): 8ms
  + DB 쿼리 2 (도쿄 복제본까지 HTTP): 8ms
  + 처리: 3ms
  = 합계: ~24ms ← 가장 빠르지만, 다중 리전 DB 필요

아키텍처 D: 인증은 엣지 + 데이터는 리전

도쿄의 사용자 → 도쿄의 엣지 미들웨어: 5ms (JWT 검증)
  → 버지니아의 오리진: 140ms
  + DB 쿼리 1 (같은 리전): 2ms
  + DB 쿼리 2 (같은 리전): 2ms
  + 처리: 5ms
  = 합계: ~154ms
  (하지만 인증은 이미 검증됨 — 오리진이 재검증할 필요 없음)
  (그리고 비인가 요청은 엣지에서 차단됨 — 오리진에 도달하지 않음)

주요 시사점:

  1. 엣지 + 오리진 데이터베이스 = 종종 더 느림 리전 서버만 사용하는 것보다
  2. 엣지 + 다중 리전 데이터베이스 = 가장 빠름 하지만 가장 비싸고 복잡함
  3. 게이트키핑은 엣지 + 데이터는 리전 = 최고의 실용적 균형
  4. 순수 엣지(데이터베이스 없음) = 리다이렉트와 인증 확인 같은 것에 무적

아키텍처 D가 대부분의 프로젝트에서 사용하는 것입니다. 엣지는 잘하는 것(빠른 결정, 인증, 라우팅)을 처리하고, 리전 Node.js 서버는 잘하는 것(데이터베이스 쿼리, 무거운 연산)을 처리합니다.

엣지가 진정으로 이기는 때: 데이터베이스 없는 작업#

데이터베이스가 관여하지 않을 때 계산은 완전히 뒤집힙니다:

리다이렉트 (엣지):
  도쿄의 사용자 → 도쿄의 엣지 → 리다이렉트 응답: ~5ms

리다이렉트 (리전):
  도쿄의 사용자 → 버지니아의 오리진 → 리다이렉트 응답: ~280ms
정적 API 응답 (엣지 + 캐시):
  도쿄의 사용자 → 도쿄의 엣지 → 캐시된 응답: ~5ms

정적 API 응답 (리전):
  도쿄의 사용자 → 버지니아의 오리진 → 응답: ~280ms
봇 차단 (엣지):
  어디서든 악성 봇 → 엣지 (가장 가까운) → 403 응답: ~5ms
  (봇이 오리진 서버에 도달하지 않음)

봇 차단 (리전):
  어디서든 악성 봇 → 버지니아의 오리진 → 403 응답: ~280ms
  (봇이 여전히 오리진 리소스를 소비함)

데이터베이스가 필요 없는 작업에서 엣지는 20-50배 빠릅니다. 이것은 마케팅이 아닙니다 — 물리학입니다.

나의 결정 프레임워크#

프로덕션에서 엣지 함수를 1년간 사용한 후, 모든 새 엔드포인트나 로직에 대해 사용하는 플로우차트입니다:

단계 1: Node.js API가 필요한가?#

fs, net, child_process, 또는 네이티브 모듈을 임포트한다면 — Node.js 리전. 논쟁의 여지가 없습니다.

단계 2: 데이터베이스 쿼리가 필요한가?#

예이고, 사용자 근처에 읽기 복제본이 없다면 — Node.js 리전 (데이터베이스와 같은 리전). 데이터베이스 왕복이 모든 것을 지배합니다.

예이고, 전 세계에 분산된 읽기 복제본이 있다면 — 엣지가 작동할 수 있으며, HTTP 기반 데이터베이스 클라이언트를 사용합니다.

단계 3: 요청에 대한 결정(라우팅, 인증, 리다이렉트)인가?#

예라면 — 엣지. 이것이 최적의 영역입니다. 요청이 오리진에 도달하기 전에 어떤 일이 일어날지 결정하는 빠른 결정을 내리는 것입니다.

단계 4: 응답이 캐시 가능한가?#

예라면 — 적절한 Cache-Control 헤더와 함께 엣지. 첫 번째 요청이 오리진으로 가더라도, 후속 요청은 엣지 캐시에서 서빙됩니다.

단계 5: CPU 집약적인가?#

상당한 연산(이미지 처리, PDF 생성, 대규모 데이터 변환)이 포함된다면 — Node.js 리전.

단계 6: 지연 시간에 얼마나 민감한가?#

백그라운드 작업이나 웹훅이라면 — Node.js 리전. 아무도 기다리지 않습니다. 모든 ms가 중요한 사용자 대면 요청이라면 — 다른 기준을 충족한다면 엣지.

치트 시트#

typescript
// ✅ 엣지에 완벽
// - 미들웨어 (인증, 리다이렉트, 리라이트, 헤더)
// - 지리적 위치 로직
// - A/B 테스트 할당
// - 봇 탐지 / WAF 규칙
// - 캐시 친화적 API 응답
// - 피처 플래그 확인
// - CORS 프리플라이트 응답
// - 정적 데이터 변환 (DB 없음)
// - 웹훅 서명 검증
 
// ❌ Node.js 리전에 유지
// - 데이터베이스 CRUD 작업
// - 파일 업로드 / 처리
// - 이미지 조작
// - PDF 생성
// - 이메일 전송 (HTTP API 사용하되, 리전에서)
// - WebSocket 서버
// - 백그라운드 작업 / 큐
// - 네이티브 npm 패키지를 사용하는 모든 것
// - 데이터베이스 쿼리가 있는 SSR 페이지
// - 데이터베이스에 접근하는 GraphQL 리졸버
 
// 🤔 상황에 따라 다름
// - 인증 (JWT는 엣지, 세션-DB는 리전)
// - API 라우트 (DB 없으면 엣지, DB 있으면 리전)
// - 서버 렌더링 페이지 (데이터가 캐시/fetch에서 오면 엣지, DB면 리전)
// - 실시간 기능 (초기 인증은 엣지, 영구 연결은 리전)

실제로 엣지에서 실행하는 것#

이 사이트의 경우 다음과 같이 구분합니다:

엣지 (미들웨어):

  • 로케일 감지 및 리다이렉트
  • 봇 필터링
  • 보안 헤더 (CSP, HSTS 등)
  • 접근 로깅
  • 속도 제한 (기본)

Node.js 리전:

  • 블로그 콘텐츠 렌더링 (MDX 처리에 Velite를 통한 Node.js API 필요)
  • Redis를 사용하는 API 라우트
  • OG 이미지 생성 (더 많은 CPU 시간 필요)
  • RSS 피드 생성

정적 (런타임 없음):

  • 도구 페이지 (빌드 타임에 사전 렌더링)
  • 블로그 포스트 페이지 (빌드 타임에 사전 렌더링)
  • 모든 이미지와 자산 (CDN 서빙)

최고의 런타임은 종종 런타임이 없는 것입니다. 빌드 타임에 사전 렌더링하여 정적 자산으로 서빙할 수 있다면, 그것이 항상 어떤 엣지 함수보다 빠를 것입니다. 엣지는 모든 요청에서 진정으로 동적이어야 하는 것들을 위한 것입니다.

정직한 요약#

엣지 함수는 전통적인 서버의 대체가 아닙니다. 보완입니다. 아키텍처 도구 상자의 추가 도구입니다 — 올바른 사용 사례에서는 매우 강력하고 잘못된 사용 사례에서는 적극적으로 해로운 도구입니다.

계속 돌아오는 경험 법칙: 함수가 단일 리전의 데이터베이스에 접근해야 한다면, 함수를 엣지에 놓는 것은 도움이 되지 않습니다 — 해롭습니다. 홉 하나를 추가한 것입니다. 함수는 더 빨리 실행되지만, 데이터베이스에 다시 접근하는 데 100ms 이상을 소비합니다. 순 결과: 한 리전에서 모든 것을 실행하는 것보다 느립니다.

하지만 요청 자체의 정보만으로 결정을 내릴 수 있는 것들 — 지리적 위치, 쿠키, 헤더, JWT — 에서 엣지는 무적입니다. 그 5ms 엣지 응답은 합성 벤치마크가 아닙니다. 실제로 존재하며, 사용자가 차이를 느낍니다.

모든 것을 엣지로 옮기지 마세요. 모든 것을 엣지에서 빼지도 마세요. 각 로직을 물리학이 유리한 곳에 배치하세요.

관련 게시물