Type something to search...

09. MovieDetail — 영화 상세 페이지 만들기

요약

Gemini CLI로 구현하기 — 영화 상세 페이지

  1. npx gemini
  2. 1
    src/components/MovieDetail.jsx를 작성해줘. useParams로 영화 ID를 받아서 TMDB API movie/[id]?append_to_response=videos로 상세정보와 예고편을 한 번에 가져와야 해. 포스터, 원제, 개봉일, 러닝타임, 장르 배지, 줄거리를 표시하고, YouTube Trailer가 있으면 Modal 팝업으로 재생해줘. useNavigate로 뒤로가기 버튼도 만들어줘. axios 인스턴스는 [axios 경로]에서 가져와줘.
  3. 사용 가이드: [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 파일을 열고 임시 코드를 모두 지운 뒤 작성한다.

src/components/MovieDetail.jsx
1
import { useState, useEffect } from "react";
2
import { useParams, useNavigate } from "react-router";
3
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4
import { faArrowLeft, faPlay } from "@fortawesome/free-solid-svg-icons";
5
import api from "../api/axios";
6
import { Spinner, Modal, Button } from "./UI.jsx";
7
8
export 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);
설명
2useNavigate — “뒤로가기” 버튼에 사용한다. navigate(-1)으로 이전 페이지로 돌아간다.
4왼쪽 화살표(faArrowLeft)와 재생(faPlay) 아이콘이다.
64편에서 만든 Spinner, Modal, Button을 모두 사용한다.
9URL에서 영화 ID를 꺼낸다. /movie/550이면 id"550"이다.
12movie — 영화 상세 데이터. 초기값 null이다.
13loading — 초기값 true로 시작한다. API 응답이 오면 false로 바꾼다.
14showTrailer — 예고편 팝업의 열림/닫힘 상태이다.

같은 MovieDetail.jsx 파일에서, const [showTrailer, ... 줄 바로 아래에 이어서 작성한다. 이 코드는 URL에서 꺼낸 영화 ID로 TMDB에 “이 영화의 상세 정보와 예고편 영상 목록을 한꺼번에 보내줘”라고 요청하는 부분이다.

src/components/MovieDetail.jsx
1
useEffect(() => {
2
api
3
.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-5append_to_response: "videos" — 영화 기본 정보와 함께 예고편 영상 목록도 한 번에 가져온다. TMDB API의 특수 기능이다.
6-8성공하면 movie에 데이터를 저장하고 loading을 끈다.
10-12실패하면 movienull로 설정하고 loading을 끈다. 에러 화면을 표시하기 위해서이다.
14[id]id가 바뀌면 API를 다시 호출한다.

TMDB API는 기본적으로 영화 정보만 반환한다. append_to_response를 사용하면 한 번의 요청으로 추가 데이터를 함께 받을 수 있다.

1
GET /movie/550?append_to_response=videos

이렇게 하면 응답에 movie.videos.results 배열이 추가되어, 예고편 영상 정보를 별도 API 호출 없이 바로 사용할 수 있다.


3. 2단계 — 로딩/에러 처리

같은 MovieDetail.jsx 파일에서, useEffect 블록 바로 아래에 이어서 작성한다. 이 부분은 문지기 역할이다. 데이터가 아직 도착하지 않았으면 로딩 화면을 보여주고, 에러가 발생했으면 에러 메시지를 보여주고, 정상이면 그 아래 코드로 넘겨보내 준다.

src/components/MovieDetail.jsx
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 || [];
설명
1loadingtrue이면 전체 화면 스피너를 표시하고 종료한다. JSX에서 <Spinner full />처럼 값 없이 prop 이름만 쓰면 full={true}와 동일하다. Spinner 컴포넌트 내부에서 fulltrue이면 화면 전체를 채운다.
3-8movienull이면 에러 메시지를 표시한다.
11포스터 이미지 URL을 조합한다.
12예고편 영상 목록을 꺼낸다. movie.videos가 없으면 빈 배열이다.
13.find()로 YouTube 예고편을 찾는다. 없으면 undefined이다.
14장르 배열을 꺼낸다.

배열에서 조건을 만족하는 첫 번째 요소 하나를 반환하는 메서드이다. 조건을 만족하는 요소가 없으면 undefined를 반환한다.

1
배열.find((요소) => 조건식)

콜백 함수는 배열의 요소를 하나씩 받아 조건을 검사한다. 조건이 true인 첫 번째 요소를 만나는 순간 바로 반환하고 탐색을 중단한다.

1
const arr = [
2
{ type: "Teaser", site: "YouTube" },
3
{ type: "Trailer", site: "YouTube" }, // ← 이 요소에서 조건 일치, 탐색 중단
4
{ type: "Trailer", site: "Vimeo" }, // ← 여기까지 오지 않는다
5
];
6
7
// type이 "Trailer"이고 site가 "YouTube"인 첫 번째 요소를 반환
8
arr.find((v) => v.type === "Trailer" && v.site === "YouTube");
9
// 결과: { type: "Trailer", site: "YouTube" }
10
11
// 조건을 만족하는 요소가 없으면 undefined 반환
12
arr.find((v) => v.site === "Netflix");
13
// 결과: undefined
메서드반환값사용 목적
.find()조건을 만족하는 첫 번째 요소 (없으면 undefined)특정 요소 하나를 꺼낼 때
.filter()조건을 만족하는 모든 요소의 배열 (없으면 빈 배열 [])조건에 맞는 여러 요소를 모을 때

이 코드에서는 예고편 영상이 여러 개 있어도 첫 번째 YouTube Trailer 하나만 있으면 충분하므로 .find()를 사용한다.


4. 3단계 — 영화 정보 레이아웃

같은 MovieDetail.jsx 파일에서, const genres = ... 줄 아래에 return 문을 작성한다. 코드가 길기 때문에 세 부분으로 나누어 설명한다. 탭을 눌러 각 부분을 확인하면서 순서대로 이어 붙인다.


5. 동작 확인

확인 항목기대 결과
카드 클릭상세 페이지로 이동, 포스터 + 정보 표시
”뒤로가기” 클릭이전 페이지로 돌아감
장르 태그노란색 둥근 배지로 표시
”예고편 보기” 클릭검정 팝업에서 YouTube 영상 자동 재생
X 버튼 클릭팝업 닫힘

6. 전체 코드

src/components/MovieDetail.jsx
1
import { useState, useEffect } from "react";
2
import { useParams, useNavigate } from "react-router";
3
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
4
import { faArrowLeft, faPlay } from "@fortawesome/free-solid-svg-icons";
5
import api from "../api/axios";
6
import { Spinner, Modal, Button } from "./UI.jsx";
7
8
export 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
api
18
.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
<img
57
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
<iframe
99
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
allowFullScreen
104
/>
105
</div>
106
</Modal>
107
)}
108
</div>
109
);
110
}