Supabase 완전 정복 시리즈 7편 — Edge Functions: Deno 기반 서버리스 함수 완벽 가이드




시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 2편 – 데이터베이스 & 자동 API (REST/GraphQL) 3편 – 인증(Auth) — 소셜 로그인, JWT, MFA, SSO 4편 – RLS 보안 — Row Level Security 완벽 가이드 5편 – 실시간 기능(Realtime) — Broadcast, Presence, Postgres Changes 6편 – 스토리지(Storage) — 파일 업로드, 이미지 최적화, CDN 7편 👉 Edge Functions — Deno 기반 서버리스 함수 완벽 가이드 (현재 글) 8편 – AI & 벡터 검색 (pgvector + Vector Buckets) …


들어가며

Next.js App Router가 있는데 왜 Edge Functions가 필요할까요?

물론 Next.js의 Route Handlers나 Server Actions으로도 서버 로직을 처리할 수 있습니다. 하지만 다음 상황에서는 Edge Functions가 훨씬 적합합니다.

  • Stripe, GitHub 같은 외부 서비스의 웹훅 수신: 별도 서버 없이 서명 검증 후 DB 업데이트
  • 프론트엔드와 분리된 백엔드 로직: 모바일 앱, 다른 클라이언트에서도 공통으로 호출
  • 환경 변수 보안: 클라이언트에 노출하면 안 되는 시크릿 키 사용 (OpenAI, 결제 API 등)
  • 글로벌 엣지 배포: 사용자 위치에 가장 가까운 서버에서 실행 → 초저지연
  • Next.js 없이 Supabase만 사용하는 경우

이번 편에서 다룰 내용:

  • Edge Functions 아키텍처 & Deno 런타임
  • 로컬 개발 환경 설정 (Supabase CLI)
  • 기본 함수 작성 패턴
  • Next.js에서 Edge Functions 호출
  • 인증 처리 (JWT 검증)
  • 시크릿(환경 변수) 관리
  • 실전 패턴: 이메일 발송, Stripe 웹훅, OpenAI 프록시, 이미지 리사이징
  • 배포 및 로그 확인

Edge Functions 아키텍처

Deno 런타임

Edge Functions는 Deno 기반으로 동작합니다. Node.js와 비슷하지만 몇 가지 차이가 있습니다.

항목Node.jsDeno
모듈require() / CommonJSESM (import) 기본
TypeScript별도 설정 필요네이티브 지원
패키지npm + node_modulesURL 직접 import 또는 npm: prefix
보안기본 전체 권한기본 샌드박스 (명시적 권한 필요)
환경 변수process.envDeno.env.get()

2025년 기준: Supabase Edge Functions는 NPM 패키지도 npm: 접두사로 직접 사용 가능합니다.

// npm 패키지 import (2가지 방식)
import Stripe from 'npm:stripe@14'                // npm: 접두사
import { Resend } from 'https://esm.sh/resend'    // esm.sh CDN

글로벌 배포 구조

사용자 요청
    ↓
Global API Gateway (IP 기반 지역 감지)
    ↓
가장 가까운 Edge Location (예: 한국 → 도쿄 or 서울)
    ↓
Edge Function Isolate 실행
    ↓
응답 반환

각 함수는 Isolate(격리된 실행 환경)에서 동작합니다. 요청 간 상태가 공유되지 않는 완전한 무상태(Stateless) 구조입니다.


로컬 개발 환경 설정

Supabase CLI 설치

# macOS
brew install supabase/tap/supabase

# npm (전역 설치)
npm install -g supabase

# 버전 확인
supabase --version

프로젝트 초기화 및 로컬 실행

# 프로젝트 초기화
supabase init

# 로컬 Supabase 스택 시작 (Docker 필요)
supabase start

# 출력 예시:
# API URL: http://localhost:54321
# DB URL:  postgresql://postgres:postgres@localhost:54322/postgres
# Studio:  http://localhost:54323
# anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Edge Function 생성

# 새 함수 생성
supabase functions new hello-world

# 생성 결과:
# supabase/functions/hello-world/index.ts

VSCode Deno 설정

