콘텐츠로 이동
Study NoteSupabase

10. Edge Functions

함수 URL은 공개되어 있다. “Edge Function이니까 안전하다”는 착각이 가장 위험하다

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에 넣을 것
supabase/functions/hello-world/index.ts
import { withSupabase } from 'npm:@supabase/server'
export default {
fetch: withSupabase({ auth: 'user' }, async (req, ctx) => {
const { name } = await req.json()
return Response.json({
message: `안녕하세요 ${name}님!`,
userId: ctx.userClaims?.id,
})
}),
}

withSupabase는 자격 증명 검증과 Supabase 클라이언트 생성을 대신 해준다. auth: 'user'는 유효한 사용자 JWT만 받고, ctx.supabase에는 그 사용자의 RLS가 적용된다. 기존 예제에서 흔히 보이는 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 'apikey: <로컬 anon key>' \
--header 'Authorization: Bearer <로그인 사용자의 access token>' \
--header 'Content-Type: application/json' \
--data '{"name":"앨리스"}'
# 배포
supabase functions deploy hello-world
# 전체 배포 / 목록 / 삭제
supabase functions deploy
supabase functions list
supabase 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 list
const apiKey = Deno.env.get('OPENAI_API_KEY')

기본으로 주입되는 값들 —

변수설명
SUPABASE_URL프로젝트 URL
SUPABASE_PUBLISHABLE_KEYS구: SUPABASE_ANON_KEY. 키 회전 중엔 여러 개일 수 있어 복수형
SUPABASE_SECRET_KEYS구: SUPABASE_SERVICE_ROLE_KEY
SUPABASE_JWKSJWT 서명 검증용 공개키 세트
SUPABASE_DB_URL직접 연결 문자열

withSupabase가 만들어 준 두 클라이언트를 구분한다. 기본은 A다.

export default {
fetch: withSupabase({ auth: 'user' }, async (_req, ctx) => {
const { data, error } = await ctx.supabase.from('documents').select()
// ctx.supabase에는 호출자의 JWT가 전달되어 RLS가 적용된다
return Response.json({ data, error })
}),
}

호출자의 JWT를 그대로 넘겨 DB가 판정하게 한다. 권한 로직을 함수에 복제하지 않아도 되는 게 장점이다.

플랫폼의 verify_jwt는 함수 코드가 실행되기 전에 Authorization 헤더의 사용자 JWT를 검증한다. 사용자 전용 함수(auth: 'user')에는 켜 두고, secret/publishable API 키나 외부 웹훅처럼 사용자 JWT 없이 호출하는 함수는 끈 뒤 withSupabase 또는 제공자 서명으로 검증한다.

supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false
터미널 창
# 또는 배포 시 플래그로
supabase functions deploy stripe-webhook --no-verify-jwt
// 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(Cross-Origin Resource Sharing) 처리가 반드시 필요하다 — 다른 출처(도메인)로의 요청을 브라우저가 기본 차단하기 때문에, 함수가 허용 헤더로 풀어줘야 한다. 동작 원리는 web 덱 11장에서 깊게 다룬다.

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, 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' },
})
})

DB 변경을 계기로 함수를 자동 실행한다.

orders 테이블 INSERT가 Database Webhook을 거쳐 Edge Function을 부르고 외부 서비스로 나가는 흐름
  • 대시보드 Database → Webhooks에서 테이블·이벤트·대상 URL을 설정한다
  • 내부적으로는 pg_net 확장을 쓰는 트리거다 — 비동기 HTTP 호출이라 DB 트랜잭션을 막지 않는다
  • 재시도 정책이 제한적이므로 중요한 작업은 큐(pgmq)를 거치게 설계한다 (11장)
쓰기 좋은 경우Vercel 쪽이 나은 경우
DB 이벤트에 반응하는 로직 (웹훅 수신자)프론트엔드와 강하게 결합된 로직
Auth Hook의 HTTP 구현렌더링과 함께 실행되는 데이터 조회
여러 클라이언트(웹·모바일·서버)가 공유하는 로직Node 전용 패키지가 필요한 작업
프론트엔드 배포와 무관하게 살아야 하는 로직프레임워크 기능(스트리밍, 캐시 태그)을 쓰는 경우
Supabase 시크릿만 필요한 작업팀의 배포 파이프라인이 이미 Vercel 중심일 때

12장에서 이 판단 기준을 훨씬 자세히 다룬다. 지금은 “둘 다 서버 코드를 둘 수 있다”만 기억하면 된다.

  1. 콜드 스타트 — 오래 호출이 없으면 첫 요청이 느리다. 무거운 import를 줄인다
  2. 자원 제한 — 메모리 256MB, 요청당 CPU 시간 2초, 응답 대기 150초가 상한이다. 워커의 wall-clock 상한은 Free 150초, 유료 400초다. 장시간·CPU 집약 작업은 외부 워커로 넘긴다
  3. JWT 검증을 끄고 자체 검증을 잊음 — 가장 흔한 보안 사고
  4. secret key를 무비판적으로 사용 — 함수 URL은 공개되어 있다는 전제로 설계한다
  5. CORS preflight 미처리 — 브라우저에서만 실패한다
  6. Node 전용 패키지 사용 — 로컬에서는 되고 배포 후 깨지는 경우가 있다
  7. 로컬과 배포 환경의 시크릿 불일치 — secrets set을 잊으면 배포본만 실패한다
  8. 함수가 비멱등 — 웹훅 재시도 시 중복 처리된다
  • Edge Functions = Deno 서버리스. 시크릿이 필요한 코드의 자리
  • functions new → functions serve(로컬) → functions deploy
  • DB 접근은 호출자 권한(RLS 적용)이 기본, secret key는 검증 후에만
  • verify_jwt = false로 열었다면 반드시 자체 검증을 넣는다
  • 웹훅 연동 시 함수는 멱등하게 만든다
  • Vercel Route Handler와 역할이 겹친다 → 12장에서 정리