Supabase 시작하기: 프로젝트 생성부터 RLS·Edge Function 실전 설정

목차

Postgres 기반 백엔드 하나가 자꾸 느려져서 원인을 추적하다가 Supabase로 마이그레이션을 검토하던 중이었다. supabase 시작하기 문서를 한 번이라도 훑어본 사람은 알겠지만, "5분 만에 백엔드 구축"이라는 홍보 문구와 실제 운영 환경에서 마주치는 문제 사이에는 거리가 있다. Auth, Storage, Realtime, Edge Functions가 한 데 묶여 있다는 건 분명히 매력적이지만, 그 매력은 첫 프로젝트가 production traffic을 받기 시작할 때부터 다른 무게로 다가온다.

그런데, 이 글은 단순한 튜토리얼이 아니다. 프로젝트 생성, RLS 설정, Edge Function 배포까지의 표준 절차를 다루되, 각 단계에서 공식 문서가 충분히 설명하지 않는 지점 — 특히 RLS 정책의 실행 비용, Edge Function의 콜드 스타트, Session Pooler와 Transaction Pooler의 차이 — 을 같이 짚는다. 작성 시점은 2026년 6월이고, Supabase CLI는 v1.219.x 기준이다.

왜 또 BaaS인가 — 문제 정의부터

Firebase가 2014년 인수된 이후 BaaS(Backend as a Service) 시장은 한동안 정체였다. Firebase는 NoSQL 기반이라 관계형 쿼리에 약했고, AWS Amplify는 GraphQL 스키마 정의의 학습 곡선이 가팔랐다. 그 사이에 등장한 Supabase는 "Postgres를 그대로 백엔드로 노출시킨다"는 접근으로 차별점을 만들었다(출처: Supabase 공식 블로그, 2020-07 첫 공개 글).

실제로, 문제는 "Postgres를 그대로 노출"이라는 말이 가진 함의다. 일반적인 백엔드 API는 요청을 받아 권한을 검사한 뒤 DB에 쿼리를 던진다. Supabase는 이 권한 검사를 DB 레벨로 옮긴다. 즉 클라이언트(브라우저, 모바일 앱)가 PostgREST를 통해 Postgres와 직접 통신하고, 권한은 Row Level Security 정책으로 막는다. 이 구조는 백엔드 코드를 줄이지만 DB가 짊어지는 일은 더 늘어난다.

또한, 기존 백엔드(Express + Postgres) 구조에서 권한 검사는 보통 미들웨어 한 줄이다. Supabase에서는 같은 검사가 RLS 정책의 SQL 표현식이 된다. 표현식이 복잡해질수록 인덱스가 안 타는 경우가 생긴다. 이게 이 글의 출발점이다.

프로젝트 생성과 초기 설정 — CLI 기준

실제로, 대시보드에서 클릭만으로 프로젝트를 만들 수 있지만, 운영 단계로 들어가면 CLI 기반이 거의 필수다. 마이그레이션 파일 버전 관리, 환경 분리(local/staging/prod), CI/CD 파이프라인 연결이 전부 CLI를 전제로 한다.

CLI 설치와 로컬 스택 기동

# macOS 기준. Homebrew로 설치한다
brew install supabase/tap/supabase

# 프로젝트 디렉토리에서 초기화
supabase init

# 로컬 스택 기동 (Postgres + PostgREST + GoTrue + Storage + Edge Runtime)
supabase start

supabase start는 Docker 컨테이너 7~8개를 띄운다. M2 Pro 기준 첫 기동은 90초 안팎, 캐시된 이후엔 25초 정도 걸렸다. 메모리는 약 1.8GB를 잡아먹는다. 로컬에서 작업할 때 Docker Desktop 메모리 한도를 4GB 이하로 두면 다른 컨테이너와 충돌이 잦으니 6GB 정도로 올려두는 게 안전하다.

원격 프로젝트 링크와 마이그레이션

