Claude Code로 Supabase 연동하기: Auth, RLS, Storage, Edge Functions 실전 가이드
Claude Code로 Supabase를 안전하게 구현하는 방법. Auth, RLS, Storage, Edge Functions, migration, 테스트까지 정리합니다.
Supabase는 빠르게 데모를 만들기 좋지만, 권한 설계를 대충 넘기면 바로 위험해집니다. Claude Code에 “로그인과 데이터베이스를 붙여줘”라고만 요청하면 화면은 동작해도 Row Level Security, Storage policy, migration, 서버 세션 처리가 빠질 수 있습니다.
Supabase는 Postgres, Auth, Storage, Edge Functions를 함께 제공하는 BaaS입니다. BaaS는 Backend as a Service의 약자로, 인증과 파일 저장 같은 백엔드 기능을 클라우드 서비스로 사용하는 방식입니다. Supabase의 장점은 빠른 개발뿐 아니라 Postgres와 RLS로 데이터 레이어에서 권한을 강제할 수 있다는 점입니다.
이 글은 Next.js App Router와 TypeScript 기준입니다. Claude Code에 전달할 요구사항 파일, schema/RLS 리뷰, migration과 타입 생성 명령, 복사해서 쓸 수 있는 코드, 테스트 명령, 실패 사례, 리뷰 체크리스트를 다룹니다. 인증 기본기는 인증 구현 가이드, DB 설계는 데이터베이스 설계, 변경 관리는 DB 마이그레이션 자동화도 함께 보세요.
공식 문서 기준 잡기
구현 전에 Supabase Docs, Auth, Row Level Security, Edge Functions, Storage를 기준으로 삼습니다.
새 Next.js 프로젝트는 @supabase/ssr로 cookie-based Auth를 구성하고, 브라우저에 노출되는 키는 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY를 사용하는 흐름이 적절합니다. 기존 anon key 프로젝트도 있지만, 새 구현에서는 publishable key와 명시적인 RLS를 전제로 설명하는 편이 안전합니다.
전체 구조
예시는 프로젝트 노트 기능입니다. 로그인 사용자가 노트를 만들고, private Storage bucket에 첨부 파일을 올리며, Edge Function으로 알림성 처리를 실행합니다.
flowchart LR
User["Browser"] --> Next["Next.js App Router"]
Next --> SSR["@supabase/ssr client"]
SSR --> Auth["Supabase Auth"]
SSR --> DB["Postgres tables"]
SSR --> Storage["Storage bucket"]
Next --> Fn["Edge Function"]
Fn --> DB
DB --> RLS["RLS policies"]
Storage --> StorageRLS["storage.objects policies"]
권한의 최종 경계는 React 컴포넌트가 아니라 Postgres RLS와 Storage policy입니다. UI 체크는 사용자 경험을 돕지만, 보안의 마지막 장치가 되어서는 안 됩니다.
Claude Code 요구사항 파일
먼저 범위를 문서로 고정합니다.
# docs/supabase-notes-requirements.md
## Goal
Build a Supabase-backed project notes feature in Next.js App Router.
## Stack
- Next.js App Router
- TypeScript
- @supabase/supabase-js
- @supabase/ssr
- Supabase Auth, Postgres, Storage, Edge Functions
## Data model
- project_notes table
- Each note belongs to auth.users.id through owner_id
- Public notes are readable by anyone
- Private notes are readable only by the owner
- Owners can insert, update, and delete only their own notes
## Security rules
- Never expose a secret key or service role key in browser code
- Use NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY for browser and SSR clients
- Enable RLS on every public table
- Use explicit TO anon or TO authenticated in every policy
- Storage uploads must be restricted to a user-owned folder
## Claude Code workflow
1. Create SQL migration first.
2. Review RLS policies before writing UI.
3. Generate TypeScript database types.
4. Implement Supabase clients.
5. Implement server actions and upload helper.
6. Add test or manual verification commands.
7. Return a review checklist with file paths.
첫 요청은 “SQL migration만 작성하고 UI는 아직 만들지 말라”로 제한합니다. Masa의 검증 메모에서도 UI부터 만들면 owner_id를 form input에서 받는 코드가 섞이기 쉬웠습니다.
설치와 환경 변수
npm install @supabase/supabase-js @supabase/ssr zod
npm install --save-dev supabase vitest
npx supabase init
npx supabase start
실제 값은 .env.local에만 둡니다.
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxxxxxxxxxxxxxxxxxxx
secret key나 service role key를 NEXT_PUBLIC_에 넣으면 안 됩니다. 관리자 권한이 필요한 처리는 서버 전용 영역에 격리하고, RLS를 우회해야 하는 이유를 리뷰 기록에 남깁니다.
Migration과 RLS
-- supabase/migrations/202606010001_create_project_notes.sql
create table if not exists public.project_notes (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id) on delete cascade,
title text not null check (char_length(title) between 1 and 120),
body text not null default '',
visibility text not null default 'private'
check (visibility in ('private', 'public')),
attachment_path text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists project_notes_owner_created_idx
on public.project_notes (owner_id, created_at desc);
alter table public.project_notes enable row level security;
create policy "Anyone can read public notes"
on public.project_notes
for select
to anon, authenticated
using (
visibility = 'public'
or (select auth.uid()) = owner_id
);
create policy "Owners can insert notes"
on public.project_notes
for insert
to authenticated
with check ((select auth.uid()) = owner_id);
create policy "Owners can update notes"
on public.project_notes
for update
to authenticated
using ((select auth.uid()) = owner_id)
with check ((select auth.uid()) = owner_id);
create policy "Owners can delete notes"
on public.project_notes
for delete
to authenticated
using ((select auth.uid()) = owner_id);
Storage는 사용자 ID를 첫 번째 폴더로 쓰는 규칙으로 제한합니다.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'note-attachments',
'note-attachments',
false,
5242880,
array['image/png', 'image/jpeg', 'application/pdf']
)
on conflict (id) do update
set public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
create policy "Users can upload own note attachments"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
create policy "Users can read own note attachments"
on storage.objects
for select
to authenticated
using (
bucket_id = 'note-attachments'
and (select auth.uid())::text = (storage.foldername(name))[1]
);
npx supabase db reset
npx supabase gen types typescript --local > src/lib/database.types.ts
npm run typecheck
Supabase client 분리
// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/lib/database.types";
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
}
// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/lib/database.types";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
// Server Components cannot set cookies directly.
}
},
},
},
);
}
Auth, CRUD, Storage
// app/login/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
export async function signIn(formData: FormData) {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: error.message };
redirect("/dashboard");
}
// src/features/notes/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { createClient } from "@/lib/supabase/server";
type CreateNoteInput = {
title: string;
body: string;
visibility?: "private" | "public";
attachmentPath?: string | null;
};
export async function createNote(input: CreateNoteInput) {
const supabase = await createClient();
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) throw new Error("Authentication required");
const { data, error } = await supabase
.from("project_notes")
.insert({
owner_id: user.id,
title: input.title,
body: input.body,
visibility: input.visibility ?? "private",
attachment_path: input.attachmentPath ?? null,
})
.select("id,title,visibility")
.single();
if (error) throw error;
revalidatePath("/dashboard");
return data;
}
// src/features/notes/upload-note-attachment.ts
"use client";
import { createClient } from "@/lib/supabase/client";
export async function uploadNoteAttachment(file: File, userId: string) {
const supabase = createClient();
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const path = `${userId}/${crypto.randomUUID()}.${ext}`;
const { error } = await supabase.storage
.from("note-attachments")
.upload(path, file, {
cacheControl: "3600",
upsert: false,
contentType: file.type,
});
if (error) throw error;
return path;
}
Edge Function
npx supabase functions new notify-note-created
// supabase/functions/notify-note-created/index.ts
import { createClient } from "npm:@supabase/supabase-js@2";
Deno.serve(async (req) => {
const authorization = req.headers.get("Authorization");
if (!authorization) {
return Response.json({ error: "Missing authorization" }, { status: 401 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_ANON_KEY")!,
{ global: { headers: { Authorization: authorization } } },
);
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
return Response.json({ ok: true, userId: user.id });
});
npx supabase functions serve notify-note-created --env-file .env.local
npx supabase functions deploy notify-note-created
3가지 사용 사례
| 사용 사례 | Supabase 기능 | Claude Code 작업 | 사람이 볼 지점 |
|---|---|---|---|
| SaaS 팀 노트 | Auth, Postgres, RLS | 테이블, Server Action, 목록 UI | 팀 권한과 소유자 권한 |
| 회원 전용 자료 | Auth, Storage, signed URL | 업로드 UI, 다운로드 API | private bucket 여부 |
| 이벤트 예약 | Postgres, Edge Functions | 예약 테이블, 알림 함수 | 중복 예약, 재시도, 취소 |
흔한 함정
RLS를 켜기만 하면 데이터가 막히고, using (true)를 남발하면 데이터가 열립니다. anon, authenticated, 다른 사용자 케이스를 나눠 테스트해야 합니다.
secret key를 클라이언트에 넣는 것은 치명적입니다. 브라우저에는 publishable key만 노출하고, 관리자 권한은 서버에 격리합니다.
Storage 경로를 UI가 마음대로 정하게 두면 policy와 실제 경로가 어긋납니다. userId/random-file 같은 규칙을 코드와 SQL에 동시에 반영하세요.
schema 변경 뒤 타입 생성을 잊으면 Claude Code가 낡은 타입에 맞춰 코드를 씁니다. migration 뒤에는 database.types.ts를 갱신합니다.
Edge Function도 공개 HTTP 엔드포인트입니다. Authorization 검증과 RLS-aware client 사용을 기본으로 둡니다.
리뷰 프롬프트
Review only the Supabase integration.
Check RLS, explicit TO roles, publishable key usage, no secret key in client code,
owner_id derived from authenticated user, Storage paths matching policies,
generated types in sync, and Edge Functions validating Authorization.
Return findings with file paths and line numbers.
수동 확인은 익명 public read, 로그인 후 private read, 다른 사용자 note 업데이트 실패, 다른 사용자 Storage 폴더 업로드 실패, 미인증 Edge Function 호출 실패까지 포함합니다.
ClaudeCodeLab은 Supabase + Claude Code 구현 리뷰, RLS 설계, migration 운영, CLAUDE.md 정비, 팀 교육을 도울 수 있습니다. 한국어 페이지가 없는 경우에도 Claude Code training and consultation에서 팀 도입 상담 흐름을 확인할 수 있습니다.
이 흐름을 실제로 시도했을 때 가장 효과적이었던 점은 UI보다 RLS migration 리뷰를 먼저 한 것입니다. Masa의 메모에서는 UI 우선 접근보다 migration 우선 접근이 diff를 작게 만들고, 공개 전 체크 항목을 훨씬 선명하게 만들었습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.