콘텐츠로 이동

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 구현체
Terminal window
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 '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) => {...}) 형태도 여전히 동작한다.

Terminal window
# 로컬 실행 (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 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.invokeHTTP 에러도 error로 돌려준다. error.context에 응답 객체가 들어 있으니 디버깅할 때 여기를 본다.

Terminal window
# .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_KEY 구: SUPABASE_ANON_KEY
SUPABASE_SECRET_KEY 구: SUPABASE_SERVICE_ROLE_KEY
SUPABASE_DB_URL 직접 연결 문자열

두 가지 방식이 있고, 기본은 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가 판정하게 한다. 권한 로직을 함수에 복제하지 않아도 되는 게 장점이다.

기본적으로 게이트웨이가 Authorization 헤더의 JWT를 검증한다. 웹훅처럼 외부에서 JWT 없이 호출해야 하는 함수는 이 검증을 꺼야 한다.

supabase/config.toml
[functions.stripe-webhook]
verify_jwt = false
Terminal window
# 또는 배포 시 플래그로
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 처리가 반드시 필요하다.

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 변경을 계기로 함수를 자동 실행한다.

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장)
쓰기 좋은 경우 Vercel 쪽이 나은 경우
DB 이벤트에 반응하는 로직 (웹훅 수신자) 프론트엔드와 강하게 결합된 로직
Auth Hook의 HTTP 구현 렌더링과 함께 실행되는 데이터 조회
여러 클라이언트(웹·모바일·서버)가 공유하는 로직 Node 전용 패키지가 필요한 작업
프론트엔드 배포와 무관하게 살아야 하는 로직 프레임워크 기능(스트리밍, 캐시 태그)을 쓰는 경우
Supabase 시크릿만 필요한 작업 팀의 배포 파이프라인이 이미 Vercel 중심일 때

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

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