Supabase RLS로 게시판 만들기 — 목록은 누구나, 작성·수정·삭제는 로그인 본인만

1편(소셜 로그인)과 2편(세션으로 내 API 호출)에 이어, 이번엔 진짜 서비스다운 기능 — 게시판을 만듭니다. 요구사항은 명확합니다.
- 목록 보기 / 상세 보기 : 로그인 없이 누구나
- 글쓰기 / 수정 / 삭제 : 로그인한 사용자만, 그리고 본인 글만
이 규칙을 어디서 강제할까요? API 코드에서 if로 일일이 검사할 수도 있지만, 빠뜨리면 바로 보안 구멍입니다. Supabase의 진짜 무기는 RLS(Row Level Security, 행 단위 보안) — 데이터베이스 자체가 규칙을 강제합니다.

0. 핵심 개념: RLS는 "DB가 지키는 규칙"
일반적인 백엔드는 API 코드에서 권한을 검사합니다. 문제는 검사를 빠뜨리면 그냥 뚫린다는 것입니다. RLS는 다릅니다.
RLS를 켜면, 그 테이블의 모든 행은 정책(policy)을 통과한 경우에만 보이거나 수정됩니다. API가 실수로 검사를 빼먹어도 DB가 막아줍니다. 보안의 마지막 방어선입니다.
우리 게시판의 규칙을 RLS 정책으로 옮기면 이렇게 됩니다.
| 동작 | 누가 | 정책 |
|---|---|---|
| SELECT(목록/상세) | 누구나(anon 포함) | using (true) |
| INSERT(작성) | 로그인 사용자 | to authenticated + 작성자=본인 |
| UPDATE(수정) | 본인 | auth.uid() = user_id |
| DELETE(삭제) | 본인 | auth.uid() = user_id |
1. 테이블 + RLS 정책 (마이그레이션)
supabase migration new create_posts_board 로 파일을 만들고 아래 SQL을 작성합니다. 여기가 이 글의 핵심입니다.
-- 게시판(posts) 테이블 create table public.posts ( id bigint generated always as identity primary key, title text not null, content text not null, -- 작성자. 기본값으로 현재 로그인 사용자(auth.uid())가 들어갑니다. user_id uuid not null default auth.uid() references auth.users (id) on delete cascade, author_email text, -- 화면 표시용(데모). 실서비스는 닉네임 프로필을 쓰세요. created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- RLS 켜기 — 이 한 줄이 모든 규칙의 시작입니다. alter table public.posts enable row level security; -- 목록/상세: 로그인 없이 "누구나" 읽기 허용 create policy "누구나 글을 읽을 수 있다" on public.posts for select using (true); -- 작성: "로그인 사용자"만, 작성자는 반드시 본인 create policy "로그인 사용자만 글을 쓸 수 있다" on public.posts for insert to authenticated with check ((select auth.uid()) = user_id); -- 수정: "본인 글"만 create policy "본인 글만 수정할 수 있다" on public.posts for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id); -- 삭제: "본인 글"만 create policy "본인 글만 삭제할 수 있다" on public.posts for delete to authenticated using ((select auth.uid()) = user_id); -- Data API 접근 권한(RLS와 별개로 테이블 접근 자체를 허용) grant select on public.posts to anon, authenticated; grant insert, update, delete on public.posts to authenticated;
정책 하나씩 뜯어보면:
using (true)— SELECT에 조건이 없으니 모든 행이 보입니다 → 목록/상세 공개.to authenticated— 이 정책은 "로그인한 역할"에게만 적용 → 비로그인(anon)은 INSERT/UPDATE/DELETE 자체가 불가.with check ((select auth.uid()) = user_id)— 새로(또는 수정 후) 저장되는 행의user_id가 나여야 통과 → 남의 이름으로 글 쓰기 방지.using ((select auth.uid()) = user_id)— 수정/삭제 대상 행이 내 글일 때만 통과 → 남의 글 수정/삭제 차단.
💡 UPDATE에는
using(어떤 행을 고를지)과with check(저장될 값이 유효한지) 둘 다 필요합니다.with check가 없으면user_id를 남에게 넘겨버리는 악용이 가능합니다.
로컬이면 supabase db push(연결된 클라우드) 또는 supabase migration up(로컬)으로 적용합니다.
RLS가 진짜 막는지 1초 검증
# 익명 키로 글쓰기 시도 → RLS가 거부 (42501) curl -X POST "https://<프로젝트>.supabase.co/rest/v1/posts" \ -H "apikey: <ANON_KEY>" -H "Content-Type: application/json" \ -d '{"title":"x","content":"y"}' # → {"code":"42501","message":"new row violates row-level security policy ..."}
API를 거치지 않고 DB에 직접 찔러도 막힙니다. 이게 RLS의 힘입니다.
2. 인증 헬퍼 — 쿠키와 Bearer 토큰 모두 지원
게시판 쓰기 API는 브라우저(쿠키) 와 외부 클라이언트(Bearer 토큰) 둘 다에서 호출될 수 있습니다. 중요한 건, 단순히 "누구인지 확인"하는 걸 넘어 그 사용자 권한으로 DB 요청이 나가야 RLS가 올바로 적용된다는 점입니다.
src/lib/supabase/server.ts 에 헬퍼를 둡니다.
// 쿠키 세션 또는 Authorization: Bearer 토큰으로 사용자를 확인하고, // 그 사용자 권한으로 DB 요청이 나가도록 클라이언트를 설정합니다. export async function getAuthContext(request: Request) { const cookieStore = await cookies(); const authHeader = request.headers.get("authorization"); const token = authHeader?.toLowerCase().startsWith("bearer ") ? authHeader.slice(7) : undefined; const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: cookieHandlers(cookieStore), // Bearer 토큰이 오면, DB 요청도 그 토큰으로 보냅니다(= 그 사용자 권한). global: token ? { headers: { Authorization: `Bearer ${token}` } } : undefined, }, ); const { data: { user }, } = await supabase.auth.getUser(token); return { supabase, user }; }
이렇게 하면 supabase.from("posts").insert(...) 가 그 사용자 권한으로 실행되어 RLS의 auth.uid() 가 정확히 동작합니다.
3. 게시판 CRUD API (Route Handler)
목록 + 작성 — src/app/api/posts/route.ts
import { NextResponse } from "next/server"; import { createClient, getAuthContext } from "@/lib/supabase/server"; // GET /api/posts — 목록 (로그인 없이 누구나) export async function GET() { const supabase = await createClient(); const { data, error } = await supabase .from("posts") .select("id, title, author_email, created_at") .order("created_at", { ascending: false }); if (error) return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json(data); } // POST /api/posts — 작성 (로그인 필요) export async function POST(request: Request) { const { supabase, user } = await getAuthContext(request); if (!user) { return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); } const { title, content } = await request.json(); if (!title || !content) { return NextResponse.json({ error: "제목과 내용을 입력하세요." }, { status: 400 }); } const { data, error } = await supabase .from("posts") .insert({ title, content, user_id: user.id, author_email: user.email }) .select() .single(); if (error) return NextResponse.json({ error: error.message }, { status: 500 }); return NextResponse.json(data, { status: 201 }); }
상세 + 수정 + 삭제 — src/app/api/posts/[id]/route.ts
import { NextResponse } from "next/server"; import { createClient, getAuthContext } from "@/lib/supabase/server"; type Context = { params: Promise<{ id: string }> }; // GET /api/posts/[id] — 상세 (누구나) export async function GET(_request: Request, { params }: Context) { const { id } = await params; const supabase = await createClient(); const { data, error } = await supabase .from("posts").select("*").eq("id", id).maybeSingle(); if (error) return NextResponse.json({ error: error.message }, { status: 500 }); if (!data) return NextResponse.json({ error: "글을 찾을 수 없습니다." }, { status: 404 }); return NextResponse.json(data); } // PUT /api/posts/[id] — 수정 (본인만) export async function PUT(request: Request, { params }: Context) { const { id } = await params; const { supabase, user } = await getAuthContext(request); if (!user) return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); const { title, content } = await request.json(); const { data, error } = await supabase .from("posts") .update({ title, content, updated_at: new Date().toISOString() }) .eq("id", id) .select(); if (error) return NextResponse.json({ error: error.message }, { status: 500 }); // RLS 때문에 "본인 글"이 아니면 0건 → 403 if (data.length === 0) { return NextResponse.json({ error: "수정 권한이 없거나 글이 없습니다." }, { status: 403 }); } return NextResponse.json(data[0]); } // DELETE /api/posts/[id] — 삭제 (본인만) export async function DELETE(request: Request, { params }: Context) { const { id } = await params; const { supabase, user } = await getAuthContext(request); if (!user) return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); const { data, error } = await supabase .from("posts").delete().eq("id", id).select(); if (error) return NextResponse.json({ error: error.message }, { status: 500 }); if (data.length === 0) { return NextResponse.json({ error: "삭제 권한이 없거나 글이 없습니다." }, { status: 403 }); } return NextResponse.json({ success: true }); }
👀 포인트: 수정/삭제에서 "권한 없음"을 직접 검사하지 않습니다. RLS가 본인 글이 아니면 0건을 반환하므로,
data.length === 0이면 403을 돌려줄 뿐입니다. 권한의 진짜 판단은 DB가 합니다.
4. 화면(UI)
UI는 위 API를 호출하는 클라이언트 컴포넌트입니다. 상세 페이지는 본인 글일 때만 수정/삭제 버튼을 보여줍니다.

