🌱앱 식물 키우기 오픈!

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

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

3주차 학습 내용


Part 1. Git Repository 생성 및 초기 설정 진행

  • 이전과 동일하게  npx create-next-app@14 inflearn-supabase-netflix-clone 해주고, 2주차의 코드를 가져왔다.

  • 테이블의 column을 지정해주고, 강사님께서 가져오신 영화 데이터를 받아 DB를 구성했다.

imageimage

 

Part 2. UI 작업

  • 이번에는 다이나믹 라우트를 사용해서 포스터를 클릭하면 해당 포스터의 id를 들고 상세페이지로 이동한다. 사용법은 간단하게 대괄호를 열고 닫은 폴더명을 사용하면 됨.

  • 그거 말고는 전체적으로 예제와 같게 UI작업을 했고, 이전 드롭박스때 처럼 grid로 간단하게 반응형을 구현해줌.

image 

 

Part 3. 영화 검색 기능 & 영화 개별 상세페이지 구현

🌀 Recoil 사용 방법

1. 설치

먼저 Recoil을 설치한다: npm install recoil

2. 서버 컴포넌트(layout.tsx)에 직접 쓰면

Recoil은 클라이언트 사이드 전용 라이브러리이기 때문에
layout.tsx에서 바로 사용하면 에러가 발생한다.

3. RecoilProvider 따로 만들어 감싸기

config/RecoilProvider.tsx 파일을 만들고, 아래와 같이 구성한다

// config/RecoilProvider.tsx
"use client";

import { RecoilRoot } from "recoil";

export default function RecoilProvider({ children }: React.PropsWithChildren) {
  return <RecoilRoot>{children}</RecoilRoot>;
}

 

4. 전역 상태 정의 (atoms.ts)

utils/recoil/atoms.ts에 전역 상태를 선언한다:

// utils/recoil/atoms.ts
import { atom } from "recoil";

export const searchState = atom({
  key: "searchState",
  default: "",
});

 

5. 컴포넌트에서 사용하기

import { useRecoilState } from "recoil";
import { searchState } from "utils/recoil/atoms";

const [search, setSearch] = useRecoilState(searchState);

 

Part 4. 무한 스크롤 기능 구현하기 & 더 나은 검색을 위한 SEO 작업하기

 

핵심 포인트

  • react-queryuseInfiniteQuery 사용

  • react-intersection-observer로 마지막 요소 감지해서 추가 데이터 불러오기

🔧 구현 흐름

  1. useInfiniteQuery에서 pageParam으로 현재 페이지 관리

  2. searchMovies()를 호출해서 검색어와 페이지 정보를 넘김

  3. getNextPageParam으로 다음 페이지 조건 처리

  4. 마지막 아이템에 ref 붙여서 화면에 보이면 자동 로딩

 

"use client";

import { useInfiniteQuery } from "@tanstack/react-query";
import MovieCard from "./movie-card";
import { searchMovies } from "actions/movieActions";
import { Spinner } from "@material-tailwind/react";
import { useRecoilValue } from "recoil";
import { searchState } from "utils/recoil/atoms";
import { useInView } from "react-intersection-observer";
import { useEffect } from "react";

