Studying/미니 프로젝트

mini project - 영화 페이지 만들기(3단계)

creamymood 2025. 5. 15. 11:52

 

언제쯤.. 뚝딱뚝딱 할 수 있으까.. ?

 


Nav-bar 검색 기능 구현하기 (API 사용)

 


구현 단계:

  1. Nav-bar에 검색 입력 필드 추가:
    • Nav-bar에 검색 입력 필드를 추가하고, 입력된 검색어를 상태로 관리합니다.
  2. useDebounce Hook 구현 및 사용:
    • useDebounce 커스텀 훅을 생성하여, 검색어 입력 후 일정 시간이 지난 후에 debounce 값을 업데이트하고, 그 값을 반환합니다.
  3. API 호출 함수 작성:
    • TMdb API를 호출하여 검색 결과를 가져오는 함수를 작성합니다.
  4. 검색 결과 표시:
    • NavBar에서 디바운스된 값이 업데이트될 때마다 searchParams로 전달합니다.
    • 메인페이지에서 searchParams를 통해 검색어를 받아옵니다.
    • 해당 검색어를 기반으로 API를 호출한 후, 응답 데이터를 MovieCard에 전달하여 렌더링합니다.

단계별 계획 (movieCard 에서 렌더링)

 

1. Input창에 입력된 검색어는.. 굳이 전역 상태로 관리할 필요가 없을 것 같아서 우선 useState로써 관리.

2. useDebounce 훅 먼저 만든 뒤, 그걸 타이밍 관리 useEffect를 사용하고, debounce된 값을 최종 검색 값으로, 

3. 입력값 받아서 → navigate('/?q=검색어')로 URL 변경

4. URL에서 query 값 꺼냄 (searchParams) → 그 query 값으로 TMDb API 호출

5. 그리고 home페이지에서 query값이 있을 경우, 검색 영화를 보여주고. 아닐 경우 인기 영화 목록을 보여주는 조건부 렌더링

 

* 지금 첫번째 방법은, 무비카드 그러니까, 인기영화가 렌더링 되는 페이지에서

조건부 렌더링으로 > 영화 검색 결과가 있을 경우 > 검색 영화를 보여주고

                                                        아닐 경우 > 인기 영화를 보여주는 방식 인거다.

 

navBar에서 input에 타이핑 → 디바운싱 → 최종 검색어 발생

그 검색어를 기반으로 navigate('/'); → Home 페이지로 이동

Home 컴포넌트는: 검색어가 있으면 → 검색 API 요청 → 결과 보여줌 검색어가 없으면 → 인기 영화 보여줌

 

두번째 도전해볼 방법은, 검색영화가 있으면,

search 컴포넌트를 만들어서 그쪽으로 이동해서, 영화를 거기서 렌더링 할 수 있는지 실험해볼 예정 ~ to be continued..


 

❶ Nav-bar에 검색 입력 필드 추가 (인풋창 추가)

:   Nav-bar에 검색 입력 필드를 추가하고, 입력된 검색어를 상태로 관리합니다

 

1단계 최종 코드는 다음과 같다.

더보기
import React, { useEffect, useState } from "react";
import Join from "../page/Join";
import Login from "../page/Login";
import { Link, useNavigate } from "react-router-dom";

// useBounce 훅 만들기. 
function useDebounce (value, delay) {
    const [debouncedValue, setDebouncedValue] = useState(value)

   useEffect(()=> {
    const handler = setTimeout(()=> {
        setDebouncedValue(value)
     }, delay)
     return() => {
        clearTimeout(handler)
     }
   },[delay, value])

   return debouncedValue
}



