🎁 모든 강의 30% + 무료 강의 선물🎁

[인프런 워밍업 클럽 3기 풀스택] 1주차 발자국

[인프런 워밍업 클럽 3기 풀스택] 1주차 발자국

 

[풀스택 완성] Supabase로 웹사이트 3개 클론하기 (Next.js 14) - 로펀

 

1주차는 Supabase, Next.js, Tailwindcss, Recoil, React Query 소개와 기본 문법, Server Action CRUD 사용법을 배웠다.

Supabase 특징

  • 오픈 소스 프로젝트 (자체 서버구축 가능)

  • PostgresSQL 기반 (복잡한 요구사항 개발 가능)

  • Firebase 대비 저렴

  • 다양한 연동방식 지원 (+SDK, DB Connection, GraphQL, API)

 

Next.js

  • Next.js의 폴더명이 곧 Route와 같다.

  • page.tsx는 각 폴더의 대표 파일과 같다.

  • app/layout.tsx는 화면 레이아웃을 잡아주는 Next.js 서버 컴포넌트이므로,
    클라이언트에서만 동작하는 상태 관리 라이브러리(Zustand, Recoil, React Query 등)를 직접 사용할 수 없다.


    따라서 React Query의 Client Provider를 별도의 클라이언트 컴포넌트로 만들어 layout.tsx에 주입해야 한다.
    (서버 컴포넌트는 클라이언트 상태나 브라우저 API에 접근할 수 없음)

  • 서버 액션은 API 라우트 없이 서버에서 직접 실행 가능하다.

 

React Query

  • 서버에서 가져온 데이터를 캐싱, 동기화, 상태 관리까지 자동으로 해주는 라이브러리

  • useQuery(): 데이터 조회 (GET 요청)

    const todosQuery = useQuery({
      queryKey: ["todos"],  // 캐싱 키
      queryFn: fetchTodos,  // API 요청 함수
    });
    
    const { data, isLoading, error } = todosQuery;
    
    • queryKey: 데이터 캐싱 키

    • queryFn: 데이터 호출 함수

    • data: fetchTodos의 반환 값

    • isLoading: 데이터 로딩 여부 (true면 로딩 중)

    • error: 요청 실패 시 에러 객체

 

  • useMutation(): 데이터 변경 (POST, PUT, DELETE)

    const mutation = useMutation({
      mutationFn: addTodo,
      onSuccess: () => { console.log("성공!"); },
      onError: (error) => { console.error("에러 발생:", error); },
    });
    
    • mutationFn: API 호출 함수

    • onSuccess: 성공 시 실행할 함수

    • onError: 실패 시 실행할 함수

 

TODO LIST 만들기

Supabase todo 테이블 정의

image

Supabase Next.js 연결

  • .env 환경변수 설정

    • Supabase Project API Keys를 사용하여 환경변수 추가

  • package.json 수정

    • scripts에서 generate-types 옵션 수정

  • Supabase 로그인 후 타입 생성

    • generate-types 실행하여 데이터베이스 테이블의 타입 정의 가져오기

 

Next.js에서 Supabase 클라이언트 생성

  • 브라우저/서버 환경에서 사용할 Supabase 클라이언트 생성

  • Next.js의 모든 request에서 Supabase 인증 토큰을 갱신하도록 middleware 설정

 

TODO 조회 - server action

export async function getTodos({ searchInput = "" }): Promise<TodoRow[]> {
  const supabase = await createServerSupabaseClient();
  const { data, error } = await supabase
    .from("todo")
    .select("*")
    .like("title", `%${searchInput}%`)
    .order("created_at", { ascending: true });

  if (error) {
    handleError(error);
  }

  return data;
}

getTodos 함수는 searchInput을 기본값 ""로 받아 Promise<TodoRow[]> 타입을 반환한다.

이 함수는 서버 환경에서 실행되며, Supabase 클라이언트를 사용하여 todo 테이블에서 데이터를 조회한다.

  • title 컬럼에서 searchInput을 포함한 값을 검색하며

  • created_at을 기준으로 오름차순으로 정렬하여 반환한다.

검색된 데이터가 없거나 오류가 발생하면 handleError 함수가 호출된다.

 

TODO 조회 - React Query useQuery()

const todosQuery = useQuery({
    queryKey: ["todos", searchInput],
    queryFn: () => getTodos({ searchInput }),
});

return (
    // ...
    {todosQuery.data && 
        todosQuery.data.map((todo) => <Todo key={todo.id} todo={todo} />)}
    // ...
)

useQuery는 todosQuery라는 데이터를 서버에서 가져오는 비동기 작업을 관리한다.

queryKey는 ["todos", searchInput]으로 설정되어, searchInput에 따라 조회된 결과가 달라지도록 한다.

queryFn은 서버 액션 getTodos를 호출해, searchInput에 맞는 TODO 데이터를 반환한다.

반환된 데이터(todosQuery.data)가 있을 경우, map()을 사용하여 각 TODO 항목을 렌더링한다.

 

1주차 미션

github: https://github.com/thayoon/nextjs-supabase-todolist

image

생성된 TODO의 생성 시간을 저장하고 이를 표시하는 기능을 추가하세요.

  • TODO 항목 옆에 생성 시간을 표시하기

  • component/todo.tsx

