프론트엔드

[React] 영화 대여 온라인몰 확장 구현하기

밍들레밍 2025. 5. 4. 21:13

지난 시간에 클론코딩 해본 것에 검색 기능+홈화면으로 돌아오기 기능을 추가로 구현해본 것에 디벨롭하여 영화 대여 온라인몰을 구현해보기로 했다. 

 

 

 구현해볼 것

  • 장르별로 모아보기
  • 영화 상세 페이지
  • 찜(하트) 추가
  • MyBox에서 모아보기
  • 대여하기 추가

 

 

1. 장르별로 모아보기

  • 드롭다운을 통해 장르별로 영화를 모아볼 수 있도록함
  • 사용 기능
    • HTML 기본 드롭다운 UI 구성
    • genreMap.js에 장르별 아이디 구분
    • usestate로 영화 장르 상태값 관리
    • filter()로 영화 장르 기준으로 필터링
const handleGenreChange = (e) => {
  const genreId = e.target.value;
  setSelectedGenre(genreId);

  if (genreId === '') {
    setFilteredMovies(dummy.results); // 전체 목록
  } else {
    const filtered = dummy.results.filter((movie) =>
      movie.genre_ids.includes(Number(genreId))
    );
    setFilteredMovies(filtered);
  }
};
// genreMap.js
export const genreMap = {
  28: "액션",
  12: "모험",
  16: "애니메이션",
  35: "코미디",
  80: "범죄",
  99: "다큐멘터리",
  18: "드라마",
  10751: "가족",
  14: "판타지",
  36: "역사",
  27: "공포",
  10402: "음악",
  9648: "미스터리",
  10749: "로맨스",
  878: "SF",
  10770: "TV 영화",
  53: "스릴러",
  10752: "전쟁",
  37: "서부",
};

구현 화면

 

2. 영화 상세 페이지

 

  • 카드 클릭 시 영화 상세 정보 페이지로 이동. 포스터, 제목, 줄거리, 개봉일, 평점을 표시함
  • 설명이 길어지면 스크롤 기능
  • 뒤로가기 버튼을 누를시 홈화면으로 복귀
  • 사용 기술
    • useParams: URL 파라미터로 영화 ID 가져오기
    • dummy.results.find()로 해당 영화 데이터 필터링
    • 조건부 렌더링으로 유효하지 않은 ID 처리 (if (!movie))
    • 돌아가기 버튼에 link경로 추가
    • CSS overflow-y: auto(텍스트 영역에만)
<Link to="/">
        <button style={{ marginTop: "20px" }}>← 돌아가기</button>
      </Link>

MovieDetail.jsx

import { useParams, Link } from "react-router-dom";
import { dummy } from "../movieDummy";

export default function MovieDetail() {
  const { id } = useParams();
  const movie = dummy.results.find((m) => m.id === Number(id));

  if (!movie) {
    return (
      <div style={{ color: "white", padding: "20px" }}>
        <h2>영화를 찾을 수 없습니다 😢</h2>
        <Link to="/">
          <button style={{ marginTop: "20px" }}>← 돌아가기</button>
        </Link>
      </div>
    );
  }

  return (
    <div style={{ color: "white", padding: "20px" }}>
      <h2>{movie.title}</h2>
      <img
        src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
        alt={movie.title}
        style={{ width: "300px", borderRadius: "8px" }}
      />
      <p><strong>줄거리:</strong> {movie.overview}</p>
      <p><strong>개봉일:</strong> {movie.release_date}</p>
      <p><strong>평점:</strong> {movie.vote_average}</p>
      <Link to="/">
        <button style={{ marginTop: "20px" }}>← 돌아가기</button>
      </Link>
    </div>
  );
}

 

구현 화면

 

 

3. 찜(하트) 추가 (MyBox에서 모아보기)

  • 각 영화 카드에 🤍 버튼을 눌러 찜 추가/해제 가능
  • 사용 기술
    • useState, useContext, createContext: 전역 상태 myBox 관리
    • Array.prototype.some(): 찜 여부 판별
    • 조건부 렌더링 (isSaved ? '❤️' : '🤍')
  • 발생한 문제 - 클릭 이벤트 전파로 인해 하트를 눌렀을 때 상세 페이지로도 같이 이동됨 
    • onClick={(e) => e.stopPropagation()} 으로 클릭 이벤트 차단

 

4. MyBox 페이지 구현

  • 찜한 영화만 따로 모아 보여주는 /mybox 페이지 구현
  • 찜 목록이 비어 있을 때는 메시지 출력
  • 헤더 우측에 ❤️ MyBox 버튼(링크) 추가
  • 홈으로 돌아가는 버튼 구현
  • 사용 기술
    • <Link to="/mybox">
    • react-router-dom의 Route로 별도 페이지 구성
    • 조건부 렌더링 (myBox.length === 0)
    • CSS flex 정렬, textAlign 등으로 안내 문구 중앙 배치
import { useMyBox } from "../context/MyBoxContext";
import MovieCard  from "../MovieCard";
import './MyBox.css'
import { Link } from "react-router-dom";

