Hi, I’m Kang Byeong-hyeon.
[카카오 테크 캠퍼스 2기] 1단계 8주차 WIL - API를 활용한 영화 검색 사이트
1. 사용자 지정 CSS
색상 변수 정의
CSS에서 사용할 색상 변수를 정의한다.
색상 변수를 사용하면 일관된 색상 테마를 쉽게 적용하고 관리할 수 있다.
html {
--color-black: #0e111b;
--color-white: #fff;
--color-white-50: rgba(255, 255, 255, 0.5);
--color-white-30: rgba(255, 255, 255, 0.3);
--color-white-20: rgba(255, 255, 255, 0.2);
--color-white-10: rgba(255, 255, 255, 0.1);
--color-white-5: rgba(255, 255, 255, 0.05);
--color-primary: #fdc000;
--color-hover: #f86a05;
--color-area: #1c212e;
}
색상 변수 사용
정의한 색상 변수를 실제 var()를 통해 CSS에 적용하여 사이트의 기본 스타일을 설정한다.
body {
font-family: "Roboto", sans-serif;
line-height: 1.4;
background-color: var(--color-black);
color: var(--color-white);
}
2. Headline 컴포넌트
컴포넌트 구현
영화 검색 사이트의 주요 헤드라인을 표시하는 Headline 컴포넌트를 구현한다.
import { Component } from "../core";
export default class Headline extends Component {
render() {
this.el.classList.add("headline");
this.el.innerHTML = `
<h1>
<span>OMDb API</span><br />
THE OPEN <br />
MOVIE DATABASE
</h1>
<p>
The OMDb API is a RESTful web service to obtain movie information,
all content and images on the site are contributed and maintained by our users.<br />
If you find this service useful, please consider making a one-time donation or become a patron.
</p>
`;
}
}
스타일링
헤드라인 컴포넌트에 이전에 변수로 지정한 CSS 속성 값을 사용하여 스타일링한다.
.headline {
margin-bottom: 40px;
}
.headline h1 {
font-family: "Oswald", sans-serif;
font-size: 80px;
line-height: 1;
margin-bottom: 40px;
}
.headline h1 span {
color: var(--color-primary);
}
.headline p {
color: var(--color-white-30);
}
3. Home 컴포넌트
컴포넌트 구현
Home 컴포넌트는 메인 페이지를 구성하며, Headline 컴포넌트를 포함하여 전체적인 레이아웃을 설정한다.
import Headline from "../components/Headline";
import { Component } from "../core";
export default class Home extends Component {
render() {
const headline = new Headline().el;
this.el.classList.add("container");
this.el.append(headline);
}
}
결과물

