09. MovieDetail — 영화 상세 페이지 만들기
예고편 팝업, 장르 태그, 뒤로가기를 포함한 영화 상세 페이지를 구현한다
코드 블록의 Try it Yourself 버튼으로 직접 실행할 수 있다.
구문
💡TIP
Gemini CLI로 구현하기 — 영화 상세 페이지
- 프롬프트:
gemini "src/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 파일을 열고 임시 코드를 모두 지운 뒤 작성한다.
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faPlay } from "@fortawesome/free-solid-svg-icons";
import api from "../api/axios";
import { Spinner, Modal, Button } from "./UI.jsx";
export function MovieDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [movie, setMovie] = useState(null);
const [loading, setLoading] = useState(true);
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에 "이 영화의 상세 정보와 예고편 영상 목록을 한꺼번에 보내줘"라고 요청하는 부분이다.
useEffect(() => {
api
.get(`movie/${id}`, {
params: { append_to_response: "videos" },
})
.then((res) => {
setMovie(res.data);
setLoading(false);
})
.catch(() => {
setMovie(null);
setLoading(false);
});
}, [id]);
| 줄 | 설명 |
|---|---|
| 3-5 | append_to_response: "videos" — 영화 기본 정보와 함께 예고편 영상 목록도 한 번에 가져온다. TMDB API의 특수 기능이다. |
| 6-8 | 성공하면 movie에 데이터를 저장하고 loading을 끈다. |
| 10-12 | 실패하면 movie를 null로 설정하고 loading을 끈다. 에러 화면을 표시하기 위해서이다. |
| 14 | [id] — id가 바뀌면 API를 다시 호출한다. |
3. 2단계 — 로딩/에러 처리
같은 MovieDetail.jsx 파일에서, useEffect 블록 바로 아래에 이어서 작성한다. 이 부분은 문지기 역할이다. 데이터가 아직 도착하지 않았으면 로딩 화면을 보여주고, 에러가 발생했으면 에러 메시지를 보여주고, 정상이면 그 아래 코드로 넘겨보내 준다.
if (loading) return <Spinner full />;
if (!movie) {
return (
<div className="bg-black min-h-screen flex items-center justify-center">
<p className="text-red-400 text-2xl">영화 정보를 불러오지 못했습니다.</p>
</div>
);
}
const poster = `https://image.tmdb.org/t/p/w500/${movie.poster_path}`;
const videoList = movie.videos ? movie.videos.results : [];
const trailer = videoList.find((v) => v.type === "Trailer" && v.site === "YouTube");
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 | 장르 배열을 꺼낸다. |
4. 3단계 — 영화 정보 레이아웃
같은 MovieDetail.jsx 파일에서, const genres = ... 줄 아래에 return 문을 작성한다. 코드가 길기 때문에 세 부분으로 나누어 설명한다. 탭을 눌러 각 부분을 확인하면서 순서대로 이어 붙인다.
return (
<div className="bg-black min-h-screen relative">
<div className="relative pt-24 pb-16">
<div className="container mx-auto px-6">
<Button variant="ghost" onClick={() => navigate(-1)} className="mb-6 inline-flex items-center gap-2">
<FontAwesomeIcon icon={faArrowLeft} />
<span>뒤로가기</span>
</Button>
<div className="flex flex-col md:flex-row gap-10">
<img
src={poster}
alt={movie.title}
className="w-full md:w-72 rounded-lg object-cover shadow-2xl"
/>
| 줄 | 설명 |
|---|---|
| 5 | navigate(-1) — 브라우저 히스토리에서 한 단계 뒤로 간다. |
| 10 | 모바일은 세로(flex-col), PC는 가로(md:flex-row)로 배치한다. |
| 14 | shadow-2xl로 포스터에 큰 그림자 효과를 준다. |
5. 동작 확인
| 확인 항목 | 기대 결과 |
|---|---|
| 카드 클릭 | 상세 페이지로 이동, 포스터 + 정보 표시 |
| "뒤로가기" 클릭 | 이전 페이지로 돌아감 |
| 장르 태그 | 노란색 둥근 배지로 표시 |
| "예고편 보기" 클릭 | 검정 팝업에서 YouTube 영상 자동 재생 |
| X 버튼 클릭 | 팝업 닫힘 |
6. 전체 코드
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faPlay } from "@fortawesome/free-solid-svg-icons";
import api from "../api/axios";
import { Spinner, Modal, Button } from "./UI.jsx";
export function MovieDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [movie, setMovie] = useState(null);
const [loading, setLoading] = useState(true);
const [showTrailer, setShowTrailer] = useState(false);
useEffect(() => {
api
.get(`movie/${id}`, {
params: { append_to_response: "videos" },
})
.then((res) => {
setMovie(res.data);
setLoading(false);
})
.catch(() => {
setMovie(null);
setLoading(false);
});
}, [id]);
if (loading) return <Spinner full />;
if (!movie) {
return (
<div className="bg-black min-h-screen flex items-center justify-center">
<p className="text-red-400 text-2xl">영화 정보를 불러오지 못했습니다.</p>
</div>
);
}
const poster = `https://image.tmdb.org/t/p/w500/${movie.poster_path}`;
const videoList = movie.videos ? movie.videos.results : [];
const trailer = videoList.find((v) => v.type === "Trailer" && v.site === "YouTube");
const genres = movie.genres || [];
return (
<div className="bg-black min-h-screen relative">
<div className="relative pt-24 pb-16">
<div className="container mx-auto px-6">
<Button variant="ghost" onClick={() => navigate(-1)} className="mb-6 inline-flex items-center gap-2">
<FontAwesomeIcon icon={faArrowLeft} />
<span>뒤로가기</span>
</Button>
<div className="flex flex-col md:flex-row gap-10">
<img
src={poster}
alt={movie.title}
className="w-full md:w-72 rounded-lg object-cover shadow-2xl"
/>
<div className="flex flex-col gap-4 text-white">
<h1 className="text-4xl font-bold text-yellow-400">{movie.title}</h1>
<p className="text-gray-400 text-lg">{movie.original_title}</p>
<div className="flex gap-4 text-sm text-gray-300">
<span>개봉일: {movie.release_date || "미정"}</span>
<span>러닝타임: {movie.runtime || 0}분</span>
</div>
<div className="flex gap-2 flex-wrap">
{genres.map((genre) => (
<span key={genre.id} className="bg-yellow-400 text-black px-3 py-1 rounded-full text-sm font-bold">
{genre.name}
</span>
))}
</div>
<p className="text-gray-300 leading-relaxed max-w-xl">
{movie.overview || "줄거리 정보가 없습니다."}
</p>
{trailer && (
<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">
<FontAwesomeIcon icon={faPlay} />
예고편 보기
</Button>
)}
</div>
</div>
</div>
</div>
{showTrailer && trailer && (
<Modal onClose={() => setShowTrailer(false)}>
<div className="w-full max-w-4xl aspect-video">
<iframe
className="w-full h-full rounded-lg"
src={`https://www.youtube.com/embed/${trailer.key}?autoplay=1`}
title="Trailer"
allow="autoplay; encrypted-media"
allowFullScreen
/>
</div>
</Modal>
)}
</div>
);
}