[인프런 워밍업 클럽 Full Stack 3기] 3주차

[인프런 워밍업 클럽 Full Stack 3기] 3주차

1. 주요 학습 내용

1.1. generageMetadata

-  App Router에서 페이지별 메타데이터를 동적으로 생성하기 위한 비동기 함수

1.1.1. 사용할 만한 메타데이터 객체 키와 타입

interface MovieMetadata {
  title: string
  description: string 
  keywords: string[]
  authors: { name: string, url: string }[]
  openGraph: {
    title: string
    description: string
    url: string
    siteName: string
    images: string[]
    locale: string
    type: string
  }
  robots: {
    index: boolean
    follow: Boolean
  }
}

1.2. useInfiniteQuery

무한 스크롤이나 "더 보기" 버튼과 같은 페이지네이션 UI를 구현할 때 사용하는 TanStack Query의 특수 훅

1.2.1. 주요 매개변수

1. queryKey: 쿼리 식별자 (캐싱 키)

2. queryFn: 데이터를 가져오는 함수

3. initialPageParam: 첫 페이지 파라미터

4. getNextPageParam: 다음 페이지 파라미터 계산 함수

5. getPreviousPageParam: 이전 페이지 파라미터 계산 함수 (양방향 페이지네이션)

 

2. 미션

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

우선 이 넷플릭스의 사용자는 나 한사람만 있다 가정하고 작업을 진행했습니다.

이를 기반으로 movie 테이블엔 is_favorite 컬럼을 추가하고 타입은 boolean으로 설정했습니다.

image

찜하기 버튼은 토글 방식의 하트 모양 버튼(/🤍)으로 직관적인 UI를 만들고자 했습니다. 이 버튼을 영화 목록 페이지와 상세 페이지 모두에서 사용할 수 있게 포스터 우측 상단에 배치했습니다. 사용자 경험을 개선하기 위해 Sonner 라이브러리를 활용한 토스트 메시지도 추가했습니다.

image

2.1.1. 미션 진행 중 고민사항

영화 목록 페이지에서 찜하기 버튼을 배치할 때 두 가지 UX 부분 고민이 있었습니다.

  • 클릭 영역 분리: Link 컴포넌트와 찜하기 버튼의 클릭 영역이 겹치는 문제를 z-index를 활용해 해결했습니다.

  • 상태 갱신 전략: 찜하기 버튼 클릭 시 전체 목록을 갱신할지, 해당 버튼의 상태만 갱신할지 고민했습니다. 전체 목록을 갱신하게 되면 사용자 입장에선 스크롤을 다시 내려봐야 하는 불편함이 클 것 같아 개별 버튼만 갱신하는 방식을 선택했습니다.

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

영화 목록을 요청하는 서버 액션 코드엔 찜 여부: 내림차순, id: 오름차순으로 정렬된 결과를 받을 수 있게 order 메서드를 추가했습니다.

// actions/movieAction.ts
// (일부 코드는 '...'으로 생략했습니다.)
export const searchMovies = async ({ search, page, pageSize }: searchMoviesProps) => {
  console.log({ search, page, pageSize });
  const supabase = await createServerSupabaseClient();
  const { data, count, error } = await supabase
    .from("movie")
    .select("*")
    .ilike("title", %${search}%)
    .order("is_favorite", { ascending: false })
    .order("id", { ascending: true })
    .range((page - 1) pageSize, page pageSize - 1);
// ...
});

image

 

3. 추가 작업사항

3.1. 영화 검색 시 대소문자 구분 없이 가능하게 수정

영화 검색을 몇 번 사용해보니 대소문자를 구분하여 검색이 되고 있는 것을 발견했습니다. 대부분의 사용자라면 해당 기능은 불편함을 느낄거라 생각되어 영화 목록을 요청하는 서버 액션 코드에서 사용되던 like 메서드를 ilike 메서드로 교체하여 대소문자 구분 없이 영화 검색을 할 수 있게 만들었습니다. (적용 코드는 앞서 소개된 2.2. 항목을 참조해 주세요.)

3.2. Image 컴포넌트 사용

img 태그를 사용하니 경고 문구가 표시되서 next.js에서 권장하는 Image 컴포넌트를 사용하여 영화 포스터를 표시했습니다.

Image 컴포넌트는 기본적으로 몇몇 css 속성이 미리 지정되어 있어서 img 태그와 다르게 이미지 크기를 설정해야 했습니다.

// /components/MovieCard/MovieCardItem.tsx
// (일부 코드는 '...'으로 생략했습니다.)

