Supabase 데이터와 접근 권한
인증·RLS·CRUD로 자신의 명단만 읽고 수정할 수 있는 작은 서비스를 완성하기
브라우저 뒤에 실제 데이터가 생기면
배열을 바꾸는 React 실습에서는 새로고침하면 데이터가 사라집니다. 이번에는 Supabase의 Postgres와 인증을 연결해 로그인한 사용자가 만든 명단을 저장합니다. 프론트엔드는 요청을 보내고 결과를 표시하며 어떤 행을 읽거나 바꿀지는 데이터베이스가 결정합니다.
공개 가능한 publishable key는 비밀번호가 아닙니다. 이 키가 브라우저에 보인다는 사실을 전제로 인증과 Row Level Security(RLS)를 적용합니다. secret key 또는 legacy service_role 키는 RLS를 우회할 수 있으므로 브라우저에 넣지 않습니다.
필수: RLS를 켜고 로그인한 사용자의 조회·등록을 확인합니다. 심화: 수정·삭제 정책과 두 계정 간 데이터 격리를 검증합니다. RLS 자체는 브라우저 연동에서 생략하지 않습니다.
세션 전 준비
Supabase 프로젝트를 만들고 가상 이름을 쓰는 테스트 계정 두 개를 준비합니다. Auth의 Users에서 테스트 계정을 만들거나 프로젝트의 회원 가입 흐름으로 생성하고 이메일 확인 정책에 따라 확인을 완료합니다. 프로젝트 접근 권한과 환경변수 설정은 세션 전에 확인합니다. 실제 회원 명단·연락처를 연습 데이터로 가져오지 않습니다.
데이터 계약
| 필드 | 역할 | 변경 주체 |
|---|---|---|
| id | 행의 고유 UUID | 데이터베이스 기본값 |
| owner_id | 이 행을 만든 사용자 | 로그인 세션에서 결정 |
| name | 화면에 보일 가상 이름 | 입력 후 검증 |
| created_at | 생성 시각 | 데이터베이스 |
이 실습은 사용자별 비공개 명단입니다. 모든 동아리 회원에게 공유하는 명단으로 바꾸려면 별도의 회원 자격·읽기 권한 정책이 필요합니다.
테이블과 RLS 만들기
비어 있는 실습 프로젝트의 SQL Editor에서 한 번 실행합니다. 기존 동명 테이블이 있다면 삭제하지 말고 구조를 먼저 비교하세요.
create table public.practice_members ( id uuid primary key default gen_random_uuid(), owner_id uuid not null default auth.uid() references auth.users(id) on delete cascade, name text not null check (char_length(btrim(name)) between 1 and 40), created_at timestamptz not null default now());
alter table public.practice_members enable row level security;grant select, insert, update, delete on public.practice_members to authenticated;
create policy "read own members" on public.practice_membersfor select to authenticated using ((select auth.uid()) = owner_id);
create policy "insert own members" on public.practice_membersfor insert to authenticated with check ((select auth.uid()) = owner_id);
create policy "update own members" on public.practice_membersfor update to authenticatedusing ((select auth.uid()) = owner_id)with check ((select auth.uid()) = owner_id);
create policy "delete own members" on public.practice_membersfor delete to authenticated using ((select auth.uid()) = owner_id);USING은 기존 행을 대상으로 삼을 수 있는지, WITH CHECK는 생성·수정한 행이 허용되는지 검사합니다. UPDATE에는 기존 행을 찾기 위한 SELECT 정책도 필요합니다. 정책을 끄면 안 되는 이유를 “프론트에서 버튼을 숨겼으니 안전하다”와 비교해 설명하세요.
클라이언트 연결
앞의 Vite React 프로젝트에서 npm install @supabase/supabase-js를 실행합니다. .env.local에 아래 이름을 두고 프로젝트의 실제 공개 설정을 넣습니다. 파일을 Git에 올리지 않습니다.
VITE_SUPABASE_URL=실습_프로젝트_URLVITE_SUPABASE_PUBLISHABLE_KEY=실습_프로젝트_publishable_keyVite의 VITE_ 변수는 빌드된 브라우저 코드에 포함됩니다. 이름에 KEY가 있어도 비밀이 보장되지 않습니다. 환경변수를 바꾼 뒤 개발 서버를 다시 실행합니다.
src/supabase.js:
import { createClient } from "@supabase/supabase-js";
const url = import.meta.env.VITE_SUPABASE_URL;const key = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;if (!url || !key) throw new Error("Supabase 공개 설정을 확인하세요.");export const supabase = createClient(url, key);실행: 필수 로그인·조회·등록, 심화 수정·삭제
src/App.jsx 전체 예제입니다. TypeScript 프로젝트를 이어 쓴다면 타입을 추가하는 과제로 전환하고 먼저 별도의 JavaScript 실습에서 요청과 RLS를 확인해도 됩니다.
import { useState } from "react";import { supabase } from "./supabase";
export default function App() { const [members, setMembers] = useState([]); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [name, setName] = useState(""); const [message, setMessage] = useState(""); const [busy, setBusy] = useState(false);
async function load() { const { data, error } = await supabase.from("practice_members") .select("id, name").order("created_at").limit(50); if (error) throw error; setMembers(data); } async function run(action) { setBusy(true); setMessage(""); try { await action(); } catch { setMessage("처리하지 못했습니다. 로그인과 RLS 정책을 확인하세요."); } finally { setBusy(false); } } async function mutate(query) { const { error } = await query; if (error) throw error; await load(); } return ( <main> <h1>나의 연습 명단</h1> <form onSubmit={event => { event.preventDefault(); void run(async () => { setMembers([]); const { error } = await supabase.auth.signInWithPassword({ email, password }); if (error) throw error; setPassword(""); await load(); }); }}> <label>이메일<input type="email" required value={email} onChange={event => setEmail(event.target.value)} /></label> <label>비밀번호<input type="password" required value={password} onChange={event => setPassword(event.target.value)} /></label> <button disabled={busy}>로그인하고 조회</button> </form> <button disabled={busy} onClick={() => void run(async () => { const { error } = await supabase.auth.signOut(); if (error) throw error; setMembers([]); })}>로그아웃</button> <form onSubmit={event => { event.preventDefault(); if (!name.trim()) return; void run(async () => { await mutate(supabase.from("practice_members").insert({ name: name.trim() })); setName(""); }); }}> <label>가상 이름<input required maxLength={40} value={name} onChange={event => setName(event.target.value)} /></label> <button disabled={busy}>추가</button> </form> <p role="status">{message || (busy ? "처리 중…" : members.length + "명")}</p> <ul>{members.map(member => <li key={member.id}> {member.name} <button disabled={busy || !name.trim()} onClick={() => void run(() => mutate(supabase.from("practice_members").update({ name: name.trim() }) .eq("id", member.id)) )}>입력한 이름으로 수정</button> <button disabled={busy} onClick={() => void run(() => mutate(supabase.from("practice_members").delete().eq("id", member.id)) )}>삭제</button> </li>)}</ul> </main> );}위 예제는 인증·CRUD 경계를 관찰하는 최소 화면입니다. 세션 자동 복원, 회원 가입, 만료 처리, 재확인 대화상자는 최종 프로젝트에서 제품 정책에 맞게 추가합니다. UPDATE와 DELETE에 ID 조건이 없으면 자신의 모든 행에 영향을 줄 수 있으므로 조건을 꼭 확인합니다.
세션 실습과 보안 확인
필수 단계는 테이블·기본 정책 30분, 클라이언트 연결 35분, 로그인·조회·등록 25분으로 진행합니다. 수정·삭제와 서로 다른 사용자 검사는 30분 심화 단계로 이어갑니다.
- 사용자 A로 두 행을 생성하고 새로고침 후 다시 로그인해 조회합니다.
- A의 행 ID를 기록하고 로그아웃합니다. 로그인 없이 생성하면 실패해야 합니다.
- 사용자 B로 로그인합니다. A의 명단이 보이지 않아야 합니다.
- B의 브라우저 요청에서 A의 행 ID로 UPDATE·DELETE를 시도해도 A의 데이터가 바뀌지 않아야 합니다. 확인은 다시 A로 로그인해서 합니다.
- B가 owner_id를 A로 지정한 INSERT는 거부되어야 합니다.
RLS가 대상 행을 0개로 제한해도 UPDATE·DELETE가 반드시 오류를 반환하지는 않습니다. “에러가 없었다” 대신 실제 영향받은 행과 A의 데이터를 확인합니다. SQL Editor는 일반 브라우저 사용자의 권한을 그대로 재현하지 않으므로 사용자별 검사는 클라이언트 세션으로 진행합니다.
막혔을 때
- 빈 배열: 실제 행이 없는지, 로그인했는지, SELECT 정책이 있는지 구분합니다.
- 정책 위반: owner_id 기본값과 현재 사용자, INSERT의 WITH CHECK를 확인합니다.
- 로그인 실패: 테스트 계정의 이메일 확인과 비밀번호, 프로젝트 URL을 확인합니다.
- 네트워크 요청 URL이 비어 있음: 환경변수 파일 위치와 개발 서버 재시작을 확인합니다.
더 읽어보기
연결된 PBL 미션과 VOD
| 주차·미션 | 참고 VOD 범위 |
|---|---|
| 9주 · Supabase | 리액트 실무 6·7·8장 |
7–9주의 VOD 표시는 외부 상호작용·서버 연동·앱 제작 범위를 함께 안내합니다. Router·TypeScript·Supabase의 세부 API는 각 공식 문서로 보완합니다.
강좌 안내: 참고 강좌 1 · 참고 강좌 2. 이 문서는 영상 전체를 옮긴 전사 자료가 아닙니다. 세부 내용은 위 공식 문서에서 확인합니다.