시리즈 목차 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.js | Deno |
|---|---|---|
| 모듈 | require() / CommonJS | ESM (import) 기본 |
| TypeScript | 별도 설정 필요 | 네이티브 지원 |
| 패키지 | npm + node_modules | URL 직접 import 또는 npm: prefix |
| 보안 | 기본 전체 권한 | 기본 샌드박스 (명시적 권한 필요) |
| 환경 변수 | process.env | Deno.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(검색 증강 생성) 파이프라인을 구축하는 방법을 살펴봅니다.