THE DEVLOG

scribbly.

Next JS 치트 시트

2025.05.16 12:30:21

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는 아래와 같이 설정한다.
nametypedefault valueprimary
idint8nullO
user_iduuidauth.uid()X
contenttextnullX
created_attimestamptznow()X
updated_attimestamptznow()X
deleted_attimestamptznullX

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
    SELECT * FROM todos_with_rls WHERE auth.uid() = todos_with_rls.user_id;
    
    결과적으로, todos_with_rls 테이블의 user_id 컬럼이 현재 로그인한 사용자의 auth.uid() 값과 동일한 경우에만 행이 반환된다.

using 표현식과 with check 표현식의 차이는 아래와 같다.

구분usingwith 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
);

업데이트 RLS에 대한 설명 - Reddit

  • 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>
  );
}
  1. formAction 이라는 새로운 server action을 생성해주었다. server action 함수는 "use server" 지시자를 입력하여 만든다.
  2. FormData 인터페이스의 .get(name)라는 인스턴스 메서드를 통해 FormData에 있는 요소를 가져올 수 있다. 이때 파라미터는 HTML요소의 name 어트리뷰트와 일치한다.
  3. formData.get(name)으로 가져온 데이터는 any 타입으로 지정되어 있어 Type Guard를 사용해주어야 한다.
  4. 이후 formData.get(name)으로 가져온 데이터를 앞서 만든 createTodos라는 server action에 파라미터로 넘겨주며 이를 실행한다.

Fetch를 이용한 데이터 캐싱

기본적으로 Next.js App Router에서 사용되는 방법이다.

  1. 확장된 fetch로 api를 호출한다.
  2. 이때 fetch의 옵션에 { next : { tags : [태그명]}}을 넣어준다.
  3. 이후엔 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

  1. TodoListFetch 컴포넌트에서는 fetch()를 통해 Route Handlers를 호출한다. 해당 데이터는 todosByFetch-${userId}의 태그로 관리된다.
  2. 버튼을 눌러 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>&quot;확장된 Fetch&quot;를 이용하여 데이터를 캐싱하기</h1>
      {/* 새로고침 버튼 */}
      <form>
        <button formAction={buttonAction} type="submit">
          Fetch로 불러온 리스트 새로고침하기
        </button>
      </form>
      {/* TodoList목록 */}
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.content}</li>
        ))}
      </ul>
    </div>
  );
}