10. Edge Functions
함수 URL은 공개되어 있다. “Edge Function이니까 안전하다”는 착각이 가장 위험하다
Edge Functions란
섹션 제목: “Edge Functions란”Deno 런타임 기반의 서버리스 함수다. TypeScript를 그대로 실행하고,
전 세계 엣지 로케이션에 배포되어 사용자와 가까운 곳에서 실행된다.
https://<ref>.supabase.co/functions/v1/<함수명>으로 호출된다.
핵심은 secret key를 안전하게 쓸 수 있는 자리라는 것이다. 브라우저에 둘 수 없는 것들이 여기 온다 —
- 외부 API 호출 (결제, 이메일, LLM) — API 키가 필요한 것
- 웹훅 수신 (Stripe, GitHub 등) — 서명 검증 후 DB 반영
- 관리자 작업 — RLS를 우회해야 하는 일괄 처리
- Auth Hook의 HTTP 구현체
첫 함수
섹션 제목: “첫 함수”supabase functions new hello-world디렉터리supabase/functions/
디렉터리hello-world/
- index.ts
디렉터리_shared/ 여러 함수가 공유하는 코드 (관례)
- cors.ts
- deno.json import map
- .env
.gitignore에 넣을 것
import { withSupabase } from 'jsr:@supabase/functions-js'
export default { fetch: withSupabase({ auth: ['publishable', 'secret'] }, async (req, ctx) => { const { name } = await req.json() return Response.json({ message: `안녕하세요 ${name}님!` }) }),}withSupabase는 API 키 검증과 Supabase 클라이언트 생성을 대신 해준다.
기존 예제에서 흔히 보이는 Deno.serve(async (req) => {...}) 형태도 여전히 동작한다.
로컬 실행과 배포
섹션 제목: “로컬 실행과 배포”# 로컬 실행 (Docker 필요, 핫 리로드 지원)supabase functions serve hello-world
# 호출해 보기curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/hello-world' \ --header 'Authorization: Bearer <로컬 anon key>' \ --header 'Content-Type: application/json' \ --data '{"name":"앨리스"}'
# 배포supabase functions deploy hello-world
# 전체 배포 / 목록 / 삭제supabase functions deploysupabase functions listsupabase functions delete hello-world로그는 대시보드 Edge Functions → 함수 선택 → Logs에서 본다.
console.log가 여기로 나오고, 로컬에서는 터미널에 바로 찍힌다.
호출하기
섹션 제목: “호출하기”// supabase-js — 현재 사용자의 JWT가 자동으로 실린다const { data, error } = await supabase.functions.invoke('hello-world', { body: { name: 'JavaScript' },})
// 헤더 추가 / 메서드 지정await supabase.functions.invoke('report', { method: 'POST', headers: { 'x-trace-id': traceId }, body: { month: '2026-08' },})functions.invoke는 HTTP 에러도 error로 돌려준다.
error.context에 응답 객체가 들어 있으니 디버깅할 때 여기를 본다.
시크릿 관리
섹션 제목: “시크릿 관리”# .env 파일로 한 번에 등록supabase secrets set --env-file ./supabase/functions/.env
# 개별 등록supabase secrets set OPENAI_API_KEY=sk-xxxx STRIPE_SECRET=sk_live_xxxx
# 목록 확인 (값은 해시로만 보인다)supabase secrets listconst apiKey = Deno.env.get('OPENAI_API_KEY')기본으로 주입되는 값들 —
| 변수 | 설명 |
|---|---|
SUPABASE_URL |
프로젝트 URL |
SUPABASE_PUBLISHABLE_KEY |
구: SUPABASE_ANON_KEY |
SUPABASE_SECRET_KEY |
구: SUPABASE_SERVICE_ROLE_KEY |
SUPABASE_DB_URL |
직접 연결 문자열 |
함수 안에서 DB 접근
섹션 제목: “함수 안에서 DB 접근”두 가지 방식이 있고, 기본은 A다.
import { createClient } from 'jsr:@supabase/supabase-js@2'
const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_PUBLISHABLE_KEY')!, { global: { headers: { Authorization: req.headers.get('Authorization')! } } },)const { data: { user } } = await supabase.auth.getUser()// 이 클라이언트의 쿼리에는 RLS가 적용된다호출자의 JWT를 그대로 넘겨 DB가 판정하게 한다. 권한 로직을 함수에 복제하지 않아도 되는 게 장점이다.
const admin = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SECRET_KEY')!, { auth: { persistSession: false } },)await admin.from('audit_logs').insert({ action: 'export', user_id: user.id })감사 로그 기록, 관리자 일괄 처리처럼 정말 우회해야 하는 작업에만 쓴다.
JWT 검증과 인가
섹션 제목: “JWT 검증과 인가”기본적으로 게이트웨이가 Authorization 헤더의 JWT를 검증한다.
웹훅처럼 외부에서 JWT 없이 호출해야 하는 함수는 이 검증을 꺼야 한다.
[functions.stripe-webhook]verify_jwt = false# 또는 배포 시 플래그로supabase functions deploy stripe-webhook --no-verify-jwt의존성과 CORS
섹션 제목: “의존성과 CORS”// npm 패키지 — npm: 접두사import Stripe from 'npm:stripe@17'import { Resend } from 'npm:resend'
// JSR (Deno의 표준 레지스트리)import { createClient } from 'jsr:@supabase/supabase-js@2'
// Deno 표준 라이브러리import { encodeBase64 } from 'jsr:@std/encoding/base64'// supabase/functions/deno.json — import map으로 정리하면 관리가 편하다{ "imports": { "@supabase/supabase-js": "jsr:@supabase/supabase-js@2", "stripe": "npm:stripe@17" }}- Node 전용 API에 의존하는 패키지는 동작하지 않을 수 있다 (
fs,child_process등) - 버전을 반드시 고정한다. 고정하지 않으면 배포마다 다른 버전이 올라갈 수 있다
- 번들 크기가 크면 콜드 스타트가 느려진다
브라우저에서 직접 호출한다면 CORS 처리가 반드시 필요하다.
export const corsHeaders = { 'Access-Control-Allow-Origin': '*', // 프로덕션에서는 실제 도메인으로 좁힐 것 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Methods': 'POST, OPTIONS',}Deno.serve(async (req) => { // preflight 요청 처리 — 빠뜨리면 브라우저에서만 호출이 실패한다 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
return new Response(JSON.stringify({ message: 'hello' }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, })})Database Webhooks와 연동
섹션 제목: “Database Webhooks와 연동”DB 변경을 계기로 함수를 자동 실행한다.
flowchart LR
A["orders 테이블<br/>INSERT"] --> T["Database Webhook<br/>pg_net 기반 트리거"]
T --> F["Edge Function<br/>send-order-email"]
F --> E["외부 서비스<br/>Resend · Slack"]
classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class F key
class A,T,E mute
- 대시보드 Database → Webhooks에서 테이블·이벤트·대상 URL을 설정한다
- 내부적으로는
pg_net확장을 쓰는 트리거다 — 비동기 HTTP 호출이라 DB 트랜잭션을 막지 않는다 - 재시도 정책이 제한적이므로 중요한 작업은 큐(pgmq)를 거치게 설계한다 (11장)
언제 Edge Function을 쓰나
섹션 제목: “언제 Edge Function을 쓰나”| 쓰기 좋은 경우 | Vercel 쪽이 나은 경우 |
|---|---|
| DB 이벤트에 반응하는 로직 (웹훅 수신자) | 프론트엔드와 강하게 결합된 로직 |
| Auth Hook의 HTTP 구현 | 렌더링과 함께 실행되는 데이터 조회 |
| 여러 클라이언트(웹·모바일·서버)가 공유하는 로직 | Node 전용 패키지가 필요한 작업 |
| 프론트엔드 배포와 무관하게 살아야 하는 로직 | 프레임워크 기능(스트리밍, 캐시 태그)을 쓰는 경우 |
| Supabase 시크릿만 필요한 작업 | 팀의 배포 파이프라인이 이미 Vercel 중심일 때 |
12장에서 이 판단 기준을 훨씬 자세히 다룬다. 지금은 **“둘 다 서버 코드를 둘 수 있다”**만 기억하면 된다.
제약과 함정
섹션 제목: “제약과 함정”- 콜드 스타트 — 오래 호출이 없으면 첫 요청이 느리다. 무거운 import를 줄인다
- 실행 시간 제한 — 장시간 작업에는 부적합. 큐에 넣고 백그라운드로 넘긴다
- JWT 검증을 끄고 자체 검증을 잊음 — 가장 흔한 보안 사고
- secret key를 무비판적으로 사용 — 함수 URL은 공개되어 있다는 전제로 설계한다
- CORS preflight 미처리 — 브라우저에서만 실패한다
- Node 전용 패키지 사용 — 로컬에서는 되고 배포 후 깨지는 경우가 있다
- 로컬과 배포 환경의 시크릿 불일치 —
secrets set을 잊으면 배포본만 실패한다 - 함수가 비멱등 — 웹훅 재시도 시 중복 처리된다
10장 요약
섹션 제목: “10장 요약”- Edge Functions = Deno 서버리스. 시크릿이 필요한 코드의 자리
functions new→functions serve(로컬) →functions deploy- DB 접근은 호출자 권한(RLS 적용)이 기본, secret key는 검증 후에만
verify_jwt = false로 열었다면 반드시 자체 검증을 넣는다- 웹훅 연동 시 함수는 멱등하게 만든다
- Vercel Route Handler와 역할이 겹친다 → 12장에서 정리