mini project - 영화 페이지 만들기(3단계)
Nav-bar 검색 기능 구현하기 (API 사용)
구현 단계:
- Nav-bar에 검색 입력 필드 추가:
- Nav-bar에 검색 입력 필드를 추가하고, 입력된 검색어를 상태로 관리합니다.
- useDebounce Hook 구현 및 사용:
- useDebounce 커스텀 훅을 생성하여, 검색어 입력 후 일정 시간이 지난 후에 debounce 값을 업데이트하고, 그 값을 반환합니다.
- API 호출 함수 작성:
- TMdb API를 호출하여 검색 결과를 가져오는 함수를 작성합니다.
- 검색 결과 표시:
- 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]);
✍️ 생각: "이제 검색어 입력 멈춘 뒤 자동으로 /로 이동해!"
🔚 최종적으로 정리하면 이렇게 돼:
- useState로 검색어 상태 만든다
- input 태그로 입력 UI 만든다
- 디바운스 훅 연결해서 빠른 타이핑을 조절
- navigate로 페이지 이동 준비
- 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를 사용하는 이유 ?
핵심 개념 정리
🔍 네가 만든 흐름 (정리)
- NavBar에서 searchTerm을 useState로 관리
- useDebounce(searchTerm, delay)로 디바운스된 값을 따로 저장
- 디바운스된 값이 바뀔 때 API 호출해서 결과 받아오기
이 흐름만으로도 API 호출하고 결과 보여주는 건 충분히 가능해!
그럼 searchParams는 왜 필요한가?
URL의 쿼리스트링(예: ?query=spiderman)으로 검색어를 반영해두면:
장점
- 페이지 새로고침해도 검색어와 검색결과가 유지됨
- 브라우저에서 새로고침해도 URL에 query=xxx가 있으니까 다시 그걸로 검색 가능!
- 링크 공유 가능
- 누군가한테 URL을 보내면, 그 사람도 같은 검색결과를 볼 수 있어
- 브라우저 히스토리와 연동
- 뒤로 가기 했을 때 이전 검색어로 돌아갈 수 있어
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. 영화 검색어 자동 검색( 예를 들어, "안" 이라고 치면, 밑에 추천 검색어로 안녕 이런식으로 뜨게끔)