// .vscode/settings.json
{
  "deno.enable": true,
  "deno.unstable": true,
  "deno.enablePaths": ["supabase/functions"],
  "[typescript]": {
    "editor.defaultFormatter": "denoland.vscode-deno"
  }
}

로컬에서 함수 서빙

# 전체 함수 서빙
supabase functions serve

# 특정 함수만 서빙
supabase functions serve hello-world

# 환경 변수 파일 지정
supabase functions serve --env-file supabase/.env.local

# JWT 검증 없이 (웹훅 테스트 시)
supabase functions serve hello-world --no-verify-jwt

기본 함수 패턴

Hello World

// supabase/functions/hello-world/index.ts
Deno.serve(async (req: Request) => {
  const { name } = await req.json()

  return new Response(
    JSON.stringify({ message: `Hello, ${name}!` }),
    {
      headers: { 'Content-Type': 'application/json' },
      status: 200,
    }
  )
})

CORS 처리 (브라우저에서 직접 호출 시 필수)

// supabase/functions/_shared/cors.ts
export const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers':
    'authorization, x-client-info, apikey, content-type',
  'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, PUT, DELETE',
}
// supabase/functions/my-function/index.ts
import { corsHeaders } from '../_shared/cors.ts'

Deno.serve(async (req: Request) => {
  // OPTIONS preflight 요청 처리 (CORS)
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  try {
    const body = await req.json()

    return new Response(
      JSON.stringify({ data: body }),
      {
        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
        status: 200,
      }
    )
  } catch (error) {
    return new Response(
      JSON.stringify({ error: error.message }),
      {
        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
        status: 400,
      }
    )
  }
})

Supabase 클라이언트 사용 (공유 모듈)

// supabase/functions/_shared/supabase.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

// 일반 사용자 권한 (RLS 적용)
export function createUserClient(authHeader: string) {
  return createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_ANON_KEY')!,
    {
      global: { headers: { Authorization: authHeader } },
    }
  )
}

// 서비스 롤 (RLS 우회 — 신중하게 사용)
export function createAdminClient() {
  return createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )
}

인증 처리

JWT 검증 및 사용자 정보 추출

기본적으로 Edge Functions는 요청 헤더의 JWT를 자동 검증합니다. 함수 내에서 사용자 정보를 가져오는 패턴:

// supabase/functions/get-my-profile/index.ts
import { corsHeaders } from '../_shared/cors.ts'
import { createUserClient } from '../_shared/supabase.ts'

Deno.serve(async (req: Request) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  // Authorization 헤더에서 JWT 추출
  const authHeader = req.headers.get('Authorization')
  if (!authHeader) {
    return new Response(
      JSON.stringify({ error: '인증이 필요합니다.' }),
      { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 401 }
    )
  }

  // 사용자 클라이언트 생성 (RLS 적용)
  const supabase = createUserClient(authHeader)

  // 현재 사용자 정보 가져오기
  const { data: { user }, error: authError } = await supabase.auth.getUser()
  if (authError || !user) {
    return new Response(
      JSON.stringify({ error: '유효하지 않은 토큰입니다.' }),
      { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 401 }
    )
  }

  // 프로필 조회
  const { data: profile, error } = await supabase
    .from('profiles')
    .select('*')
    .eq('id', user.id)
    .single()

  if (error) {
    return new Response(
      JSON.stringify({ error: error.message }),
      { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 }
    )
  }

  return new Response(
    JSON.stringify({ profile }),
    { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 }
  )
})

시크릿(환경 변수) 관리

로컬 환경 변수

# supabase/.env.local
RESEND_API_KEY=re_xxxxxxxxxxxx
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxx
OPENAI_API_KEY=sk-xxxxxxxxxxxx

프로덕션 시크릿 설정

# 단일 시크릿 설정
supabase secrets set RESEND_API_KEY=re_xxxxxxxxxxxx

# 파일에서 일괄 설정
supabase secrets set --env-file supabase/.env.local

# 시크릿 목록 확인
supabase secrets list

# 시크릿 삭제
supabase secrets unset RESEND_API_KEY

함수 내 환경 변수 사용

// Deno 환경 변수 접근
const apiKey = Deno.env.get('RESEND_API_KEY')
if (!apiKey) throw new Error('RESEND_API_KEY가 설정되지 않았습니다.')

