UMC 8th Web 워크북

🍎 Custom-hook (4주차 UMC 강의 정리 + @)

minnote29 2025. 4. 12. 22:00

🍎 리액트의 동작 과정

  • main.tsx를 보면, 
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
  • document.getElementById('root')! - 루트에 해당하는 아이디 값을 가져온다? 이 말은 일단 index.html 가면 root 태그가 있다. 실질적으로 우리는 자바스크립트 코드를 다운을 다 받은 다음에 루트 태그 안에 값을 넣어준다고 생각하면 된다. 
  • <div id="root"></div>

 

 

🍎

3주차에 배웠던 내용을 이어서 하면, 기존에 내가 구현한 코드는 아래와 같다.

import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import axios from "axios";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { MovieDetailWithCredits } from "../types/detail";
import MovieHero from "../components/MovieHero.tsx";
import MovieCredits from "../components/MovieCredits.tsx";

const MovieDetailPage = () => {
  const { movieId } = useParams<{ movieId: string }>();
  const [movie, setMovie] = useState<MovieDetailWithCredits | null>(null);
  const [isPending, setIsPending] = useState(false);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    const fetchMovieDetail = async () => {
      setIsPending(true);
      try {
        const { data } = await axios.get<MovieDetailWithCredits>(
          `https://api.themoviedb.org/3/movie/${movieId}?language=ko-KR&append_to_response=credits`,
          {
            headers: {
              Authorization: `Bearer ${import.meta.env.VITE_TMDB_KEY}`,
            },
          }
        );
        setMovie(data);
      } catch {
        setIsError(true);
      } finally {
        setIsPending(false);
      }
    };

    if (movieId) {
      fetchMovieDetail();
    }
  }, [movieId]);

  if (isPending) {
    return (
      <div className="flex items-center justify-center h-dvh">
        <LoadingSpinner />
      </div>
    );
  }

  if (isError || !movie) {
    return (
      <div className="text-red-500 text-2xl text-center mt-10">
        영화 정보를 불러오는 데 실패했습니다.
      </div>
    );
  }

  return (
    <>
      <MovieHero movie={movie} />
      <MovieCredits cast={movie.credits.cast} />
    </>
  );
};

export default MovieDetailPage;

영상을 따라가면서 재구성해봤다.

 

 

🍎

일단 tmdb에 있는 movies의 details를 들어가서 데이터 패칭하기에 앞서서 응답 값을 가져온다. 

tmdb에서 이렇게 입력하고 try it을 하면 응답 값이 뜨는 걸 볼 수 있는데 그것을 복붙한다. 그리고 types/movie.ts에 타입을 전부 적어준다.

type ProductionCompany = {
    id: number;
    long_path: string;
    name: string;
    origin_country: string;
}

type ProductionCountries = {
    iso_3166_1: string;
    name: string;
}

type SpokenLanguages = {
    english_nmae: string;
    iso_639_1: string;
    name: string;
}

export type MovieDetailResponse = {
    "adult": boolean,
    "backdrop_path": string,
    "belongs_to_collection": {
      "id": number,
      "name": string,
      "poster_path": string,
      "backdrop_path": string,
    },
    "budget": number,
    "genres": Genre[],
    "homepage": string,
    "id": number,
    "imdb_id": string,
    "origin_country": string[],
    "original_language": string,
    "original_title": string,
    "overview": string,
    "popularity": number,
    "poster_path": string,
    "production_companies": ProductionCompany[],
    "production_countries": ProductionCountries[],
    "release_date": string,
    "revenue": number,
    "runtime": number,
    "spoken_languages": SpokenLanguages[],
    "status": string,
    "tagline": string,
    "title": string,
    "video": boolean,
    "vote_average": number,
    "vote_count": number,
  }

 

  • MoviePage와 MovieDetailPage에서 3주차 처럼 따로 코드들을 구현을 했으면, 공통적으로 중복되서 쓰인 코드들이 있을 것이다. 
  • 일단, 차이점이 뭔지를 확인을 해야한다. 
  • 보면 isPending, movie, isError 이렇게 3개가 중복되어 사용되는 걸 볼 수 있다. 
const [movies, setMovies] = useState<Movie[]>([]);
  
// 1. 로딩 상태
const [isPending, setIsPending] = useState(false);
// 2. 에러 상태
const [isError, setIsError] = useState(false);
  • useEffect도 일치하는 것이 있는데 그 중 차이점은 axios 요청에 대한 타입이 달라진다. (MovieResponse <-> MovieDetailResponse)
  • 또한, url도 다를 것이다. hearder는 같고, set~ 함수도 똑같고, dependancy 부분이 좀 다를 것이다. 
  • 암튼, 이런 상황을 보면, 중복되는 코드들을 줄여야 겠다는 생각을 하게 될 것이다. 

