[React] 무비앱 #1 - 네이버 API + 크롤링으로 검색 기능과 실시간 랭킹 구현

네이버에서 제공하는 OPEN API를 사용해 영화 검색 기능을 구현하고, 네이버 영화 페이지에서 랭킹 영역을 크롤링해 실시간 랭킹 피커까지 제작해보자.

📎 Demo

Keyword

API, AXIOS, Crawling, News picker


준비 사항




Tech Stack

  • Client: React
  • Backend: Node.js, Express.js

사용 언어

  • TypeScript
  • SCSS



폴더 구조

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 (서버 세팅)



Layout




1. 사전 세팅

1-1. .env로 민감한 정보 관리

최상위 폴더(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();

...

1-2. CORS 이슈 해결

Client

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

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에 있는 라이브러리들이 받아진다.




2. 검색 영역 구현

이제 루트 경로에서 클라이언트와 서버를 동시에 연다.

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

2-1. 검색어 입력과 데이터 요청

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 요청해보기



2-2. 검색 결과 출력

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 } &nbsp;
              </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로 보기 좋게 하는 건 나중에 😅




👉 [React] 무비앱 #2 - 네이버 API + 크롤링으로 검색 기능과 실시간 랭킹 구현

좋은 웹페이지 즐겨찾기