4. Search 컴포넌트
Search 컴포넌트는 사용자가 영화 제목을 입력하고 검색할 수 있도록 한다.
movieStore를 통해 상태를 관리하고, searchMovies 함수를 호출하여 영화 데이터를 가져온다.
컴포넌트 구현
import { Component } from "../core";
import movieStore, { searchMovies } from "../store/movie";
export default class Search extends Component {
render() {
this.el.classList.add("search");
this.el.innerHTML = `
<input type="text" placeholder="Enter the movie title to search!"/>
<button type="button" class="btn btn--primary">Search!</button>
`;
const inputEl = this.el.querySelector("input");
inputEl.addEventListener("input", () => {
movieStore.state.searchText = inputEl.value;
});
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && movieStore.state.searchText.trim())
searchMovies(1);
});
const btnEl = this.el.querySelector("button");
btnEl.addEventListener("click", () => {
if (movieStore.state.searchText.trim()) searchMovies(1);
});
}
}
- input 이벤트: 사용자가 입력 필드에 텍스트를 입력할 때마다
movieStore.state.searchText가 업데이트된다. - keydown 이벤트: 사용자가 Enter 키를 눌렀을 때, 검색 텍스트가 비어있지 않으면
searchMovies함수를 호출하여 영화를 검색한다. - click 이벤트: 사용자가 버튼을 클릭했을 때, 검색 텍스트가 비어있지 않으면
searchMovies함수를 호출한다.
버튼 공통 스타일링
버튼의 일관된 스타일을 유지하기 위해 버튼의 공통 스타일을 정의하고, 컴포넌트 내의 검색 입력 필드에 대한 스타일링을 정의한다.
.btn {
height: 50px;
padding: 0 20px;
border: none;
border-radius: 4px;
outline: none;
font-size: 14px;
font-weight: 700;
color: var(--color-white);
background-color: var(--color-area);
cursor: pointer;
transition: 0.3s;
}
.btn:hover {
color: var(--color-hover);
}
.btn--primary {
background-color: var(--color-primary);
color: var(--color-black);
}
.btn--primary:hover {
background-color: var(--color-hover);
color: var(--color-white);
}
.search {
display: flex;
gap: 10px;
margin-bottom: 30px;
}
.search input {
flex-grow: 1;
height: 50px;
padding: 0 20px;
border-radius: 4px;
border: none;
outline: none;
font-size: 14px;
color: var(--color-white);
background-color: var(--color-area);
}
.search input::placeholder {
color: var(--color-white-30);
}
.search .btn {
flex-grow: 1;
max-width: 150px;
}
Movie Store 구현
movieStore는 상태 관리와 API 호출을 담당하고, searchMovies 함수는 OMDb API를 호출하여 검색 결과를 가져온다.
import { Store } from "../core";
const store = new Store({
searchText: "",
page: 1,
movies: [],
});
export default store;
export const searchMovies = async (page) => {
const res = await fetch(
`https://omdbapi.com?apikey=7035c60c&s=${store.state.searchText}&page=${page}`
);
const json = await res.json();
console.log(json);
};
- Store 초기화:
searchText,page,movies등의 상태를 관리한다. - searchMovies 함수: OMDb API를 호출하여 검색 결과를 가져오고 콘솔에 출력한다.
결과물

5. MovieList 컴포넌트
MovieList 컴포넌트는 movieStore의 movies 상태를 구독하여, 상태가 변경될 때마다 검색 결과를 새로 렌더링한다.
컴포넌트 구현
import { Component } from "../core";
import movieStore from "../store/movie";
export default class MovieList extends Component {
constructor() {
super();
movieStore.subscribe("movies", () => {
this.render();
});
}
render() {
this.el.classList.add("movie-list");
this.el.innerHTML = `
<div class="movies"></div>
`;
const moviesEl = this.el.querySelector(".movies");
moviesEl.append(
...movieStore.state.movies.map((movie) => {
const movieEl = document.createElement("div");
movieEl.classList.add("movie");
movieEl.textContent = movie.Title;
return movieEl;
})
);
}
}
- 구독 설정: 컴포넌트가 생성될 때
movieStore.subscribe를 통해movies상태를 구독하고, 상태가 변경되면this.render()가 호출된다. - 렌더링:
render메서드에서는.movie-list클래스를 가진 div를 생성하고, 그 안에movies상태의 각 영화 타이틀을div요소로 만들어 추가한다.
Home 컴포넌트 업데이트
Home 컴포넌트를 업데이트하여 Headline, Search, MovieList 컴포넌트를 포함하도록 한다.
import Headline from "../components/Headline";
import MovieList from "../components/MovieList";
import Search from "../components/Search";
import { Component } from "../core";
export default class Home extends Component {
render() {
const headline = new Headline().el;
const search = new Search().el;
const movieList = new MovieList().el;
this.el.classList.add("container");
this.el.append(headline, search, movieList);
}
}
- 컴포넌트 생성:
Headline,Search,MovieList컴포넌트를 생성하고, 각각의el속성을 통해 DOM 요소를 가져온다. - 렌더링:
this.el에.container클래스를 추가하고, 생성된 컴포넌트들을 자식 요소로 추가한다.
결과물

