From 4e4609ba39dc6b689866c87613b82577dd879a7b Mon Sep 17 00:00:00 2001 From: sunny Date: Thu, 19 Jun 2025 18:08:43 +0900 Subject: [PATCH 1/2] =?UTF-8?q?keyword=20:=2010=EC=A3=BC=EC=B0=A8=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- keyword/Chapter10/React_Optimization.md | 159 ++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 keyword/Chapter10/React_Optimization.md diff --git a/keyword/Chapter10/React_Optimization.md b/keyword/Chapter10/React_Optimization.md new file mode 100644 index 00000000..e508122a --- /dev/null +++ b/keyword/Chapter10/React_Optimization.md @@ -0,0 +1,159 @@ +# React 최적화 정리: useMemo, useCallback, React.memo, Referential Equality + +## Referential Equality (참조 동일성) + +### 1. 정의 + +Referential Equality는 **두 값이 메모리에서 동일한 참조(주소)를 가리키는지** 판단하는 방식이다. + +```js +const a = { value: 1 }; +const b = { value: 1 }; +console.log(a === b); // false (다른 참조) + +const c = a; +console.log(a === c); // true (같은 참조) +``` + +- 객체, 배열, 함수는 참조형 데이터로 주소값을 비교 (`===`) +- 원시값은 값 자체를 비교 + +### 2. React 최적화와의 관계 + +- React는 `props`, `state` 등을 비교할 때 **참조 동일성** 기준으로 판단 +- `React.memo`, `useCallback`, `useMemo`, `useEffect` 등의 **의존성 비교 기준도 참조 동일성** +- 같은 값을 새로 생성하면 참조가 달라져 **불필요한 리렌더링 발생** + +```tsx +// 매번 새로운 객체 → memo 무효화 + +``` + +--- + +## useCallback + +### 1. 개념 및 정의 + +`useCallback`은 함수형 컴포넌트가 리렌더링될 때 동일한 함수를 재사용할 수 있도록 하는 Hook이다. +즉, **함수의 참조값을 유지**해 자식 컴포넌트의 **불필요한 리렌더링 방지**에 사용된다. + +```tsx +const memoizedCallback = useCallback(() => { + // 실행할 함수 +}, [dependency1, dependency2]); +``` + +### 2. 사용 목적 + +- 자식에게 함수를 props로 전달할 때, 매번 새 함수가 생성되면 자식도 리렌더링됨 +- `useCallback`을 사용하면 동일한 참조값을 유지할 수 있음 + +### 3. 의존성 배열 + +- `[]`: 컴포넌트가 처음 렌더링될 때만 생성 +- `[state]`: state가 변경될 때마다 새 함수 생성 + +```tsx +// 잘못된 예시 +const increment = useCallback(() => { + setCount(count + 1); // stale closure 가능성 +}, []); + +// 올바른 예시 +const increment = useCallback(() => { + setCount((prev) => prev + 1); +}, []); +``` + +### 4. 사용 예시 + +```tsx +const handleClick = useCallback(() => { + console.log("Clicked!"); +}, []); + +; +``` + +### 5. 주의사항 + +- 함수가 캐싱되므로 메모리 사용 +- 불필요한 사용은 오히려 성능 저하 +- 의존성 배열 정확하게 작성해야 함 + +--- + +## React.memo + +### 1. 개념 및 정의 + +- `React.memo`는 고차 컴포넌트로, **props가 바뀌지 않으면 리렌더링을 건너뛰는 기능**을 제공한다. + +```tsx +const MemoizedComponent = React.memo(MyComponent); +``` + +### 2. 사용 목적 + +- 리렌더링 비용이 큰 컴포넌트에서 최적화를 위해 사용 +- `props`가 **얕은 비교(shallow compare)**로 동일한 경우 렌더링 생략 + +### 3. 사용 예시 + +```tsx +const Button = React.memo(({ onClick, children }) => { + console.log("Button rendered"); + return ; +}); + +const handleClick = useCallback(() => { + console.log("clicked"); +}, []); + +; +``` + +### 4. 주의사항 + +- 객체, 배열, 함수는 참조값이 달라질 수 있어 memo가 무효화됨 → `useMemo`, `useCallback`과 함께 사용 필요 +- 깊은 비교는 하지 않음 → 커스텀 비교 함수 필요 시 `React.memo(Component, areEqual)` + +--- + +## useMemo + +### 1. 개요 + +`useMemo`는 렌더링 시 **비용이 많이 드는 계산 결과를 기억(memoization)**하여 +**불필요한 재계산을 방지**하고 컴포넌트 성능을 최적화하는 Hook이다. + +### 2. 문법 + +```tsx +const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]); +``` + +### 3. 주요 특징 + +- 리렌더링 시 의존성이 변하지 않으면 계산 생략 +- 값(Value)을 메모이제이션 +- `useCallback`과 달리 **함수 실행 결과(값)**을 캐싱 + +### 4. 사용 예시 + +```tsx +const primeList = useMemo(() => findPrimes(limit), [limit]); +const sortedList = useMemo(() => data.sort(customCompare), [data]); +``` + +### 5. 사용 시기 + +- 권장 시점: 정렬, 필터링, 계산 등 연산 비용이 클 때 +- 비권장 시점: 간단한 연산, 렌더링이 드물 때 +- 주의: 항상 새 값을 반환해야 하는 경우가 아니라면 불필요할 수 있음 + +### 6. 자주 발생하는 실수 + +- 빈 의존성 배열 사용 → 업데이트 반영 안 됨 +- 단순 연산까지 useMemo로 감싸 성능 저하 및 복잡도 증가 From ef3b24426b9d7b959faf23afbe7e657f6ccc87fd Mon Sep 17 00:00:00 2001 From: sunny Date: Tue, 24 Jun 2025 23:30:21 +0900 Subject: [PATCH 2/2] =?UTF-8?q?mission:=2010=EC=A3=BC=EC=B0=A8=20=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EC=98=81=ED=99=94=20=EC=82=AC=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mission/Chapter10/SearchMovie/.gitignore | 25 ++++ mission/Chapter10/SearchMovie/README.md | 54 +++++++++ mission/Chapter10/SearchMovie/index.html | 13 +++ mission/Chapter10/SearchMovie/package.json | 36 ++++++ mission/Chapter10/SearchMovie/public/vite.svg | 1 + mission/Chapter10/SearchMovie/src/App.css | 0 mission/Chapter10/SearchMovie/src/App.tsx | 11 ++ .../SearchMovie/src/apis/axiosClient.ts | 8 ++ .../SearchMovie/src/assets/react.svg | 1 + .../SearchMovie/src/components/Input.tsx | 22 ++++ .../src/components/LanguageSelector.tsx | 32 ++++++ .../SearchMovie/src/components/MovieCard.tsx | 49 ++++++++ .../src/components/MovieDetalModal.tsx | 107 ++++++++++++++++++ .../src/components/MovieFilter.tsx | 76 +++++++++++++ .../SearchMovie/src/components/MovieList.tsx | 31 +++++ .../SearchMovie/src/components/SelectBox.tsx | 31 +++++ .../SearchMovie/src/constants/movie.ts | 5 + .../SearchMovie/src/hooks/useFetch.ts | 35 ++++++ mission/Chapter10/SearchMovie/src/index.css | 1 + mission/Chapter10/SearchMovie/src/main.tsx | 5 + .../SearchMovie/src/pages/Homepage.tsx | 63 +++++++++++ .../Chapter10/SearchMovie/src/types/movie.ts | 34 ++++++ .../Chapter10/SearchMovie/src/vite-env.d.ts | 1 + .../Chapter10/SearchMovie/tsconfig.app.json | 27 +++++ mission/Chapter10/SearchMovie/tsconfig.json | 7 ++ .../Chapter10/SearchMovie/tsconfig.node.json | 25 ++++ mission/Chapter10/SearchMovie/vite.config.ts | 9 ++ 27 files changed, 709 insertions(+) create mode 100644 mission/Chapter10/SearchMovie/.gitignore create mode 100644 mission/Chapter10/SearchMovie/README.md create mode 100644 mission/Chapter10/SearchMovie/index.html create mode 100644 mission/Chapter10/SearchMovie/package.json create mode 100644 mission/Chapter10/SearchMovie/public/vite.svg create mode 100644 mission/Chapter10/SearchMovie/src/App.css create mode 100644 mission/Chapter10/SearchMovie/src/App.tsx create mode 100644 mission/Chapter10/SearchMovie/src/apis/axiosClient.ts create mode 100644 mission/Chapter10/SearchMovie/src/assets/react.svg create mode 100644 mission/Chapter10/SearchMovie/src/components/Input.tsx create mode 100644 mission/Chapter10/SearchMovie/src/components/LanguageSelector.tsx create mode 100644 mission/Chapter10/SearchMovie/src/components/MovieCard.tsx create mode 100644 mission/Chapter10/SearchMovie/src/components/MovieDetalModal.tsx create mode 100644 mission/Chapter10/SearchMovie/src/components/MovieFilter.tsx create mode 100644 mission/Chapter10/SearchMovie/src/components/MovieList.tsx create mode 100644 mission/Chapter10/SearchMovie/src/components/SelectBox.tsx create mode 100644 mission/Chapter10/SearchMovie/src/constants/movie.ts create mode 100644 mission/Chapter10/SearchMovie/src/hooks/useFetch.ts create mode 100644 mission/Chapter10/SearchMovie/src/index.css create mode 100644 mission/Chapter10/SearchMovie/src/main.tsx create mode 100644 mission/Chapter10/SearchMovie/src/pages/Homepage.tsx create mode 100644 mission/Chapter10/SearchMovie/src/types/movie.ts create mode 100644 mission/Chapter10/SearchMovie/src/vite-env.d.ts create mode 100644 mission/Chapter10/SearchMovie/tsconfig.app.json create mode 100644 mission/Chapter10/SearchMovie/tsconfig.json create mode 100644 mission/Chapter10/SearchMovie/tsconfig.node.json create mode 100644 mission/Chapter10/SearchMovie/vite.config.ts diff --git a/mission/Chapter10/SearchMovie/.gitignore b/mission/Chapter10/SearchMovie/.gitignore new file mode 100644 index 00000000..d7307976 --- /dev/null +++ b/mission/Chapter10/SearchMovie/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +*.env diff --git a/mission/Chapter10/SearchMovie/README.md b/mission/Chapter10/SearchMovie/README.md new file mode 100644 index 00000000..da984443 --- /dev/null +++ b/mission/Chapter10/SearchMovie/README.md @@ -0,0 +1,54 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config({ + extends: [ + // Remove ...tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + ], + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config({ + plugins: { + // Add the react-x and react-dom plugins + 'react-x': reactX, + 'react-dom': reactDom, + }, + rules: { + // other rules... + // Enable its recommended typescript rules + ...reactX.configs['recommended-typescript'].rules, + ...reactDom.configs.recommended.rules, + }, +}) +``` diff --git a/mission/Chapter10/SearchMovie/index.html b/mission/Chapter10/SearchMovie/index.html new file mode 100644 index 00000000..e4b78eae --- /dev/null +++ b/mission/Chapter10/SearchMovie/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/mission/Chapter10/SearchMovie/package.json b/mission/Chapter10/SearchMovie/package.json new file mode 100644 index 00000000..c6ecd71e --- /dev/null +++ b/mission/Chapter10/SearchMovie/package.json @@ -0,0 +1,36 @@ +{ + "name": "shoppingcart", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@reduxjs/toolkit": "^2.8.2", + "@tailwindcss/vite": "^4.1.8", + "axios": "^1.10.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-icons": "^5.5.0", + "react-redux": "^9.2.0", + "tailwindcss": "^4.1.8", + "zustand": "^5.0.5" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } +} diff --git a/mission/Chapter10/SearchMovie/public/vite.svg b/mission/Chapter10/SearchMovie/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/mission/Chapter10/SearchMovie/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mission/Chapter10/SearchMovie/src/App.css b/mission/Chapter10/SearchMovie/src/App.css new file mode 100644 index 00000000..e69de29b diff --git a/mission/Chapter10/SearchMovie/src/App.tsx b/mission/Chapter10/SearchMovie/src/App.tsx new file mode 100644 index 00000000..e46730cb --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/App.tsx @@ -0,0 +1,11 @@ +import Homepage from "./pages/Homepage"; + +function App() { + return ( + <> + + + ); +} + +export default App; diff --git a/mission/Chapter10/SearchMovie/src/apis/axiosClient.ts b/mission/Chapter10/SearchMovie/src/apis/axiosClient.ts new file mode 100644 index 00000000..3974a536 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/apis/axiosClient.ts @@ -0,0 +1,8 @@ +import axios from "axios"; + +export const axiosClient = axios.create({ + baseURL : "https://api.themoviedb.org/3", + headers : { + Authorization : `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`, + }, +}) diff --git a/mission/Chapter10/SearchMovie/src/assets/react.svg b/mission/Chapter10/SearchMovie/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mission/Chapter10/SearchMovie/src/components/Input.tsx b/mission/Chapter10/SearchMovie/src/components/Input.tsx new file mode 100644 index 00000000..26f52e37 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/components/Input.tsx @@ -0,0 +1,22 @@ +interface InputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; +} + +export const Input = ({ + value, + onChange, + placeholder = "검색어를 입력하세요.", + className, +}: InputProps) => { + return ( + onChange(e.target.value)} + /> + ); +}; diff --git a/mission/Chapter10/SearchMovie/src/components/LanguageSelector.tsx b/mission/Chapter10/SearchMovie/src/components/LanguageSelector.tsx new file mode 100644 index 00000000..00ca6b4e --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/components/LanguageSelector.tsx @@ -0,0 +1,32 @@ +interface LanguageOption { + value: string; + label: string; +} + +interface LanguageSelectProps { + value: string; + onChange: (value: string) => void; + options: LanguageOption[]; + className?: string; +} + +export const LanguageSelector = ({ + value, + onChange, + options, + className = "", +}: LanguageSelectProps) => { + return ( + + ); +}; diff --git a/mission/Chapter10/SearchMovie/src/components/MovieCard.tsx b/mission/Chapter10/SearchMovie/src/components/MovieCard.tsx new file mode 100644 index 00000000..91d50fd8 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/components/MovieCard.tsx @@ -0,0 +1,49 @@ +import type { Movie } from "../types/movie"; + +interface MovieCardProps { + movie: Movie; + onClick: (movie: Movie) => void; +} + +const MovieCard = ({ movie, onClick }: MovieCardProps) => { + const imageBaseUrl = "https://image.tmdb.org/t/p/w500"; + const fallbackImageImage = "http://via.placeholder.com/640x480"; + + return ( +
onClick(movie)} + > +
+ {`${movie.title} + +
+ {movie.vote_average.toFixed(1)} +
+
+ +
+

{movie.title}

+

+ {movie.release_date} | {movie.original_language.toUpperCase()} +

+ +

+ {movie.overview.length > 100 + ? `${movie.overview.slice(0, 100)}` + : movie.overview} +

+
+
+ ); +}; + +export default MovieCard; diff --git a/mission/Chapter10/SearchMovie/src/components/MovieDetalModal.tsx b/mission/Chapter10/SearchMovie/src/components/MovieDetalModal.tsx new file mode 100644 index 00000000..703d4c13 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/components/MovieDetalModal.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import type { Movie } from "../types/movie"; + +type Props = { + isOpen: boolean; + onClose: () => void; + movie: Movie | null; +}; + +const imageBaseUrl = "https://image.tmdb.org/t/p/w500"; +const backdropBaseUrl = "https://image.tmdb.org/t/p/original"; + +const MovieDetailModal: React.FC = ({ isOpen, onClose, movie }) => { + if (!isOpen || !movie) return null; + + return ( +
+
+
+
+

+ {movie.title} +

+

+ {movie.original_title} +

+ +
+
+ +
+ {movie.title} + +
+
+ {movie.vote_average.toFixed(1)} + + ({movie.vote_count} 평가) + +
+ +

+ 개봉일: {movie.release_date} +

+

+ 인기도: {movie.popularity} +

+

+ 성인 영화 여부:{" "} + {movie.adult ? "성인" : "전체 관람가"} +

+ +
+ +
+

줄거리

+

+ {movie.overview || "줄거리 정보가 없습니다."} +

+
+ +
+ + IMDb에서 검색 + + +
+
+
+
+
+ ); +}; + +export default MovieDetailModal; diff --git a/mission/Chapter10/SearchMovie/src/components/MovieFilter.tsx b/mission/Chapter10/SearchMovie/src/components/MovieFilter.tsx new file mode 100644 index 00000000..e6c5efa8 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/components/MovieFilter.tsx @@ -0,0 +1,76 @@ +import { memo, useState } from "react"; +import { Input } from "./Input.tsx"; +import { SelectBox } from "./SelectBox.tsx"; +import { LanguageSelector } from "./LanguageSelector.tsx"; +import { LANGUAGE_OPTIONS } from "../constants/movie.ts"; +import type { MovieFilters } from "../types/movie"; + +interface MovieFilterProps { + onChange: (filter: MovieFilters) => void; +} + +const MovieFilter = ({ onChange }: MovieFilterProps) => { + const [query, setQuery] = useState(""); + const [includeAdult, setIncludeAdult] = useState(false); + const [language, setLanguage] = useState("ko-KR"); + + const handleSubmit = () => { + const filters: MovieFilters = { + query, + include_adult: includeAdult, + language, + }; + onChange(filters); + }; + + return ( +
+
+
+ + +
+
+
+
+ + +
+ +
+ + +
+
+
+
+ +
+
+
+ ); +}; + +export default memo(MovieFilter); diff --git a/mission/Chapter10/SearchMovie/src/components/MovieList.tsx b/mission/Chapter10/SearchMovie/src/components/MovieList.tsx new file mode 100644 index 00000000..1cafc07b --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/components/MovieList.tsx @@ -0,0 +1,31 @@ +import type { Movie } from "../types/movie"; +import MovieCard from "./MovieCard"; + +interface MovieListProps { + movies: Movie[]; + onMovieClick: (movie: Movie) => void; +} + +const MovieList = ({ movies, onMovieClick }: MovieListProps) => { + if (movies.length === 0) { + return ( +
+

검색 결과가 없습니다.

+
+ ); + } + + return ( +
+ {movies.map((movie) => ( + onMovieClick(movie)} + /> + ))} +
+ ); +}; + +export default MovieList; diff --git a/mission/Chapter10/SearchMovie/src/components/SelectBox.tsx b/mission/Chapter10/SearchMovie/src/components/SelectBox.tsx new file mode 100644 index 00000000..5e610bda --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/components/SelectBox.tsx @@ -0,0 +1,31 @@ +interface SelectBoxProps { + checked: boolean; + onChange: (checked: boolean) => void; + label: string; + id?: string; + className?: string; +} + +export const SelectBox = ({ + checked, + onChange, + label, + id = "checkbox", + className, +}: SelectBoxProps) => { + return ( +
+ onChange(e.target.checked)} + className="size-4 rounded border-gray-300 bg-gray-200 text-blue-600 focus:ring-blue-500" + /> + + +
+ ); +}; diff --git a/mission/Chapter10/SearchMovie/src/constants/movie.ts b/mission/Chapter10/SearchMovie/src/constants/movie.ts new file mode 100644 index 00000000..7d538ee3 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/constants/movie.ts @@ -0,0 +1,5 @@ +export const LANGUAGE_OPTIONS = [ + {value : "ko-KR", label : "한국어"}, + {value : "en-US", label : "영어"}, + {value : "ja-JP", label : "일본어"} +] \ No newline at end of file diff --git a/mission/Chapter10/SearchMovie/src/hooks/useFetch.ts b/mission/Chapter10/SearchMovie/src/hooks/useFetch.ts new file mode 100644 index 00000000..61d83fc4 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/hooks/useFetch.ts @@ -0,0 +1,35 @@ +import type { AxiosRequestConfig } from "axios"; +import { useEffect, useState } from "react"; +import { axiosClient } from "../apis/axiosClient"; + +const useFetch = (url : string, options : AxiosRequestConfig) => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const fetchData = async() => { + setIsLoading(true); + + try { + const { data } = await axiosClient.get(url, {...options},); + + setData(data); + } catch { + setError("데이터를 가져오는 데 에러가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [url, options]) + + return { + data, + error, + isLoading, + }; +} + +export default useFetch; diff --git a/mission/Chapter10/SearchMovie/src/index.css b/mission/Chapter10/SearchMovie/src/index.css new file mode 100644 index 00000000..f1d8c73c --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/mission/Chapter10/SearchMovie/src/main.tsx b/mission/Chapter10/SearchMovie/src/main.tsx new file mode 100644 index 00000000..c983b841 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render(); diff --git a/mission/Chapter10/SearchMovie/src/pages/Homepage.tsx b/mission/Chapter10/SearchMovie/src/pages/Homepage.tsx new file mode 100644 index 00000000..2427e893 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/pages/Homepage.tsx @@ -0,0 +1,63 @@ +import { useCallback, useMemo, useState } from "react"; +import MovieFilter from "../components/MovieFilter"; +import MovieList from "../components/MovieList"; +import useFetch from "../hooks/useFetch"; +import type { MovieFilters, MovieResponse } from "../types/movie"; +import MovieDetailModal from "../components/MovieDetalModal"; +import type { Movie } from "../types/movie"; + +const HomePage = () => { + const [filters, setFilters] = useState({ + query: "어벤져스", + include_adult: false, + language: "ko-KR", + }); + + const [selectedMovie, setSelectedMovie] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + const axiosRequestConfig = useMemo(() => ({ params: filters }), [filters]); + + const { data, error, isLoading } = useFetch( + "/search/movie", + axiosRequestConfig + ); + + const handleChangeFilters = useCallback( + (filters: MovieFilters) => { + setFilters(filters); + }, + [setFilters] + ); + + const handleMovieClick = useCallback((movie: Movie) => { + setSelectedMovie(movie); + setIsModalOpen(true); + }, []); + + if (error) { + return
{error}
; + } + + return ( +
+ + {isLoading ? ( +
로딩 중...
+ ) : ( + + )} + + setIsModalOpen(false)} + movie={selectedMovie} + /> +
+ ); +}; + +export default HomePage; diff --git a/mission/Chapter10/SearchMovie/src/types/movie.ts b/mission/Chapter10/SearchMovie/src/types/movie.ts new file mode 100644 index 00000000..95bfa785 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/types/movie.ts @@ -0,0 +1,34 @@ +export type Movie = { + adult : boolean, + backdrop_path : string | null, + genre_ids : number[], + 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 MovieResponse = { + page : number; + results : Movie[]; + total_pages : number; + total_results : number; +} + +export type MovieFilters = { + query : string; + include_adult : boolean; + language : string; +} + +export type MovieLanguage = "ko-KR" | "en-US" | "ja-JP"; + + + diff --git a/mission/Chapter10/SearchMovie/src/vite-env.d.ts b/mission/Chapter10/SearchMovie/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/mission/Chapter10/SearchMovie/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/mission/Chapter10/SearchMovie/tsconfig.app.json b/mission/Chapter10/SearchMovie/tsconfig.app.json new file mode 100644 index 00000000..c9ccbd4c --- /dev/null +++ b/mission/Chapter10/SearchMovie/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/mission/Chapter10/SearchMovie/tsconfig.json b/mission/Chapter10/SearchMovie/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/mission/Chapter10/SearchMovie/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/mission/Chapter10/SearchMovie/tsconfig.node.json b/mission/Chapter10/SearchMovie/tsconfig.node.json new file mode 100644 index 00000000..9728af2d --- /dev/null +++ b/mission/Chapter10/SearchMovie/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/mission/Chapter10/SearchMovie/vite.config.ts b/mission/Chapter10/SearchMovie/vite.config.ts new file mode 100644 index 00000000..4a07f118 --- /dev/null +++ b/mission/Chapter10/SearchMovie/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(),tailwindcss()], +})