// Supabase 기본 환경 변수 (자동 주입)
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
const anonKey    = Deno.env.get('SUPABASE_ANON_KEY')!
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!

실전 패턴 1: 이메일 발송 (Resend)

// supabase/functions/send-email/index.ts
import { corsHeaders } from '../_shared/cors.ts'

const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY')!

interface EmailPayload {
  to: string
  subject: string
  html: string
}

Deno.serve(async (req: Request) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  try {
    const { to, subject, html }: EmailPayload = await req.json()

    const res = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${RESEND_API_KEY}`,
      },
      body: JSON.stringify({
        from: 'noreply@yourdomain.com',
        to,
        subject,
        html,
      }),
    })

    if (!res.ok) {
      const error = await res.text()
      throw new Error(`이메일 발송 실패: ${error}`)
    }

    const data = await res.json()

    return new Response(
      JSON.stringify({ success: true, id: data.id }),
      { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200 }
    )
  } catch (error) {
    return new Response(
      JSON.stringify({ error: error.message }),
      { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 }
    )
  }
})

실전 패턴 2: Stripe 웹훅

Stripe 웹훅은 JWT 없이 들어오므로 --no-verify-jwt로 배포하고, Stripe 서명으로 검증합니다.

// supabase/functions/stripe-webhook/index.ts
import Stripe from 'npm:stripe@14'
import { createAdminClient } from '../_shared/supabase.ts'

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
  apiVersion: '2024-06-20',
})

const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!

Deno.serve(async (req: Request) => {
  const signature = req.headers.get('stripe-signature')
  if (!signature) {
    return new Response('서명이 없습니다.', { status: 400 })
  }

  const body = await req.text()

  let event: Stripe.Event
  try {
    // Stripe 서명 검증
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
  } catch (err) {
    console.error('웹훅 서명 검증 실패:', err.message)
    return new Response(`서명 오류: ${err.message}`, { status: 400 })
  }

  const supabase = createAdminClient()

  // 이벤트 타입별 처리
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      const userId = session.metadata?.user_id

      if (userId) {
        await supabase
          .from('subscriptions')
          .upsert({
            user_id: userId,
            stripe_customer_id: session.customer as string,
            stripe_subscription_id: session.subscription as string,
            status: 'active',
            plan: session.metadata?.plan ?? 'pro',
          })
      }
      break
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription

      await supabase
        .from('subscriptions')
        .update({ status: 'canceled' })
        .eq('stripe_subscription_id', subscription.id)
      break
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice

      await supabase
        .from('subscriptions')
        .update({ status: 'past_due' })
        .eq('stripe_subscription_id', invoice.subscription as string)
      break
    }

    default:
      console.log(`처리하지 않는 이벤트: ${event.type}`)
  }

  return new Response(JSON.stringify({ received: true }), {
    headers: { 'Content-Type': 'application/json' },
    status: 200,
  })
})
# JWT 검증 없이 배포 (웹훅은 Stripe 서명으로 검증)
supabase functions deploy stripe-webhook --no-verify-jwt

실전 패턴 3: OpenAI 프록시

클라이언트에서 OpenAI API 키를 노출하지 않기 위해 Edge Function을 프록시로 활용합니다.

// supabase/functions/ai-chat/index.ts
import { corsHeaders } from '../_shared/cors.ts'
import { createUserClient } from '../_shared/supabase.ts'

const OPENAI_API_KEY = Deno.env.get('OPENAI_API_KEY')!

Deno.serve(async (req: Request) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  // 인증 확인
  const authHeader = req.headers.get('Authorization')
  if (!authHeader) {
    return new Response(
      JSON.stringify({ error: '인증이 필요합니다.' }),
      { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 401 }
    )
  }

  const supabase = createUserClient(authHeader)
  const { data: { user }, error: authError } = await supabase.auth.getUser()

  if (authError || !user) {
    return new Response(
      JSON.stringify({ error: '유효하지 않은 토큰' }),
      { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 401 }
    )
  }

  try {
    const { messages, model = 'gpt-4o-mini' } = await req.json()

    // OpenAI API 호출 (스트리밍)
    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${OPENAI_API_KEY}`,
      },
      body: JSON.stringify({
        model,
        messages,
        stream: true,  // 스트리밍 활성화
      }),
    })

    if (!response.ok) {
      throw new Error(`OpenAI API 오류: ${response.statusText}`)
    }

    // 스트리밍 응답 그대로 전달 (Server-Sent Events)
    return new Response(response.body, {
      headers: {
        ...corsHeaders,
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
      },
    })
  } catch (error) {
    return new Response(
      JSON.stringify({ error: error.message }),
      { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500 }
    )
  }
})

