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

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

강의수강

recoil

  • atom: 상태의 조각

  • useRecoilState: useState hook과 유사하게 전역적으로 recoil 상태를 읽고 쓰는 데 사용

  • useRecoilValue: recoil 상태를 읽는 데만 사용

     

react-intersection-observer

  • intersercion Observer API를 react에서 사용하기 쉽도록 도와주는 라이브러리

  • useInView 훅은 react-intersection-observer 라이브러리에서 제공하는 기능으로, 특정 요소가 화면에 보이는지(inView)를 감지하고, 해당 요소에 ref를 할당하여 추적할 수 있게 한다.

  • threshold: 0 옵션은 요소의 0%라도 화면에 나타나면 inViewtrue가 되도록 설정

useInfiniteQuery

  const { data, isFetchingNextPage, isFetching, fetchNextPage, hasNextPage } =
    useInfiniteQuery({
      initialPageParam: 1,
      queryKey: ["movie", search],
      queryFn: ({ pageParam }) =>
        searchMovies({
          search,
          pageSize,
          page: pageParam,
          favoriteCount,
        }),
      getNextPageParam: (lastPage) => {
        if (lastPage.data.length < pageSize) {
          return null;
        }
        return lastPage.page ? lastPage.page + 1 : null;
      },
    });
  • 무한 스크롤 기능을 쉽게 구현할 수 있도록 해주는 hook

  • initialPageParam 으로 처음에 가져올 페이지 번호를 설정

  • queryFn: -> searchMovies 함수를 호출해 데이터를 패치

  • getNextPageParam: 다음 페이지 번호를 반환해 추가 데이터를 불러 오도록 함 조건에 따라 null을 반환해 무한 스크롤을 멈출 수 있게 해준다

미션

사용자가 특정 영화를 “찜”할 수 있도록 Supabase를 활용해 즐겨찾기 리스트 구현

  • supabase movie 테이블에 favorite: boolean column 추가 기본 값: false

  • actions/move-actions.ts에 update action 코드 추가

type MovieUpdate = Database["public"]["Tables"]["movie"]["Update"];

export async function updateMovie(movie: MovieUpdate) {
  const supabase = await createServerSupabaseClient();

  const { error } = await supabase
    .from("movie")
    .update({
      ...movie,
    })
    .eq("id", movie.id);

  handleError(error);
}
  • 현재 Movie가 찜되어 있는지 나타내는 컴포넌트 MovieFavoriteIcon

  • favorite boolean값을 받아 찜했으면 핑크색 하트를 찜 안되어있으면 빈하트를 렌더링

export default function MovieFavoriteIcon({ favorite }: { favorite: boolean }) {
  return (
    <>
      {favorite ? (
        <i
          className="fa-solid fa-heart font-bold"
          style={{ fontSize: 25, color: "#ff9fd2" }}
        />
      ) : (
        <i
          className="fa-regular fa-heart"
          style={{ fontSize: 25, color: "#fff" }}
        />
      )}
    </>
  );
}

  • components/movie-card, movies/[id]/ui에 MovieFavoriteIcon 컴포넌트 추가

import Link from "next/link";
import type { Movie } from "actions/movie-actions";
import MovieFavoriteIcon from "./movie-favorite-icon";

export default function MovieCard({ movie }: { movie: Movie }) {
  return (
    <div className="col-span-1 relative">
      <img src={movie.image_url} className="w-full" />

      <Link href={`/movies/${movie.id}`}>
        <div className="absolute top-0 right-0 px-1.5 py-2">
          <MovieFavoriteIcon favorite={movie.favorite} />
        </div>
        <div className="absolute top-0 bottom-0 left-0 right-0 z-10 flex justify-center items-center bg-black opacity-0 hover:opacity-80 transition-opacity duration-300">
          <p className="text-xl font-bold text-white">{movie.title}</p>
        </div>
      </Link>
    </div>
  );
}

  •  

"use client";

import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Movie, updateMovie } from "actions/movie-actions";
import { queryClient } from "config/react-query-client-provider";
import MovieFavoriteIcon from "components/movie-favorite-icon";