function Navbar() {
    const [inputValue, setInputValue ] = useState('')
    const debouncedFinal = useDebounce(inputValue,300)
    const navigate = useNavigate()


    
    const handleLogoClick = () => {
        
        navigate('/')}; // URL의 ?q=... 제거됨


    useEffect(()=>{
        if(debouncedFinal) {
            navigate(`/search?q=${debouncedFinal}`)
            setInputValue('')
        }
        
    },[debouncedFinal, navigate])


    

    return(
        <div>
            
                <h1 style={{ cursor: 'pointer' }} onClick={handleLogoClick}>oz무비</h1>
            
            
            <input type="검색" value={inputValue} onChange={(e)=>setInputValue(e.target.value)}/>
            
            <Link to="/join">
                <button>회원가입</button>
            </Link>
            
            <Link to="/login">

                <button>로그인</button>
            </Link>
            
        </div>
        
    )
}

export default Navbar;

 

이 흐름으로 작성 했다.

더보기

1. 디바운스 훅 먼저 만들기

 

 1. 입력값 상태 먼저 만든다

먼저 사용자의 입력을 받아야 하니까 useState부터 만든다.

const [searchKeyword, setSearchKeyword] = useState('');

✍️ 생각: "검색어를 입력해야 하니까 input 값을 상태로 관리해야겠지."


 2. input 태그 만든다

상태랑 연결해서 input을 만든다.
onChange로 상태를 업데이트하게 해.

<input
  type="text"
  value={searchKeyword}
  onChange={(e) => setSearchKeyword(e.target.value)}
  placeholder="Search..."
/>

✍️ 생각: "이제 사용자가 타이핑할 수 있어. 상태에 값이 잘 들어오는지도 콘솔로 찍어보면 좋고."

 

3. 디바운스 훅 가져와서 연결한다

사용자가 입력할 때마다 바로 URL이 바뀌면 너무 많으니까 디바운싱을 해준다.

const debouncedKeyword = useDebounce(searchKeyword, 300);

✍️ 생각: "검색어 입력이 끝난 후 300ms 기다렸다가 최종 값을 얻자."

4. navigate 준비

검색어 입력이 끝났을 때 검색 결과 페이지로 이동하려면 라우터가 필요해.

const navigate = useNavigate();

✍️ 생각: "React Router로 페이지 이동할 수 있게 해야지."

 

 5. 디바운스된 값이 바뀌면 페이지 이동하게 한다

useEffect로 debouncedKeyword가 바뀌었을 때 동작을 설정한다.

useEffect(() => {
  if (debouncedKeyword) {
    navigate(`/?q=${debouncedKeyword}`);
     setInputValue('') //이동 후 검색창 초기화
  }
}, [debouncedKeyword, navigate]);

✍️ 생각: "이제 검색어 입력 멈춘 뒤 자동으로 /로 이동해!"

 

🔚 최종적으로 정리하면 이렇게 돼:

  1. useState로 검색어 상태 만든다
  2. input 태그로 입력 UI 만든다
  3. 디바운스 훅 연결해서 빠른 타이핑을 조절
  4. navigate로 페이지 이동 준비
  5. useEffect로 디바운스된 값이 생기면 URL 이동

 


❷ 검색 결과 표시

:  해당 검색어를 기반으로 API를 호출한 후, 응답 데이터를 MovieCard에 전달하여 렌더링합니다.

   (결과 보여줌 검색어가 없으면 → 인기 영화 보여줌)

 

2단계 최종 코드는 다음과 같다.

더보기
import { useEffect, useState } from 'react';
import { useLocation, Link } from 'react-router-dom';