실전 패턴 4: Database Webhook (DB 이벤트 트리거)

Supabase의 Database Webhooks 기능으로 테이블 변경 시 Edge Function을 자동 호출할 수 있습니다.

// supabase/functions/on-new-user/index.ts
// 새 사용자 가입 시 환영 이메일 발송

interface WebhookPayload {
  type: 'INSERT' | 'UPDATE' | 'DELETE'
  table: string
  record: {
    id: string
    email: string
    created_at: string
  }
  old_record: null
}

Deno.serve(async (req: Request) => {
  const payload: WebhookPayload = await req.json()

  if (payload.type !== 'INSERT') {
    return new Response('OK', { status: 200 })
  }

  const { id, email } = payload.record

  // 환영 이메일 발송
  await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${Deno.env.get('RESEND_API_KEY')}`,
    },
    body: JSON.stringify({
      from: 'welcome@yourdomain.com',
      to: email,
      subject: '가입을 환영합니다! 🎉',
      html: `
        <h1>환영합니다!</h1>
        <p>회원가입을 완료해주셔서 감사합니다.</p>
        <p>사용자 ID: ${id}</p>
      `,
    }),
  })

  return new Response(JSON.stringify({ success: true }), {
    headers: { 'Content-Type': 'application/json' },
    status: 200,
  })
})

대시보드에서 Database Webhooks 설정:

  • Supabase Dashboard → Database → Webhooks → Create Webhook
  • Table: auth.users, Event: INSERT
  • URL: https://[project].supabase.co/functions/v1/on-new-user

Next.js에서 Edge Functions 호출

클라이언트 컴포넌트에서 호출

// hooks/useEdgeFunction.ts
'use client'

import { createClient } from '@/lib/supabase/client'

export function useEdgeFunction() {
  const supabase = createClient()

  async function invoke<T>(
    functionName: string,
    body?: Record<string, unknown>
  ): Promise<T> {
    const { data, error } = await supabase.functions.invoke<T>(functionName, {
      body,
    })

    if (error) throw new Error(error.message)
    return data as T
  }

  return { invoke }
}
// 사용 예시
'use client'

import { useEdgeFunction } from '@/hooks/useEdgeFunction'

export default function SendEmailButton() {
  const { invoke } = useEdgeFunction()

  const handleSend = async () => {
    try {
      const result = await invoke<{ success: boolean }>('send-email', {
        to: 'user@example.com',
        subject: '테스트 이메일',
        html: '<p>안녕하세요!</p>',
      })
      console.log('이메일 발송 성공:', result)
    } catch (error) {
      console.error('오류:', error)
    }
  }

  return <button onClick={handleSend}>이메일 발송</button>
}

Server Action에서 호출

// app/actions/ai.ts
'use server'

import { createClient } from '@/lib/supabase/server'

export async function callAI(messages: { role: string; content: string }[]) {
  const supabase = await createClient()

  const { data, error } = await supabase.functions.invoke('ai-chat', {
    body: { messages },
  })

  if (error) throw new Error(error.message)
  return data
}

fetch로 직접 호출 (인증 없는 공개 함수)

// 외부에서 직접 호출 (no-verify-jwt 함수)
const response = await fetch(
  `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/stripe-webhook`,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    },
    body: JSON.stringify({ event: 'test' }),
  }
)

배포

# 단일 함수 배포
supabase functions deploy hello-world

# JWT 검증 없이 배포 (웹훅 등)
supabase functions deploy stripe-webhook --no-verify-jwt

# 전체 함수 배포
supabase functions deploy

# 배포 시 프로젝트 참조 명시
supabase functions deploy hello-world --project-ref [project-ref]

GitHub Actions 자동 배포

# .github/workflows/deploy-functions.yml
name: Deploy Edge Functions

on:
  push:
    branches: [main]
    paths:
      - 'supabase/functions/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: supabase/setup-cli@v1
        with:
          version: latest

      - name: Deploy functions
        run: supabase functions deploy --project-ref ${{ secrets.SUPABASE_PROJECT_REF }}
        env:
          SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}

로그 확인 및 디버깅

# 실시간 로그 스트리밍
supabase functions logs hello-world --scroll

# 특정 시간 범위 로그
supabase functions logs hello-world --since 1h

# 대시보드: Supabase Dashboard → Edge Functions → [함수명] → Logs

함수 내 로깅:

Deno.serve(async (req: Request) => {
  console.log('요청 수신:', req.method, req.url)
  console.log('헤더:', Object.fromEntries(req.headers))

  try {
    const body = await req.json()
    console.log('요청 바디:', JSON.stringify(body))

    // 처리 로직...

    console.log('처리 완료')
    return new Response(JSON.stringify({ success: true }), { status: 200 })
  } catch (error) {
    console.error('오류 발생:', error.message, error.stack)
    return new Response(JSON.stringify({ error: error.message }), { status: 500 })
  }
})

폴더 구조 권장 패턴

supabase/
├── functions/
│   ├── _shared/           ← 공유 모듈 (배포 제외)
│   │   ├── cors.ts
│   │   ├── supabase.ts
│   │   └── types.ts
│   ├── hello-world/
│   │   └── index.ts
│   ├── send-email/
│   │   └── index.ts
│   ├── stripe-webhook/
│   │   └── index.ts
│   ├── ai-chat/
│   │   └── index.ts
│   └── on-new-user/
│       └── index.ts
├── migrations/
└── .env.local             ← 로컬 환경 변수 (gitignore)

💡 Fat Function 패턴: 관련 기능을 하나의 큰 함수로 묶으면 콜드 스타트를 줄일 수 있습니다. 예를 들어 email-functions라는 하나의 함수에서 이메일 관련 기능을 모두 처리하는 방식입니다.


자주 하는 실수

1. CORS 헤더 누락

// ❌ OPTIONS 처리 없이 배포 → 브라우저에서 CORS 오류
Deno.serve(async (req) => {
  return new Response(JSON.stringify({ ok: true }))
})

// ✅ OPTIONS preflight 반드시 처리
Deno.serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }
  return new Response(JSON.stringify({ ok: true }), {
    headers: { ...corsHeaders, 'Content-Type': 'application/json' }
  })
})

2. 웹훅 함수에 JWT 검증 유지

# ❌ Stripe 웹훅에 JWT 검증 → 403 오류
supabase functions deploy stripe-webhook

# ✅ JWT 검증 비활성화 + Stripe 서명으로 직접 검증
supabase functions deploy stripe-webhook --no-verify-jwt

3. Service Role Key를 클라이언트에 노출

// ❌ 클라이언트에서 service_role 키 사용
const supabase = createClient(url, SERVICE_ROLE_KEY) // 절대 금지!

// ✅ Edge Function 내에서만 service_role 사용
// Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') → 서버 사이드 전용

4. 큰 payload를 req.json()으로 파싱

// 큰 파일 처리 시 스트림 사용
const body = await req.arrayBuffer()  // 바이너리
const text = await req.text()         // 텍스트 (Stripe 서명 검증 등)
const form = await req.formData()     // 폼 데이터

마치며

Supabase Edge Functions는 Next.js App Router와 조합했을 때 강력한 시너지를 냅니다. 민감한 API 키는 Edge Function에 숨기고, Stripe 같은 외부 웹훅은 서명 검증 후 안전하게 처리하며, OpenAI 같은 AI API는 인증된 사용자만 호출할 수 있도록 프록시 역할을 합니다.

다음 편에서는 AI & 벡터 검색을 다룹니다. pgvector 확장으로 PostgreSQL에서 직접 유사도 검색을 구현하고, Supabase의 Vector Buckets와 OpenAI Embeddings를 연동해 RAG(검색 증강 생성) 파이프라인을 구축하는 방법을 살펴봅니다.




댓글 남기기