# 원격 프로젝트와 연결
supabase link --project-ref <project-ref>

# 새 마이그레이션 파일 생성
supabase migration new add_posts_table

# 로컬 변경사항을 마이그레이션으로 자동 추출
supabase db diff -f add_posts_table

# 원격에 적용
supabase db push

supabase db diff는 로컬 DB와 원격 schema를 비교해서 SQL 파일을 만들어준다. 처음 써봤을 때 의외였던 건, 이 명령이 잡아내지 못하는 변경이 있다는 점이다. RLS 정책 자체는 잘 잡아내는데, 정책에 사용한 헬퍼 함수의 본문 변경은 놓치는 경우가 있더라. 이슈가 올라와 있다(GitHub supabase/cli #2104, 2024-11). 우회법은 단순하다. 함수는 항상 CREATE OR REPLACE FUNCTION으로 명시적 마이그레이션 파일을 만들어 둔다.

Row Level Security — 정책은 SQL이고, SQL은 비용이다

한편, Supabase에서 RLS는 선택이 아니다. anon key, authenticated key 모두 공개 자산이라 RLS 없이 테이블을 노출하면 그대로 뚫린다. 공식 문서도 첫 페이지에서 강조한다(출처: Supabase Docs, Auth > Row Level Security).

기본 정책 패턴

-- posts 테이블에 RLS 활성화
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- 본인이 쓴 글만 SELECT 가능
CREATE POLICY "users_can_read_own_posts"
ON posts FOR SELECT
TO authenticated
USING (auth.uid() = author_id);

-- 본인 글만 UPDATE 가능
CREATE POLICY "users_can_update_own_posts"
ON posts FOR UPDATE
TO authenticated
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);

반면, 여기까지는 어디서나 볼 수 있는 예제다. 문제는 그 다음이다. auth.uid()는 JWT에서 user id를 꺼내는 함수인데, 정책에 들어가면 모든 행마다 호출되는 것처럼 보일 수 있다. Supabase가 2023년 발표한 RLS 성능 가이드에서 이 함수를 SELECT auth.uid()로 감싸라고 권고한 이유가 그것이다(출처: Supabase Blog "RLS Performance and Best Practices", 2023-11).

인덱스가 안 타는 케이스

따라서, 직관에 반하는 케이스가 하나 있다. 아래 정책을 보자.

-- 안 좋은 예
CREATE POLICY "team_members_can_read"
ON posts FOR SELECT
TO authenticated
USING (
  team_id IN (
    SELECT team_id FROM team_members WHERE user_id = auth.uid()
  )
);

이 정책은 posts 테이블에 행이 많아질수록 급격히 느려지는 경향이 있다. EXPLAIN을 떠보면 서브쿼리가 매번 재실행되는 형태로 풀린다. 같은 의도를 SECURITY DEFINER 함수로 분리하면 플랜이 달라진다.

-- 권장 패턴
CREATE OR REPLACE FUNCTION get_my_team_ids()
RETURNS SETOF uuid
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
  SELECT team_id FROM team_members WHERE user_id = auth.uid()
$$;

CREATE POLICY "team_members_can_read_v2"
ON posts FOR SELECT
TO authenticated
USING (team_id IN (SELECT get_my_team_ids()));

STABLE 키워드가 핵심이다. Postgres는 STABLE 함수를 같은 트랜잭션 안에서 캐시 가능한 대상으로 본다. 행 100만 건 테이블에서 체감 차이가 분명히 났다. 정확한 벤치마크는 환경마다 다르겠지만, 인덱스만 잘 걸려 있으면 수십 배 차이가 나도 이상하지 않다.

정책 디버깅