function Home() {
  const [movies, setMovies] = useState([]);
  const location = useLocation();

  const query = new URLSearchParams(location.search).get('query');

  useEffect(() => {
    const options = {
      method: 'GET',
      headers: {
        accept: 'application/json',
        Authorization: 'Bearer ...' // 토큰 생략
      }
    };

    const fetchData = async () => {
      const url = query
        ? `https://api.themoviedb.org/3/search/movie?query=${query}&language=en-US&page=1`
        : `https://api.themoviedb.org/3/movie/popular?language=en-US&page=1`;

      try {
        const res = await fetch(url, options);
        const data = await res.json();
        setMovies(data.results || []);
      } catch (err) {
        console.error(err);
      }
    };

    fetchData();
  }, [query]);

  return (
    <>
      {movies
        .filter(movie => movie.adult === false)
        .map((movie, id) => (
          <Link key={id} to={`/details/${movie.id}`}>
            <MovieCard
              poster={movie.poster_path}
              title={movie.title}
              rating={movie.vote_average}
            />
          </Link>
        ))}
    </>
  );
}

export default Home;

 

이 흐름으로 작성 했다.

더보기

 

import { useEffect, useState } from 'react';

"컴포넌트 안에서 상태 관리와 사이드 이펙트를 다뤄야 하니까 useState랑 useEffect를 먼저 불러와야지."

import { useLocation, Link } from 'react-router-dom';

"react-router-dom을 쓰고 있으니까 현재 URL의 query string을 가져오기 위해 useLocation 필요하고, 각 영화 클릭 시 디테일 페이지로 이동시키려면 Link도 필요해."

function Home() {

"홈 페이지 컴포넌트니까 Home 함수로 정의 시작."

  const [movies, setMovies] = useState([]);

"영화 데이터를 저장할 state가 필요하니까 movies라는 state를 빈 배열로 초기화."

  const location = useLocation();

"현재 주소에서 쿼리 정보를 뽑아야 하니까 useLocation을 써서 현재 위치를 가져와."

  const query = new URLSearchParams(location.search).get('query');

"URL 쿼리 중에서 query라는 key가 있는지 확인하고, 있으면 그 값을 가져와야 함. 검색어 처리용."


  useEffect(() => {

"query 값이 바뀔 때마다 데이터를 새로 불러오게 useEffect 사용."

    const options = {
      method: 'GET',
      headers: {
        accept: 'application/json',
        Authorization: 'Bearer ...' // 토큰 생략
      }
    };

"API 호출 시 필요한 옵션들 설정. 특히 인증 토큰은 헤더에 넣어야 해."

    const fetchData = async () => {

"비동기로 API를 호출할 함수 fetchData를 정의."

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

"만약 query가 있으면 검색 API를, 없으면 인기 영화 API를 호출하도록 조건 처리."


 

      try {
        const res = await fetch(url, options);
        const data = await res.json();
        setMovies(data.results || []);
      } catch (err) {
        console.error(err);
      }

"fetch로 API 호출하고 응답을 JSON으로 파싱. 결과를 movies state에 저장. 에러는 콘솔에 찍기."

fetch()는 네트워크 요청을 보내는 함수예요.

url에 요청을 보내고 결과(response)를 res에 저장해요.

await 덕분에 응답이 올 때까지 기다렸다가 다음 줄 실행해요.
options는 일반적으로 헤더, 메서드, API 키 등을 담고 있어요.

 


받아온 data 안에 있는 results 배열을 setMovies()로 저장해요.

만약 data.results가 undefined거나 null이면 → 빈 배열 []을 저장해요.

이건 React에서 상태 업데이트하는 부분이에요.



    };

    fetchData();
  }, [query]);

"이펙트 안에서 fetch 함수 실행. query가 바뀔 때마다 다시 실행되게 dependency 배열에 넣음."


  return (
    <>
      {movies
        .filter(movie => movie.adult === false)
        .map((movie, id) => (

"movies 중에서 성인 영화는 제외하고, 남은 영화들을 반복문으로 출력. 영화마다 고유한 링크 생성."

          <Link key={id} to={`/details/${movie.id}`}>
            <MovieCard
              poster={movie.poster_path}
              title={movie.title}
              rating={movie.vote_average}
            />
          </Link>
        ))}
    </>
  );
}

"영화 하나하나에 대해 MovieCard 컴포넌트로 포스터, 제목, 평점 표시. 클릭 시 상세 페이지로 이동."

