Supabase에서 사용자·프로필·작업의 관계와 저장값 제약은 어떻게 설계하나요?
사용자·프로필·작업의 관계는 외래키로 연결하고 제목·상태 같은 저장값은 제약 조건으로 제한합니다. 다만 데이터 모양이 올바르다고 사용자의 쓰기 권한까지 생기는 것은 아닙니다.
적용 조건·범위: 검증 범위: 실제 Supabase 요청, 계정 생성, 메일 발송, 원격 DB 정책·실행 계획 검증은 이 편집에서 실행하지 않았습니다. 위 결과는 독자가 확인할 기대값입니다.
본문 근거: supabase.com/docs/guides/auth/managing-user-data · supabase.com/docs/guides/database/postgres/row-level-security
auth.users와 public.profiles 분리
Supabase Auth는 auth schema 안에서 사용자 인증 정보를 관리합니다.
이 테이블은 앱의 일반적인 public 데이터 테이블과 역할이 다릅니다.
닉네임, 표시 이름, 앱 설정 같은 값을 저장하고 싶다고 auth.users에 앱 컬럼을 직접 추가하는 방식으로 시작하지 않습니다.
대신 public.profiles를 만들고 사용자 ID를 참조합니다.
auth.users
인증 계정
public.profiles
앱에서 사용하는 프로필
이렇게 나누면 인증 시스템과 앱 도메인 데이터를 분리할 수 있습니다.
profiles 설계
최소 프로필은 다음과 같습니다.
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()
);
id는 새로운 임의 UUID를 만드는 대신 Auth 사용자 ID와 동일한 값을 사용합니다.
한 사용자와 한 프로필을 1:1로 연결하기 쉽습니다.
on delete cascade는 Auth 사용자가 삭제될 때 연결된 프로필도 제거하는 정책입니다.
무조건 좋은 옵션은 아닙니다.
서비스가 탈퇴 후 데이터를 별도로 보관해야 한다면 다른 정책이 필요합니다.
제공된 예제 스키마는 Auth 사용자 삭제 시 연결된 프로필과 작업을 함께 정리하도록 선택했습니다. 서비스의 보관 요구가 다르다면 이 삭제 정책도 별도로 설계해야 합니다.
tasks 설계
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()
);
owner_id가 profiles.id를 참조합니다.
profiles.id 자체가 auth.users.id와 동일한 UUID이므로 이후 RLS에서 다음 비교를 사용할 수 있습니다.
(select auth.uid()) = owner_id
status 값 제한
이 예제의 작업은 다음 두 상태만 사용한다고 가정합니다.
todo
done
CHECK를 추가합니다.
constraint tasks_status_check
check (status in ('todo', 'done'))
이 제약 조건은 잘못된 문자열이 데이터베이스에 저장되는 것을 막습니다.
TypeScript union만으로 DB를 보호할 수 없다는 점이 중요합니다.
type TaskStatus = 'todo' | 'done';
프론트엔드 타입은 개발 편의와 정적 검사를 담당하고, 데이터베이스 CHECK는 실제 저장 규칙을 담당합니다.
둘 다 필요할 수 있습니다.
제목 길이도 DB에서 제한하기
constraint tasks_title_check
check (
char_length(btrim(title))
between 1 and 120
)
앞뒤 공백을 제거한 기준으로 1~120자여야 합니다.
UI의 maxLength와 DB의 CHECK는 같은 목적을 다른 계층에서 보완합니다.
외래키가 있다고 권한이 생기는 것은 아닙니다
외래키는 관계의 무결성을 확인합니다.
tasks.owner_id가
실제로 존재하는 profiles.id인가?
RLS는 접근 권한을 확인합니다.
현재 사용자가
이 tasks 행을 읽거나 수정할 수 있는가?
둘은 완전히 다른 역할입니다.
외래키를 만들었다고 다른 사용자 데이터 접근이 자동으로 차단되지는 않습니다.
테이블을 만든 직후 RLS부터 활성화
public schema는 Data API를 통해 노출될 수 있는 영역입니다.
따라서 테이블을 만든 뒤 다음 회차까지 열린 상태로 두지 않습니다.
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;
이번 회차에서는 사용자용 policy를 아직 만들지 않습니다.
따라서 publishable key로 profiles나 tasks를 읽거나 쓰려고 하면 차단되는 것이 정상입니다.
이것이 5회차의 완료 상태입니다.
테이블 생성
↓
관계 생성
↓
RLS ON
↓
기본 권한 닫기
↓
아직 사용자 접근 불가
6회차에서 필요한 GRANT와 RLS policy를 명시적으로 추가합니다.
기본 인덱스
tasks.owner_id는 사용자별 작업 목록의 핵심 조건이 됩니다.
create index tasks_owner_id_idx
on public.tasks(owner_id);
이번 회차에서는 이 정도만 둡니다.
status, created_at까지 조합한 복합 인덱스는 실제 쿼리를 만든 뒤 9회차에서 판단합니다.
인덱스를 많이 만드는 것이 목표가 아닙니다.
TypeScript Task 타입
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;
};
CreateTaskInput에 owner_id를 넣지 않습니다.
사용자가 폼에서 임의의 owner_id를 전달하는 구조를 만들지 않기 위해서입니다.
소유자는 현재 로그인 사용자로 결정합니다.
자주 발생하는 오류
auth.users에 display_name을 직접 추가하려 한다
앱 프로필을 public.profiles로 분리합니다.
tasks.owner_id에 외래키가 없다
존재하지 않는 사용자 UUID를 저장할 수 있는 구조가 됩니다.
외래키가 있으니 RLS가 없어도 된다고 생각한다
외래키는 데이터 관계, RLS는 접근 권한입니다.
RLS policy를 다음 회차에서 만들 예정이라 RLS 자체도 나중에 켠다
테이블 생성 직후 RLS를 활성화해 닫힌 상태부터 시작합니다.
이 기술만 따로 연습하기
이 글의 완성 실습 ZIP을 별도 폴더에 풀어 단독 샘플로 사용합니다. 이전 회차의 작업 폴더는 필요하지 않습니다. Node.js 24 계열과 ZIP의 package-lock.json을 기준으로 README의 설치·환경 변수 절차를 한 번 진행합니다. 필요한 개발용 Supabase 프로젝트와 공개 가능한 URL·publishable key는 독자가 준비합니다. 실습은 운영 데이터와 분리합니다. 전체 파일은 아래 코드 보기에서도 확인할 수 있습니다.
이 실습의 입력은 SQL 파일 05_taskflow_schema.sql과 다음 세 가지 데이터 후보입니다. 코드에 등장하는 auth.users는 Supabase Auth가 관리하는 사용자 테이블이며 독자가 별도로 같은 이름의 테이블을 만드는 대상이 아닙니다.
| 후보 | 판정할 제약 | 기대 결과 |
|---|---|---|
| 존재하는 프로필 ID, 제목 “문서 읽기”, status todo | FK·제목 길이·상태 | 데이터 제약을 만족함 |
| 존재하지 않는 프로필 ID | tasks.owner_id 외래키 | 거부 |
| 공백 제목 또는 status archived | tasks_title_check / tasks_status_check | 거부 |
- 위의 스키마 설명과 SQL을 읽고 각 후보를 제한하는 정확한 제약 이름을 표에 표시합니다.
- 별도 개발 DB에서 README 순서대로 스키마를 적용하고 Table Editor의 관계·제약 정의를 확인합니다.
- 사용자 요청 관점에서는 이 단계에 authenticated GRANT와 허용 policy가 아직 없다는 점을 함께 기록합니다. 유효한 데이터 모양이라고 쓰기 권한도 생기는 것은 아닙니다.
기대 결과: Auth 사용자 → 앱 프로필 → 작업 소유자의 관계도와, 데이터 제약/사용자 권한을 구분한 판정표가 완성됩니다.
자가 확인·변형: 작업 제목을 121자로 바꾸면 어떤 제약이 거부할까요? tasks_title_check입니다. 권한 테스트는 다음 CRUD/RLS 실습의 별도 목표입니다.
검증 범위: 실제 Supabase 요청, 계정 생성, 메일 발송, 원격 DB 정책·실행 계획 검증은 이 편집에서 실행하지 않았습니다. 위 결과는 독자가 확인할 기대값입니다.
학습 완료 체크리스트
- auth.users와 public.profiles의 역할 차이를 설명할 수 있습니다.
- profiles.id가 auth.users.id를 참조하는 이유를 알고 있습니다.
- tasks.owner_id가 profiles.id를 참조하는 이유를 알고 있습니다.
- FK와 RLS의 차이를 설명할 수 있습니다.
- CHECK와 TypeScript union의 역할 차이를 설명할 수 있습니다.
- 테이블 생성 직후 RLS를 활성화하는 이유를 알고 있습니다.
- 이번 회차 완료 시 사용자 API 접근이 차단되는 것이 정상임을 이해했습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 데이터 저장·인증·접근 권한을 연결합니다.
이번 주제: Supabase Postgres 관계형 데이터 모델링: 외래키와 TaskFlow 스키마 설계.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
이전 단계 시작 ZIP을 먼저 별도 작업 폴더에 풉니다. 현재 회차 ZIP은 적용 후 비교할 완성본입니다.
3회차 Auth 시작 ZIP에서 supabase/migrations/05_taskflow_schema.sql, lib/types/task.ts, docs/data-model.md를 추가합니다. 선택 실습인 이메일 회차를 건너뛰어도 스키마 작업에 지장이 없습니다.
이전 체크포인트
전체 순서:
supabase-step-04-email-confirmation
4회차를 건너뛴 경우:
supabase-step-03-auth-basics
현재 상태
Auth 사용자 존재
공개 lab 테이블 존재
실제 TaskFlow 관계형 모델 없음
이번 변경 파일
supabase/
└─ migrations/
└─ 05_taskflow_schema.sql
lib/
└─ types/
└─ task.ts
docs/
└─ data-model.md
전체 파일·코드 보기
실습 프로젝트 전체 코드
아래는 이 회차까지의 코드를 누적한 독립 실행용 최소 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/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 05 완료 체크포인트 — relational-schema
원문 회차 코드를 누적한 독립 실행용 최소 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`.
`/login`은 이메일/비밀번호 인증 화면입니다. 실제 가입/확인 메일은 본인 개발 프로젝트에서 직접 검증하세요.
실제 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;
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"
]
}
실행·결과 확인
SQL 적용 후 Table Editor 또는 SQL로 구조를 확인합니다.
예:
select
table_name
from information_schema.tables
where table_schema = 'public'
and table_name in ('profiles', 'tasks');
외래키도 확인합니다.
그리고 publishable key 기반 클라이언트에서 profiles/tasks를 직접 읽으려 했을 때 사용자용 GRANT와 policy가 아직 없으므로 접근이 차단되는지 확인합니다.
이 단계에서 개인 데이터가 조회된다면 완료가 아니라 보안 설정을 다시 확인해야 합니다.
완료 체크포인트
supabase-step-05-relational-schema
프로젝트 적용 후 확인 기록
독자 질문: 외래키가 연결된 테이블을 만들면 다른 사용자 접근도 자동으로 차단될까?
따라 할 단계
- 5회차 migration에서 profiles.id와 tasks.owner_id의 외래키·NOT NULL·CHECK를 확인한다.
- RLS가 켜져 있고 anon/authenticated 기본 권한을 닫았는지 확인한다.
- A·B 테스트 계정을 준비해, 6회차 policy를 추가하기 전 각 계정의 tasks SELECT와 INSERT를 시도하고 상태를 기록한다.
예상 결과와 A/B 계정 범위
- 외래키는 잘못된 관계를 막지만 소유자 접근 권한을 대신하지 않는다. 현재 기본 권한을 닫은 단계에서는 A·B 모두 일반 사용자 CRUD가 허용되지 않아야 한다.
- 오류 메시지 또는 0행은 GRANT·RLS 설정에 따라 달라지므로 응답의 data·error·영향받은 행을 함께 기록한다.
흔한 오류 점검
- 다른 계정 작업이 보이면 RLS 활성화·테이블 GRANT·사용 중인 키를 점검한다.
- 외래키 오류와 권한 오류를 구분하고, 관리자 키로 사용자 격리를 시험하지 않는다.
검증 기록: 이번 편집에서 실제 Supabase 요청·A/B 계정 실험은 실행하지 않았으므로 미확인입니다.
다음 학습
6회차에서 본인 행만 허용하는 GRANT·policy를 추가한 뒤 A/B 허용·거부를 다시 확인한다.
다음 학습
다음 글에서는 닫아 둔 profiles와 tasks에 필요한 권한만 엽니다.
로그인한 사용자는 자신의 profile과 tasks만 다룰 수 있게 만들고, 두 계정 A/B로 데이터 격리를 검증합니다.
다음 글:
Supabase 사용자별 CRUD와 RLS: 두 계정으로 데이터 격리 검증하기
공식 문서
확인일: 2026-09-21
- Supabase Auth: https://supabase.com/docs/guides/auth
- Row Level Security: https://supabase.com/docs/guides/database/postgres/row-level-security
- Managing User Data: https://supabase.com/docs/guides/auth/managing-user-data
체크포인트 다운로드
글 소개·학습 준비·전체 목차

Supabase · 5 / 9 · 중급 학습 · 관계형 스키마
외래키는 존재하는 사용자·프로필·작업의 관계를 보장하지만 사용자 접근 권한을 정하지 않습니다. CHECK는 저장값의 규칙을, GRANT와 RLS는 요청의 권한을 담당합니다. 아래 두 테이블 스키마에서 잘못된 관계·제목·상태를 각각 판정합니다.
상단에서는 제공된 두 테이블 스키마로 관계와 제약을 독립적으로 이해하고, 하단에서 TaskFlow 데이터 모델에 적용합니다.
핵심 관계는 두 개입니다.
auth.users
│
│ id
▼
public.profiles
│
│ id = 사용자 UUID
▼
public.tasks.owner_id
profiles.id는 auth.users.id를 참조하고, tasks.owner_id는 profiles.id를 참조합니다.
이 구조를 고정하면 사용자 인증, 앱 프로필, 사용자 작업을 각각 다른 책임으로 나누면서도 외래키로 연결할 수 있습니다.
이번 글에서 배우는 내용
- auth schema와 public schema 역할 분리
- primary key와 foreign key
- profiles 1:1 사용자 모델
- tasks 소유자 관계
- NOT NULL
- CHECK
- DEFAULT
- ON DELETE CASCADE
- 외래키와 RLS가 서로 다른 기능인 이유
- 테이블 생성 직후 RLS를 먼저 닫는 방법
선수 지식
- Supabase Auth 사용자 개념
- SQL의 테이블·행·열
- TypeScript 객체 타입 기초
- 3회차 Auth 기본
4회차 메일 현지화는 선택 실습이므로 건너뛰어도 됩니다.
목차
이 글이 도움이 되었나요?
분류별 과정 이동 (선택)
읽음 기록
읽음 — / 전체 9개 · 필수 8개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.