Supabase로 간단한 TODO앱을 만들며 테이블을 생성하고, 수정하고, RLS 정책을 적용하는 방법을 다룬다.
RLS 정책을 다룰 때에는 Supabase 템플릿을 우선적으로 확인하면서 적용하는 것이 좋다.
CRUD 구현
테이블 추가
- Supabase - 대시보드 - 테이블 에디터로 접속한다.
https://supabase.com/dashboard/project/{Project Id}/editor
- Client에서 기본적으로 다루는 Schema는 Public이다. Public Schema를 선택한 후, 'Create a new table'을 클릭한다.
- 테이블 명은 'todos_with_rls'
- Columns는 아래와 같이 설정한다.
name | type | default value | primary |
---|---|---|---|
id | int8 | null | O |
user_id | uuid | auth.uid() | X |
content | text | null | X |
created_at | timestamptz | now() | X |
updated_at | timestamptz | now() | X |
deleted_at | timestamptz | null | X |
user_id
는 Name에서 foreign key 옵션을 클릭하여 아래와 같이 설정한다.
- Select a schema : auth
- Select a table to reference to : users
- Select columns from auth.usersto reference to : public.todos_with_rls.user_id -> auth.users.id
- Action if referenced row is updated : Cascade
- Action if referenced row is removed : Cascade
timestamptz
는 클라이언트의 세션 시간대를 조회하여 UTC 기준으로 변환하여 저장하는 타입이다.
Postgres SQL의 Row-Level Security (RLS)
- 행 수준 보안. 사용자가 어떤 행에 접근할 수 있는지를 설정할 수 있는 기능을 한다.
create policy "policy_name"
on "public"."todos_with_rls"
as PERMISSIVE
for SELECT
to public
using (true);
- create policy "policy_name" : 정책을 생성한다. 이름을 "policy_name"로 설정했다.
- on "public"."todos_with_rls" : 적용할 테이블을 지정한다. "public" 스키마의 "todos_with_rls" 테이블을 지정했다.
- as PERMISSIVE : 정책의 유형을 지정한다.
PERMISSIVE
는 접근할 수 있는 사용자를 지정하는 유형이고,RESTRICTIVE
는 접근이 불가능한 사용자를 지정하는 유형이다. - for SELECT : 작동 대상 및 권한.
SELECTE(조회)
,INSERT(삽입)
,UPDATE(수정)
,DELETE(삭제)
- to public : 대상 사용자.
public(모두)
,authenticated(로그인 된 사용자)
- using () : 괄호 안의 조건을 충족할 때에만 해당 조건이 작동한다.
using (true)
는 항상.using ((select auth.uid()) = user_id)
는 user_id가 같을 때에 작동한다.- using 정책이 적용되면, 내부적으로 WHERE 절을 활용하여 필터링이 수행된다. 즉, 다음과 같은 필터링이 자동으로 적용된다. Row Level Security - Supabase Docs
결과적으로, todos_with_rls 테이블의 user_id 컬럼이 현재 로그인한 사용자의 auth.uid() 값과 동일한 경우에만 행이 반환된다.SELECT * FROM todos_with_rls WHERE auth.uid() = todos_with_rls.user_id;
using 표현식과 with check 표현식의 차이는 아래와 같다.
구분 | using | with check |
---|---|---|
목적 | 데이터에 대한 조회, 업데이트, 삭제 권한을 제한 | 데이터 삽입 및 수정 시 유효성 검사 |
시점 | 쿼리 실행 시, 행이 선택될 때 조건을 적용 | 데이터가 삽입되거나 수정될 때 조건을 적용 |
사용처 | 사용자가 접근할 수 있는 행을 필터링 | 새로운 행이 데이터베이스에 삽입되거나 업데이트될 때 조건 검증 |
RLS Policy 추가
- Supabase - 대시보드 - Authentication - Configuration - Policies로 접속한다.
https://supabase.com/dashboard/project/{Project ID}/auth/policies
- todos_with_rls 테이블에 'Create policy' 버튼을 클릭한다.
Select - Enable read access for all users
를 클릭한 후Save Policy
를 눌러 정책을 추가해준다. 앞서 예시로 든using(true)
를 사용한 Select 정책이 추가된다.Insert - Enable insert for authenticated users only
를 클릭한 후Save Policy
를 눌러 정책을 추가해준다.with check (true)
라는 표현식이 끝에 붙는데, with check는 명령을 실행하기 전에 조건을 충족하는지를 체크한다. true로 두었기에 로그인 한 유저 누구나 작성할 수 있다.Update - Enable update for users based on email
템플릿을 클릭하면 아래와 같이 템플릿이 나온다.
create policy "Enable update for users based on email"
on "public"."todos_with_rls"
as PERMISSIVE
for UPDATE
to public
using (
(select auth.jwt()) ->> 'email' = email
with check (
(select auth.jwt()) ->> 'email' = email
);
이를 uid와 비교하도록 아래와 같이 수정한 후 Save Policy를 한다.
create policy "Enable update for users based on user_id"
on "public"."todos_with_rls"
as PERMISSIVE
for UPDATE
to public
using (
(select auth.uid()) = user_id
with check (
(select auth.uid()) = user_id
);
DELETE - Enable delete for users based on user_id
를 클릭한 후Save Policy
를 눌러 정책을 추가해준다.
이로써 CRUD에 대한 RLS 정책들을 추가 완료하였다.
CRUD Server Actions 구현
SELECT
select("*")
로 데이터를 불러온 후, .eq()
, .lt()
, .is()
와 같은 연산자를 추가하여 조건을 넣는다.
// todoList 가져오기 + by UserId
export const getTodosByUserId = async (userId: string) => {
const supabase = await createClient();
const result = await supabase
.from("todos_with_rls")
.select("*")
.is("deleted_at", null)
.eq("user_id", userId);
return result.data;
};
위의 예시에서 사용된 SELECT 구문은 아래와 같다.
SELECT * FROM "todos_with_rls"
WHERE deleted_at IS NULL
AND user_id = '사용자_ID';
특정 문자열을 받아 검색하는 구문은 아래와 같다. 이때 ilike는 대소문자를 구별하지 않는 검색이다.
// todoList 가져오기 + search
export const getTodosBySearch = async (terms: string) => {
const supabase = await createClient();
const result = await supabase
.from("todos_with_rls")
.select("*")
.is("deleted_at", null)
.ilike("content", `%${terms}%`)
.order("id", { ascending: false })
.limit(500);
return result.data;
};
INSERT
// todoList 생성하기
export const createTodos = async (content: string) => {
const supabase = await createClient();
const result = await supabase
.from("todos_with_rls")
.insert({
content,
})
.select();
return result.data;
};
UPDATE
// todoList 업데이트 하기
export const updateTodos = async (id: number, content: string) => {
const supabase = await createClient();
const result = await supabase
.from("todos_with_rls")
.update({
content,
updated_at: new Date().toISOString(),
})
.eq("id", id)
.select();
return result.data;
};
DELETE
// todoList softDelete
export const deleteTodosSoft = async (id: number) => {
const supabase = await createClient();
const result = await supabase
.from("todos_with_rls")
.update({
deleted_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq("id", id)
.select();
return result.data;
};
Data Fetching과 Caching
앞서 만든 Todo Actions를 통해 Data Fetching과 Caching을 구현한다.
먼저 app\example\page.tsx 를 아래와 같이 만든다.
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
export default async function Page() {
const supabase = await createClient();
const userId = (await supabase.auth.getUser()).data.user?.id;
// ✅ userId가 없으면 로그인 페이지로, 있으면 해당 경로로 리다이렉트
if (!userId) {
redirect("/auth/login");
} else {
redirect(`/example/${userId}`);
}
}
redirect(
/example/${userId});
를 이용해 app\example\[userId]\page.tsx
로 이동시킨다.
id를 params로 받아 Caching하기 위함이다.
app\example\[userId]\page.tsx
는 아래와 같이 만든다.
import TodoAdder from "@/components/example/TodoAdder";
import TodoListCached from "@/components/example/TodoListCached";
import TodoListFetch from "@/components/example/TodoListFetch";
interface PageProps {
params: Promise<{
userId: string;
}>;
}
export default async function Page({ params }: PageProps) {
const { userId } = await params;
return (
<div>
<TodoAdder />
<TodoListCached userId={userId} />
<TodoListFetch userId={userId} />
</div>
);
}
TodoAdder
: Todo 리스트를 생성하는 컴포넌트이다.
TodoListCached
: Todo 리스트를 server action으로 불러온 후, unstable_cache를 통해서 caching하는 컴포넌트이다.
TodoListFetch
: Todo 리스트를 Next.js의 확장된 fetch를 통해 불러와 caching하는 컴포넌트이다.
server action에 타입 지정하기
npx supabase gen
명령어로 타입을 지정했다면 응답으로 올 DATA의 타입도 생성이 된다.
Database["public"]["Tables"]["todos_with_rls"]["Insert"]
타입은 Insert를 실행했을 때의 데이터이다.
아래와 같이 createTodos의 응답값에 대하여 Promise<Array<Database["public"]["Tables"]["todos_with_rls"]["Insert"]> | null>
라는 타입을 지정해주자.
result는 기본적으로 PostgrestSingleResponse<any[]>
타입을 갖는데, server action에 타입을 지정해줌으로써 result.data의 타입이 추론된다.
// todoList 생성하기
export const createTodos = async (
content: string
): Promise<Array<
Database["public"]["Tables"]["todos_with_rls"]["Insert"]
> | null> => {
const supabase = await createClient();
const result = await supabase
.from("todos_with_rls")
.insert({
content,
})
.select();
return result.data;
};
TodoAdder Component
//components\example\TodoAdder.tsx
import { createTodos } from "@/app/example/[userId]/actions";
export default function TodoAdder() {
async function formAction(formData: FormData) {
"use server";
const content = formData.get("contentInput");
if (typeof content !== "string") return; // Type Guard
await createTodos(content);
// 생성을 완료한 후 실행할 로직들을 이곳에 작성합니다.
}
return (
<form action={formAction}>
<input name="contentInput" placeholder="할 일을 적어라" />
<button type="submit">할 일 추가</button>
</form>
);
}
formAction
이라는 새로운 server action을 생성해주었다. server action 함수는 "use server" 지시자를 입력하여 만든다.FormData
인터페이스의.get(name)
라는 인스턴스 메서드를 통해 FormData에 있는 요소를 가져올 수 있다. 이때 파라미터는 HTML요소의 name 어트리뷰트와 일치한다.formData.get(name)
으로 가져온 데이터는any
타입으로 지정되어 있어 Type Guard를 사용해주어야 한다.- 이후
formData.get(name)
으로 가져온 데이터를 앞서 만든createTodos
라는 server action에 파라미터로 넘겨주며 이를 실행한다.
Fetch를 이용한 데이터 캐싱
기본적으로 Next.js App Router에서 사용되는 방법이다.
- 확장된 fetch로 api를 호출한다.
- 이때 fetch의 옵션에
{ next : { tags : [태그명]}}
을 넣어준다. - 이후엔
revalidateTag
함수를 이용하여 해당 fetch로 캐시한 데이터를 revalidate 시킬 수 있다.
- 주의사항 : revalidateTag는 서버에 cache된 데이터를 revalidate하는 것이기 때문에 'use client' 지시자 안에서는 사용할 수 없음. server action 등으로 따로 빼야함.
Route Handlers
Route Handler는 Search Params를 통해 userId를 가져오도록 한다.
이후 getTodos 서버액션을 이용하여 데이터를 읽어 반환한다.
// app/api/todos/route.ts
import { getTodosByUserId } from "@/app/example/[userId]/actions";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
// searchParams에서 userId를 가져온다.
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
if (!userId) {
return NextResponse.json({ error: "userId is required" }, { status: 400 });
}
try {
// getTodosByUserId 서버 액션을 이용하여 데이터를 가져온다.
const todos = await getTodosByUserId(userId);
return NextResponse.json(todos);
} catch (_e) {
return NextResponse.json(
{ error: "Failed to fetch todos" },
{ status: 500 }
);
}
}
이때 catch(error)에서 eslint 에러가 발생하므로 아래와 같이 eslint.config.mjs 를 수정하였다. How to disable warn about some unused params
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
// 추가된 부분
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
}, // _로 시작하는 인자는 사용되지 않아도 경고하지 않음
],
},
},
];
위와 같이 예외 패턴을 만든 후, catch(_error)를 사용하면 에러가 뜨지 않는다.
TodoListFetch Component
- TodoListFetch 컴포넌트에서는 fetch()를 통해 Route Handlers를 호출한다. 해당 데이터는
todosByFetch-${userId}
의 태그로 관리된다. - 버튼을 눌러 buttonAction을 실행시키면,
todosByFetch-${userId}
를 revalidate한다.
import { Database } from "@/types/supabase";
import { revalidateTag } from "next/cache";
interface TodoListFetchProps {
userId: string;
}
type Todo = Database["public"]["Tables"]["todos_with_rls"]["Row"];
export default async function TodoListFetch({ userId }: TodoListFetchProps) {
// 데이터를 캐싱할 때 관리할 태그명을 지정한다.
const cacheTag = `todosByFetch-${userId}`;
// () => 캐싱된 데이터를 revalidateTag로 revalidate하는 함수
async function buttonAction() {
"use server";
revalidateTag(cacheTag);
}
// fetch를 통해 데이터를 불러온다.
const res = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/todos?userId=${userId}`,
{
next: { revalidate: 10000000, tags: [cacheTag] }, // 장기간 캐싱 (revalidateTag로 갱신)
}
);
// 데이터를 파싱한다.
const todos: Todo[] = await res.json();
return (
<div>
{/* 컴포넌트 설명 */}
<h1>"확장된 Fetch"를 이용하여 데이터를 캐싱하기</h1>
{/* 새로고침 버튼 */}
<form>
<button formAction={buttonAction} type="submit">
Fetch로 불러온 리스트 새로고침하기
</button>
</form>
{/* TodoList목록 */}
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.content}</li>
))}
</ul>
</div>
);
}