Supabase에서 두 계정으로 사용자별 CRUD와 RLS 격리를 어떻게 확인하나요?
사용자별 데이터는 화면에서 숨기는 데서 끝내지 않고 직접 API 요청도 제한해야 합니다. Supabase의 GRANT로 필요한 작업을 허용하고 RLS로 본인 행만 접근하게 만드세요.
적용 조건·범위: 따라서 “error가 null이면 반드시 한 행을 변경했다”라고 가정하지 않습니다.
본문 근거: supabase.com/docs/guides/database/postgres/row-level-security · supabase.com/docs/guides/auth · supabase.com/docs/reference/javascript/select
GRANT와 RLS는 함께 봅니다
PostgreSQL은 먼저 이 역할이 해당 작업을 할 수 있는지 확인하고, 그다음 RLS가 어떤 행에 적용할지 결정합니다.
1. GRANT
작업 허용 여부
2. RLS
허용되는 행
예를 들어 authenticated에게 SELECT GRANT가 없다면 SELECT policy가 맞아도 작업 자체가 거부됩니다.
반대로 GRANT만 있고 RLS가 적절하지 않으면 범위가 넓어질 수 있습니다.
profiles 권한
Task를 추가하려면 먼저 자신의 profiles 행이 있어야 합니다.
authenticated에게 필요한 작업만 허용합니다.
grant select, insert, update
on table public.profiles
to authenticated;
비로그인 사용자는 아무 권한도 주지 않습니다.
내 profile 조회
create policy "profiles_select_self"
on public.profiles
for select
to authenticated
using (
(select auth.uid()) is not null
and (select auth.uid()) = id
);
내 profile 생성
create policy "profiles_insert_self"
on public.profiles
for insert
to authenticated
with check (
(select auth.uid()) is not null
and (select auth.uid()) = id
);
사용자가 다른 UUID로 profile을 만들 수 없습니다.
내 profile 수정
create policy "profiles_update_self"
on public.profiles
for update
to authenticated
using (
(select auth.uid()) = id
)
with check (
(select auth.uid()) = id
);
tasks 권한
authenticated 사용자에게 CRUD를 허용합니다.
grant select, insert, update, delete
on table public.tasks
to authenticated;
하지만 모든 tasks 행을 허용하는 것은 아닙니다.
RLS가 owner_id와 현재 사용자를 비교합니다.
내 작업만 SELECT
create policy "tasks_select_own"
on public.tasks
for select
to authenticated
using (
(select auth.uid()) is not null
and (select auth.uid()) = owner_id
);
내 소유자로만 INSERT
create policy "tasks_insert_own"
on public.tasks
for insert
to authenticated
with check (
(select auth.uid()) is not null
and (select auth.uid()) = owner_id
);
A가 owner_id에 B의 UUID를 넣으면 정책을 통과하지 못합니다.
내 작업만 UPDATE
UPDATE는 기존 행과 수정 후 행을 모두 확인합니다.
create policy "tasks_update_own"
on public.tasks
for update
to authenticated
using (
(select auth.uid()) = owner_id
)
with check (
(select auth.uid()) = owner_id
);
USING은 수정할 기존 행을 검사합니다.
WITH CHECK는 수정 결과를 검사합니다.
둘 다 두면 A가 자기 작업을 선택한 뒤 owner_id를 B로 바꾸는 소유권 이전도 막을 수 있습니다.
내 작업만 DELETE
create policy "tasks_delete_own"
on public.tasks
for delete
to authenticated
using (
(select auth.uid()) = owner_id
);
현재 사용자 profile 준비
작업을 추가하기 전에 현재 Auth 사용자의 profile이 있어야 합니다.
브라우저 코드에서 현재 사용자를 확인합니다.
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
throw new Error('로그인이 필요합니다.');
}
그다음 자신의 profile을 확인합니다.
const { data: profile } = await supabase
.from('profiles')
.select('id')
.eq('id', user.id)
.maybeSingle();
if (!profile) {
const { error: insertError } = await supabase
.from('profiles')
.insert({
id: user.id,
display_name: null,
});
if (insertError) {
throw insertError;
}
}
여기서 다른 사용자 ID를 입력받지 않습니다.
현재 인증 사용자의 user.id만 사용합니다.
Task repository
lib/tasks/supabase-task-repository.ts:
import type {
CreateTaskInput,
Task,
TaskStatus,
} from '@/lib/types/task';
import { createClient } from '@/lib/supabase/client';
async function getCurrentUserId() {
const supabase = createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
throw new Error('로그인이 필요합니다.');
}
return user.id;
}
export async function ensureProfile() {
const supabase = createClient();
const userId = await getCurrentUserId();
const { data, error } = await supabase
.from('profiles')
.select('id')
.eq('id', userId)
.maybeSingle();
if (error) {
throw error;
}
if (data) {
return;
}
const { error: insertError } = await supabase
.from('profiles')
.insert({
id: userId,
display_name: null,
});
if (insertError) {
throw insertError;
}
}
export async function getTasks() {
const supabase = createClient();
const { data, error } = await supabase
.from('tasks')
.select(
'id, owner_id, title, status, created_at, updated_at',
)
.order('created_at', {
ascending: false,
});
if (error) {
throw error;
}
return (data ?? []) as Task[];
}
export async function createTask(
input: CreateTaskInput,
) {
const supabase = createClient();
const userId = await getCurrentUserId();
const title = input.title.trim();
if (!title) {
throw new Error('작업 제목을 입력해 주세요.');
}
const { data, error } = await supabase
.from('tasks')
.insert({
owner_id: userId,
title,
status: 'todo',
})
.select(
'id, owner_id, title, status, created_at, updated_at',
)
.single();
if (error) {
throw error;
}
return data as Task;
}
export async function updateTaskStatus(
taskId: string,
status: TaskStatus,
) {
const supabase = createClient();
const { data, error } = await supabase
.from('tasks')
.update({
status,
updated_at: new Date().toISOString(),
})
.eq('id', taskId)
.select(
'id, owner_id, title, status, created_at, updated_at',
)
.maybeSingle();
if (error) {
throw error;
}
return data as Task | null;
}
export async function deleteTask(
taskId: string,
) {
const supabase = createClient();
const { data, error } = await supabase
.from('tasks')
.delete()
.eq('id', taskId)
.select('id')
.maybeSingle();
if (error) {
throw error;
}
return data;
}
update와 delete에서 다른 사용자 행을 지정했을 때 RLS 때문에 대상 행이 보이지 않으면 오류 대신 반환 행이 없을 수 있습니다.
따라서 “error가 null이면 반드시 한 행을 변경했다”라고 가정하지 않습니다.
maybeSingle 결과의 data도 확인합니다.
두 계정으로 검증하기
브라우저 프로필 두 개 또는 서로 분리된 브라우저 세션을 준비합니다.
계정 A
계정 B
계정 A
작업 생성:
A 작업
계정 B
작업 생성:
B 작업
각 계정 목록은 자신의 작업만 보여야 합니다.
그리고 A 세션에서 B 작업의 UUID를 알고 있다고 가정하고 update/delete를 직접 시도합니다.
예상 결과:
| 요청 | 예상 |
|---|---|
| anon SELECT tasks | 거부 |
| A → A 작업 SELECT | 허용 |
| A → B 작업 SELECT | 결과에 포함되지 않음 |
| A → A 작업 UPDATE | 허용 |
| A → B 작업 UPDATE | 변경 행 없음 |
| A → owner_id를 B로 변경 | 거부 |
| A → A 작업 DELETE | 허용 |
| A → B 작업 DELETE | 삭제 행 없음 |
화면에서 B 작업을 숨겼기 때문에 안전한 것이 아닙니다.
Data API 요청 자체가 데이터베이스 정책에 의해 제한되어야 합니다.
자주 발생하는 오류
INSERT policy에 USING을 쓴다
INSERT는 새 행을 검사하므로 WITH CHECK를 사용합니다.
UPDATE에 USING만 둔다
기존 행은 본인 소유여도 수정 결과 owner_id를 바꿀 수 있는지 확인해야 합니다.
WITH CHECK도 둡니다.
관리자 키로 테스트한다
secret/service_role 권한은 RLS를 우회합니다.
사용자 RLS 검증용 브라우저 클라이언트에 사용하지 않습니다.
다른 사용자 UPDATE가 error 없이 끝났다
실제로 반환된 행이 있는지도 확인합니다.
RLS 때문에 대상 행이 보이지 않으면 0행 결과가 될 수 있습니다.
이 기술만 따로 연습하기
기대 결과: 생성 응답의 status는 todo, 변경 응답은 같은 ID의 done, 삭제 응답은 삭제한 ID입니다. UPDATE/DELETE의 null 응답은 변경 성공으로 기록하지 않습니다.
직접 실행: 환경 준비와 전체 파일
이 글의 완성 실습 ZIP을 별도 폴더에 풀어 단독 샘플로 사용합니다. 이전 회차의 작업 폴더는 필요하지 않습니다. Node.js 24 계열과 ZIP의 package-lock.json을 기준으로 README의 설치·환경 변수 절차를 한 번 진행합니다. 필요한 개발용 Supabase 프로젝트와 공개 가능한 URL·publishable key는 독자가 준비합니다. 실습은 운영 데이터와 분리합니다. 전체 파일은 아래 코드 보기에서도 확인할 수 있습니다.
이 ZIP은 CRUD repository 함수를 제공하지만 /tasks 화면은 아직 공개 테이블 조회 예제입니다. 개인 작업 폼이 이미 있다고 가정하지 않습니다. README의 개발 DB SQL을 순서대로 적용한 뒤 아래 파일을 app/crud-lab/page.tsx로 새로 추가합니다. 이 파일은 전체 내용이며 import 대상은 ZIP에 모두 포함되어 있습니다.
'use client';
import { useState } from 'react';
import {
ensureProfile,
getTasks,
createTask,
updateTaskStatus,
deleteTask,
} from '@/lib/tasks/supabase-task-repository';
export default function CrudLab() {
const [createdId, setCreatedId] = useState('');
const [log, setLog] = useState('먼저 /login에서 로그인하세요.');
const [busy, setBusy] = useState(false);
async function run(work: () => Promise<unknown>) {
setBusy(true);
try {
const value = await work();
setLog(JSON.stringify(value, null, 2) ?? '완료');
} catch (error) {
setLog(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
}
return (
<main>
<h1>내 계정 CRUD 실험</h1>
<p>이번 화면에서 만든 작업 ID: {createdId || '없음'}</p>
<button disabled={busy} onClick={() => run(async () => {
await ensureProfile();
const task = await createTask({ title: 'CRUD 단독 실습' });
setCreatedId(task.id);
return task;
})}>실습 작업 만들기</button>
<button disabled={busy} onClick={() => run(getTasks)}>내 작업 조회</button>
<button disabled={busy || !createdId} onClick={() => run(
() => updateTaskStatus(createdId, 'done'),
)}>실습 작업 완료로 변경</button>
<button disabled={busy || !createdId} onClick={() => run(async () => {
const deleted = await deleteTask(createdId);
if (deleted) setCreatedId('');
return deleted;
})}>이번에 만든 실습 작업 삭제</button>
<pre aria-live="polite">{log}</pre>
</main>
);
}
- npm run typecheck 후 npm run dev를 실행합니다. /login에서 테스트 계정으로 로그인한 다음 /crud-lab으로 이동합니다.
- 실습 작업 만들기 → 내 작업 조회 → 실습 작업 완료로 변경 → 다시 조회를 순서대로 누릅니다. 각 응답의 id·owner_id·status를 기록합니다.
- 이번에 만든 실습 작업 삭제를 누르고 다시 조회해 해당 ID가 없어졌는지 확인합니다. 삭제 버튼은 이 화면이 생성한 ID만 대상으로 삼습니다.
- 다른 브라우저 프로필에서 B 계정으로 로그인하고 내 작업 조회를 눌러 A 작업이 보이지 않는지 확인합니다. 타인 ID로 직접 요청하는 거부 테스트는 하단의 권한 행렬을 별도로 진행합니다.
자가 확인·변형: ensureProfile은 왜 작업 생성 전에 필요한가요? owner_id가 profiles.id를 참조하기 때문입니다. 빈 제목은 repository가 거부하며 데이터베이스에도 제약이 있습니다.
검증 범위: 위 CRUD 실습 페이지를 기존 6회차 앱에 추가하여 TypeScript 검사와 Next.js 빌드가 통과했습니다. 실제 Supabase 요청, 계정 생성, 메일 발송, 원격 DB 정책·실행 계획 검증은 이 편집에서 실행하지 않았습니다. 위 결과는 독자가 확인할 기대값입니다.
학습 완료 체크리스트
- GRANT와 RLS가 적용되는 순서를 설명할 수 있습니다.
- SELECT의 USING을 작성할 수 있습니다.
- INSERT의 WITH CHECK를 작성할 수 있습니다.
- UPDATE에 USING과 WITH CHECK가 모두 필요한 이유를 설명할 수 있습니다.
- 두 계정으로 데이터 격리를 검증할 수 있습니다.
- 관리자 키를 사용자 RLS 검증에 사용하면 안 되는 이유를 알고 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 데이터 저장·인증·접근 권한을 연결합니다.
이번 주제: Supabase 사용자별 CRUD와 RLS: 두 계정으로 데이터 격리 검증하기.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
이전 단계 시작 ZIP을 먼저 별도 작업 폴더에 풉니다. 현재 회차 ZIP은 적용 후 비교할 완성본입니다.
5회차 시작 ZIP에서 06_profiles_tasks_rls.sql과 supabase-task-repository.ts를 추가합니다. 권한 정책과 데이터 접근 함수를 연결하는 단계이며 개인 CRUD 폼은 기존 ZIP에 없습니다. 상단의 /crud-lab 파일을 별도 추가하면 함수 호출 결과를 확인할 수 있습니다.
이전 체크포인트
supabase-step-05-relational-schema
현재 상태
profiles / tasks 존재
RLS enabled
일반 사용자 GRANT 없음
사용자 policy 없음
이번 변경 파일
supabase/
└─ migrations/
└─ 06_profiles_tasks_rls.sql
lib/
├─ types/
│ └─ task.ts
└─ tasks/
└─ supabase-task-repository.ts
이번 누적 실습은 5회차에서 만든 profiles/tasks를 사용합니다. 2회차 공개 조회용 public_tasks_lab와 혼합하지 않습니다.
전체 파일·코드 보기
실습 프로젝트 전체 코드
아래는 이 회차까지의 코드를 누적한 독립 실행용 최소 Next.js App Router 학습 앱 전체입니다. 기존 Next.js 38편 앱 전체를 복제한 것이 아닙니다. ZIP에는 의존성 잠금 파일 package-lock.json도 포함되며 본문에서는 이 자동 생성 잠금 파일만 생략합니다. 기존 TaskFlow 앱을 이어서 사용한다면 기존 설정을 보존하고 해당 회차 변경 파일을 적용하세요.
.env.example
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
.gitignore
node_modules/
.next/
.env.local
*.tsbuildinfo
app/auth/confirmed/page.tsx
export default function AuthConfirmedPage() {
return (
<main>
<h1>이메일 확인 안내</h1>
<p>메일의 확인 링크를 완료했다면 로그인 화면에서 이메일과 비밀번호로 로그인해 주세요.</p>
<p>이 페이지를 열었다는 사실만으로 인증 상태를 판정하지 않습니다.</p>
</main>
);
}
app/layout.tsx
import type { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) { return <html lang="ko"><body>{children}</body></html>; }
app/login/page.tsx
import { AuthForm } from '@/components/auth/auth-form';
export default function LoginPage() {
return (
<main>
<h1>TaskFlow 로그인</h1>
<AuthForm />
</main>
);
}
app/page.tsx
import Link from "next/link";
export default function Home() { return <main><h1>TaskFlow Supabase</h1><Link href="/tasks">작업 목록</Link> <Link href="/login">로그인</Link></main>; }
app/tasks/page.tsx
import { createPublicSupabaseClient } from '@/lib/supabase/public-server';
type PublicTask = {
id: number;
title: string;
created_at: string;
};
export const dynamic = 'force-dynamic';
export default async function TasksPage() {
const supabase = createPublicSupabaseClient();
const { data, error } = await supabase
.from('public_tasks_lab')
.select('id, title, created_at')
.order('id');
if (error) {
return (
<main>
<h1>TaskFlow</h1>
<p>Supabase 데이터를 불러오지 못했습니다.</p>
<pre>{error.message}</pre>
</main>
);
}
const tasks = (data ?? []) as PublicTask[];
return (
<main>
<h1>TaskFlow</h1>
{tasks.length === 0 ? (
<p>공개 작업이 없습니다.</p>
) : (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
)}
</main>
);
}
components/auth/auth-form.tsx
'use client';
import { FormEvent, useEffect, useState } from 'react';
import type { Session } from '@supabase/supabase-js';
import { createClient } from '@/lib/supabase/client';
export function AuthForm() {
const supabase = createClient();
const [session, setSession] = useState<Session | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
const [pending, setPending] = useState(false);
useEffect(() => {
void supabase.auth.getSession().then(({ data }) => {
setSession(data.session);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, nextSession) => {
setSession(nextSession);
});
return () => subscription.unsubscribe();
}, [supabase]);
async function signUp(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setMessage('');
const { data, error } = await supabase.auth.signUp({
email,
password,
});
setPending(false);
if (error) {
setMessage(error.message);
return;
}
setMessage(
data.session
? '가입과 로그인이 완료되었습니다.'
: '가입 요청이 완료되었습니다. 이메일을 확인해 주세요.',
);
}
async function signIn() {
setPending(true);
setMessage('');
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
setPending(false);
setMessage(error ? error.message : '로그인했습니다.');
}
async function signOut() {
setPending(true);
setMessage('');
const { error } = await supabase.auth.signOut();
setPending(false);
setMessage(error ? error.message : '로그아웃했습니다.');
}
if (session) {
return (
<section>
<p>로그인 상태입니다.</p>
<p>{session.user.email}</p>
<button type="button" onClick={signOut} disabled={pending}>
로그아웃
</button>
{message && <p>{message}</p>}
</section>
);
}
return (
<form onSubmit={signUp}>
<label>
이메일
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label>
비밀번호
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={8}
required
/>
</label>
<button type="submit" disabled={pending}>
회원가입
</button>
<button type="button" onClick={signIn} disabled={pending}>
로그인
</button>
{message && <p>{message}</p>}
</form>
);
}
docs/auth-email-template.md
# Auth Email Template
## 대상
Confirm signup
## 제목
TaskFlow 회원가입을 확인해 주세요
## 버튼
이메일 확인하기
## 반드시 유지할 값
ConfirmationURL
## 검증
- 제목 확인
- 본문 확인
- 링크 존재 확인
- 클릭 후 이메일 확인 상태 확인
- redirect 확인
## 주의
2026-06-03 이후 무료 플랜 기본 이메일 발송 인프라의 템플릿 커스터마이징 제한을 발행/적용 시점에 다시 확인한다.
docs/auth-redirect-checklist.md
# Auth Redirect Checklist
- 로컬 Redirect URL 등록
- 운영 Redirect URL 등록
- Site URL 확인
- emailRedirectTo와 허용 URL 일치
- 링크 클릭 후 최종 이동 확인
- 실제 토큰/개인정보를 문서에 기록하지 않음
docs/backend-choice.md
# TaskFlow Backend Choice
## 시작점
next-step-38-cache-update
## 선택
Supabase
## 이유
- PostgreSQL 관계형 데이터 모델을 직접 학습한다.
- Supabase Auth와 Postgres 사용자를 연결한다.
- GRANT와 RLS를 이용해 사용자별 데이터 격리를 학습한다.
- Next.js App Router에서 쿠키 기반 SSR 인증을 연결한다.
- 관계 조회와 인덱스까지 하나의 데이터 계층에서 학습한다.
## 이번 과정에서 사용하는 기능
- PostgreSQL
- Auth
- Data API
- Row Level Security
- @supabase/supabase-js
- @supabase/ssr
## 이번 과정에서 필수로 다루지 않는 기능
- Realtime
- Storage
- Edge Functions
필요 기능이 아니므로 과정 범위를 무리하게 늘리지 않는다.
## 보안 원칙
- 브라우저에는 publishable key만 사용한다.
- secret key는 클라이언트에 포함하지 않는다.
- 사용자 데이터는 RLS를 활성화한다.
- 화면에서 숨기는 것으로 권한 검증을 대신하지 않는다.
- 실제 인증 토큰과 개인정보를 학습 자료에 저장하지 않는다.
## 백엔드 트랙
Firebase 완료본을 이어서 수정하지 않는다.
Next.js TaskFlow 최종 체크포인트에서 별도 Supabase 브랜치를 시작한다.
## 다음 단계
Supabase 프로젝트를 만들고 공개 학습 데이터를 한 번 조회한다.
docs/data-model.md
# TaskFlow Supabase Data Model
## Auth
auth.users
- Supabase Auth 계정
- 앱 프로필 데이터를 직접 추가하지 않음
## profiles
public.profiles
- id → auth.users.id
- display_name
- created_at
## tasks
public.tasks
- id
- owner_id → public.profiles.id
- title
- status
- created_at
- updated_at
## 관계
auth.users
→ public.profiles
→ public.tasks
## 보안 상태
profiles RLS enabled
tasks RLS enabled
anon 권한 없음
authenticated 권한 없음
6회차에서 필요한 GRANT와 RLS policy를 추가한다.
email-template/complete/confirmation.html
<h2>TaskFlow 회원가입을 확인해 주세요</h2>
<p>
TaskFlow 회원가입이 요청되었습니다.
</p>
<p>
아래 버튼을 눌러 이메일 확인을 완료해 주세요.
</p>
<p>
<a href="{{ .ConfirmationURL }}">
이메일 확인하기
</a>
</p>
<p>
회원가입을 요청하지 않았다면 이 메일을 무시해 주세요.
</p>
email-template/starter/confirmation.html
<h2>Confirm your signup</h2>
<p>Follow this link to confirm your user:</p>
<p>
<a href="{{ .ConfirmationURL }}">
Confirm your mail
</a>
</p>
lib/supabase/client.ts
'use client';
import { createClient as createSupabaseClient, type SupabaseClient } from '@supabase/supabase-js';
let client: SupabaseClient | undefined;
export function createClient() {
if (client) {
return client;
}
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!url || !key) {
throw new Error('Supabase 공개 설정이 필요합니다.');
}
client = createSupabaseClient(url, key);
return client;
}
lib/supabase/public-server.ts
import 'server-only';
import { createClient } from '@supabase/supabase-js';
export function createPublicSupabaseClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!url || !key) {
throw new Error('Supabase 공개 설정이 필요합니다.');
}
return createClient(url, key, {
auth: {
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
});
}
lib/tasks/supabase-task-repository.ts
import type {
CreateTaskInput,
Task,
TaskStatus,
} from '@/lib/types/task';
import { createClient } from '@/lib/supabase/client';
async function getCurrentUserId() {
const supabase = createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
throw new Error('로그인이 필요합니다.');
}
return user.id;
}
export async function ensureProfile() {
const supabase = createClient();
const userId = await getCurrentUserId();
const { data, error } = await supabase
.from('profiles')
.select('id')
.eq('id', userId)
.maybeSingle();
if (error) {
throw error;
}
if (data) {
return;
}
const { error: insertError } = await supabase
.from('profiles')
.insert({
id: userId,
display_name: null,
});
if (insertError) {
throw insertError;
}
}
export async function getTasks() {
const supabase = createClient();
const { data, error } = await supabase
.from('tasks')
.select(
'id, owner_id, title, status, created_at, updated_at',
)
.order('created_at', {
ascending: false,
});
if (error) {
throw error;
}
return (data ?? []) as Task[];
}
export async function createTask(
input: CreateTaskInput,
) {
const supabase = createClient();
const userId = await getCurrentUserId();
const title = input.title.trim();
if (!title) {
throw new Error('작업 제목을 입력해 주세요.');
}
const { data, error } = await supabase
.from('tasks')
.insert({
owner_id: userId,
title,
status: 'todo',
})
.select(
'id, owner_id, title, status, created_at, updated_at',
)
.single();
if (error) {
throw error;
}
return data as Task;
}
export async function updateTaskStatus(
taskId: string,
status: TaskStatus,
) {
const supabase = createClient();
const { data, error } = await supabase
.from('tasks')
.update({
status,
updated_at: new Date().toISOString(),
})
.eq('id', taskId)
.select(
'id, owner_id, title, status, created_at, updated_at',
)
.maybeSingle();
if (error) {
throw error;
}
return data as Task | null;
}
export async function deleteTask(
taskId: string,
) {
const supabase = createClient();
const { data, error } = await supabase
.from('tasks')
.delete()
.eq('id', taskId)
.select('id')
.maybeSingle();
if (error) {
throw error;
}
return data;
}
lib/types/task.ts
export type TaskStatus =
| 'todo'
| 'done';
export type Task = {
id: string;
owner_id: string;
title: string;
status: TaskStatus;
created_at: string;
updated_at: string;
};
export type CreateTaskInput = {
title: string;
};
next-env.d.ts
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package.json
{
"name": "taskflow-supabase-checkpoint",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.5",
"react": "19.3.0",
"react-dom": "19.3.0",
"@supabase/supabase-js": "2.116.0",
"server-only": "0.0.1"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "24.10.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3"
}
}
README.md
# Supabase 06 완료 체크포인트 — owned-crud
원문 회차 코드를 누적한 독립 실행용 최소 Next.js App Router 학습 앱입니다. 이전 Next.js 38편 앱 전체나 추가 CRUD 화면을 복제한 프로젝트가 아닙니다.
Node.js 24.18.0, npm 11.16.0 기준입니다.
1. `npm ci`
2. `.env.example`을 `.env.local`로 복사하고 개발 전용 Supabase URL/publishable key를 입력합니다.
3. 아래 순서대로 개발 DB에 해당 SQL을 한 번씩 적용합니다. 같은 객체가 있으면 덮어쓰지 않습니다.
4. `npm run dev` 후 `/tasks`를 확인합니다.
5. `npm run typecheck` 및 `npm run build`를 실행합니다.
SQL: `supabase/learning/02_public_tasks_lab.sql` → `supabase/migrations/05_taskflow_schema.sql` → `06_profiles_tasks_rls.sql`.
`/login`은 이메일/비밀번호 인증 화면입니다. 실제 가입/확인 메일은 본인 개발 프로젝트에서 직접 검증하세요.
이 회차는 `lib/tasks/supabase-task-repository.ts`의 CRUD 함수를 학습합니다. 새 CRUD UI는 포함하지 않으며 `/tasks`는 2편 공개 조회 화면입니다. 개인 작업 서버 조회 화면은 8편부터 교체됩니다.
실제 secret/service_role 키, 비밀번호, 원격 DB 계정은 포함하지 않습니다. node_modules/.next/.env.local은 제외했습니다. ZIP의 package-lock.json으로 정확한 의존성을 재현하세요.
supabase/learning/02_public_tasks_lab.sql
begin;
create table public.public_tasks_lab (
id bigint generated always as identity primary key,
title text not null,
created_at timestamptz not null default now()
);
insert into public.public_tasks_lab (title)
values
('Supabase 연결 확인'),
('RLS 공개 읽기 확인');
alter table public.public_tasks_lab
enable row level security;
revoke all
on table public.public_tasks_lab
from public, anon, authenticated;
grant select
on table public.public_tasks_lab
to anon, authenticated;
create policy "public_tasks_lab_read"
on public.public_tasks_lab
for select
to anon, authenticated
using (true);
commit;
supabase/migrations/05_taskflow_schema.sql
begin;
create table public.profiles (
id uuid primary key
references auth.users(id)
on delete cascade,
display_name text,
created_at timestamptz not null default now()
);
create table public.tasks (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null
references public.profiles(id)
on delete cascade,
title text not null,
status text not null default 'todo',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint tasks_status_check
check (status in ('todo', 'done')),
constraint tasks_title_check
check (
char_length(btrim(title))
between 1 and 120
)
);
create index tasks_owner_id_idx
on public.tasks(owner_id);
alter table public.profiles
enable row level security;
alter table public.tasks
enable row level security;
revoke all
on table public.profiles
from public, anon, authenticated;
revoke all
on table public.tasks
from public, anon, authenticated;
commit;
supabase/migrations/06_profiles_tasks_rls.sql
begin;
grant select, insert, update
on table public.profiles
to authenticated;
grant select, insert, update, delete
on table public.tasks
to authenticated;
create policy "profiles_select_self"
on public.profiles
for select
to authenticated
using (
(select auth.uid()) is not null
and (select auth.uid()) = id
);
create policy "profiles_insert_self"
on public.profiles
for insert
to authenticated
with check (
(select auth.uid()) is not null
and (select auth.uid()) = id
);
create policy "profiles_update_self"
on public.profiles
for update
to authenticated
using (
(select auth.uid()) = id
)
with check (
(select auth.uid()) = id
);
create policy "tasks_select_own"
on public.tasks
for select
to authenticated
using (
(select auth.uid()) is not null
and (select auth.uid()) = owner_id
);
create policy "tasks_insert_own"
on public.tasks
for insert
to authenticated
with check (
(select auth.uid()) is not null
and (select auth.uid()) = owner_id
);
create policy "tasks_update_own"
on public.tasks
for update
to authenticated
using (
(select auth.uid()) = owner_id
)
with check (
(select auth.uid()) = owner_id
);
create policy "tasks_delete_own"
on public.tasks
for delete
to authenticated
using (
(select auth.uid()) = owner_id
);
commit;
tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"allowJs": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
실행·결과 확인
반드시 서로 다른 로그인 세션 두 개로 확인합니다.
기록할 것은 실제 이메일이나 JWT가 아니라 결과입니다.
A 자기 행 SELECT: 허용
A → B 행 SELECT: 미노출
A 자기 행 UPDATE: 허용
A → B 행 UPDATE: 변경 없음
A owner_id 변경: 거부
anon 접근: 거부
로컬 SQL 테스트와 실제 Supabase Auth 사용자 테스트를 같은 검증으로 표시하지 않습니다.
실제 계정 두 개를 사용하지 않았다면 “정책 예상 결과”와 “실행 결과”를 구분합니다.
완료 체크포인트
supabase-step-06-owned-crud
프로젝트 적용 후 확인 기록
독자 질문: A가 B의 작업 ID를 알아도 읽기·수정·삭제가 막히는가?
따라 할 단계
- 서로 다른 브라우저 프로필에서 A와 B를 각각 로그인하고 상단의 /crud-lab 화면을 추가해 각자 작업 하나를 생성한다. 기존 /tasks 공개 조회 화면에는 개인 작업 생성 폼이 없다. 생성한 ID를 안전한 로컬 기록에 적는다.
- A 세션으로 자신의 행 SELECT·INSERT·UPDATE·DELETE를 시도한 뒤 B 작업 ID를 지정한 SELECT·UPDATE·DELETE도 시도한다.
- B 세션에서도 대상을 바꿔 같은 실험을 반복하고, 각 실패 요청 직후 소유자 계정으로 재조회해 데이터가 그대로인지 확인한다.
예상 결과와 A/B 계정 범위
- A는 A 행만, B는 B 행만 조회·수정·삭제할 수 있어야 한다. 상대방 행은 SELECT 0행, UPDATE/DELETE 0행 등으로 보일 수 있고, 상대방 owner_id로 INSERT/소유권 변경은 거부되어야 한다.
- error가 null이어도 변경된 행이 0개이면 허용 성공으로 표시하지 않는다. service_role/secret 키는 이 실험에서 사용하지 않는다.
흔한 오류 점검
- 타인 행이 보이면 RLS 활성화·GRANT·USING/WITH CHECK·현재 세션과 키를 확인한다.
- 한 브라우저의 세션을 A/B가 공유하면 계정 격리를 확인한 것이 아니므로 프로필을 분리한다.
검증 기록: 이번 편집에서 실제 Supabase 요청·A/B 계정 실험은 실행하지 않았으므로 미확인입니다.
다음 학습
7회차에서 같은 허용·거부 행렬을 pgTAP 회귀 테스트로 고정한다.
다음 학습
수동으로 A/B 계정을 바꿔 보는 것만으로는 정책 변경 때 회귀 오류를 막기 어렵습니다.
다음 글에서는 권한 행렬을 먼저 작성하고, migration과 pgTAP 테스트를 저장소에 남겨 GRANT와 RLS 정책을 자동으로 검증합니다.
다음 글:
Supabase RLS 권한 설계: GRANT·policy·키 경계와 데이터베이스 테스트
공식 문서
확인일: 2026-09-21
- Row Level Security: https://supabase.com/docs/guides/database/postgres/row-level-security
- Auth: https://supabase.com/docs/guides/auth
- JavaScript Database: https://supabase.com/docs/reference/javascript/select
체크포인트 다운로드
글 소개·학습 준비·전체 목차