6. MovieItem 컴포넌트
MovieItem 컴포넌트는 영화 정보를 받아와서 영화 포스터와 제목, 년도를 카드 형태로 표시한다.
컴포넌트 구현
import { Component } from "../core";
export default class MovieItem extends Component {
constructor(props) {
super({ props, tagName: "a" });
}
render() {
const { movie } = this.props;
this.el.setAttribute("href", `#/movie?id=${movie.imdbID}`);
this.el.classList.add("movie");
this.el.style.backgroundImage = `url(${movie.Poster})`;
this.el.innerHTML = `
<div class="info">
<div class="year">
${movie.Year}
</div>
<div class="title">
${movie.Title}
</div>
</div>
`;
}
}
- 생성자:
props를 받아와서super메서드로 부모 클래스인Component에 전달한다. 이후tagName을a로 설정하여 링크 요소로 만든다. - render 메서드:
movie객체에서 정보를 추출하여 HTML 요소에 적용한다. 이후 영화 포스터를 배경 이미지로 설정하고, 영화 제목과 년도를 카드 하단에 표시한다.
MovieList 컴포넌트 업데이트
MovieList 컴포넌트를 업데이트하여, 각 영화를 MovieItem 컴포넌트로 표시하도록 한다.
import { Component } from "../core";
import movieStore from "../store/movie";
import MovieItem from "./MovieItem";
export default class MovieList extends Component {
constructor() {
super();
movieStore.subscribe("movies", () => {
this.render();
});
}
render() {
this.el.classList.add("movie-list");
this.el.innerHTML = `
<div class="movies"></div>
`;
const moviesEl = this.el.querySelector(".movies");
moviesEl.append(
...movieStore.state.movies.map((movie) => new MovieItem({ movie }).el)
);
}
}
- 구독 설정:
movieStore.subscribe를 통해movies상태를 구독한다. 상태가 변경되면this.render()가 호출된다. - 렌더링:
render메서드에서.movies클래스를 가진 div 요소를 생성하고, 각 영화 정보를MovieItem컴포넌트로 변환하여 추가한다.
스타일링
.movie-list {
padding: 20px;
border-radius: 4px;
background-color: var(--color-area);
}
.movie-list .movies {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
}
.movies .movie {
--width: 200px;
width: var(--width);
height: calc(var(--width) * 3 / 2);
border-radius: 4px;
background-color: var(--color-black);
background-size: cover;
overflow: hidden;
position: relative;
}
.movies .movie:hover::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border: 6px solid var(--color-primary);
}
.movies .movie .info {
width: 100%;
padding: 14px;
box-sizing: border-box;
font-size: 14px;
text-align: center;
position: absolute;
left: 0;
bottom: 0;
background-color: rgba(14, 17, 27, 0.3);
backdrop-filter: blur(10px);
}
.movies .movie .info .year {
color: var(--color-primary);
}
.movies .movie .info .title {
color: var(--color-white);
}
.movie: 각 영화 카드에 크기와 스타일을 지정하고, 호버 시 테두리를 추가한다..info: 영화 정보 부분에 배경색과 블러 필터를 적용하여 가독성을 높인다.
결과물