=> custom-hook을 사용하자.

 

 

 

🍎 Custom-hook

Custom-hook은 뭔가 많이 사용하거나 겹치는 것 등을 hook 파일에 따로 넣어서 관리한다고 받아들이는 것이 좋다. 사실은 더 구체적이지만, 간단히 표현하면 그렇다. -> 앞선 코드들이 겹치거나 중복되는 코드들 같은 경우에 custom-hook을 사용해서 코드를 줄여줄 수 있다. 

  • 만드는 방법은 간단한데, 보통 커스텀훅을 만들 때는 hooks 폴더를 선언을 해서 프레픽스를 앞에 붙이 파일을 생성해준다. (use~) 
  • fetch 관련된 훅을 만들 테니까 어디서나 공용으로 사용할 수 있는 useCustomFetch.ts를 만들어 볼 것이다.  
  • useCustomFetch 함수에서 어떤 것을 반환을 할 거냐 할때, useCustomFetch에 대한 response 타입을 제네릭으로 선언을 하는데 그거에 대해서는 아래처럼 선언을 할 수 있다. 
function useCustomFetch(): ApiResponse {}

 

  • ApiResponse에 대한 것을 타입 폴더에 정의해서 처리해도 되지만, 함수에 대한 인터페이스니까 서로 응집되어 있으면 코드를 작성하는 사람도 편하기 때문에 몰아서 작성해 볼 수 있다.
  •  이 함수를 활용하는 입장에서는 이 인터페이스만 보고도 이 함수가 어떤 것을 반환하는지 아니까 이것을 그대로 활용해봐라! 라고 생각하면 된다. 
  •  그래서 ApiReponse에 대한 인터페이스를 만들어 줄 것인데, 이제는 movies랑, isPending, isError에 대해서 몰라도 된다. useEffect에 대한 부분도! 
  • 근데 사용하는 사람의 입장에서는 아래처럼 다 알면, MoviePage에 있는 코드들은 전부 필요가 없어질 것이다. 그럼 코드 자체가 상당히 깔끔해질 것이다. 그래서 useCustomFetch에서 인터페이스를 정의해서 처리를 할 것이다. 
const {data, isPending, isError} = useCustomFetch(url);
  • 인터페이스에서 위처럼 쓰기 위해 data를 불러와야 하는데 어떻게 타입을 지정할 수 있을지에 대해 고민을 하게 될 것이다.
  • MovieResponse랑 MovieDetailResponse를 비교만 해봐도 타입 자체가 다르다는 걸 알 수 있을 것이다. 
  • 이런 상황 때문에 any를 사용하는 사람들이 많다. 물론, 타입스크립트를 쓰는 데도 진짜 말도 안 되게 오류를 못 찾는 경우는 any를 쓸 수 있긴 한데, 보통은 다 제네릭으로 해결이 된다. -> T라는 제네릭을 선언을 해준다.   
  • 그리고 const {data, isPending, isError} = useCustomFetch(url); 이 형태를 따르려면 url을 반환해줘야 한다.
  • 함수 안에서 data, isPending, isError 이 3가지에 대해서, data 같은 경우는 다른 화면에서도 공용으로 사용하기 위해 movie에서 다른 변수로 이름을 바꿔주고, Movie[]에 대한 타입은 제네릭 타입으로 넣어준다고 했으므로 T 타입을 넣어준다. 
  • 근데, 이 데이터가 처음에는 없을 수 있으니까 null이 들어갈 수 있다고 처리를 해준다. 이전에는 빈 배열로 처리를 했지만, 영화 목록 같은 경우였으면 배열 안에 들어오긴 하는데 영화 상세 조회 같은 경우는 객체에 반환됐었다. 그렇기 때문에 이를 배열이라고 선언해주기가 쉽지가 않다. 그래서 이 부분을 null로 해줬다.
interface ApiResponse<T> { // 제네릭 T
    data: T | null; // data에 대한 타입은 우리가 줄게! (T 또는 null이 될 수도 있으니까)
    isPending: boolean;
    isError: boolean;
}

function useCustomFetch<T>(url:string): ApiResponse<T> {
    const [data, setData] = useState<T>(null); // Movie[]에 대한 타입은 제네릭 타입으로 넣어준다고 했으므로 T 타입을 넣어준다. 
    // 다른 화면에서도 공용으로 쓸 수 있는 data로 정의를 해줌.

    const [isPending, setIsPending] = useState(false);
    const [isError, setIsError] = useState(false);

} 
// const {data, isPending, isError} = useCustomFetch("http://"); 형태를 따르기 위해서.
// 제네릭 T를 받아보기 위해서 useCustomFetch에도 T를 정의를 해줘야 한다.  

