JavaScriptJavaScript 기초 · 10중급

[JavaScript 완성 프로젝트] 영화 검색 앱 만들기

JavaScript프로젝트실습완성영화앱

JavaScript의 모든 것을 담은 마지막 프로젝트

HTML 시리즈, CSS 시리즈를 거쳐 드디어 JavaScript의 마지막 프로젝트입니다.

이번에는 실제로 쓸 수 있는 영화 검색 앱을 만듭니다.

구현할 기능

기능사용 기술
영화 목록 표시배열, DOM 렌더링
제목으로 검색이벤트, 디바운스, filter
장르 필터select 이벤트, filter
별점순/제목순 정렬sort
즐겨찾기 추가/제거localStorage, classList
모달로 상세 정보모달 패턴
반응형 카드 그리드CSS Flexbox

파일 구조

movie-app/
├── index.html
├── style.css
└── script.js

index.html

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>영화 검색 앱</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>

  <header>
    <h1>🎬 영화 검색</h1>
  </header>

  <div class="controls">
    <input type="text" id="search" placeholder="영화 제목 검색..." />
    <select id="genre-filter">
      <option value="">전체 장르</option>
      <option value="SF">SF</option>
      <option value="드라마">드라마</option>
      <option value="액션">액션</option>
      <option value="뮤지컬">뮤지컬</option>
      <option value="스릴러">스릴러</option>
    </select>
    <select id="sort">
      <option value="rating">별점 높은 순</option>
      <option value="title">제목 순</option>
      <option value="year">최신 순</option>
    </select>
    <button id="fav-toggle">★ 즐겨찾기만 보기</button>
  </div>

  <p id="result-count"></p>
  <div id="movie-grid" class="movie-grid"></div>

  <!-- 모달 -->
  <div id="modal" class="modal-overlay" hidden>
    <div class="modal-box" id="modal-content"></div>
  </div>

  <script src="script.js"></script>
</body>
</html>

style.css

* { box-sizing: border-box; margin: 0; padding: 0; }

body {
  font-family: -apple-system, "Noto Sans KR", sans-serif;
  background: #f5f5f5;
  color: #222;
}

header {
  background: #1a1a2e;
  color: white;
  padding: 20px 24px;
  font-size: 1.5rem;
}

.controls {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  padding: 20px 24px;
  background: white;
  border-bottom: 1px solid #e0e0e0;
}

.controls input,
.controls select {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
}

.controls input { flex: 1; min-width: 160px; }

#fav-toggle {
  padding: 8px 16px;
  border: 1px solid #ddd;
  border-radius: 6px;
  cursor: pointer;
  background: white;
  transition: all 0.2s;
}

#fav-toggle.active {
  background: #f5a623;
  color: white;
  border-color: #f5a623;
}

#result-count {
  padding: 12px 24px;
  color: #666;
  font-size: 14px;
}

.movie-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  padding: 0 24px 40px;
}

.movie-card {
  flex: 1 1 220px;
  background: white;
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
  cursor: pointer;
  transition: transform 0.2s, box-shadow 0.2s;
  position: relative;
}

.movie-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}