7. MovieListMore 컴포넌트
MovieListMore 컴포넌트는 사용자가 영화 목록을 스크롤하면서 끝에 도달했을 때 추가적인 영화 목록을 불러올 수 있도록 한다.
Store 및 영화 검색 함수 구현
영화 관련 상태 및 기능을 관리하기 위해 store와 searchMovies 함수를 구현한다.
store에 pageMax(최대 페이지 수) 상태를 추가하고, searchMovies 함수는 주어진 페이지의 영화 목록을 검색하고 상태를 업데이트하도록 한다.
import { Store } from "../core";
const store = new Store({
searchText: "",
page: 1,
pageMax: 1,
movies: [],
});
export default store;
export const searchMovies = async (page) => {
store.state.page = page;
if (page === 1) {
store.state.movies = [];
}
const res = await fetch(
`https://omdbapi.com?apikey=7035c60c&s=${store.state.searchText}&page=${page}`
);
const { Search, totalResults } = await res.json();
store.state.movies = [...store.state.movies, ...Search];
store.state.pageMax = Math.ceil(Number(totalResults) / 10);
};
- 현재 페이지 설정: 함수가 호출될 때 받은 페이지 번호를 상태에 설정한다.
- API 응답 처리: API로부터 받은 응답 데이터에서 영화 목록과 총 결과 수를 추출한다.
- 상태 업데이트: 받은 영화 목록을 이전 목록에 추가하고, 페이지 최대값을 설정한다.
컴포넌트 구현
import { Component } from "../core";
import movieStore, { searchMovies } from "../store/movie";
export default class MovieListMore extends Component {
constructor() {
super({ tagName: "button" });
movieStore.subscribe("pageMax", () => {
const { page, pageMax } = movieStore.state;
pageMax > page
? this.el.classList.remove("hide")
: this.el.classList.add("hide");
});
}
render() {
this.el.classList.add("btn", "view-more", "hide");
this.el.textContent = "View more..";
this.el.addEventListener("click", async () => {
await searchMovies(movieStore.state.page + 1);
});
}
}
- 조건적 표시: 페이지 최대값이 현재 페이지보다 큰 경우에만 버튼이 보여지도록 한다.
- 클릭 이벤트: 버튼을 클릭하면 다음 페이지의 영화 목록을 검색하게 된다.
스타일링
.view-more {
width: 100%;
max-width: 300px;
margin: 20px auto;
display: block;
}
.view-more.hide {
display: none;
}
Home 컴포넌트 업데이트
Home 컴포넌트를 업데이트하여 MovieListMore 컴포넌트를 포함하도록 한다.
import Headline from "../components/Headline";
import MovieList from "../components/MovieList";
import MovieListMore from "../components/MovieListMore";
import Search from "../components/Search";
import { Component } from "../core";
export default class Home extends Component {
render() {
const headline = new Headline().el;
const search = new Search().el;
const movieList = new MovieList().el;
const movieListMore = new MovieListMore().el;
this.el.classList.add("container");
this.el.append(headline, search, movieList, movieListMore);
}
}
- 컴포넌트 생성:
MovieListMore컴포넌트를 생성하고, 각각의el속성을 통해 DOM 요소를 가져온다. - 렌더링: 생성된
MovieListMore컴포넌트를 자식 요소로 추가한다.
결과물

8. 로딩 애니메이션 추가 (loader / spinner)
영화 목록을 불러오는 동안 로딩을 시각적으로 표시하기 위해 loader 또는 spinner를 추가한다. 이를 통해 사용자는 작업이 진행 중임을 알 수 있다.
스타일링
.the-loader {
width: 30px;
height: 30px;
margin: 30px auto;
border: 4px solid var(--color-primary); /* 테두리 색상 설정 */
border-top-color: transparent; /* 상단 테두리를 투명하게 만들어 회전 효과를 줍니다. */
border-radius: 50%; /* 원형 로더를 만들기 위해 원형 모양으로 설정 */
animation: loader 1s infinite linear; /* 회전 애니메이션 적용 */
}
.the-loader.hide {
display: none; /* 로딩이 완료되면 로더를 숨깁니다. */
}
@keyframes loader {
0% {
transform: rotate(0deg); /* 회전 애니메이션 시작 각도 */
}
100% {
transform: rotate(360deg); /* 회전 애니메이션 종료 각도 */
}
}
MovieList 컴포넌트 업데이트
import { Component } from "../core";
import movieStore from "../store/movie";
import MovieItem from "./MovieItem";
export default class MovieList extends Component {
constructor() {
super();
movieStore.subscribe("movies", () => {
this.render();
});
movieStore.subscribe("loading", () => {
this.render();
});
}
render() {
this.el.classList.add("movie-list");
this.el.innerHTML = `
<div class="movies"></div>
<div class="the-loader hide"></div>
`;
const moviesEl = this.el.querySelector(".movies");
moviesEl.append(
...movieStore.state.movies.map((movie) => new MovieItem({ movie }).el)
);
const loaderEl = this.el.querySelector(".the-loader");
movieStore.state.loading
? loaderEl.classList.remove("hide")
: loaderEl.classList.add("hide");
}
}
- 로딩 애니메이션 표시:
movies와loading상태를 구독하여 상태가 변경될 때 마다render메서드를 호출한다.loading상태에 따라 로더를 표시하거나 숨기도록 한다.
Store 및 검색 함수 업데이트
import { Store } from "../core";
const store = new Store({
searchText: "",
page: 1,
pageMax: 1,
movies: [],
loading: false,
});
export default store;
export const searchMovies = async (page) => {
store.state.loading = true;
store.state.page = page;
if (page === 1) {
store.state.movies = [];
}
const res = await fetch(
`https://omdbapi.com?apikey=7035c60c&s=${store.state.searchText}&page=${page}`
);
const { Search, totalResults } = await res.json();
store.state.movies = [...store.state.movies, ...Search];
store.state.pageMax = Math.ceil(Number(totalResults) / 10);
store.state.loading = false;
};
- 로딩 상태 관리:
loading상태를 추가한다.searchMovies함수가 호출될 때, 검색이 시작되면loading상태를true로 설정한다.- API 호출 및 데이터 처리가 완료되면
loading상태를false로 설정한다.
결과물