특히, RLS가 켜진 상태에서 쿼리가 빈 결과를 주면 처음엔 당황한다. SQL 자체는 맞는데 결과가 없다. 그럴 땐 임시로 SET LOCAL ROLE postgres로 정책을 우회하고 같은 쿼리를 다시 던져본다. 둘의 결과가 다르면 정책이 원인이다. Supabase Dashboard의 SQL Editor에 "Run as authenticated" 토글이 있는데(2025년 초 추가, Release Notes v1.0.0), 이걸 켜면 anon/authenticated 역할로 즉시 테스트할 수 있다.

Edge Function — 콜드 스타트라는 현실

Supabase Edge Functions는 Deno 런타임 위에서 돈다. AWS Lambda와 달리 글로벌 엣지에 배포되어 응답 지연이 작다는 게 마케팅 포인트지만, 실제로 운영해보면 콜드 스타트가 무시할 수 없다.

// supabase/functions/hello/index.ts
import { serve } from "https://deno.land/std@0.224.0/http/server.ts";

serve(async (req) => {
  const { name } = await req.json();
  return new Response(
    JSON.stringify({ message: `안녕, ${name}` }),
    { headers: { "Content-Type": "application/json" } }
  );
});

특히, 이 함수를 배포한 뒤 5분간 호출이 없다가 다시 호출하면, 응답 시간이 평소의 5~10배로 튄다. Edge Runtime이 인스턴스를 해제하고 다시 띄우는 비용이다. 공식 문서는 콜드 스타트 시간을 "수백 밀리초"라고만 적어두는데(출처: Supabase Docs, Edge Functions > Limits), 외부 패키지를 많이 임포트할수록 이 값이 비선형적으로 늘어난다.

콜드 스타트를 줄이는 실전 팁

  • import 문은 최상단에 모은다. 동적 import()는 첫 호출 때 추가 비용이 붙는다.
  • npm: specifier보다 가능한 한 deno.land 표준 라이브러리를 쓴다. npm 패키지는 변환 레이어를 거쳐 더 무겁다.
  • 외부 서비스 클라이언트(예: OpenAI SDK)는 함수 본문 안에서 new하지 말고 모듈 스코프에서 한 번만 생성한다.

진짜 시간 민감한 경로 — 결제 webhook, 검색 자동완성 같은 — 에는 Edge Function 대신 PostgREST의 rpc 함수를 쓰는 게 차라리 빠른 경우가 많다. Postgres 함수는 콜드 스타트가 없다.

로컬에서 디버깅

# 로컬 실행
supabase functions serve hello --env-file ./supabase/.env.local

# 호출
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/hello' \
  --header 'Authorization: Bearer <anon-key>' \
  --header 'Content-Type: application/json' \
  --data '{"name":"세계"}'

--env-file을 빼먹으면 Edge Function 안에서 Deno.env.get()이 undefined를 돌려준다. 이 부분은 공식 문서에도 적혀 있지만, 처음엔 환경 변수가 왜 안 잡히는지 한참 헤맨 시행착오 구간이었다.

Connection Pooling — 의외의 진짜 원인

Supabase로 옮기고 나서 가장 의외였던 부분이 여기다. 기존 백엔드에서는 잘 돌던 동시 접속 처리가, Supabase에서는 같은 부하에서 remaining connection slots are reserved 에러를 뱉었다. 원인 추적에 한참이 걸렸다.

Supabase는 두 종류의 connection pooler를 제공한다.

항목 Session Pooler (포트 5432) Transaction Pooler (포트 6543)
모드 세션 단위 트랜잭션 단위
Prepared statements 지원 미지원
LISTEN/NOTIFY 지원 미지원
동시 연결 한도 인스턴스 크기에 비례 훨씬 큼
권장 용도 장기 연결, 마이그레이션 서버리스, 단발성 쿼리

특히, 서버리스 함수(Vercel Functions, AWS Lambda)에서 Supabase에 접속할 때는 거의 항상 Transaction Pooler를 써야 한다. Session Pooler를 쓰면 함수 인스턴스가 끝나도 연결이 잠시 살아 있다가 끊어지는 흐름이라, 동시 호출이 몰리면 한도를 금방 넘는다(출처: Supabase Docs, Database > Connecting to your database).