Supabase · 6 / 9 · 중급 학습 · 사용자별 CRUD와 RLS
RLS를 켠 테이블은 적절한 GRANT와 policy를 함께 구성해야 사용자 요청을 허용할 수 있습니다.
상단에서는 스키마와 repository가 포함된 샘플에 작은 실습 화면을 붙여 자신의 행 CRUD를 확인하고, 하단에서 누적 프로젝트와 A/B 권한 실험을 이어갑니다.
이번 회차에서는 필요한 SQL 작업만 authenticated 역할에 허용하고, RLS policy로 자신의 행만 접근하도록 제한합니다.
핵심 목표는 화면에서 다른 사용자의 데이터를 숨기는 것이 아닙니다.
직접 API 요청을 보내도 A 사용자가 B 사용자의 작업을 읽거나 수정할 수 없어야 합니다.
이번 글에서 배우는 내용
- authenticated 역할
- GRANT와 RLS의 차이
- auth.uid()
- SELECT USING
- INSERT WITH CHECK
- UPDATE USING + WITH CHECK
- DELETE USING
- profiles 자기 행 정책
- tasks 소유자 정책
- 두 계정 A/B 데이터 격리
- 빈 결과와 permission error 차이
선수 지식
- 5회차 profiles/tasks 관계형 스키마
- Supabase Auth 로그인
- SQL SELECT / INSERT / UPDATE / DELETE
- React Client Component 기본
목차
이 글이 도움이 되었나요?
분류별 과정 이동 (선택)
읽음 기록
읽음 — / 전체 9개 · 필수 8개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.