🐨CoalaCoding
DocsExamplesTry itBoardB반
🐨CoalaCoding

개발자를 위한 한국어 웹 기술 문서

문서

  • JavaScript
  • Web Publishing
  • React
  • Python

커뮤니티

  • 게시판
  • 예제 모음
  • Try it 에디터

기타

  • GitHub
  • 관리자
© 2026 CoalaCoding. All rights reserved.
  • 22_무비앱-완료본
  • 01. GOFLIX 프로젝트 소개와 개발환경 설정
  • 02. React 진입점과 라우팅 설정
  • 03. Axios로 TMDB API 연결하기
  • 04. 공통 UI 컴포넌트 만들기 (UI.jsx)
  • 05. App.jsx — 레이아웃 구성과 데이터 가져오기
  • 06. Header와 Footer 만들기
  • 07. Home.jsx — 메인 페이지 완성하기
  • 08. Section과 Card — 영화 카드 목록 만들기
  • 09. MovieDetail — 영화 상세 페이지 만들기
  • 10. Category, ErrorPage 완성하기
  • 11. AI 챗봇 연동하기
  • 12_Swiper_캐러셀_적용과_프로젝트_마무리
  • 13. GOFLEX Gemini CLI 바이브코딩 프롬프트 템플릿
  • 00_시작하기
  • 01_App
  • 02_CSS
  • 03_Nav
  • 04_Hero
  • 05_AboutMe
  • 06_Projects
  • 07_Contact
  • 08_Footer
  • 09_완성_정리
  • 10_바이브코딩
  1. 홈
  2. 문서
  3. React
  4. 실전 프로젝트
  5. 09. MovieDetail — 영화 상세 페이지 만들기

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);
줄설명
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에 "이 영화의 상세 정보와 예고편 영상 목록을 한꺼번에 보내줘"라고 요청하는 부분이다.

  useEffect(() => {
    api
      .get(`movie/${id}`, {
        params: { append_to_response: "videos" },
      })
      .then((res) => {
        setMovie(res.data);
        setLoading(false);
      })
      .catch(() => {
        setMovie(null);
        setLoading(false);
      });
  }, [id]);
줄설명
3-5append_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 || [];
줄설명
1loading이 true이면 전체 화면 스피너를 표시하고 종료한다. JSX에서 <Spinner full />처럼 값 없이 prop 이름만 쓰면 full={true}와 동일하다. Spinner 컴포넌트 내부에서 full이 true이면 화면 전체를 채운다.
3-8movie가 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"
            />
줄설명
5navigate(-1) — 브라우저 히스토리에서 한 단계 뒤로 간다.
10모바일은 세로(flex-col), PC는 가로(md:flex-row)로 배치한다.
14shadow-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>
  );
}

목차

  • 구문