9. 예외 처리와 메시지 출력
API 호출 중 발생할 수 있는 오류를 처리하여 사용자에게 명확한 피드백을 제공하도록 한다.
Store와 검색 함수 업데이트
store와 searchMovies 함수를 업데이트하여 예외 처리와 메시지 상태를 추가한다.
import { Store } from "../core";
const store = new Store({
searchText: "",
page: 1,
pageMax: 1,
movies: [],
loading: false,
message: "Search for the movie title!",
});
export default store;
export const searchMovies = async (page) => {
store.state.loading = true;
store.state.page = page;
if (page === 1) {
store.state.movies = [];
store.state.message = "";
}
try {
const res = await fetch(
`https://omdbapi.com?apikey=7035c60c&s=${store.state.searchText}&page=${page}`
);
const { Search, totalResults, Response, Error } = await res.json();
if (Response === "True") {
store.state.movies = [...store.state.movies, ...Search];
store.state.pageMax = Math.ceil(Number(totalResults) / 10);
} else {
store.state.message = Error;
}
} catch (err) {
console.error("searchMovies error:", err);
store.state.message = "An error occurred while searching for movies.";
} finally {
store.state.loading = false;
}
};
- 초기 상태 추가:
message상태를 추가하여 초기 메시지를 설정한다. - 예외 처리:
try...catch문을 사용하여 API 호출 중 발생할 수 있는 예외를 처리하고, 에러 메시지를message상태에 설정한다. - API 응답 처리: API 응답이 성공적인 경우와 실패한 경우를 처리하여 각각 적절한 상태를 설정한다.
MovieList 컴포넌트 업데이트
MovieList 컴포넌트를 업데이트하여 메시지를 표시한다.
import { Component } from "../core";
import movieStore from "../store/movie";
import MovieItem from "./MovieItem";
export default class MovieList extends Component {
constructor() {
super();
movieStore.subscribe("movies", () => this.render());
movieStore.subscribe("loading", () => this.render());
movieStore.subscribe("message", () => this.render());
}
render() {
this.el.classList.add("movie-list");
this.el.innerHTML = `
${
movieStore.state.message
? `<div class="message">${movieStore.state.message}</div>`
: `<div class="movies"></div>`
}
<div class="the-loader hide"></div>
`;
const moviesEl = this.el.querySelector(".movies");
moviesEl?.append(
...movieStore.state.movies.map((movie) => new MovieItem({ movie }).el)
);
const loaderEl = this.el.querySelector(".the-loader");
movieStore.state.loading
? loaderEl.classList.remove("hide")
: loaderEl.classList.add("hide");
}
}
- 상태 구독:
message상태를 구독하여 상태 변경 시render메서드를 호출한다. - 조건부 렌더링:
message상태가 있을 때와 없을 때 각각 다른 내용을 렌더링한다.message상태가 있을 경우 메시지를 표시하고, 없을 경우 영화 목록을 표시한다.
스타일링
.movie-list .message {
color: var(--color-primary);
font-size: 20px;
text-align: center;
}
결과물