function MyBox() {
  const {myBox}=useMyBox();

  return (
    <div>
      <h2 style={{ color: "white", marginTop: "30px", textAlign: "center" }}>나의 찜 목록</h2>

      <div style={{ padding: "20px", textAlign: "center" }}>
        <Link
          to="/"
          style={{
            color: "white",
            textDecoration: "none",
            fontSize: "18px",
            border: "1px solid white",
            padding: "8px 12px",
            borderRadius: "5px",
          }}
        >
          ← 홈으로
        </Link>
      </div>

      <div className="mybox-container">
        {myBox.length === 0 ? (
          <p
            style={{
              color: "white",
              fontSize: "25px",
              textAlign: "center",
              marginTop: "100px",
            }}
          >
            찜한 영화가 없습니다.
          </p>
        ) : (
          <div className="app-container">
            {myBox.map((movie) => (
              <MovieCard key={movie.id} movie={movie} />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

export default MyBox;

구현 화면

 

5. 뒤로가기 경로 분기 로직 구현

 

  • 홈에서 상세 페이지로 들어온 경우 → 홈으로 돌아가야 함
  • MyBox에서 들어온 경우 → 다시 MyBox로 돌아가야 함
  • 발생한 문제
    • 상세페이지에서 돌아가기를 눌러도 항상 MyBox로 돌아가는 현상 발생
    • 모든 카드가 state={{ from: 'mybox' }}를 가지는 문제
    • 해결
      • useLocation()으로 현재 경로 확인
      • 경로가 /mybox일 때만 state={{ from: 'mybox' }} 전달하도록 조건 분기
const location = useLocation();
const isFromMyBox = location.pathname === "/mybox";
<Link to={`/movie/${movie.id}`} state={isFromMyBox ? { from: 'mybox' } : undefined} />

 

 

 

6. 상세 페이지 하단에 대여하기 버튼 추가

  • 영화 상세페이지 맨 아래에 "대여하기" 버튼 추가
  • 사용 기능 
    • MovieDetail.jsx에 <button className="rent-button">대여하기</button> 추가 

 

 

 

현재까지의 코드

Header.jsx

import { Link } from "react-router-dom";

function Header() {
  return (
    <header className="site-header">
    <h1 className="logo">🎬 MovieBox</h1>
    <nav style={{ 
      position: 'absolute', 
      right: '40px' 
      }}>
        <Link to="/mybox" 
        style={{ 
          color: 'white', 
          textDecoration: 'none', 
          fontSize: '25px' }}>
          ❤️ MyBox
        </Link>
      </nav>
  </header>
  );
}

export default Header;

MovieList.jsx

import React from 'react';
import MovieCard from '../MovieCard';
import '../index.css'

function MovieList({movies}) {
  return (
    <div className="app-container">
        {movies.map((movie) => (
          <MovieCard
          key={movie.id}
          movie={{
            id: movie.id,
            title: movie.title,
            posterUrl: `https://image.tmdb.org/t/p/w500/${movie.poster_path}`,
            vote_average: movie.vote_average
          }}
        />
        ))}
      </div>
  );
}

export default MovieList;

SearchBar.jsx

import React from 'react';
import { genreMap } from '../genreMap';
import '../index.css'

function SearchBar({search,onSearchChange,onKeyDown,onSearch,onReset,selectedGenre,onGenreChange}) {
  return (
    <div className="search-container">
        <input
          type="text"
          placeholder="영화 제목 검색"
          value={search}
          onChange={onSearchChange}
          onKeyDown={onKeyDown}
        />
        <button onClick={onSearch}>검색</button>
        <button className="reset-button" onClick={onReset}>뒤로가기</button>

        <select
          className="genre-dropdown"
          value={selectedGenre}
          onChange={onGenreChange}
        >
          <option value="">장르 선택</option>
          {Object.entries(genreMap).map(([id, name]) => (
            <option key={id} value={id}>
              {name}
            </option>
          ))}
        </select>
      </div>
  );
}

export default SearchBar;

MyBoxContext.jsx

import { createContext, useContext, useState } from "react";

const MyBoxContext = createContext();

export function MyBoxProvider({ children }) {
  const [myBox, setMyBox] = useState([]);

  const toggleMovie = (movie) => {
    setMyBox((prev) => {
      const exists = prev.find((m) => m.id === movie.id);
      return exists ? prev.filter((m) => m.id !== movie.id) : [...prev, movie];
    });
  };

  return (
    <MyBoxContext.Provider value={{ myBox, toggleMovie }}>
      {children}
    </MyBoxContext.Provider>
  );
}

export function useMyBox() {
  return useContext(MyBoxContext);
}

MovieDetail.jsx

import { useParams, Link, useLocation  } from "react-router-dom";
import { dummy } from "../movieDummy";


export default function MovieDetail() {
  const { id } = useParams();
  const movie = dummy.results.find((m) => m.id === Number(id));
  const location = useLocation();
  const from = location.state?.from;

  if (!movie) {
    return (
      <div style={{ color: "white", padding: "20px" }}>
        <h2>영화를 찾을 수 없습니다</h2>
        <Link to="/">
          <button style={{ marginTop: "20px" }}>← 돌아가기</button>
        </Link>
      </div>
    );
  }

  return (
    <div className="detail-container">
      <div className="detail-card">
        <img
          className="detail-poster"
          src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
          alt={movie.title}
        />
        <div className="detail-info">
          <h2>{movie.title}</h2>
          <p><strong>줄거리</strong><br />{movie.overview}</p>
          <p><strong>개봉일:</strong> {movie.release_date}</p>
          <p><strong>평점:</strong> {movie.vote_average}</p>
          <button className="rent-button">대여하기</button>
          <br></br>
          <Link to={from === "mybox" ? "/mybox" : "/"} className="back-button">
            ← 돌아가기
          </Link>
        </div>
      </div>
    </div>
  );
}

MyBox.jsx

import { useMyBox } from "../context/MyBoxContext";
import MovieCard  from "../MovieCard";
import './MyBox.css'
import { Link } from "react-router-dom";

function MyBox() {
  const {myBox}=useMyBox();

  return (
    <div>
      <h2 style={{ color: "white", marginTop: "30px", textAlign: "center" }}>나의 찜 목록</h2>

      <div style={{ padding: "20px", textAlign: "center" }}>
        <Link
          to="/"
          style={{
            color: "white",
            textDecoration: "none",
            fontSize: "18px",
            border: "1px solid white",
            padding: "8px 12px",
            borderRadius: "5px",
          }}
        >
          ← 홈으로
        </Link>
      </div>

      <div className="mybox-container">
        {myBox.length === 0 ? (
          <p
            style={{
              color: "white",
              fontSize: "25px",
              textAlign: "center",
              marginTop: "100px",
            }}
          >
            찜한 영화가 없습니다.
          </p>
        ) : (
          <div className="app-container">
            {myBox.map((movie) => (
              <MovieCard key={movie.id} movie={movie} />
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

export default MyBox;

App.jsx

import { useState } from "react";
import { dummy } from "./movieDummy";
import Header from "./components/Header";
import SearchBar from "./components/SearchBar";
import MovieList from "./components/MovieList";


function App() {
  const [search, setSearch] = useState("");
  const [filteredMovies, setFilteredMovies] = useState(dummy.results);
  const [selectedGenre, setSelectedGenre] = useState("");

  const handleSearch = () => {
    const newMovies = dummy.results.filter((item) =>
      item.title.toLowerCase().includes(search.toLowerCase())
    );
    setFilteredMovies(newMovies);
  };

  const handleKeyDown = (e) => {
    if (e.key === "Enter") handleSearch();
  };

  const handleReset = () => {
    setSearch("");
    setFilteredMovies(dummy.results);
  };

  const handleGenreChange=(e)=>{
    const genreId=e.target.value;
    setSelectedGenre(genreId);

    if(genreId===''){
      setFilteredMovies(dummy.results);
    }else{
      const filtered=dummy.results.filter((movie)=>
      movie.genre_ids.includes(Number(genreId)));
      setFilteredMovies(filtered);
    }
  }
  return (
    <>
     <Header/>
     <SearchBar
      search={search}
      onSearchChange={(e)=>setSearch(e.target.value)}
      onSearch={handleSearch}
      onReset={handleReset}
      selectedGenre={selectedGenre}
      onGenreChange={handleGenreChange}
      onKeyDown={handleKeyDown}
     />
     <MovieList movies={filteredMovies}/> 
    </>
  );
}

export default App;

MovieCard.jsx

import { useMyBox } from './context/MyBoxContext';
import { useLocation,Link } from 'react-router-dom';
import './MovieCard.css';

function MovieCard({ movie }) {
  const { myBox, toggleMovie } = useMyBox();
  const isSaved = myBox.some(m => m.id === movie.id);
  const location = useLocation();
  const isFromMyBox = location.pathname === "/mybox";

  return (
    <div className="movie-container">
  <Link
    to={`/movie/${movie.id}`}
    state={isFromMyBox ? { from: 'mybox' } : undefined}
    className="movie-link"
  >
    <img src={movie.posterUrl} alt={movie.title} />
    <h3>{movie.title}</h3>
  </Link>
  <button 
  className="heart-button"
  onClick={(e) => {
    e.stopPropagation(); 
    toggleMovie(movie);
  }}>
    {isSaved ? '❤️' : '🤍'}
  </button>
</div>
  );
}

export default MovieCard;

main.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import MovieDetail from "./pages/MovieDetail.jsx";
import { MyBoxProvider } from "./context/MyBoxContext.jsx"; 
import MyBox from './pages/MyBox';

ReactDOM.createRoot(document.getElementById("root")).render(
  <BrowserRouter>
    <MyBoxProvider>
      <Routes>
        <Route path="/" element={<App />} />
        <Route path="/movie/:id" element={<MovieDetail />} />
        <Route path="/mybox" element={<MyBox />} />
      </Routes>
    </MyBoxProvider>
  </BrowserRouter>
);