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 시리즈 완료를 축하드립니다! 🎉
여기서 더 나아가고 싶다면:
- React — 컴포넌트 기반으로 더 큰 앱 만들기
- TypeScript — 타입 안전성 추가
- Node.js — 서버 사이드 JavaScript
- 실제 API 연동 — TMDB, YouTube API 등
지금 만든 영화 앱을 실제 API(TMDB)와 연동하는 것이 좋은 다음 단계입니다.