export default function UI({ movie }: { movie: Movie }) {
  const [isFavorite, setIsFavorite] = useState(movie.favorite);

  const updateMovieMutation = useMutation({
    mutationFn: () =>
      updateMovie({
        ...movie,
        favorite: !isFavorite,
      }),
    onSuccess: () => {
      setIsFavorite((prev) => !prev);
      queryClient.invalidateQueries({ queryKey: ["movie"] });
    },
  });

  return (
    <div className="flex flex-col md:flex-row items-center">
      <img src={movie.image_url} className="w-1/3 xl:w-1/4" />
      <div className="w-full md:w-2/3 items-center md:items-start flex flex-col p-6 gap-4">
        <div className="flex justify-between items-center w-full">
          <h1 className="text-3xl font-bold">{movie.title}</h1>
          <div>
            <button
              className="bg-pink-50 border-2 border-pink-100 rounded-md px-1.5 py-1"
              onClick={() => updateMovieMutation.mutate()}
            >
              <MovieFavoriteIcon favorite={isFavorite} />
            </button>
          </div>
        </div>

        <p className="text-lg font-medium">{movie.overview}</p>
        <div className="font-bold text-lg">
          <i className="fas fa-star mr-1" />
          Vote Average : {movie.vote_average}
        </div>
        <div className="font-bold text-lg">Popularity: {movie.popularity}</div>
        <div className="font-bold text-lg">
          Release Date: {movie.release_date}
        </div>
      </div>
    </div>
  );
}

  • movies/[id]/ui.tsx에서 해당 movie의 favorite을 설정하도록 mutate 추가

찜한 영화를 영화 리스트 화면의 최상단으로 보여주도록 정렬

위에서 추가했던 movie 테이블의 favorite 컬럼을 기준으로, 무한 스크롤 movie 요청시 찜한 영화를 우선해 가지고 오는 것을 목표로 했다.

찜한 영화 favoriteMovie , 나머지 기본 영화 remainMovie 를 각각 가지고 오는 방식으로,

  1. 예를 들어 favoriteMovie 가 6개이고 pageSize 가 12인 경우

  • page = 1: favoriteMovie.length = 6 , remainMovie.length = 6

  • page = 2: favoriteMovie.length = 0 , remainMovie.length = 12

  • page = 3: favoriteMovie.length = 0 , remainMovie.length = 12

  1. favoriteMovie 가 13개이고 pageSize 가 12인 경우

  • page = 1: favoriteMovie.length = 12 , remainMovie.length = 0

  • page = 2: favoriteMovie.length = 1 , remainMovie.length = 11

  • page = 3: favoriteMovie.length = 0 , remainMovie.length = 12

favoriteMoive 를 먼저 pageSize 만큼 가지고 오고 favoriteMoive 의 수가 pageSize 보다 작은 경우 remainMovie 를 가지고 오는 방식이다.

여기서 가장 중요한 것은 각각의 영화들의 range 다. 기존 pagepageSize 를 기준으로 range 를 정하는 경우 remainMovie 를 쿼리할 때 중간에 지나가는 경우가 생긴다. 위 2번 째 예의 page = 2일 때 remainMovie 의 범위는 0 ~ 10 이여야 한다. 이전 Movie들의 정보를 알아야 구할 수 있는데, 현재 코드에서는 이를 간단하게 구할 좋은 방법이 떠오르지 않았다. 따라서 supabase에 저장한 favoriteMovie 의 수를 미리 가지고와 이를 이용해 favoriteCount , page, pageNumber 세 개의 값들을 조합해 범위를 구했다.

function getRange(favoriteCount: number, page: number, pageSize: number) {
  const start = (page - 1) * pageSize; // 현재 페이지의 시작 인덱스
  const end = start + pageSize - 1; // 현재 페이지의 마지막 인덱스

  const favStart = start < favoriteCount ? start : favoriteCount;
  const favEnd = Math.min(end, favoriteCount - 1);
  const favLength = Math.max(favEnd - favStart + 1, 0);

  const remStart = Math.max(0, start - favoriteCount);
  const remEnd = remStart + (pageSize - favLength) - 1;
  const remLength = Math.max(remEnd - remStart + 1, 0);

  return {
    favoriteRange: favLength > 0 ? [favStart, favEnd] : [],
    remainRange: remLength > 0 ? [remStart, remEnd] : [],
  };
}