export default function MovieCardList() {
  const search = useRecoilValue(searchState);
  const { data, isFetching, isFetchingNextPage, fetchNextPage, hasNextPage } =
    useInfiniteQuery({
      initialPageParam: 1,
      queryKey: ["movie", search],
      queryFn: ({ pageParam }) =>
        searchMovies({ search, page: pageParam, pageSize: 12 }),
      getNextPageParam: (lastPage) =>
        lastPage.page ? lastPage.page + 1 : null,
    });

  const { ref, inView } = useInView({
    threshold: 0,
  });

  useEffect(() => {
    if (inView && hasNextPage && !isFetching && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage]);

  useEffect(() => {
    console.log(inView);
  }, [inView]);

  return (
    <div className="grid gap-1 md:grid-cols-4 grid-cols-3 w-full h-full">
      {isFetching || (isFetchingNextPage && <Spinner />)}
      <>
        {data?.pages
          ?.map((page) => page.data)
          ?.flat()
          ?.map((movie) => (
            <MovieCard key={movie.id} movie={movie} />
          ))}
        <div className="h-1" ref={ref}></div>
      </>
    </div>
  );
}

 

🔧 구현 흐름

Next.js의 generateMetadata()를 사용해서
페이지별로 동적으로 메타 태그 생성.

 

💡 포인트

  • 페이지 타이틀에 영화 제목 자동 반영

  • 설명은 영화 overview에서 가져옴

  • OG 이미지도 함께 등록해서 링크 공유 시 썸네일 출력됨

export async function generateMetadata({ params, searchParams }) {
  const movie = await getMovie(params.id);

  return {
    title: movie.title,
    description: movie.overview,
    openGraph: {
      images: [movie.image_url],
    },
  };
} 

 

미션: 북마크 기능 만들기

미션: 찜하기 기능을 만들어서, 찜한 영화를 영화 리스트 화면의 최상단에 보여주기

원래는 영화 리스트 화면에서 북마크한 영화를 최상단에 보여주는 미션이었지만,
실제 사용자 입장에서 불편할 것 같아서 헤더에 "bookmark" 메뉴를 따로 만들고
해당 페이지에서 북마크한 영화만 모아보는 방식으로 약간 변형해서 구현했습니다.

 

1. Supabase 테이블에 bookmark 추가해주기

image

Supabase의 movie 테이블에 bookmark라는 boolean 컬럼을 추가하고,
기본값을 false로 설정하기 위해 SQL Editer에서 UPDATE movie SET bookmark = false WHERE bookmark IS NULL;를 입력해줌

 

2. 북마크 토글 함수 생성

// actions/movieActions.ts

export async function toggleBookmark(id: number, current: boolean) {
  const supabase = await createServerSupabaseClient();

  const { error } = await supabase
    .from("movie")
    .update({ bookmark: !current }) // 현재 값 반대로 토글
    .eq("id", id); // 해당 ID만 업데이트

  handleError(error);
}

movieActions에서 북마크 토글을 지원하는 함수를 만들어줌.

 

3. 상세 페이지에서 북마크 버튼 만들기

// app/movies/[id]/ui.tsx

"use client";

import { toggleBookmark } from "actions/movieActions";
import { useState, useTransition } from "react";
import { BookmarkIcon } from "@heroicons/react/24/outline";
import { BookmarkIcon as BookmarkSolidIcon } from "@heroicons/react/24/solid";

export default function MovieDetail({ movie }) {
  const [bookmarked, setBookmarked] = useState(movie.bookmark);
  const [isPending, startTransition] = useTransition();

  const handleClick = () => {
    setBookmarked((prev) => !prev); // UI 먼저 변경
    startTransition(async () => {
      await toggleBookmark(movie.id, bookmarked); // 서버에 실제 반영
    });
  };

  return (
    <div>
      <h1>{movie.title}</h1>
      <button onClick={handleClick} disabled={isPending}>
        {bookmarked ? (
          <BookmarkSolidIcon className="w-6 h-6 text-yellow-500" />
        ) : (
          <BookmarkIcon className="w-6 h-6 text-gray-500" />
        )}
      </button>
    </div>
  );
}

npm install @heroicons/react을 설치해서 토글 아이콘을 생성함.

2번에서 만든 함수를 연결해서 북마크를 키고 끄면 DB에도 연동되게 만들었음.

 

4. 북마크한 영화만 보여주는 페이지 만들기

// actions/movieActions.ts

export async function getBookmarkedMovies() {
  const supabase = await createServerSupabaseClient();

  const { data, error } = await supabase
    .from("movie")
    .select("*")
    .eq("bookmark", true) // bookmark가 true인 영화만 가져옴
    .order("id", { ascending: true }); // id 순으로 정렬

  handleError(error);
  return data;
}

movieActions에서 북마크한 영화만 보여주는 함수를 만들어줌.

 

// app/movies/bookmark/page.tsx

import MovieCard from "@/components/movie-card";
import { getBookmarkedMovies } from "actions/movieActions";

export default async function BookmarkPage() {
  const movies = await getBookmarkedMovies();

  return (
    <div className="grid grid-cols-3 gap-4">
      {movies.map((movie) => (
        <MovieCard key={movie.id} movie={movie} />
      ))}
    </div>
  );
}

header에서 북마크를 클릭하면 북마크 페이지로 이동하게 만들었고

위에서 만든 북마크 영화 함수를 사용해서 북마크한 영화만 보여주도록 만들었음.

 

아쉬운 부분

무한스크롤이 아직 조금 어려워서 북마크 페이지에서는 아직 구현해지 못했다.

추후에 좀 더 공부해보고 넣어주면 좋을 것 같다!


회고

이번 주차 수업 중에서는 무한 스크롤이 가장 어려웠지만,
과제는 이전 주차처럼 단순히 '딸깍하면 되는 문제'가 아니어서
"어떻게 구현할까?" 부터 고민하는 재미가 있었다.

Supabase에서 테이블을 직접 작성하고,
서버 액션에서 원하는 기능을 하는 함수를 만들고,
그 함수를 페이지에 붙여서
내가 의도한 대로 동작하는 걸 확인했을 때 정말 재밌었다.

뭔가 시간이 지나면서 점점 supabase와 친해지는 느낌을 받았다.

엄청 어렵지는 않지만,
약간만 생각하면 풀리는 적당한 난이도라서 더 좋았던 과제였다.

댓글을 작성해보세요.


채널톡 아이콘