export default Home;

"이 컴포넌트를 외부에서 사용할 수 있게 export."


필요하시면 이 컴포넌트에 들어가는 MovieCard도 같이 만들어드릴 수 있어요!
또는 이 코드에서 어떤 부분을 더 개선하거나 리팩토링할 수 있을지도 원하시면 도와드릴게요.

여기서, 검색어 있다면, 검색어 영화 api 호출을. 검색 결과가 없다면 인기 영화를 호출 하는 코드는 다음과 같다.

더보기

좋아, Jane! 👇
"검색어가 있으면 → 검색 영화 API 호출, 없으면 → 인기 영화 API 호출"
이 조건은 useEffect 안의 fetch URL을 고르는 부분에 있어.

바로 이 부분이 핵심이야:

조건이 적용된 부분

const url = query
  ? `https://api.themoviedb.org/3/search/movie?query=${query}&language=en-US&page=1`
  : `https://api.themoviedb.org/3/movie/popular?language=en-US&page=1`;
  • query가 존재하면 (검색어 있음) → search/movie API 사용
  • query가 없으면 (검색 안함) → popular API 사용

🔍 query는 어디서 오냐면?

const location = useLocation();
const query = new URLSearchParams(location.search).get('query');

즉, 네가 NavBar에서 navigate("/?query=엥무비"); 이렇게 보내준 URL에서
쿼리 스트링 (?query=...) 값을 꺼내오는 거야.

 

전체 흐름 다시 정리


NavBar 검색어 입력 후 navigate('/?query=검색어') 실행
Home 컴포넌트 URL에서 query 값을 가져옴
useEffect 안에서 query가 있으면 → search/movie 호출query가 없으면 → movie/popular 호출
렌더링 movies 배열에 따라 영화 카드 리스트 렌더링

 

 

api는 조건부 렌더링으로 받아와지고

여기서, 검색 결과가 있다면, 검색 결과 영화를. 검색 결과가 없다면 인기 영화를 렌더링 하는 코드는 다음과 같다.

더보기

return 파트다.

return (
  <>
    {movies
      .filter(movie => movie.adult === false)
      .map((movie, id) => (
        <Link key={id} to={`/details/${movie.id}`}>
          <MovieCard
            poster={movie.poster_path}
            title={movie.title}
            rating={movie.vote_average}
          />
        </Link>
      ))}
  </>
);

이건 결국 movies 상태에 들어 있는 영화들만 보여주는 UI야.

 

그래서 결론은:

  • movies가 검색 API 결과일 경우 → 검색된 영화들이 렌더링
  • movies가 인기 영화 API 결과일 경우 → 인기 영화들이 렌더링

즉, movies에 어떤 값이 있느냐에 따라
자동으로 그에 맞는 영화 목록이 보여지는 거야!
렌더링 부분 자체는 공통이고,
그 앞단 로직 (fetchData)에서 어떤 데이터를 넣어주느냐가 핵심 포인트지.

 

 

필요하면 이런 것도 추가 가능해:

{movies.length === 0 ? (
  <p>검색 결과가 없습니다.</p>
) : (
  movies
    .filter(movie => movie.adult === false)
    .map((movie, id) => (
      <Link key={id} to={`/details/${movie.id}`}>
        <MovieCard
          poster={movie.poster_path}
          title={movie.title}
          rating={movie.vote_average}
        />
      </Link>
    ))
)}

 


새롭게 공부하게 된 부분

1. debounce된 최종 값으로, api를 바로 호출 하면 될텐데, searchParams를 사용하는 이유 ?

더보기

핵심 개념 정리

🔍 네가 만든 흐름 (정리)

  1. NavBar에서 searchTerm을 useState로 관리
  2. useDebounce(searchTerm, delay)로 디바운스된 값을 따로 저장
  3. 디바운스된 값이 바뀔 때 API 호출해서 결과 받아오기