getRange 함수는 favoriteCount , page, pageNumber 세 개의 값들을 이용해 favoriteMovie , remainMovie 의 범위를 구해 반환하는 함수다.

현재 페이지의 시작 인덱스 start 가 찜한 영화의 수 favoriteCount 보다 작은 경우 이번 페이지에서는 찜한 영화만을 가지고 올 거기에 favStartstart 가 된다. 크거나 같은 경우에는 찜한 영화가 부족함으로 favoriteCount 값으로 설정한다.

favEnd = endfavoriteCount 보다 큰 경우, 존재하는 찜한 영화 개수까지만 가져온다.

remStart = 찜한 영화 개수를 넘어서야 나머지 영화를 가지고 오므로 start - favoriteCount 가 0보다 큰 경우인지 확인하면 값을 설정

remEnd = 찜한 영화 개수(favLength ) 를 채운 후 남은 공간만큼 나머지 영화를 가지고 온다

최종 변경 코드

actions/movie-actions.ts

async function searchMoviesByFavorite(
  search: string,
  range: number[],
  isFavorite: boolean
) {
  if (range.length === 0) {
    return [];
  }
  const [start, end] = range;

  const supabase = await createServerSupabaseClient();

  const { data, error } = await supabase
    .from("movie")
    .select("*")
    .like("title", `%${search}%`)
    .eq("favorite", isFavorite)
    .order("id")
    .range(start, end);

  handleError(error);

  return data;
}

export async function searchMovies({
  search,
  page,
  pageSize,
  favoriteCount,
}: {
  search: string;
  page: number;
  pageSize: number;
  favoriteCount: number;
}) {
  const { favoriteRange, remainRange } = getRange(
    favoriteCount,
    page,
    pageSize
  );
  const [favoriteMovies, remainMovies] = await Promise.all([
    searchMoviesByFavorite(search, favoriteRange, true),
    searchMoviesByFavorite(search, remainRange, false),
  ]);

  const data = [...favoriteMovies, ...remainMovies];

  return {
    data,
    page,
    pageSize,
    hasNextPage: data.length === pageSize,
  };
}

app/page.tsx

// page.tsx
import { createServerSupabaseClient } from "utils/supabase/server";
import UI from "./ui";

export const metadata = {
  title: "TMDBFLIX",
  description: "Netflix clone using TMDB API",
};

export default async function Page() {
  const supabase = await createServerSupabaseClient();
  const { count } = await supabase
    .from("movie")
    .select("*", { count: "exact", head: true })
    .eq("favorite", true);

  return <UI favoriteCount={count} />;
}

// ui.tsx
"use client";

import MovieCardList from "components/movie-card-list";

export default function UI({ favoriteCount }: { favoriteCount: number }) {
  return (
    <main className="mt-14 mb-12">
      <MovieCardList favoriteCount={favoriteCount} />
    </main>
  );
}

MovieCardList 컴포넌트에서 사용할 수 있도록 총 찜한 영화의 수를 쿼리해서 prop으로 전달

components/Movie-card-list.tsx

export default function MovieCardList({
  favoriteCount,
}: {
  favoriteCount: number;
}) {
  const search = useRecoilValue(searchState);

  const pageSize = 12;

  const { data, isFetchingNextPage, isFetching, fetchNextPage, hasNextPage } =
    useInfiniteQuery({
      initialPageParam: 1,
      queryKey: ["movie", search],
      queryFn: ({ pageParam }) =>
        searchMovies({
          search,
          pageSize,
          page: pageParam,
          favoriteCount,
        }),
      getNextPageParam: (lastPage) => {
        if (lastPage.data.length < pageSize) {
          return null;
        }
        return lastPage.page ? lastPage.page + 1 : null;
      },
    });
    ...
  }

찜한 영화의 수 favoriteCountuseInfiniteQuery queryFn searchMovies 에 추가로 전달

댓글을 작성해보세요.


채널톡 아이콘