09. MovieDetail — 영화 상세 페이지 만들기
요약
Gemini CLI로 구현하기 — 영화 상세 페이지
npx gemini-
1src/components/MovieDetail.jsx를 작성해줘. useParams로 영화 ID를 받아서 TMDB API movie/[id]?append_to_response=videos로 상세정보와 예고편을 한 번에 가져와야 해. 포스터, 원제, 개봉일, 러닝타임, 장르 배지, 줄거리를 표시하고, YouTube Trailer가 있으면 Modal 팝업으로 재생해줘. useNavigate로 뒤로가기 버튼도 만들어줘. axios 인스턴스는 [axios 경로]에서 가져와줘.
- 사용 가이드:
[axios 경로]를../api/axios로 바꾼다. 예고편 자동 재생을 원하지 않으면 프롬프트에 “autoplay 없이”를 추가한다.
1. 상세 페이지의 기능
이전 교안과 달리, 새 상세 페이지는 훨씬 풍부한 기능을 가진다.
| 기능 | 설명 |
|---|---|
| 포스터 + 기본 정보 | 제목, 원제, 개봉일, 러닝타임 |
| 장르 태그 | 노란색 배지로 장르 표시 |
| 평점 + 투표수 | ★ 8.4 (1,234명 평가) |
| 줄거리 | 한국어 줄거리 표시 |
| 예고편 보기 | YouTube 예고편을 Modal 팝업으로 재생 |
| 뒤로가기 | 이전 페이지로 돌아가는 버튼 |
네 단계로 나누어 작업한다.
| 순서 | 만들 기능 | 핵심 개념 |
|---|---|---|
| 1단계 | import + API 호출 | useParams, append_to_response |
| 2단계 | 로딩/에러 처리 | 조건부 렌더링 |
| 3단계 | 영화 정보 레이아웃 | 장르 배지, 평점 포맷 |
| 4단계 | 예고편 팝업 | Modal, YouTube iframe |
2. 1단계 — import + API 호출
src/components/MovieDetail.jsx 파일을 열고 임시 코드를 모두 지운 뒤 작성한다.
1import { useState, useEffect } from "react";2import { useParams, useNavigate } from "react-router";3import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";4import { faArrowLeft, faPlay } from "@fortawesome/free-solid-svg-icons";5import api from "../api/axios";6import { Spinner, Modal, Button } from "./UI.jsx";7
8export function MovieDetail() {9 const { id } = useParams();10 const navigate = useNavigate();11
12 const [movie, setMovie] = useState(null);13 const [loading, setLoading] = useState(true);14 const [showTrailer, setShowTrailer] = useState(false);| 줄 | 설명 |
|---|---|
| 2 | useNavigate — “뒤로가기” 버튼에 사용한다. navigate(-1)으로 이전 페이지로 돌아간다. |
| 4 | 왼쪽 화살표(faArrowLeft)와 재생(faPlay) 아이콘이다. |
| 6 | 4편에서 만든 Spinner, Modal, Button을 모두 사용한다. |
| 9 | URL에서 영화 ID를 꺼낸다. /movie/550이면 id는 "550"이다. |
| 12 | movie — 영화 상세 데이터. 초기값 null이다. |
| 13 | loading — 초기값 true로 시작한다. API 응답이 오면 false로 바꾼다. |
| 14 | showTrailer — 예고편 팝업의 열림/닫힘 상태이다. |
같은 MovieDetail.jsx 파일에서, const [showTrailer, ... 줄 바로 아래에 이어서 작성한다. 이 코드는 URL에서 꺼낸 영화 ID로 TMDB에 “이 영화의 상세 정보와 예고편 영상 목록을 한꺼번에 보내줘”라고 요청하는 부분이다.
1 useEffect(() => {2 api3 .get(`movie/${id}`, {4 params: { append_to_response: "videos" },5 })6 .then((res) => {7 setMovie(res.data);8 setLoading(false);9 })10 .catch(() => {11 setMovie(null);12 setLoading(false);13 });14 }, [id]);| 줄 | 설명 |
|---|---|
| 3-5 | append_to_response: "videos" — 영화 기본 정보와 함께 예고편 영상 목록도 한 번에 가져온다. TMDB API의 특수 기능이다. |
| 6-8 | 성공하면 movie에 데이터를 저장하고 loading을 끈다. |
| 10-12 | 실패하면 movie를 null로 설정하고 loading을 끈다. 에러 화면을 표시하기 위해서이다. |
| 14 | [id] — id가 바뀌면 API를 다시 호출한다. |
TMDB API는 기본적으로 영화 정보만 반환한다. append_to_response를 사용하면 한 번의 요청으로 추가 데이터를 함께 받을 수 있다.
1GET /movie/550?append_to_response=videos이렇게 하면 응답에 movie.videos.results 배열이 추가되어, 예고편 영상 정보를 별도 API 호출 없이 바로 사용할 수 있다.
3. 2단계 — 로딩/에러 처리
같은 MovieDetail.jsx 파일에서, useEffect 블록 바로 아래에 이어서 작성한다. 이 부분은 문지기 역할이다. 데이터가 아직 도착하지 않았으면 로딩 화면을 보여주고, 에러가 발생했으면 에러 메시지를 보여주고, 정상이면 그 아래 코드로 넘겨보내 준다.
1 if (loading) return <Spinner full />;2
3 if (!movie) {4 return (5 <div className="bg-black min-h-screen flex items-center justify-center">6 <p className="text-red-400 text-2xl">영화 정보를 불러오지 못했습니다.</p>7 </div>8 );9 }10
11 const poster = `https://image.tmdb.org/t/p/w500/${movie.poster_path}`;12 const videoList = movie.videos ? movie.videos.results : [];13 const trailer = videoList.find((v) => v.type === "Trailer" && v.site === "YouTube");14 const genres = movie.genres || [];| 줄 | 설명 |
|---|---|
| 1 | loading이 true이면 전체 화면 스피너를 표시하고 종료한다. JSX에서 <Spinner full />처럼 값 없이 prop 이름만 쓰면 full={true}와 동일하다. Spinner 컴포넌트 내부에서 full이 true이면 화면 전체를 채운다. |
| 3-8 | movie가 null이면 에러 메시지를 표시한다. |
| 11 | 포스터 이미지 URL을 조합한다. |
| 12 | 예고편 영상 목록을 꺼낸다. movie.videos가 없으면 빈 배열이다. |
| 13 | .find()로 YouTube 예고편을 찾는다. 없으면 undefined이다. |
| 14 | 장르 배열을 꺼낸다. |
배열에서 조건을 만족하는 첫 번째 요소 하나를 반환하는 메서드이다. 조건을 만족하는 요소가 없으면 undefined를 반환한다.
1배열.find((요소) => 조건식)콜백 함수는 배열의 요소를 하나씩 받아 조건을 검사한다. 조건이 true인 첫 번째 요소를 만나는 순간 바로 반환하고 탐색을 중단한다.
1const arr = [2 { type: "Teaser", site: "YouTube" },3 { type: "Trailer", site: "YouTube" }, // ← 이 요소에서 조건 일치, 탐색 중단4 { type: "Trailer", site: "Vimeo" }, // ← 여기까지 오지 않는다5];6
7// type이 "Trailer"이고 site가 "YouTube"인 첫 번째 요소를 반환8arr.find((v) => v.type === "Trailer" && v.site === "YouTube");9// 결과: { type: "Trailer", site: "YouTube" }10
11// 조건을 만족하는 요소가 없으면 undefined 반환12arr.find((v) => v.site === "Netflix");13// 결과: undefined| 메서드 | 반환값 | 사용 목적 |
|---|---|---|
.find() | 조건을 만족하는 첫 번째 요소 (없으면 undefined) | 특정 요소 하나를 꺼낼 때 |
.filter() | 조건을 만족하는 모든 요소의 배열 (없으면 빈 배열 []) | 조건에 맞는 여러 요소를 모을 때 |
이 코드에서는 예고편 영상이 여러 개 있어도 첫 번째 YouTube Trailer 하나만 있으면 충분하므로 .find()를 사용한다.
4. 3단계 — 영화 정보 레이아웃
같은 MovieDetail.jsx 파일에서, const genres = ... 줄 아래에 return 문을 작성한다. 코드가 길기 때문에 세 부분으로 나누어 설명한다. 탭을 눌러 각 부분을 확인하면서 순서대로 이어 붙인다.
- 뒤로가기 + 포스터
- 제목 + 장르
- 예고편 버튼 + 닫기
1 return (2 <div className="bg-black min-h-screen relative">3 <div className="relative pt-24 pb-16">4 <div className="container mx-auto px-6">5 <Button variant="ghost" onClick={() => navigate(-1)} className="mb-6 inline-flex items-center gap-2">6 <FontAwesomeIcon icon={faArrowLeft} />7 <span>뒤로가기</span>8 </Button>9
10 <div className="flex flex-col md:flex-row gap-10">11 <img12 src={poster}13 alt={movie.title}14 className="w-full md:w-72 rounded-lg object-cover shadow-2xl"15 />| 줄 | 설명 |
|---|---|
| 5 | navigate(-1) — 브라우저 히스토리에서 한 단계 뒤로 간다. |
| 10 | 모바일은 세로(flex-col), PC는 가로(md:flex-row)로 배치한다. |
| 14 | shadow-2xl로 포스터에 큰 그림자 효과를 준다. |
1 <div className="flex flex-col gap-4 text-white">2 <h1 className="text-4xl font-bold text-yellow-400">{movie.title}</h1>3 <p className="text-gray-400 text-lg">{movie.original_title}</p>4
5 <div className="flex gap-4 text-sm text-gray-300">6 <span>개봉일: {movie.release_date || "미정"}</span>7 <span>러닝타임: {movie.runtime || 0}분</span>8 </div>9
10 <div className="flex gap-2 flex-wrap">11 {genres.map((genre) => (12 <span key={genre.id} className="bg-yellow-400 text-black px-3 py-1 rounded-full text-sm font-bold">13 {genre.name}14 </span>15 ))}16 </div>17
18
19 <p className="text-gray-300 leading-relaxed max-w-xl">20 {movie.overview || "줄거리 정보가 없습니다."}21 </p>| 줄 | 설명 |
|---|---|
| 3 | original_title — 영화의 원어 제목이다. |
| 6 | `movie.release_date |
| 11-15 | genres.map()으로 장르 배열을 순회하며 노란색 배지를 만든다. rounded-full은 완전한 둥근 모서리이다. |
1 {trailer && (2 <Button variant="danger" onClick={() => setShowTrailer(true)} className="mt-2 px-6 py-3 rounded-lg inline-flex items-center gap-2 w-fit transition-colors">3 <FontAwesomeIcon icon={faPlay} />4 예고편 보기5 </Button>6 )}7 </div>8 </div>9 </div>10 </div>11
12 {showTrailer && trailer && (13 <Modal onClose={() => setShowTrailer(false)}>14 <div className="w-full max-w-4xl aspect-video">15 <iframe16 className="w-full h-full rounded-lg"17 src={`https://www.youtube.com/embed/${trailer.key}?autoplay=1`}18 title="Trailer"19 allow="autoplay; encrypted-media"20 allowFullScreen21 />22 </div>23 </Modal>24 )}25 </div>26 );27}| 줄 | 설명 |
|---|---|
| 1 | trailer가 있을 때만 “예고편 보기” 버튼을 표시한다. |
| 2 | variant="danger" — 빨간색 버튼이다. w-fit은 내용물 크기에 맞추는 너비이다. |
| 12-24 | showTrailer가 true이면 4편에서 만든 Modal 컴포넌트 안에 YouTube iframe을 표시한다. |
| 17 | trailer.key는 YouTube 영상 ID이다. autoplay=1로 자동 재생한다. |
| 14 | aspect-video는 16:9 비율을 유지하는 Tailwind 클래스이다. |
5. 동작 확인
| 확인 항목 | 기대 결과 |
|---|---|
| 카드 클릭 | 상세 페이지로 이동, 포스터 + 정보 표시 |
| ”뒤로가기” 클릭 | 이전 페이지로 돌아감 |
| 장르 태그 | 노란색 둥근 배지로 표시 |
| ”예고편 보기” 클릭 | 검정 팝업에서 YouTube 영상 자동 재생 |
| X 버튼 클릭 | 팝업 닫힘 |
6. 전체 코드
1import { useState, useEffect } from "react";2import { useParams, useNavigate } from "react-router";3import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";4import { faArrowLeft, faPlay } from "@fortawesome/free-solid-svg-icons";5import api from "../api/axios";6import { Spinner, Modal, Button } from "./UI.jsx";7
8export function MovieDetail() {9 const { id } = useParams();10 const navigate = useNavigate();11
12 const [movie, setMovie] = useState(null);13 const [loading, setLoading] = useState(true);14 const [showTrailer, setShowTrailer] = useState(false);15
16 useEffect(() => {17 api18 .get(`movie/${id}`, {19 params: { append_to_response: "videos" },20 })21 .then((res) => {22 setMovie(res.data);23 setLoading(false);24 })25 .catch(() => {26 setMovie(null);27 setLoading(false);28 });29 }, [id]);30
31 if (loading) return <Spinner full />;32
33 if (!movie) {34 return (35 <div className="bg-black min-h-screen flex items-center justify-center">36 <p className="text-red-400 text-2xl">영화 정보를 불러오지 못했습니다.</p>37 </div>38 );39 }40
41 const poster = `https://image.tmdb.org/t/p/w500/${movie.poster_path}`;42 const videoList = movie.videos ? movie.videos.results : [];43 const trailer = videoList.find((v) => v.type === "Trailer" && v.site === "YouTube");44 const genres = movie.genres || [];45
46 return (47 <div className="bg-black min-h-screen relative">48 <div className="relative pt-24 pb-16">49 <div className="container mx-auto px-6">50 <Button variant="ghost" onClick={() => navigate(-1)} className="mb-6 inline-flex items-center gap-2">51 <FontAwesomeIcon icon={faArrowLeft} />52 <span>뒤로가기</span>53 </Button>54
55 <div className="flex flex-col md:flex-row gap-10">56 <img57 src={poster}58 alt={movie.title}59 className="w-full md:w-72 rounded-lg object-cover shadow-2xl"60 />61
62 <div className="flex flex-col gap-4 text-white">63 <h1 className="text-4xl font-bold text-yellow-400">{movie.title}</h1>64 <p className="text-gray-400 text-lg">{movie.original_title}</p>65
66 <div className="flex gap-4 text-sm text-gray-300">67 <span>개봉일: {movie.release_date || "미정"}</span>68 <span>러닝타임: {movie.runtime || 0}분</span>69 </div>70
71 <div className="flex gap-2 flex-wrap">72 {genres.map((genre) => (73 <span key={genre.id} className="bg-yellow-400 text-black px-3 py-1 rounded-full text-sm font-bold">74 {genre.name}75 </span>76 ))}77 </div>78
79
80 <p className="text-gray-300 leading-relaxed max-w-xl">81 {movie.overview || "줄거리 정보가 없습니다."}82 </p>83
84 {trailer && (85 <Button variant="danger" onClick={() => setShowTrailer(true)} className="mt-2 px-6 py-3 rounded-lg inline-flex items-center gap-2 w-fit transition-colors">86 <FontAwesomeIcon icon={faPlay} />87 예고편 보기88 </Button>89 )}90 </div>91 </div>92 </div>93 </div>94
95 {showTrailer && trailer && (96 <Modal onClose={() => setShowTrailer(false)}>97 <div className="w-full max-w-4xl aspect-video">98 <iframe99 className="w-full h-full rounded-lg"100 src={`https://www.youtube.com/embed/${trailer.key}?autoplay=1`}101 title="Trailer"102 allow="autoplay; encrypted-media"103 allowFullScreen104 />105 </div>106 </Modal>107 )}108 </div>109 );110}