이 흐름만으로도 API 호출하고 결과 보여주는 건 충분히 가능해!

 

그럼 searchParams는 왜 필요한가?

URL의 쿼리스트링(예: ?query=spiderman)으로 검색어를 반영해두면:

장점

  1. 페이지 새로고침해도 검색어와 검색결과가 유지됨
    • 브라우저에서 새로고침해도 URL에 query=xxx가 있으니까 다시 그걸로 검색 가능!
  2. 링크 공유 가능
    • 누군가한테 URL을 보내면, 그 사람도 같은 검색결과를 볼 수 있어
  3. 브라우저 히스토리와 연동
    • 뒤로 가기 했을 때 이전 검색어로 돌아갈 수 있어

2. searchParams는 url에 있는 값을 추출하는 건데, 나는 디바운스 값을 url을 설정한 적이 없는데? 하려고 하면 어떡하지?

 

-> 디바운스 값이 바뀔 때 navigate()를 호출해서 URL을 먼저 바꿔주는 게 핵심이야.
URL이 바뀌고 → 그 URL을 기반으로 MainPage에서 searchParams로 추출 후, API 호출이라는 순서야!

 

더보기

 

  • NavBar에서
    • 입력값 받아서
    • debouncedTerm이 바뀌면 → navigate('/?query=검색어')로 URL 변경
  • MainPage에서
    • URL에서 query 값 꺼냄 (searchParams)
    • 그 query 값으로 TMDb API 호출
  • 응답 받은 영화 목록을
    • map() 돌려서 MovieCard나 원하는 컴포넌트로 뿌려주면 됨

 

더보기

navigate()는 URL을 바꾸면서 라우팅(페이지 이동) 도 함께 할 수 있는 함수로, 이걸 사용하면 돼.

navigate()란?

useNavigate() 훅으로 가져오는 함수로, 아래처럼 사용해:

import { useNavigate } from 'react-router-dom';

const navigate = useNavigate();

navigate('/search?q=apple');
  • 이 코드를 실행하면 브라우저 URL이로 바뀌고,
  • /search?q=apple
  • Route에 등록된 /search 페이지로 이동해.

그래서 요약하면:


사용 방법 동작
navigate('/search?q=apple') 경로 이동 + 쿼리스트링 변경
navigate('/home') 단순 페이지 이동
navigate(-1) 뒤로 가기 (브라우저 히스토리)
navigate('/search', { replace: true }) 페이지 이동 + 기존 기록 덮어쓰기 (뒤로가기 안됨)

 

그리고 setSearchParams와 차이점


함수 페이지 이동? url 바뀜?
navigate('/search?q=abc') ✅ O ✅ O
setSearchParams({ q: 'abc' }) ❌ X (같은 페이지에 머무름) ✅ O (쿼리만 바뀜)

 

예시 상황

// Navbar에서 검색어 입력 후 검색 결과 페이지로 이동
const onSearch = (keyword) => {
  navigate(`/search?q=${keyword}`);
};
  • 👉 이러면 SearchPage 컴포넌트로 이동하면서 쿼리도 같이 넘길 수 있어.

궁금한 부분 더 물어봐도 돼!
React Router는 처음엔 헷갈리지만 흐름 잡히면 진짜 강력해! 💪😊

 

3. 코드 중에, navigate를 변수에 넣어서 사용하던데. 바로 useNavigate()이렇게 쓰면 안되는건가?

바로 이 파트 말이야..

const navigate = useNavigate();

    const handleLogoClick = () => {
        navigate('/', { replace: true }); // URL의 ?q=... 제거됨
      };
더보기

좋은 질문이에요, Jane! 간단히 말하면:

 useNavigate(/search?q=${debouncedKeyword}) 처럼 직접 인자로 URL을 넣어서 바로 호출하는 건 안 됩니다.

 

 왜 그런가요?