.movie-title { font-weight: 700; font-size: 16px; margin-bottom: 8px; }
.movie-meta { color: #666; font-size: 13px; margin-bottom: 8px; }
.movie-rating { color: #f5a623; font-weight: 700; }
.movie-genre {
  display: inline-block;
  background: #eef2ff;
  color: #4f46e5;
  padding: 2px 10px;
  border-radius: 20px;
  font-size: 12px;
}

.fav-btn {
  position: absolute;
  top: 12px;
  right: 12px;
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
  color: #ccc;
  transition: color 0.2s;
}

.fav-btn.active { color: #f5a623; }

/* 모달 */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 100;
}

.modal-box {
  background: white;
  padding: 32px;
  border-radius: 16px;
  max-width: 480px;
  width: 90%;
}

.modal-box h2 { margin-bottom: 12px; }
.modal-box p { line-height: 1.7; color: #444; }
.modal-close {
  margin-top: 20px;
  padding: 10px 24px;
  background: #1a1a2e;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
}

script.js

// ===== 데이터 =====
const MOVIES = [
  { id: 1, title: "인터스텔라",    year: 2014, rating: 8.6, genre: "SF",    synopsis: "우주를 탐험하는 아버지의 이야기. 시간과 중력을 넘는 SF 걸작." },
  { id: 2, title: "기생충",        year: 2019, rating: 8.5, genre: "드라마", synopsis: "두 가족의 극명한 대비를 통해 사회 계층을 날카롭게 풍자한 작품." },
  { id: 3, title: "어벤져스",      year: 2012, rating: 8.0, genre: "액션",   synopsis: "마블 히어로들이 한자리에 모여 세계를 구하는 블록버스터." },
  { id: 4, title: "라라랜드",      year: 2016, rating: 8.0, genre: "뮤지컬", synopsis: "꿈을 쫓는 두 남녀의 사랑과 이별을 그린 아름다운 뮤지컬." },
  { id: 5, title: "조커",          year: 2019, rating: 8.4, genre: "드라마", synopsis: "평범한 광대가 악당이 되기까지의 비극적 이야기." },
  { id: 6, title: "매트릭스",      year: 1999, rating: 8.7, genre: "SF",    synopsis: "가상 현실과 진짜 현실 사이에서 진실을 찾는 철학적 액션." },
  { id: 7, title: "올드보이",      year: 2003, rating: 8.4, genre: "스릴러", synopsis: "15년간 이유 없이 감금됐다 풀려난 남자의 복수극." },
  { id: 8, title: "다크나이트",    year: 2008, rating: 9.0, genre: "액션",   synopsis: "배트맨과 조커의 철학적 대결. 슈퍼히어로 영화의 교과서." },
];

// ===== 상태 =====
let favorites = JSON.parse(localStorage.getItem("favorites")) || [];
let showFavOnly = false;
let currentMovies = [...MOVIES];

// ===== 유틸 =====
function isFav(id) {
  return favorites.includes(id);
}

function toggleFav(id) {
  if (isFav(id)) {
    favorites = favorites.filter(f => f !== id);
  } else {
    favorites.push(id);
  }
  localStorage.setItem("favorites", JSON.stringify(favorites));
  render();
}

// ===== 렌더링 =====
function render() {
  const query = document.querySelector("#search").value.toLowerCase();
  const genre = document.querySelector("#genre-filter").value;
  const sortBy = document.querySelector("#sort").value;

  let filtered = MOVIES
    .filter(m => m.title.toLowerCase().includes(query))
    .filter(m => genre === "" || m.genre === genre)
    .filter(m => showFavOnly ? isFav(m.id) : true);

  if (sortBy === "rating") filtered.sort((a, b) => b.rating - a.rating);
  if (sortBy === "title")  filtered.sort((a, b) => a.title.localeCompare(b.title));
  if (sortBy === "year")   filtered.sort((a, b) => b.year - a.year);

  currentMovies = filtered;

  document.querySelector("#result-count").textContent = `총 ${filtered.length}편`;

  document.querySelector("#movie-grid").innerHTML = filtered.length === 0
    ? "<p>검색 결과가 없습니다.</p>"
    : filtered.map(m => `
        <div class="movie-card" onclick="openModal(${m.id})">
          <button class="fav-btn ${isFav(m.id) ? "active" : ""}"
            onclick="event.stopPropagation(); toggleFav(${m.id})">★</button>
          <div class="movie-title">${m.title}</div>
          <div class="movie-meta">${m.year}년 · <span class="movie-genre">${m.genre}</span></div>
          <div class="movie-rating">★ ${m.rating}</div>
        </div>
      `).join("");
}

// ===== 모달 =====
function openModal(id) {
  const movie = MOVIES.find(m => m.id === id);
  const modal = document.querySelector("#modal");
  document.querySelector("#modal-content").innerHTML = `
    <h2>${movie.title}</h2>
    <p>${movie.year}년 · ${movie.genre} · ★ ${movie.rating}</p>
    <p style="margin-top:12px">${movie.synopsis}</p>
    <button class="modal-close" onclick="closeModal()">닫기</button>
  `;
  modal.hidden = false;
}

function closeModal() {
  document.querySelector("#modal").hidden = true;
}

// ===== 이벤트 =====
const debounce = (fn, ms) => {
  let t;
  return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
};

document.querySelector("#search").addEventListener("input", debounce(render, 250));
document.querySelector("#genre-filter").addEventListener("change", render);
document.querySelector("#sort").addEventListener("change", render);

document.querySelector("#fav-toggle").addEventListener("click", (e) => {
  showFavOnly = !showFavOnly;
  e.target.classList.toggle("active", showFavOnly);
  render();
});

document.querySelector("#modal").addEventListener("click", (e) => {
  if (e.target === e.currentTarget) closeModal();
});

document.addEventListener("keydown", (e) => {
  if (e.key === "Escape") closeModal();
});

// ===== 초기 실행 =====
render();

완성 후 체크리스트

  • 영화 목록이 카드 형태로 표시됨
  • 검색 시 실시간으로 필터링됨 (디바운스 적용)
  • 장르 필터가 작동함
  • 정렬이 작동함
  • 즐겨찾기 추가/제거 후 새로고침해도 유지됨
  • 카드 클릭 시 모달로 상세 정보 표시
  • 모달 닫기: 닫기 버튼, 오버레이 클릭, ESC 키

다음 단계

HTML + CSS + JavaScript 시리즈 완료를 축하드립니다! 🎉

여기서 더 나아가고 싶다면:

  1. React — 컴포넌트 기반으로 더 큰 앱 만들기
  2. TypeScript — 타입 안전성 추가
  3. Node.js — 서버 사이드 JavaScript
  4. 실제 API 연동 — TMDB, YouTube API 등

지금 만든 영화 앱을 실제 API(TMDB)와 연동하는 것이 좋은 다음 단계입니다.

궁금한 점이 있으신가요?

협업·의뢰는 아래로, 가벼운 소통은 인스타그램 @bluefox._.hi도 환영이에요.