'use client';
// ...
export default function MovieCardItem({
  movie: { id, image_url, title, overview, popularity, release_date, vote_average, is_favorite },
}: {
  movie: Movie;
}) {
  // ...
  return (
    <div className="relative w-full aspect-[2/3]">
      <Image
        src={image_url}
        alt={title}
        fill
        className="object-cover"
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      />
      <Link href={`/movies/${id}`}>
        <div className="flex items-center justify-center absolute inset-0 z-10 bg-black opacity-0 transition-opacity duration-300 hover:opacity-80">
          <p className="text-xl font-bold text-white">{title}</p>
        </div>
      </Link>
      // ...
    </div>
  );
}

외부 도메인에서 이미지를 가져와서 표시할 경우 next.config.mjs에 도메인 설정이 필요하여 추가 작업을 진행했습니다.

// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'image.tmdb.org',
        port: '',
        pathname: '/t/p/w500/**',
      },
    ],
  },
};
export default nextConfig;

3.2.1. Image 컴포넌트의 특징

- 자동 이미지 최적화: WebP, AVIF와 같은 최신 포맷으로 변환

- 레이아웃 이동 방지: CLS(Cumulative Layout Shift) 개선

- 지연 로딩(Lazy Loading): 화면에 이미지가 나타날 때만 로드

- 자동 크기 조정: 다양한 디바이스에 맞는 최적 크기 생성

- 이미지 CDN 지원: 다양한 이미지 CDN과의 호환성

3.3. custom hooks 추가

- 이번 미션의 찜하기 기능은 영화 목록과 상세 페이지에서 사용하는 기능입니다.

- 동일한 기능이 두 곳의 페이지에서 사용되기 때문에 찜하기 기능을 hooks로 만들어서 코드 중복을 최소화하고 역할 별로 코드를 구분했습니다.

  • 찜하기 토글 hooks

// /hooks/useToggleFavorite.ts

"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toggleFavoriteMovie } from "@/actions/movieAction";
import { toast } from "sonner";

interface UseToggleFavoriteProps {
  id: number;
  title: string;
  initialIsFavorite: boolean;
}

interface UseToggleFavoriteReturn {
  isFavorite: boolean;
  toggleFavoriteMovieMutation: () => void;
  isLoading: boolean;
}

export default function useToggleFavorite({
  id,
  title,
  initialIsFavorite,
}: UseToggleFavoriteProps): UseToggleFavoriteReturn {
  const [isFavorite, setFavorite] = useState(initialIsFavorite);
  // const queryClient = useQueryClient()
  const mutation = useMutation({
    mutationFn: () => toggleFavoriteMovie(id, initialIsFavorite),
    onSuccess: () => {
      const newState = !isFavorite;
      setFavorite(newState);
      const message = newState
        ? `${title}을 즐겨찾기에 추가했습니다`



: `${title}을 즐겨찾기에서 제거했습니다.`;
      toast.success(message);
      // queryClient.invalidateQueries({ queryKey: ["movies"] })
    },
    onError: (error) => {
      toast.error("찜하기 처리 중 오류가 발생했습니다.");
      console.error("찜하기 오류:", error);
    },
  });

  function toggleFavoriteMovieMutation() {
    mutation.mutate();
  }

  return {
    isFavorite,
    toggleFavoriteMovieMutation,
    isLoading: mutation.isPending,
  };
}
  • hooks 구분 후 영화 목록 아이템 컴포넌트 코드

// /components/MovieCard/MovieCardItem.tsx
// (일부 코드는 '...'으로 생략했습니다.)
'use client';
// ...
export default function MovieCardItem({
  movie: { id, image_url, title, overview, popularity, release_date, vote_average, is_favorite },
}: {
  movie: Movie;
}) {
  // 찜하기 토글 기능 hooks 호출
  const { isFavorite, toggleFavoriteMovieMutation, isLoading } = useToggleFavorite({
      id,
      title,
      initialIsFavorite: is_favorite,
    });
  return (
    <div className="relative w-full aspect-[2/3]">
      // ...
      // 찜하기 컴포턴트 시작
      <FavoriteButton
          onClick={toggleFavoriteMovieMutation}
          isFavorite={isFavorite}
          disabled={isLoading}
        />
      // 찜하기 컴포턴트 끝
    </div>
  );

3.4. custom component 추가 (찜하기 토글 버튼)

찜하기 버튼을 재사용 가능한 컴포넌트로 분리했습니다. 이 과정에서 TypeScript의 ButtonHTMLAttributes를 활용해 타입 확장하는 방법을 배웠습니다.

"use client";

import { ButtonHTMLAttributes } from "react";
interface FavoriteButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  isFavorite: boolean;
}