useNavigate()는 React Router의 훅(hook) 이고, 컴포넌트가 렌더링될 때 호출해서 "navigate 함수"를 얻는 것이에요. 이 함수는 그 이후에 원하는 곳에서 써야 해요.

예:

const navigate = useNavigate(); // 함수 받아오기
navigate('/some-url');         // 나중에 실행

이걸 이렇게 쓸 순 없어요:

useNavigate('/some-url'); // ❌ 이건 훅의 사용 방식이 아님

 

 

기억할 점

  • 훅(useSomething)은 항상 컴포넌트 최상단에서 호출해야 해요. 조건문 안이나 함수 안에서 호출하면 안 됩니다.
  • useNavigate는 "navigate 함수"를 반환하는 훅이지, URL 이동을 직접 하는 함수가 아닙니다.

 

 올바른 예시

const navigate = useNavigate();

useEffect(() => {
  if (debouncedKeyword) {
    navigate(`/search?q=${debouncedKeyword}`);
  }
}, [debouncedKeyword]);

 

 

4. location이 뭐야.. ?그냥 바로 searchParams 쓰면 안돼?

이 코드 말이야.

const location = useLocation()
    const query = new URLSearchParams(location.search).get('q')
더보기

location이 뭐야?

location은 브라우저가 현재 보고 있는 URL에 대한 정보를 담고 있는 **전역 객체(window.location)**예요.

예시 :

 

console.log(location.href); // 전체 URL 출력 console.log(location.search); // ?query=hello&sort=desc 이런 식으로 "쿼리스트링"만 출력

URLSearchParams이 뭐야?

  • URLSearchParams는 ?key=value 형태의 쿼리 스트링을 간편하게 다루기 위한 유틸 클래스예요.
  • new URLSearchParams(location.search)는 → 현재 페이지의 쿼리 문자열에서 값을 추출할 수 있게 해줘요.

예를 들어:

const searchParams = new URLSearchParams('?query=hello&sort=desc');
searchParams.get('query'); // 'hello'

 

 

그럼 왜 그냥 searchParams 쓰면 안돼?

URLSearchParams를 쓰기 위해서는 어떤 URL의 쿼리 문자열을 분석할 건지 알려줘야 해요.
그 대상이 바로 location.search예요.

즉, 단독으로 searchParams만 쓸 수는 없고,
항상 뭔가를 기준으로 만들어야 해요. 예를 들면:

사용 예시:

const query = new URLSearchParams(location.search).get('query');

이렇게 해야 현재 페이지의 URL에 있는 ?query=... 값을 가져올 수 있어요.

 

만약 전체 URL이 있을 때는?

const url = new URL('https://example.com/page?query=hello');
const query = url.searchParams.get('query'); // 'hello'

 

 

5. 예를 들어 로고를 눌렀을 때, 다시 / 메인 페이지로 왔는데.. 아까 검색한 url이 남아있어서, url을 다시 초기화 해주는 건 어떻게 하는거지 ?

더보기

바로 이렇게!

const handleLogoClick = () => {
        navigate('/', { replace: true }); // URL의 ?q=... 제거됨
      };

navigate 함수를 사용해서 메인 페이지(/)로 이동합니다.

  • replace: true는 브라우저의 히스토리 기록을 바꾸지 않고 현재 페이지를 덮어씌우듯 이동합니다.
  • 보통 URL에 있는 검색 파라미터(?q=...)를 없애고 싶을 때 사용합니다
return(
        <div>
            
                <h1 style={{ cursor: 'pointer' }} onClick={handleLogoClick}>oz무비</h1>

 

 


언젠가 구현 도전해보고 싶은 기능

1. 검색 결과 저장 해서, 밑에 모달 처럼 뜨게끔

2. 영화 검색어 자동 검색( 예를 들어, "안" 이라고 치면, 밑에 추천 검색어로 안녕 이런식으로 뜨게끔)