export default useCustomFetch;
  • 그 다음은 이제 useEffect를 정리를 해주면 된다. 
useEffect(() => {
        const fetchData = async () => {
            setIsPending(true);

            try {
                const response = await axios.get<T>(url, {
                    headers: {
                        Authorization: `Bearer ${import.meta.env.VITE_TMDB_KEY}`,
                    }
                });
                setData(response.data);
            } catch {

            }
        }
    }, []);
  • data를 구조 분해 할당해줘서 처리해도 된다. 
try {
                const { data } = await axios.get<T>(url, {
                    headers: {
                        Authorization: `Bearer ${import.meta.env.VITE_TMDB_KEY}`,
                    }
                });
                setData(data);
            }
  • catch문 같은 경우는 에러 메세지를 받게 하기 위해 e를 넣고 isError를 string | nulle 이런 식으로 처리해서 e.data.message 이런 식으로 처리해줘도 되긴 한데 여기서는 boolean으로만 처리한다. 
  • 그리고 가장 중요한 것이 있다. 앞선 설명에서 useCustomFetch 훅만 사용하고 data, isPending, isError을 반환한다고 했기 때문에 return값을 지정해주면 된다. 
useEffect(() => {
        const fetchData = async () => {
            setIsPending(true);

            try {
                const { data } = await axios.get<T>(url, {
                    headers: {
                        Authorization: `Bearer ${import.meta.env.VITE_TMDB_KEY}`,
                    }
                });
                setData(data);
            } catch {
                setIsError(true);
            } finally {
                setIsPending(false);
            }
        };
        fetchData();
    }, [url]);
    
    return { data, isPending, isError }; // 이것만 쓸거야!

 

  • 그럼, 이제 MoviePage에서 url은 const로 따로 처리해준다. 그 다음, useEffect를 다 지워준다. data, isPending, isError 이 부분도 지워준다.
  • useCustomFetch를 쓸 때 제네릭으로 넘기기로 했으니까 제네릭으로 MovieResponse를 넘겨주면 된다. 
import { useState } from "react"
import { MovieResponse } from "../types/movie";
import MovieCard from "../components/MovieCard";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { useParams } from "react-router-dom";
import useCustomFetch from "../hooks/useCustomFetch";

export default function MoviePage() {
  const [page, setPage] = useState(1);
  const {category} = useParams<{
    category: string;
  }>();

  const url = `https://api.themoviedb.org/3/movie/${category}?language=en-US&page=${page}`;

  const { data: movies, isPending, isError } = useCustomFetch<MovieResponse>(url, 'ko-KR');

  console.log(movies);

  if (isError) {
    return (
      <div>
        <span className='text-red-500 text-2xl'>에러가 발생했습니다.</span>
      </div>
    )
  }

  return (
    <>
      {isPending && 
        <div className='flex items-center justify-center h-dvh'>
          <LoadingSpinner />
        </div>
      }

      {!isPending && (
        <div className='p-10 grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4
          lg:grid-cols-5 xl:grid-cols-6'> 
          {/* 실제 화면에 따라 달라지도록 세팅. 반응형 확인 가능. 패딩 10, gap 2*/}
            {movies?.results.map((movie) => 
              <MovieCard key={movie.id} movie={movie}/>
            )}
        </div>
      )}

      {/* {isPending ? (
        <div className='flex items-center justify-center h-dvh'>
          <LoadingSpinner />
        </div>
      ): (
        <div className='p-10 grid gap-2 grid-cols-2 sm:grid-cols-3 md:grid-cols-4
          lg:grid-cols-5 xl:grid-cols-6'> 
          실제 화면에 따라 달라지도록 세팅. 반응형 확인 가능. 패딩 10, gap 2
            {movies && movies.map((movie) => 
              <MovieCard key={movie.id} movie={movie}/>
            )}
        </div>
      )} */}

      <div className='flex items-center justify-center gap-6 p-10'>
        <button
          className='bg-violet-600 text-white px-6 py-3 rounded-lg shadow-md
          hover:bg-violet-500 transition-all duration-200 disabled:bg-gray-600
          cursor-pointer disabled:cursor-not-allowed'// 페이지가 1일 때 눌리지 않도록 disabled로 따로 스타일 적용해줌. 
          disabled={page === 1} 
          onClick={() => setPage((prev) => prev - 1)}
          > {`<`} </button>
        <span className="text-white text-lg font-semibold">{page} 페이지</span>
        <button
          className='bg-violet-600 text-white px-6 py-3 rounded-lg shadow-md
          hover:bg-violet-500 transition-all duration-200 cursor-pointer' // 여기서는 cursor-pointer 효과를 줌.
          onClick={() => setPage((prev) => prev + 1)}> 
          {`>`} 
        </button>
      </div>
    </>
  )
}
import { useParams } from "react-router-dom";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { MovieDetailWithCredits } from "../types/detail";
import MovieHero from "../components/MovieHero.tsx";
import MovieCredits from "../components/MovieCredits.tsx";
import useCustomFetch from "../hooks/useCustomFetch.ts";