export default function FavoriteButton({
  isFavorite,
  className = "absolute top-2 right-2 z-10 text-lg",
  ...props
}: FavoriteButtonProps) {
  return (
    <button type="button" className={className} {...props}>
      {isFavorite ? "❤️" : "🤍"}
    </button>
  );
}

3.5. 폴더 구조 작업

스터디 1주차때 거의 못했던 폴더 구조 개편 작업을 조금 더 진행해 보았습니다.

위에서 먼저 언급됐던 hooks 폴더가 추가됐고 컴포넌트 중 서로 연관이 있는 MovieCardList와 MovieCardItem은 MovieCard 라는 폴더로 합쳐서 서로 연관 있는 컴포넌트라는 것을 강조했습니다. 그리고 header, footer처럼 여러 컴포넌트로 이루어진 영역들을 모은 containers 폴더를 만들어서 구별했습니다.

nextjs-netflix-with-supabase/
├── app/
│   └── movies/
│       └── [id]/
│           └── page.tsx                # 영화 상세 페이지 (동적 라우트)
│
├── components/
│   ├── MovieCard/
│   │   ├── MovieCardItem.tsx           # 영화 카드 개별 아이템 컴포넌트
│   │   └── MovieCardList.tsx           # 영화 카드 목록 컴포넌트
│   ├── FavoriteButton.tsx              # 찜하기 버튼 컴포넌트
│   └── Logo.tsx                        # 로고 컴포넌트
│
├── config/
│   ├── material-tailwind-provider.tsx  # Material Tailwind 설정 제공자
│   ├── react-query-provider.tsx        # TanStack Query 설정 제공자
│   └── recoil-provider.tsx             # Recoil 상태 관리 제공자
│
├── containers/
│   ├── Footer.tsx                      # 푸터 컨테이너 컴포넌트
│   ├── Header.tsx                      # 헤더 컨테이너 컴포넌트
│   ├── Main.tsx                        # 메인 컨텐츠 컨테이너
│   └── MovieDetail.tsx                 # 영화 상세 정보 컨테이너
│
├── hooks/
│   └── useToggleFavorite.ts            # 찜하기 기능 커스텀 훅
│
├── public/
│   └── ...                             # 정적 자산 파일들 (이미지 등)
│
└── utils/
    ├── recoil/
    │   └── atoms.ts                    # Recoil 상태 원자 정의
    └── supabase/
        ├── client.ts                   # Supabase 클라이언트 설정
        ├── server.ts                   # Supabase 서버 클라이언트 설정
        └── storage.ts                  # Supabase 스토리지 유틸리티

3.5.1. 일부 영역에서 tailwindcss가 적용 안되던 문제

폴더 구조 변경 후 일부 영역에서 TailwindCSS가 적용되지 않는 문제를 발견했습니다. 원인은 새로 추가한 containers 폴더가 Tailwind 설정에 포함되지 않았기 때문이었습니다.

다음부턴 폴더 구조 변경 시 관련 설정 파일들도 더 꼼꼼하게 확인해야 할 것 같습니다.

import type { Config } from "tailwindcss";
import withMT from "@material-tailwind/react/utils/withMT";

const config: Config = {
  content: [
    "./utils/**/*.{js,ts,jsx,tsx,mdx}",
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
    // "./containers/**/*.{js,ts,jsx,tsx,mdx}", 나중에 추가함
  ],
  theme: {},
  plugins: [require("@tailwindcss/typography")],
};

export default withMT(config);

 

4. 회고

이번 주는 여러 선약으로 인해 지난주만큼 스터디에 많은 시간을 쏟지 못했습니다. 하지만 공부했던 내용을 발자국에 정리하면서 보니, 생각보다 많은 것을 배웠다는 사실을 깨달았습니다. Next.js와 Supabase에 조금씩 적응해가고 있음을 체감할 수 있어 뿌듯했습니다.

어느덧 다음 주면 4주차 스터디가 마무리됩니다. 마지막 주차의 과제들은 보다 더 도전적이고 흥미로운 내용으로 구성되어 있어 기대가 됩니다.

다음 주에도 모든 과제를 완수하고, 추가적인 새로운 것도 시도하면서 배운 내용을 발자국에 정리해 나갈 계획입니다.
그러면 제 자신의 성장을 더 잘 확인할 수 있을 것 같습니다.

이상입니다.

댓글을 작성해보세요.


채널톡 아이콘