Supabase 관계 조회에서 필요한 열과 인덱스 후보는 어떻게 정하나요?
Supabase에서 작업과 프로필을 함께 읽으려면 외래키 관계를 따라 필요한 열만 선택하고 필터·정렬·행 수 제한을 정합니다. 조회 결과가 맞는지와 인덱스가 도움이 되는지는 별도로 확인하세요.
적용 조건·범위: 화면에서 받은 임의의 사용자 UUID로 권한 범위를 정하지 않습니다. 현재 예제는 RLS가 owner_id 접근 범위를 제한합니다. 조회 최적화에서는 검증한 로그인 사용자 ID를 같은 필터로 명시하는 방법도 검토할 수 있습니다. 이 필터가 RLS를 대신하는 것은 아니며, 공식 RLS 성능 안내처럼 실행 계획과 함께 판단합니다.
본문 근거: supabase.com/docs/guides/database/joins-and-nesting · supabase.com/docs/guides/database/query-optimization · 공식 RLS 성능 안내
대표 쿼리부터 정의하기
독립 샘플 목록이 사용할 조회 조건을 먼저 고정합니다.
현재 로그인 사용자의 작업
필요하면 status 필터
최신 작업 우선
한 번에 20개
프로필의 display_name 함께 조회
RLS가 이미 현재 사용자의 행만 보이게 제한합니다.
따라서 애플리케이션 쿼리는 화면 기능에 필요한 필터와 정렬에 집중합니다.
외래키 기반 관계 조회
제공된 스키마에는 다음 외래키 관계가 있습니다.
tasks.owner_id
→ profiles.id
Supabase Data API는 외래키를 이용해 관련 테이블을 중첩 조회할 수 있습니다.
예:
const { data, error } = await supabase
.from('tasks')
.select(
'id, title, status, created_at, owner:profiles(display_name)',
);
owner라는 alias를 사용해 반환 결과를 읽기 쉽게 만듭니다.
프로필의 모든 컬럼이 필요하지 않으므로 display_name만 선택합니다.
select('*')를 습관처럼 사용하지 않기
다음 요청은 간단하지만 화면에 필요하지 않은 데이터까지 가져올 수 있습니다.
.select('*')
목록 화면에서는 실제로 쓰는 컬럼을 고릅니다.
id
title
status
created_at
profiles.display_name
데이터 양이 커질수록 컬럼 선택과 페이지 크기 제한이 중요해집니다.
상태 필터
status가 all이면 필터를 붙이지 않고, todo/done이면 eq 조건을 추가합니다.
lib/tasks/queries.ts:
import {
createClient,
} from '@/lib/supabase/server';
export type TaskFilter =
| 'all'
| 'todo'
| 'done';
export async function getTaskList(
filter: TaskFilter,
) {
const supabase =
await createClient();
let query = supabase
.from('tasks')
.select(
'id, title, status, created_at, owner:profiles(display_name)',
)
.order(
'created_at',
{
ascending: false,
},
)
.limit(20);
if (filter !== 'all') {
query = query.eq(
'status',
filter,
);
}
const { data, error } =
await query;
if (error) {
throw error;
}
return data ?? [];
}
화면에서 받은 임의의 사용자 UUID로 권한 범위를 정하지 않습니다. 현재 예제는 RLS가 owner_id 접근 범위를 제한합니다. 조회 최적화에서는 검증한 로그인 사용자 ID를 같은 필터로 명시하는 방법도 검토할 수 있습니다. 이 필터가 RLS를 대신하는 것은 아니며, 공식 RLS 성능 안내처럼 실행 계획과 함께 판단합니다.
화면이 타인의 사용자 ID를 넘겨도 권한 경계가 바뀌지 않아야 합니다.
최신순 정렬과 limit
.order(
'created_at',
{
ascending: false,
},
)
.limit(20)
전체 tasks를 한 번에 가져온 뒤 브라우저에서 slice하지 않습니다.
데이터베이스 쿼리 단계에서 필요한 범위를 줄입니다.
실제 페이지네이션이 필요해지면 range 또는 cursor 전략을 별도로 설계할 수 있습니다.
이번 글의 범위는 첫 20개 목록입니다.
기본 인덱스
제공된 스키마의 기본 인덱스는 다음과 같습니다.
create index tasks_owner_id_idx
on public.tasks(owner_id);
RLS policy가 owner_id를 사용하므로 중요한 기본 인덱스입니다.
하지만 목록 쿼리는 최신순 정렬도 합니다.
대표 쿼리가 다음과 같은 형태라면:
select
id,
title,
status,
created_at
from public.tasks
where owner_id = $1
order by created_at desc
limit 20;
다음 복합 인덱스를 검토할 수 있습니다.
create index tasks_owner_created_at_idx
on public.tasks (
owner_id,
created_at desc
);
사용자별 목록과 최신순 정렬을 함께 지원하는 후보입니다.
status까지 인덱스에 넣어야 할까
다음처럼 무조건 추가하지 않습니다.
create index tasks_owner_status_created_at_idx
on public.tasks (
owner_id,
status,
created_at desc
);
status 필터가 매우 자주 사용되고 데이터 분포가 충분히 선택적이며 실행 계획에서 이득이 확인될 때 검토합니다.
todo/done처럼 값 종류가 적은 컬럼은 단독 인덱스가 큰 이득이 없을 수 있습니다.
쿼리와 데이터 분포를 보고 판단합니다.
migration
supabase/migrations/09_indexes.sql:
begin;
create index if not exists
tasks_owner_created_at_idx
on public.tasks (
owner_id,
created_at desc
);
commit;
기존 tasks_owner_id_idx를 바로 삭제하지 않습니다.
실제 실행 계획과 사용량을 보고 중복 여부를 판단합니다.
EXPLAIN
SQL Editor에서 대표 쿼리의 계획을 확인할 수 있습니다.
explain
select
id,
title,
status,
created_at
from public.tasks
where owner_id =
'00000000-0000-0000-0000-000000000000'
order by created_at desc
limit 20;
예제 UUID는 실제 사용자 값이 아닙니다.
EXPLAIN은 쿼리를 실행하지 않고 계획을 보여주는 용도로 사용할 수 있습니다.
EXPLAIN ANALYZE는 실제 쿼리를 실행하므로 UPDATE/DELETE 같은 변경 쿼리에서 무심코 사용하면 데이터에 영향을 줄 수 있습니다.
운영 데이터에서는 특히 주의합니다.
작은 테이블에서는 Seq Scan이 정상일 수 있습니다
행이 몇 개뿐이라면 인덱스를 만들었는데도 PostgreSQL이 Seq Scan을 선택할 수 있습니다.
전체 테이블을 읽는 비용이 더 작기 때문입니다.
Seq Scan이 보인다
≠
인덱스 실패
데이터 규모와 비용 추정치를 함께 봅니다.
Query Performance와 Index Advisor
Supabase Studio의 Query Performance Report에서 실제 쿼리를 확인할 수 있습니다.
Index Advisor는 특정 쿼리에 도움이 될 수 있는 인덱스를 추천합니다.
하지만 추천 결과를 무조건 migration으로 복사하지 않습니다.
확인할 질문:
이 쿼리가 실제 서비스에서 자주 실행되는가?
이미 비슷한 인덱스가 있는가?
쓰기 비용은 감당 가능한가?
테이블 데이터가 충분히 큰가?
RLS 조건과 화면 필터가 어떤 열을 사용하는가?
인덱스는 INSERT/UPDATE/DELETE 때도 유지해야 하므로 읽기 성능과 쓰기 비용을 함께 봅니다.
RLS 성능도 인덱스와 연결됩니다
tasks policy는 owner_id를 사용합니다.
using (
(select auth.uid()) = owner_id
)
사용자별 데이터가 커질수록 owner_id 인덱스는 RLS가 대상 행을 찾는 데에도 중요할 수 있습니다.
보안 규칙과 성능은 목적이 다르지만 같은 컬럼을 반복해서 사용한다면 인덱스 검토 대상이 됩니다.
Server Component 목록 페이지
app/tasks/page.tsx:
import {
redirect,
} from 'next/navigation';
import {
getTaskList,
type TaskFilter,
} from '@/lib/tasks/queries';
import {
createClient,
} from '@/lib/supabase/server';
type Props = {
searchParams: Promise<{
status?: string;
}>;
};
function parseFilter(
value: string | undefined,
): TaskFilter {
if (
value === 'todo'
|| value === 'done'
) {
return value;
}
return 'all';
}
export default async function TasksPage({
searchParams,
}: Props) {
const supabase =
await createClient();
const {
data,
error,
} = await supabase.auth.getClaims();
if (
error
|| !data?.claims?.sub
) {
redirect('/login');
}
const params =
await searchParams;
const filter =
parseFilter(
params.status,
);
const tasks =
await getTaskList(
filter,
);
return (
<main>
<h1>내 작업</h1>
<nav>
<a href="/tasks">
전체
</a>
<a href="/tasks?status=todo">
진행 전
</a>
<a href="/tasks?status=done">
완료
</a>
</nav>
{tasks.length === 0 ? (
<p>
조건에 맞는 작업이 없습니다.
</p>
) : (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<strong>
{task.title}
</strong>
<span>
{task.status}
</span>
</li>
))}
</ul>
)}
</main>
);
}
운영 전 최종 체크리스트
API key
publishable key만 클라이언트에 존재
secret key는 브라우저 번들에 없음
legacy key 의존 여부 확인
RLS
profiles RLS enabled
tasks RLS enabled
anon 개인 데이터 접근 거부
authenticated 자기 행만 허용
A/B 테스트 통과
pgTAP 회귀 테스트 통과
Auth
client/server client 분리
proxy session refresh 구조 존재
getClaims로 서버 보호
로그아웃 후 보호 페이지 접근 차단
인증 응답 공유 캐시 금지
환경 변수
.env.local Git 제외
.env.example 실제 값 없음
로그/스크린샷에 토큰 없음
Query
select('*') 남용 없음
목록 limit 존재
대표 쿼리 확인
필요한 인덱스 검토
Index Advisor 제안 수동 검토
자주 발생하는 오류
모든 컬럼에 인덱스를 만든다
쓰기 비용과 저장 공간이 늘어납니다.
대표 쿼리에 필요한 인덱스부터 만듭니다.
Index Advisor가 추천했으니 바로 적용한다
기존 인덱스, 호출 빈도, 데이터 크기와 함께 검토합니다.
select('*')를 계속 사용한다
화면에 필요한 컬럼을 명시합니다.
RLS가 있으니 페이지 인증 확인은 필요 없다고 생각한다
RLS는 DB 권한의 최종 경계입니다.
페이지 자체를 로그인 사용자에게만 보여주는 UX와 서버 route 보호는 별도로 필요합니다.
이 기술만 따로 연습하기
이 글의 완성 실습 ZIP을 별도 폴더에 풀어 단독 샘플로 사용합니다. 이전 회차의 작업 폴더는 필요하지 않습니다. Node.js 24 계열과 ZIP의 package-lock.json을 기준으로 README의 설치·환경 변수 절차를 한 번 진행합니다. 필요한 개발용 Supabase 프로젝트와 공개 가능한 URL·publishable key는 독자가 준비합니다. 실습은 운영 데이터와 분리합니다. 전체 파일은 아래 코드 보기에서도 확인할 수 있습니다.
단독 실습의 입력은 완성 ZIP의 lib/tasks/queries.ts와 supabase/migrations/09_indexes.sql입니다. 데이터는 개발용 계정의 작업으로 준비하며 전체 과정의 구현 과정을 기억할 필요는 없습니다. Auth·스키마·RLS 준비는 ZIP README에 포함되어 있습니다.
- lib/tasks/queries.ts에서 선택한 열, profiles 관계, created_at 내림차순, 최대 20행, all/todo/done 분기를 찾아 표시합니다.
- 로그인 후 /tasks의 필터를 all → todo → done으로 바꾸어 현재 계정의 일치 행만 나오는지 확인합니다. 준비한 행이 없으면 빈 결과로 기록합니다.
- 별도 연습 복사본에서 .limit(20)을 .limit(1)로 바꾸고 최대 한 행이라는 결과를 확인한 뒤 원래 20으로 돌립니다.
- 인덱스 적용 전후를 비교할 개발 데이터 환경에서 대표 SELECT의 EXPLAIN을 기록합니다. 실행 계획과 행 수를 함께 기록하며 성능 수치를 임의로 채우지 않습니다.
기대 결과: 필터·정렬·제한은 화면 결과로, 인덱스는 실행 계획으로 각각 판단합니다. 데이터가 적으면 Seq Scan이 합리적일 수 있으며 인덱스 생성 사실만으로 성능 향상을 단정할 수 없습니다.
자가 확인·변형: todo 필터와 limit 1을 함께 쓰면 전체 데이터 첫 행을 자른 뒤 필터링할까요? SQL 조건에 맞는 행을 정렬한 결과에서 최대 한 행을 반환합니다.
검증 범위: 실제 Supabase 요청, 계정 생성, 메일 발송, 원격 DB 정책·실행 계획 검증은 이 편집에서 실행하지 않았습니다. 위 결과는 독자가 확인할 기대값입니다.
학습 완료 체크리스트
- 외래키 기반 관계 조회를 작성할 수 있습니다.
- 화면에 필요한 컬럼만 선택할 수 있습니다.
- filter, order, limit을 조합할 수 있습니다.
- 대표 쿼리부터 정의한 뒤 인덱스를 판단할 수 있습니다.
- 작은 테이블의 Seq Scan을 무조건 문제로 보지 않습니다.
- Index Advisor 결과를 검토할 수 있습니다.
- Supabase 운영 전 key·RLS·Auth·Query 체크리스트를 수행할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 데이터 저장·인증·접근 권한을 연결합니다.
이번 주제: Supabase 관계 조회와 인덱스 최적화: TaskFlow 운영 전 최종 점검.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 최종 적용
이전 단계 시작 ZIP을 먼저 별도 작업 폴더에 풉니다. 현재 회차 ZIP은 적용 후 비교할 완성본입니다.
8회차 시작 ZIP에 lib/tasks/queries.ts와 09_indexes.sql을 추가하고 app/tasks/page.tsx에서 조회 함수를 사용합니다. 필터·관계·정렬·제한을 한 쿼리로 모으고 실제 실행 계획으로 인덱스를 판단합니다.
이전 체크포인트
supabase-step-08-ssr-auth
이번 변경 파일
lib/
└─ tasks/
└─ queries.ts
app/
└─ tasks/
└─ page.tsx
supabase/
└─ migrations/
└─ 09_indexes.sql
docs/
└─ production-checklist.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 {
redirect,
} from 'next/navigation';
import {
getTaskList,
type TaskFilter,
} from '@/lib/tasks/queries';
import {
createClient,
} from '@/lib/supabase/server';
type Props = {
searchParams: Promise<{
status?: string;
}>;
};
function parseFilter(
value: string | undefined,
): TaskFilter {
if (
value === 'todo'
|| value === 'done'
) {
return value;
}
return 'all';
}
export default async function TasksPage({
searchParams,
}: Props) {
const supabase =
await createClient();
const {
data,
error,
} = await supabase.auth.getClaims();
if (
error
|| !data?.claims?.sub
) {
redirect('/login');
}
const params =
await searchParams;
const filter =
parseFilter(
params.status,
);
const tasks =
await getTaskList(
filter,
);
return (
<main>
<h1>내 작업</h1>
<nav>
<a href="/tasks">
전체
</a>
<a href="/tasks?status=todo">
진행 전
</a>
<a href="/tasks?status=done">
완료
</a>
</nav>
{tasks.length === 0 ? (
<p>
조건에 맞는 작업이 없습니다.
</p>
) : (
<ul>
{tasks.map((task) => (
<li key={task.id}>
<strong>
{task.title}
</strong>
<span>
{task.status}
</span>
</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/authorization-matrix.md
# TaskFlow Authorization Matrix
## profiles
anon
- SELECT deny
- INSERT deny
- UPDATE deny
- DELETE deny
authenticated owner
- SELECT allow
- INSERT allow
- UPDATE allow
- DELETE deny
authenticated other
- all deny
## tasks
anon
- all deny
authenticated owner
- SELECT allow
- INSERT allow
- UPDATE allow
- DELETE allow
authenticated other
- SELECT hidden
- INSERT with other's owner_id deny
- UPDATE no rows
- DELETE no rows
ownership transfer
- deny
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를 추가한다.
docs/production-checklist.md
# TaskFlow Supabase Production Checklist
## Keys
- browser: publishable key only
- secret key: server only
- no real keys in repository
## RLS
- profiles RLS enabled
- tasks RLS enabled
- anon denied
- owner allowed
- other user denied
- pgTAP tests pass
## SSR Auth
- createBrowserClient
- request-scoped createServerClient
- proxy refresh
- getClaims for protected pages
- auth responses not shared-cacheable
## Query
- explicit columns
- limit
- representative query reviewed
- indexes reviewed
- Index Advisor suggestions reviewed manually
## Privacy
- no JWT in logs
- no passwords in fixtures
- screenshots mask email/UUID when unnecessary
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
import {
createBrowserClient,
} from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
}
lib/supabase/proxy.ts
import {
createServerClient,
} from '@supabase/ssr';
import {
NextResponse,
type NextRequest,
} from 'next/server';
export async function updateSession(
request: NextRequest,
) {
let response =
NextResponse.next({
request,
});
const supabase =
createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(
cookiesToSet,
headers,
) {
cookiesToSet.forEach(
({ name, value }) => {
request.cookies.set(
name,
value,
);
},
);
response =
NextResponse.next({
request,
});
cookiesToSet.forEach(
({
name,
value,
options,
}) => {
response.cookies.set(
name,
value,
options,
);
},
);
for (
const [name, value]
of Object.entries(headers)
) {
response.headers.set(
name,
value,
);
}
},
},
},
);
await supabase.auth.getClaims();
return response;
}
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/supabase/server.ts
import {
createServerClient,
} from '@supabase/ssr';
import {
cookies,
} from 'next/headers';
export async function createClient() {
const cookieStore =
await cookies();
return createServerClient(
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 {
// Proxy가 갱신을 담당합니다.
}
},
},
},
);
}
lib/tasks/queries.ts
import {
createClient,
} from '@/lib/supabase/server';
export type TaskFilter =
| 'all'
| 'todo'
| 'done';
export async function getTaskList(
filter: TaskFilter,
) {
const supabase =
await createClient();
let query = supabase
.from('tasks')
.select(
'id, title, status, created_at, owner:profiles(display_name)',
)
.order(
'created_at',
{
ascending: false,
},
)
.limit(20);
if (filter !== 'all') {
query = query.eq(
'status',
filter,
);
}
const { data, error } =
await query;
if (error) {
throw error;
}
return data ?? [];
}
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",
"@supabase/ssr": "0.12.7"
},
"devDependencies": {
"typescript": "5.9.3",
"@types/node": "24.10.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3"
}
}
proxy.ts
import type {
NextRequest,
} from 'next/server';
import {
updateSession,
} from '@/lib/supabase/proxy';
export async function proxy(
request: NextRequest,
) {
return updateSession(
request,
);
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
README.md
# Supabase 09 완료 체크포인트 — final
원문 회차 코드를 누적한 독립 실행용 최소 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` → `07_grants_rls.sql` → `09_indexes.sql`.
`/login`은 이메일/비밀번호 인증 화면입니다. 실제 가입/확인 메일은 본인 개발 프로젝트에서 직접 검증하세요.
`supabase/tests/tasks_rls.test.sql`은 pgTAP이 가능한 폐기 가능한 개발 DB에서 실행할 자급형 transaction 테스트입니다. 고정 가상 UUID만 쓰며 끝에 rollback합니다. 운영 DB에서 실행하지 마세요. `tests.*` 외부 helper는 필요 없습니다. Supabase CLI의 `supabase test db`는 별도의 로컬 Supabase/Docker 환경을 요구하므로 이 프로젝트 환경에서는 실행하지 않았습니다.
실제 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;
supabase/migrations/07_grants_rls.sql
begin;
alter table public.profiles
enable row level security;
alter table public.tasks
enable row level security;
revoke all
on table public.profiles
from anon;
revoke all
on table public.tasks
from anon;
grant select, insert, update
on table public.profiles
to authenticated;
grant select, insert, update, delete
on table public.tasks
to authenticated;
commit;
supabase/migrations/09_indexes.sql
begin;
create index if not exists
tasks_owner_created_at_idx
on public.tasks (
owner_id,
created_at desc
);
commit;
supabase/tests/tasks_rls.test.sql
-- Run only against a disposable development/test database, after migrations 05, 06, 07.
-- Everything, including fixtures, is rolled back. No passwords or real identities are used.
begin;
create schema if not exists extensions;
create extension if not exists pgtap with schema extensions;
grant usage on schema extensions to anon, authenticated;
set local search_path = public, extensions;
select plan(12);
select ok((select relrowsecurity from pg_class where oid = 'public.profiles'::regclass), 'profiles RLS enabled');
select ok((select relrowsecurity from pg_class where oid = 'public.tasks'::regclass), 'tasks RLS enabled');
insert into auth.users (id)
values ('00000000-0000-4000-8000-000000000001'),
('00000000-0000-4000-8000-000000000002');
insert into public.profiles (id, display_name)
values ('00000000-0000-4000-8000-000000000001', 'A'),
('00000000-0000-4000-8000-000000000002', 'B');
insert into public.tasks (owner_id, title)
values ('00000000-0000-4000-8000-000000000001', 'A task'),
('00000000-0000-4000-8000-000000000002', 'B task');
set local role authenticated;
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-000000000001', true);
select results_eq(
$$ select title from public.tasks order by title $$,
$$ values ('A task'::text) $$,
'A sees only A tasks'
);
with changed as (
update public.tasks set title = 'changed by A'
where title = 'B task' returning id
)
select is((select count(*)::integer from changed), 0, 'A cannot update B task');
select throws_ok(
$$ update public.tasks
set owner_id = '00000000-0000-4000-8000-000000000002'
where title = 'A task' $$,
'42501',
'new row violates row-level security policy for table "tasks"',
'A cannot transfer ownership to B'
);
with removed as (
delete from public.tasks where title = 'B task' returning id
)
select is((select count(*)::integer from removed), 0, 'A cannot delete B task');
with inserted as (
insert into public.tasks(owner_id, title)
values ('00000000-0000-4000-8000-000000000001', 'A new task') returning id
)
select is((select count(*)::integer from inserted), 1, 'A may insert own task');
with changed as (
update public.tasks set status = 'done'
where title = 'A new task' returning id
)
select is((select count(*)::integer from changed), 1, 'A may update own task');
select throws_ok(
$$ insert into public.tasks(owner_id, title)
values ('00000000-0000-4000-8000-000000000002', 'forged') $$,
'42501',
'new row violates row-level security policy for table "tasks"',
'A cannot insert a task owned by B'
);
with removed as (
delete from public.tasks where title = 'A new task' returning id
)
select is((select count(*)::integer from removed), 1, 'A may delete own task');
select set_config('request.jwt.claim.sub', '00000000-0000-4000-8000-000000000002', true);
select results_eq(
$$ select title from public.tasks order by title $$,
$$ values ('B task'::text) $$,
'B sees only B tasks'
);
reset role;
set local role anon;
select throws_ok(
$$ select * from public.tasks $$,
'42501', 'permission denied for table tasks',
'anonymous cannot read private tasks'
);
reset role;
select * from finish();
rollback;
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"
]
}
최종 검증
다음 검증은 서로 구분해서 기록합니다.
코드/타입 검증
npm run build
DB 정책 검증
supabase test db
실제 사용자 검증
A/B 로그인과 데이터 격리
쿼리 검증
관계 조회·filter·order·limit
성능 검증
EXPLAIN / Query Performance / Index Advisor
성능 수치는 동일한 데이터와 환경에서 실제 측정했을 때만 본문에 적습니다.
최종 체크포인트
supabase-step-09-final
이 체크포인트가 Supabase 과정의 최종 상태입니다.
프로젝트 적용 후 확인 기록
독자 질문: 관계 조회와 인덱스를 추가해도 A/B 데이터 격리가 유지되고 조회가 실제로 나아지는가?
따라 할 단계
- A·B 프로필을 분리해 각자 작업을 만들고 상태 todo/done을 섞어 넣는다.
- 각 계정으로 목록 페이지에서 프로필 관계, 상태 필터, 최신순, 최대 20개와 상대방 행 미노출을 확인한다.
- 대표 SELECT를 EXPLAIN/Query Performance에서 비교하고 인덱스 전후 계획과 실제 데이터 규모를 함께 기록한다.
예상 결과와 A/B 계정 범위
- A는 A 행만, B는 B 행만 보이고 타인 작업에 대한 변경은 거부 또는 0행이어야 한다. 작은 테이블에서 Seq Scan이 나온 것만으로 인덱스 실패라고 결론 내리지 않는다.
- 이 편집에서는 실제 A/B Supabase 요청·EXPLAIN·성능 측정을 실행하지 않았다. 결과는 기대값이며 실행 확인은 미확인이다.
흔한 오류 점검
- 관계 조회 오류가 나면 외래키와 profiles의 SELECT policy를 확인한다.
- 정렬이나 필터가 어긋나면 쿼리 조건과 limit 순서를, 응답이 느리면 대표 데이터량·실행 계획·RLS 조건 열의 인덱스를 점검한다.
검증 기록: 이번 편집에서 실제 Supabase 요청·A/B 계정 실험은 실행하지 않았으므로 미확인입니다.
다음 학습
Supabase 과정 완료 뒤 테스트 과정에서 A/B 통합 테스트를, 배포·서버 과정에서 운영 키·RLS·캐시를 재검증한다.
과정 완료
Next.js TaskFlow
↓
Supabase 첫 연결
↓
Auth
↓
이메일 확인
↓
profiles / tasks
↓
CRUD + RLS
↓
RLS 자동 테스트
↓
SSR 쿠키 인증
↓
관계 조회 + 인덱스
↓
supabase-step-09-final
Supabase 과정의 필수 다음 회차는 없습니다.
이후 테스트 과정에서는 이 체크포인트를 통합/E2E 테스트의 실제 백엔드 예제로 사용할 수 있고, 배포·서버 과정에서는 운영 환경 배포와 보안 점검의 시작점으로 재사용할 수 있습니다.
공식 문서
확인일: 2026-09-21
- Query Optimization: https://supabase.com/docs/guides/database/query-optimization
- Index Advisor: https://supabase.com/docs/guides/database/extensions/index_advisor
- Joins and Nesting: https://supabase.com/docs/guides/database/joins-and-nesting
- Row Level Security: https://supabase.com/docs/guides/database/postgres/row-level-security
체크포인트 다운로드
글 소개·학습 준비·전체 목차

Supabase · 9 / 9 · 고급 학습 · 관계 조회와 인덱스
관계 조회는 외래키를 따라 필요한 열을 선택하고 필터·정렬·행 수 제한을 함께 정합니다. 인덱스는 그 대표 쿼리의 실행 계획을 보고 판단합니다. 아래 독립 샘플에서 조회 결과와 인덱스 후보를 각각 확인하고, 하단에서 누적 프로젝트에 적용합니다.
Supabase Auth
Postgres profiles / tasks
사용자별 CRUD
GRANT + RLS
A/B 계정 데이터 격리
pgTAP 정책 테스트
Next.js SSR 쿠키 인증
조회 결과가 맞는지와 실행 계획이 적절한지를 따로 판정합니다.
실제 화면이 사용하는 대표 쿼리를 정리하고, 외래키 기반 관계 조회와 필터·정렬·limit을 적용한 뒤, 그 쿼리에 필요한 인덱스를 판단합니다.
인덱스는 많을수록 좋은 것이 아닙니다.
조회 패턴을 먼저 만들고 실행 계획과 Query Performance, Index Advisor 같은 도구를 확인한 뒤 결정해야 합니다.
이번 글에서 배우는 내용
- foreign key 기반 관계 조회
- nested select
- 필요한 컬럼만 조회
- filter
- order
- limit
- 대표 쿼리 정의
- EXPLAIN
- Query Performance
- Index Advisor
- 인덱스의 읽기 이점과 쓰기 비용
- RLS 조건 열의 인덱스
- 운영 전 Supabase 보안 점검
선수 지식
- 외래키가 있는 관계형 스키마
- 사용자별 RLS 정책
- 요청별 Server Component Supabase client
- SQL WHERE / ORDER BY 기본
- 데이터베이스 인덱스 기초
목차
- 대표 쿼리부터 정의하기
- 외래키 기반 관계 조회
select('*')를 습관처럼 사용하지 않기- 상태 필터
- 최신순 정렬과 limit
- 기본 인덱스
- status까지 인덱스에 넣어야 할까
- migration
- EXPLAIN
- 작은 테이블에서는 Seq Scan이 정상일 수 있습니다
- Query Performance와 Index Advisor
- RLS 성능도 인덱스와 연결됩니다
- Server Component 목록 페이지
- 운영 전 최종 체크리스트
- 자주 발생하는 오류
- 학습 완료 체크리스트
- TaskFlow 프로젝트 최종 적용
- 실습 프로젝트 전체 코드
- 최종 검증
- 과정 완료
- 공식 문서
- 단독 실습과 결과 확인
이 글이 도움이 되었나요?
분류별 과정 이동 (선택)
읽음 기록
읽음 — / 전체 9개 · 필수 8개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.