네이버에서 제공하는 OPEN API를 사용해 영화 검색 기능을 구현하고, 네이버 영화 페이지에서 랭킹 영역을 크롤링해 실시간 랭킹 피커까지 제작해보자.
📎 Demo
API, AXIOS, Crawling, News picker
movie-app
├── client
│ ├── public
│ │ ├── images
│ │ │ └── no-image.jpg
│ │ ├── index.html
│ │ ├── manifest.json
│ │ └── robots.txt
│ ├── src
│ │ ├── components
│ │ │ ├── Config.js (재사용 코드 관리)
│ │ │ └── views
│ │ │ ├── Footer
│ │ │ │ └── Footer.tsx
│ │ │ ├── LandingPage
│ │ │ │ ├── LandingPage.tsx
│ │ │ │ └── Sections
│ │ │ │ └── Search
│ │ │ │ ├── Ranking.tsx
│ │ │ │ ├── Search.tsx
│ │ │ │ └── SearchResult.tsx
│ │ │ ├── NavBar
│ │ │ │ └── NavBar.tsx
│ │ │ └── NotFound
│ │ │ └── NotFound.tsx
│ │ ├── App.scss
│ │ ├── App.tsx
│ │ ├── common.scss
│ │ ├── index.scss
│ │ ├── index.tsx
│ │ └── setupProxy.js (프록시 서버 설정)
│ ├── package.json
│ └── tsconfig.json
├── Procfile (Heroku에 실행할 파일 알려줌)
├── .env (환경 변수 설정)
├── package.json
└── server
├── fetching.js (크롤링)
└── index.js (서버 세팅)
최상위 폴더(movie-app)에 .env 파일을 생성하여, 네이버 개발자 센터에서 발급 받은 ID와 SECRET을 아래와 같이 입력한다.
(띄어쓰기 X)
REACT_APP_CLIENT_ID=발급받은ID
REACT_APP_CLIENT_SECRET=발급받은SECRET
❗️
REACT_APP_
은 예약어이기 때문에 변경하면 변수를 불러올 수 없다.
❗️ .env 파일의 내용이 변경되면 서버를 다시 실행해야 반영된다.
❗️ 정보가 노출되지 않도록 dev.js, .env 파일은 꼭 .gitignore에 포함한다.
서버에서 환경 변수(.env 파일)를 사용하기 위해 dotenv을 설치해준다.
npm install dotenv --save
server>index.js에 dotenv를 불러온다.
(server>index.js)
const express = require("express");
const app = express();
const port = process.env.PORT || 5000;
// dotenv
require("dotenv").config();
...
CORS 이슈를 해결하기 위해 proxy 설정을 해야 한다. 우선, client 경로에서 http-proxy-middleware를 설치하자.
npm install http-proxy-middleware --save
client>src 폴더 안에 setupProxy.js 파일을 생성한다.
그 다음 아래와 같이 작성한다.
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use (
createProxyMiddleware( '/api', {
target: 'http://localhost:5000',
changeOrigin: true
})
)
}
/api
: 프록시를 사용할 경로(path)target
: 프록시로 이용할 서버의 주소changeOrigin
: true로 설정하면 target 서버의 구성에 따라 호스트 헤더가 변경됨
이렇게 proxy를 설정해두면, /api
으로 시작되는 요청은target
으로 설정된 서버를 사용하게 된다.
✋ setupProxy.js는 src 폴더 안에만 있으면 자동으로 인식되어 프록시가 설정된다. 다만, 수정 후에는 서버를 껐다가 다시 시작해야 반영된다.
control+c
!
server/index.js 파일을 열어 CORS 허용 세팅을 해주어야 한다. 우선 루트 경로에서 cors를 설치한다.
npm install cors --save
네이버 API를 사용해야 하므로, 해당 도메인을 옵션으로 입력해준다.
const express = require("express");
const app = express();
const port = process.env.PORT || 5000;
require("dotenv").config();
// CORS
const cors = require("cors");
// body-parser
app.use(express.json());
app.use(express.urlencoded( {extended : true } ));
// CORS 허용
let corsOptions = {
origin: 'https://openapi.naver.com',
credentials: true
}
app.use(cors(corsOptions));
...
✋ 위에서 다운 받은 boilerplate로 제작하려면 루트 폴더와 클라이언트 폴더에서
npm install
을 해야 package.json에 있는 라이브러리들이 받아진다.
이제 루트 경로에서 클라이언트와 서버를 동시에 연다.
npm run dev
실행되지 않는다면, Concurrently 설치 후, package.json에서 "scripts" 하위에
"dev": "concurrently \"npm run backend\" \"npm run start --prefix client\""
입력
랜딩 페이지에 Search 컴포넌트를 import한다.
(LandingPage.tsx)
import Search from "./Sections/Search/Search"
const LandingPage = (): JSX.Element => {
return (
<section className="landing-page">
<Search />
</section>
)
}
export default LandingPage
Search.tsx는 크게 검색, 랭킹, 검색 결과 세 영역으로 나뉜다.
(Search.tsx)
import { useState, useEffect } from "react";
import axios from 'axios';
import SearchResult from "./SearchResult";
import Ranking from "./Ranking";
const Search = ():JSX.Element => {
return (
<section className="search">
<div>
// 랭킹
<Ranking />
<div className="search-cont">
// 검색 입력 폼 영역
<div className="search-form">
</div>
// 검색 결과
<div className="search-result">
<SearchResult />
</div>
</div>
</div>
</section>
)
}
export default Search
큰 틀을 잡았다면, axios를 사용해 영화 데이터를 가져오는 코드를 작성해 보겠다. 대략적인 순서는 이렇다.
클라이언트에서 axios로 서버에 get 요청을 함 -> 서버에서 axios로 API 데이터를 가져옴 -> 가져온 데이터를 클라이언트에 response로 보냄 -> 클라이언트에서 데이터를 받아 화면에 뿌림
루트 경로에서 axios를 설치해준다.
npm install axios --save
axios를 불러온 다음, API를 가져오는 라우트 메서드를 작성한다.
(server/index.js)
const express = require("express");
const app = express();
const port = process.env.PORT || 5000;
require("dotenv").config();
const cors = require("cors");
// axios
const axios = require("axios");
// body-parser
app.use(express.json());
app.use(express.urlencoded( {extended : true } ));
// CORS 허용
let corsOptions = {
origin: 'https://openapi.naver.com',
credentials: true
}
app.use(cors(corsOptions));
// 네이버 API 정보 (환경변수 사용)
const CLIENT_ID = process.env.REACT_APP_CLIENT_ID;
const CLIENT_SECRET = process.env.REACT_APP_CLIENT_SECRET;
// API 데이터 가져오기
app.get('/api/search', (req, res) => {
// 클라이언트에서 보낸 검색어
const searchKeyword = req.query.query;
axios.get('https://openapi.naver.com/v1/search/movie.json',
{
params: {
query: searchKeyword,
display: 100 // 검색 결과 노출 개수
},
headers: {
'X-Naver-Client-Id': CLIENT_ID,
'X-Naver-Client-Secret': CLIENT_SECRET
}
}).then((response) => {
const { data } = response;
// 클라이언트에 보내기
res.send(data.items);
}).catch((error) => {
let message = 'Unknown Error'
if (error instanceof Error) message = error.message
console.log(message);
})
})
...
이제 클라이언트에서 서버로 GET 요청하는 코드를 작성해야 한다.
(Search.tsx)
import { useEffect, useState } from "react";
// axios import!
import axios from "axios";
import SearchResult from "./SearchResult";
import Ranking from "./Ranking";
const Search = (): JSX.Element => {
// API로 받아온 데이터를 담을 State
const [Movies, setMovies] = useState([]);
// 검색 Input value 값을 담을 State
const [Value, setValue] = useState("");
// Loader 관리
const [Loading, setLoading] = useState(false);
// API로 데이터를 받아오는 함수
const fetchData = async () => {
// 검색어
const searchKeyword = Value;
const $resultTitle = document.querySelector('.result-title') as HTMLElement
// 데이터 불러오는 중에 Loader 띄우기
setLoading(true);
$resultTitle.innerHTML = "";
try {
if (searchKeyword === "") {
// 검색창이 비었을 때 초기화
setMovies([]);
setValue("");
} else {
// '/api/search'로 서버에 요청
const { data } = await axios.get('/api/search',
{
params: {
query: searchKeyword // 검색어를 파라미터로 보냄
}
})
// 서버에서 보낸 데이터 담기
setMovies(data);
}
} catch (error) {
let message = 'Unknown Error'
if (error instanceof Error) message = error.message
alert(message);
}
// Loader 없애기
setLoading(false);
}
// "...."의 검색 결과 띄우기
const resultTitle = () => {
const $resultTitle = document.querySelector('.result-title') as HTMLElement
$resultTitle.innerHTML = `"${Value}"의 검색 결과`
}
// 검색 input 입력 인식해 value 값을 state에 담기
const keywordChange = (e: {preventDefault: () => void; target: { value: string };}) => {
e.preventDefault();
setValue(e.target.value);
};
// 검색어 입력 후 버튼을 눌러 제출했을 때 데이터 가져오는 함수 실행
const submitKeyword = (e: { preventDefault: () => void }) => {
e.preventDefault();
fetchData();
console.log("제출!");
};
// 마운트 시 데이터 초기화 위해 실행
useEffect(() => {
fetchData();
}, []);
return (
<section className="search">
<div>
// 랭킹
<Ranking />
<div className="search-cont">
// 검색 입력 폼 영역
<div className="search-form">
<form>
<label htmlFor="name" className="form__label">
<input
type="text"
id="movie-title"
className="form__input"
name="movie_title"
placeholder="영화 제목을 입력해주세요."
required
/>
<div className="btn-box">
<input
className="btn form__submit"
type="submit"
value="검색"
/>
</div>
</label>
</form>
</div>
// 검색 결과
<div className="search-result">
<h2 className="result-title"></h2>
<SearchResult />
</div>
</div>
</div>
</section>
);
};
export default Search;
💡 await는 async 안에서만 사용할 수 있다. await는 에러가 나면 멈춰버리는 단점이 있는데, try-catch 구문이 그 점을 보완해준다.
포스트맨(POSTMAN)으로 API 요청 결과를 확인해도 된다.
📎 포스트맨으로 API 요청해보기
Search 컴포넌트에서 받은 데이터를 SearchResult 컴포넌트로 전달해보자.
props 타입을 지정하지 않으면 에러가 발생한다. interface로 미리 설정해두자.
(Search.tsx)
...
// 내보낼 데이터 타입 지정
export interface movieType {
key: number
actor: string
director: string
image: string
link: string
pubDate: string
subtitle: string
title: string
userRating: string
}
const Search = ():JSX.Element => {
const [Movies, setMovies] = useState([]);
const [Value, setValue] = useState("");
const fetchData = async () => {
...
}
const keywordChange = (e: { preventDefault: () => void; target: { value: string }; }) => {
...
}
const submitKeyword = (e: { preventDefault: () => void; }) => {
...
}
useEffect(() => {
fetchData();
}, [])
return (
<section className="search-section">
<div>
<Ranking />
<div className="search-cont">
<div className="search-form">
<h2>영화 검색</h2>
<form onSubmit={ submitKeyword }>
...
</form>
</div>
<div className="search-result">
<h2 className="result-title"></h2>
{
Loading
? (<div className="fallback-message">Laoding...</div>)
: (
Movies &&
Movies.map((movie: movieType, idx: number) => (
<SearchResult
key={ idx }
actor={ movie.actor }
director={ movie.director }
image={ movie.image }
link={ movie.link }
pubDate={ movie.pubDate }
subtitle={ movie.subtitle }
title={ movie.title }
userRating={ movie.userRating }
/>
))
)
}
</div>
</div>
</div>
</section>
)
}
export default Search
props 타입을 지정했던 interface를 import 해온다.
영화 정보가 없는 경우도 있기 때문에 그 점을 고려하여 코드를 작성한다.
(SearchResult.tsx)
import { IMAGE_URL } from '../../../../Config'
// movieType 불러오기
import { movieType } from './Search'
const SearchResult = (props: movieType):JSX.Element => {
return (
<div className="search-result__list">
{/* 포스터 */}
<div className="movie-poster">
<img src={
props.image
? props.image
: `${IMAGE_URL}no-image.jpg` // 포스터가 없을 경우 고려
} />
</div>
{/* 정보 */}
<div className="movie-info">
<div className="movie-info__detail">
<ul>
<li>
<h3 className="movie-title">
{
// '<b></b>' 문자열 제거
props.title?.replace(/<b>/gi,"").replace(/<\/b>/gi,"")
}
</h3>
</li>
<li>
<span className="movie-subtitle">
{ props.subTitle }
</span>
<span className="movie-year">
({ props.pubDate })
</span>
</li>
<li>
<span className="movie-info__name">감독: </span>
<span className="movie-director">
{
props.director
? props.director?.replace(/[^\w\sㄱ-힣]$/, '')
: "-"
}
</span>
</li>
<li>
<span className="movie-info__name">출연: </span>
<span className="movie-actor">
{
props.actor
? props.actor?.replace(/[^\w\sㄱ-힣]$/, '')
: "-"
}
</span>
</li>
<li>
<span className="movie-info__name">평점: </span>
<span className="movie-userRating">
{
props.userRating !== "0.00"
? props.userRating
: "-"
}
</span>
</li>
</ul>
</div>
<div className="movie-info__more">
<a href={ props.link } target="_blank">더보기</a>
</div>
</div>
</div>
)
}
export default SearchResult
💡 client>public에 images 폴더를 지정해두었다면, 환경 변수를 사용해서
process.env.PUBLIC_URL
+/images/
이렇게 절대 경로를 지정할 수 있다. 자주 사용하는 경로이기 때문에 Config.js에 저장해두고 재사용하면 편하다.
(Config.js)
export const IMAGE_URL = process.env.PUBLIC_URL + "/images/";
(컴포넌트.tsx)
import { IMAGE_URL } from 'config.js'
검색했을 때 결과가 출력된다면 성공이다.
CSS로 보기 좋게 하는 건 나중에 😅