상세 페이지에서 "본인 여부"를 판단하는 부분:
// 글 정보를 불러온 뒤, 현재 로그인 사용자와 글쓴이를 비교 const res = await fetch(`/api/posts/${id}`, { cache: "no-store" }); const post = await res.json(); const supabase = createClient(); // 브라우저용 const { data: { user } } = await supabase.auth.getUser(); setIsOwner(!!user && user.id === post.user_id); // → isOwner 일 때만 [수정][삭제] 버튼 렌더링
⚠️ 이건 어디까지나 버튼을 숨기는 UX일 뿐입니다. 진짜 보안은 RLS가 합니다. 버튼을 강제로 눌러 API를 호출해도, 본인 글이 아니면 DB가 403으로 막습니다. "UI 가림"과 "서버/DB 강제"는 항상 별개로 둬야 합니다.
작성 폼은 POST /api/posts 를 호출하고, 401이 오면 "로그인이 필요합니다"를 보여줍니다. 수정 폼은 PUT, 삭제는 DELETE 를 호출합니다. (전체 코드는 예제 저장소의 src/app/board/ 참고)
5. 정말 동작할까? — E2E로 전부 검증
Playwright로 실제 클라우드 Supabase에 붙여 권한 시나리오를 통째로 검증했습니다.
// 작성 → 공개 상세 → 타인 수정/삭제 차단 → 본인 수정/삭제 const createRes = await ownerApi.post("/api/posts", { data: { title: "내 글", content: "본문" } }); expect(createRes.status()).toBe(201); // 본인 작성 OK const detail = await anon.get(`/api/posts/${post.id}`); expect(detail.status()).toBe(200); // 비로그인 상세 OK expect((await otherApi.put(`/api/posts/${post.id}`, { data: {...} })).status()).toBe(403); // 타인 수정 차단 expect((await otherApi.delete(`/api/posts/${post.id}`)).status()).toBe(403); // 타인 삭제 차단 expect((await ownerApi.put(`/api/posts/${post.id}`, { data: {...} })).status()).toBe(200); // 본인 수정 OK expect((await ownerApi.delete(`/api/posts/${post.id}`)).status()).toBe(200); // 본인 삭제 OK
그리고 비로그인 글쓰기는 401:
const create = await request.post("/api/posts", { data: { title: "x", content: "y" } }); expect(create.status()).toBe(401);
UI 흐름(로그인 → 글쓰기 → 상세 → 수정 → 삭제)까지 포함해 전부 통과 ✅
✓ 목록/상세는 비로그인도 가능하고, 글쓰기는 401로 막힌다 ✓ 작성 → 공개 상세 → 타인 수정/삭제 차단(403) → 본인 수정/삭제 ✓ UI 흐름: 로그인 → 글쓰기 → 상세 → 수정 → 삭제
테스트 사용자는 매번 새로 만들고(
randomUUID이메일) 끝나면 삭제(on delete cascade로 글도 함께 정리)하므로 DB가 깨끗하게 유지됩니다.
6. 마무리 — 기억할 것
- 권한 규칙은 API의
if가 아니라 RLS(DB)로 강제하세요. API를 우회해도 안전합니다. - 쓰기 API는 그 사용자 권한으로 DB 요청이 나가게 해야
auth.uid()기반 RLS가 동작합니다(쿠키든 Bearer든). - UI의 "버튼 숨김"은 편의일 뿐, 보안은 서버/DB가 책임집니다.
- 공개 읽기 테이블이라도 RLS는 반드시 켜고, 의도한 정책만 허용하세요.
이걸로 소셜 로그인(1편) → 인증된 내 API(2편) → 권한 있는 게시판(3편)까지, 실서비스의 뼈대가 완성됐습니다. 즐거운 코딩 되세요! 🚀





댓글
댓글을 작성하려면 이 필요합니다.