그래서, prisma, drizzle 같은 ORM을 쓸 때 한 가지 더 주의할 점이 있다. Transaction Pooler는 prepared statements를 지원하지 않으므로 ORM 연결 문자열에 pgbouncer=true 또는 prepareStatement=false 옵션을 붙여야 한다. 이걸 빼먹으면 prepared statement "s1" already exists 에러가 산발적으로 뜬다. 안 떠도 어느 순간 뜬다.

Auth와 RLS의 연결 — 자주 놓치는 흐름

물론, Supabase Auth는 GoTrue 기반이고, 로그인하면 JWT를 발급한다. 이 JWT가 Authorization 헤더로 PostgREST에 전달되면, Postgres는 JWT의 sub 클레임을 auth.uid()로 노출한다. 즉 Auth와 DB는 JWT 하나로 묶여 있다.

여기서 자주 놓치는 게 있다. anon key로도 PostgREST 호출은 가능하다. 이때 auth.uid()는 NULL을 돌려준다. 정책을 TO authenticated로만 적어두면 anon 호출은 자동으로 막힌다. 반대로 TO public을 쓰면 anon도 통과시킨다. 이 둘을 헷갈리면 공개 데이터가 막히거나 비공개 데이터가 새거나 둘 중 하나가 된다.

한편, 서비스 키(service_role)는 RLS를 완전히 무시한다. 그래서 service_role 키는 절대로 클라이언트에 노출하면 안 된다. 서버 사이드 코드(Edge Function, 백엔드 서버)에서만 환경 변수로 주입해서 쓴다. 이건 공식 문서가 강하게 경고하는 부분이기도 하다(출처: Supabase Docs, API Keys).

한계와 남은 질문

여기까지 정리하고 보면 Supabase가 모든 경우에 정답인 건 아니다.

물론, 첫째, 대량 쓰기(write-heavy) 워크로드에는 PostgREST 경로가 병목이 될 수 있다. PostgREST는 HTTP→SQL 변환 레이어를 한 번 더 거치고, 그 자체도 connection pool을 잡는다. 초당 수천 건의 쓰기를 다루는 서비스라면 직접 Postgres 드라이버로 붙는 백엔드를 별도로 두는 게 낫다는 평가가 많다.

둘째, RLS의 디버깅 도구가 여전히 미흡하다. EXPLAIN ANALYZE를 RLS 적용 상태로 보려면 별도 트릭이 필요하고, 정책 간 우선순위가 직관적이지 않다. 정책이 4~5개 쌓이기 시작하면 어느 정책이 막고 있는지 추적이 쉽지 않다.

물론, 셋째, Edge Function의 관찰 가능성(observability)이 약하다. 로그는 대시보드에서 볼 수 있지만 구조화된 검색이 어렵고, APM 연동도 자유롭지 않다. 운영 단계라면 Sentry나 Logflare 같은 외부 도구를 직접 붙여야 한다.

마지막으로, 이번 글에서 다루지 않은 영역 — Realtime의 broadcast/presence 채널, Storage의 이미지 변환 파이프라인, Vector embeddings을 위한 pgvector 활용 — 은 각각이 따로 한 편의 글이 될 만한 분량이다. 특히 pgvector는 RLS와 함께 쓸 때 인덱스(HNSW, IVFFlat) 선택이 정책 비용에 직접 영향을 주는 영역이라 별도 검증이 필요하다.

지금 바로 시작한다면 세 가지를 권한다. (1) supabase init으로 로컬 스택부터 띄우고 마이그레이션 파일을 git에 올린다. (2) 모든 테이블에 RLS를 켜고 auth.uid()SELECT auth.uid()로 감싸는 패턴을 기본값으로 잡는다. (3) 서버리스에서 붙을 거라면 처음부터 Transaction Pooler(포트 6543)에 pgbouncer=true로 연결한다.

관련 글