export default function Todo({ todo }) {
  // 한국 시간, YYYY/MM/DD HH:MM 포맷 함수
  const getTime = (time: string): string =>
    new Date(time)
      .toLocaleString("en-US", {
        year: "numeric",
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
        hour12: false,
        timeZone: "Asia/Seoul",
      })
      .replace(",", "");

  const createdTime = getTime(todo.created_at);
  
  return(

      // ...

      {isEditing ? (
        <input
          className="flex-1 border-b-black border-b pb-1"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
      ) : (
	<div className="flex-1">
	  <p className={`${completed && "line-through"}`}>{title}</p>
	  <span className="text-gray-400 text-xs">⏱️{createdTime}</span>
	</div>
      )}

      // ...

  );
}
  • getTime 함수는 created_at 값을 한국 시간에 맞게 YYYY/MM/DD HH:MM 포맷으로 변환한다.

  • isEditing이 false일 때, 생성된 TODO 항목의 생성 시간을 createdTime을 통해 표시한다.

  • todo 매개변수는 useQuery로 가져온 데이터 객체이며, created_at 값을 이용해 생성 시간을 표시한다.

 

(선택 사항) completed_at 필드를 추가하여 완료한 시간도 함께 저장

  1. Supabase에서 completed_at 필드를 추가하여, TODO 완료 시각을 저장한다.

    • completed_at은 NULL을 허용하는 timestamptz 타입 칼럼이다.

  2. actions/todo-actions.ts (server action)

    • updateTodo 함수는 completed 상태에 따라 completed_at 값을 업데이트한다.

export async function updateTodo(todo: TodoRowUpdate) {
  const supabase = await createServerSupabaseClient();
  console.log(todo);

  const { data, error } = await supabase
    .from("todo")
    .update({
      ...todo,
      updated_at: new Date().toISOString(),
      completed_at: todo.completed ? new Date().toISOString() : null,
    })
    .eq("id", todo.id);

  if (error) {
    handleError(error);
  }

  return data;
}
  • completed가 true일 경우 completed_at에 현재 시각을 저장하고, false일 경우 null을 저장한다.



    3. components/todo.tsx (React Query useMutation())

    • completed_at 값을 확인하여 완료 시각을 표시한다.

export default function Todo({ todo }) {
  // 한국 시간, YYYY/MM/DD HH:MM 포맷 함수
  const getTime = (time: string): string =>
    new Date(time)
      .toLocaleString("en-US", {
        year: "numeric",
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
        hour12: false,
        timeZone: "Asia/Seoul",
      })
      .replace(",", "");

  const completedTime = todo.completed_at ? getTime(todo.completed_at) : null;
  
  const updateTodoMutation = useMutation({
    mutationFn: () =>
      updateTodo({
        id: todo.id,
        title,
        completed,
      }),
    onSuccess: () => {
      setIsEditing(false);
      queryClient.invalidateQueries({
        queryKey: ["todos"],
      });
    },
  });
  
  return(
    <div className="w-full flex items-center gap-1">
      <Checkbox
        checked={completed}
        onChange={async (e) => {
          await setCompleted(e.target.checked);
          await updateTodoMutation.mutate();
        }}
      />
      
      {isEditing ? (
        <input
          className="flex-1 border-b-black border-b pb-1"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
      ) : (
	<div className="flex-1">
	  <p className={`${completed && "line-through"}`}>{title}</p>
	  <span className="text-gray-400 text-xs">
            ⏱️{createdTime}{" "}{completedTime && `~ ${completedTime}`}
          </span>
	</div>
      )}
      
      // ...
    
    </div>
	);
}
  • completed 상태에 따라 체크박스를 변경하고, 완료된 경우 completed_at 시각을 표시한다.

  • setCompleted로 상태를 갱신하고, updateTodoMutation을 호출하여 서버에서 completed_at 값을 업데이트한다.

  • 완료된 TODO 항목에 대해 completed_at 값이 존재하면 완료 시각을 getTime() 함수로 포맷하여 표시한다.

정리:

  1. TODO 완료 상태 변경:

    • 사용자가 체크박스를 클릭하면 setCompletedcompleted 상태를 갱신한다.

    • 갱신된 상태는 updateTodoMutation.mutate()를 통해 서버로 전송되어 completed_at 값이 업데이트된다.

  2. 완료 시각 컴포넌트 업데이트:

    • 서버에서 completed_at 값이 갱신되면, 해당 TODO의 완료 시각이 한국 시간으로 포맷되어 표시된다.

    • 완료된 경우에는 completed_atgetTime() 함수를 통해 표시하고, 완료되지 않은 경우에는 null이므로 표시되지 않는다.

  3. 쿼리 데이터 갱신:

    • updateTodoMutation.onSuccess에서 queryClient.invalidateQueries({ queryKey: ["todos"] })를 호출하여 todos 쿼리의 데이터를 무효화한다.

    • 쿼리의 데이터가 무효화되면 React Query는 자동으로 서버에서 최신 데이터를 다시 불러와 화면에 반영한다.

 

1주차 회고

사실 Next.js를 아주 짧게 배운 상태로 강의를 듣게 됐는데,
섹션1에서 중요한 부분을 잘 설명해주셔서 큰 어려움없이 수강할 수 있었다.
그리고 실습을 따라하면서 어느정도 흐름은 알게 된 것 같다.
그렇지만 다른 Next.js 강의를 수강하며 구체적인 동작과정을 더 배워야 할 것 같다.
다음 실습도 기대된다. 다음주도 화이팅!

댓글을 작성해보세요.


채널톡 아이콘