🍎 리액트의 동작 과정
- 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;
}
'UMC 8th Web 워크북' 카테고리의 다른 글
🍎 로그인/회원가입 페이지 구현을 위한 복습 (0) | 2025.04.13 |
---|---|
🍎 서버 환경 관련 Ot & Swagger 간단 활용 (umc 정리) (0) | 2025.04.13 |
🍿 TMDB 영화 상세 페이지 불러오기 (Umc 3주차 미션3) (0) | 2025.04.07 |
💿 로딩 에러 처리 및 페이지 라우팅 (0) | 2025.04.06 |
🎞️ 영화 리스트 데이터 불러오기 (UMC 3주차 강의 정리) (0) | 2025.04.06 |