const MovieDetailPage = () => {
  const params = useParams();
  const url = `https://api.themoviedb.org/3/movie/${params.movieId}?append_to_response=credits`;
  
  const { isPending, isError, data: movie } = useCustomFetch<MovieDetailWithCredits>(url, 'ko-KR');

  if (isPending) {
    return (
      <div className="flex items-center justify-center h-dvh">
        <LoadingSpinner />
      </div>
    );
  }

  if (isError || !movie) {
    return (
      <div className="text-red-500 text-2xl text-center mt-10">
        영화 정보를 불러오는 데 실패했습니다.
      </div>
    );
  }

  return (
    <>
      <MovieHero movie={movie} />
      <MovieCredits cast={movie.credits.cast} />
    </>
  );
};

export default MovieDetailPage;
  • language에 대해서 따로 처리를 안 하고 싶다하면, language: Language = 'en-US' 이런 식으로 넣으면 기본 값이 영어로 들어갈 것이다. 근데 한국어로 하고 싶으면 한국어를 따로 적어주면 되는 것.
import axios from "axios";
import { useEffect, useState } from "react";

interface ApiResponse<T> { // 제네릭 T
    data: T | null; // data에 대한 타입은 우리가 줄게! (T 또는 null이 될 수도 있으니까)
    isPending: boolean;
    isError: boolean;
}

type Language = "ko-KR" | "en-US";

function useCustomFetch<T>(url:string, language: Language = 'en-US'): ApiResponse<T> {
    const [data, setData] = useState<T | null>(null); // Movie[]에 대한 타입은 제네릭 타입으로 넣어준다고 했으므로 T 타입을 넣어준다. 
    // 다른 화면에서도 공용으로 쓸 수 있는 data로 정의를 해줌.

    const [isPending, setIsPending] = useState(false);
    const [isError, setIsError] = useState(false);

    useEffect(() => {
        const fetchData = async () => {
            setIsPending(true);

            try {
                const { data } = await axios.get<T>(url, {
                    headers: {
                        Authorization: `Bearer ${import.meta.env.VITE_TMDB_KEY}`,
                    },
                    params: {
                        language,
                    },
                });
                setData(data);
            } catch {
                setIsError(true);
            } finally {
                setIsPending(false);
            }
        };
        fetchData();
    }, [url, language]);

    return { data, isPending, isError }; // 이것만 쓸거야!
} 
// const {data, isPending, isError} = useCustomFetch("http://"); 형태를 따르기 위해서.
// 제네릭 T를 받아보기 위해서 useCustomFetch에도 T를 정의를 해줘야 한다.  

export default useCustomFetch;

 

 

🍎

  • 추가적으로 보면, MovieDetailResponse가 정보가 넘치는 것을 알 수 있는데, 분명히 겹치는 Movie와 겹치는 것이 있을 것이다. 이럼 확장을 고려해봐도 좋다. -> 겹치는 타입 => export type BaseMovies = {}
  • Movie 같은 경우는genre_ids만 들어가야 한다! -> 코드 중복이니까 전부 지우고, BaseMovies는 기본적으로 받는데 대신, genre_ids만 따로 써도 동일할 것이다. 
export type BaseMovies = {
    adult: boolean;
    backdrop_path: string;
    id: number;
    original_language: string;
    original_title: string;
    overview: string;
    popularity: number;
    poster_path: string;
    release_date: string;
    title: string;
    video: boolean;
    vote_average: number;
    vote_count: number;
}
export type Movie = BaseMovies & {
    genre_ids: number[];
};
  • MovieDetail도 똑같이 처리해준다.
export type MovieDetailResponse = BaseMovies & {
    belongs_to_collection: BelongsToCollection;
    budget: number,
    genres: Genre[],
    homepage: string;
    imdb_id: string;
    origin_country: string[];
    production_companies: ProductionCompany[];
    production_countries: ProductionCountries[];
    revenue: number;
    spoken_languages: SpokenLanguages[];
    status: string;
    tagline: string;
}