[ 개인 Project ] Next.js + TypeScript를 사용한 영화 정보 API

SeungJin·2022년 4월 13일
1

Project

목록 보기
1/2

아직 TypeScript를 사용해 본 적이 없고 최근에 Next.js를 배워 두 개를 결합해서 사용해 보자 해서 간단한 프로잭트를 진행해 봤습니다.

니콜라스의 영상강의를 참고해서 만들었습니다

pages/index.tsx

import Seo from '../components/Seo';
import { useRouter } from 'next/router';
import Link from 'next/link';
import axios from 'axios';


interface Movies {
  datas: {
    results: any;
  };
}

// _app.tsx에서 pageProps를 index.tsx에 props로 줍니다.
// interface를 사용해서 조금더 간결하게 props Type를 명시해줬습니다.(아직 타입명시가 서툽니다.)
const Home = ({ datas: { results } }: Movies) => {
  const router = useRouter();
  const movieDatas = ({ id, title }: { id: number; title: string }) => {
    router.push(`/movies/${title}/${id}`);
  };

  return (
    <div>
      <h1 className="title">Movies API Test Page</h1>
      <div className="container">
        <Seo title="Home" />
        {results?.map((movie: any) => (
          <div
            onClick={() =>
              movieDatas({ id: movie.id, title: movie.original_title })
            }
            className="movie"
            key={movie.id}
          >
            <img
              src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
              alt={'Movie Img'}
            />
            <h4>
              <Link href={`/movies/${movie.original_title}/${movie.id}`}>
                <a>{movie.original_title}</a>
              </Link>
            </h4>
          </div>
        ))}
      </div>
      <style jsx>{`
        .container {
          display: grid;
          grid-template-columns: 1fr 1fr;
          padding: 20px;
          gap: 20px;
        }
        h1.title {
          text-align: center;
        }
        .movie {
          cursor: pointer;
        }

        .movie img {
          max-width: 100%;
          border-radius: 12px;
          transition: transform 0.2s ease-in-out;
          box-shadow: rgba(0, 0, 0, 0.1) 0 4px 12px;
        }

        .movie:hover img {
          transform: scale(1.05) translateY(-10px);
        }

        .movie h4 {
          font-size: 18px;
          text-align: center;
        }
      `}</style>
    </div>
  );
};

//Next.js의 특징인 SSR을 사용해 봤습니다.
export const getServerSideProps = async () => {
  const data = await (await axios.get(`http://localhost:3000/api/movies`)).data;
  return {
    props: {
      datas: data,
    },
  };
};

export default Home;

pages/_app.tsx

_app은 서버로 요청이 들어왔을 때 가장 먼저 실행되는 컴포넌트로 페이지에 적용할 공통 레이아웃의 역할을 합니다.

import '../styles/globals.css';
import type { AppProps } from 'next/app';
import Layout from '../components/Layout';

// Component, pageProps 는 현제 보는 페이지의 컴포넌트와 getServerSideProps등으로 리턴한 props를 받아옵니다
function MyApp({ Component, pageProps }: AppProps) {
  return (
    // 페이지의 기본 레이아웃을 적용
    <Layout>
    // index.tsx page에서 SSR을 사용해 받아온 영화 데이터를 pageProps로 받아와
    // index.tsx에 props를 줍니다 
      <Component {...pageProps} />
    </Layout>
  );
}

export default MyApp;

pages/movies/[...params].tsx

pages디렉토리 안에 /movies디렉토리를 추가하면 http://localhost:3000/movies url을 사용할수 있습니다. /movies안에 [...params].tsx파일을 만들면 movies/뒤에 붙는 값을 params라는 이름의 변수로 가져올수 있습니다.

파일 이름을 대괄호 [ ]로 감싸야합니다.
http://localhost:3000/디렉토리/[파일이름].tsx

import Seo from '../../components/Seo';
import axios from 'axios';
import Link from 'next/link';

export default function Detail({ movieDetail }: any) {
  return (
    <div className="container">
      <Seo title={movieDetail?.original_title} />
      <Link href={movieDetail?.homepage}>
        <a className="homePage">
          <h2>{movieDetail?.title}</h2>
        </a>
      </Link>
      <img
        src={`https://image.tmdb.org/t/p/w500${movieDetail?.poster_path}`}
        alt="img"
        className="mainImg"
      />
      <div className="tagListBox">
        <span className="tage">Tage</span>
        <div className="tagList" id="tagList">
          {movieDetail?.genres.map(
            ({ name: name }: { name: any }, index: any) => (
              <div className="tageItem" key={index}>
                {name}
              </div>
            ),
          )}
        </div>
      </div>
      <div className="movieDatas">
        <div className="movieData">
          <div>
            {`Country of origin : ${movieDetail?.production_countries[0].iso_3166_1}`}
          </div>
          <div>{`Companies : ${movieDetail?.production_countries[0].name}`}</div>
          <div>{`Movie Release : ${movieDetail?.release_date}`}</div>
          <div>{`Run Time : ${movieDetail?.runtime}m`}</div>
          <div>{`Spoken Language : ${movieDetail?.spoken_languages.map(
            (v: { english_name: any }) => v.english_name,
          )}`}</div>
          <div>{`Vote Average : ${movieDetail?.vote_average}`}</div>
          <div>{`Audience : ${movieDetail?.popularity}`}</div>
        </div>
      </div>
      <h2>Summary</h2>
      <div className="overview">
        <span>{movieDetail?.overview}</span>
      </div>
      <style jsx>{`
        .container {
          width: 100%;
          height: 100%;
          display: flex;
          flex-direction: column;
          align-items: center;
          overflow: hidden;
        }
        .mainImg {
          width: 400px;
          border-radius: 16px;
          margin-bottom: 30px;
        }
        .mainImg:hover {
          transform: scale(1.05);
          transition: 0.3s;
        }
        .tagListBox {
          width: 400px;
          display: flex;
          flex-direction: row;
          margin-bottom: 20px;
          align-items: center;
          justify-content: start;
        }
        .tagListBox .tage {
          font-size: 16px;
          font-weight: 600;
          background: #737373;
          color: #fff;
          padding: 8px;
          border-radius: 16px;
          margin-right: 10px;
        }
        .tagListBox .tagList {
          display: flex;
          flex-direction: row;
          flex-wrap: nowrap;
          overflow: scroll;
          -ms-overflow-style: none; /* IE and Edge */
          scrollbar-width: none;
          border-left: 2px solid #ddd;
          padding-left: 10px;
        }
        .tagListBox .tagList::-webkit-scrollbar {
          display: none; /* Chrome, Safari, Opera*/
        }
        .tagListBox .tageItem {
          font-size: 12px;
          padding: 8px;
          border-radius: 16px;
          margin-right: 10px;
          border: 1px solid #ddd;
          white-space: nowrap;
        }

        .movieDatas {
          width: 400px;
          display: flex;
          flex-wrap: nowrap;
          background: #737373;
          padding: 0 20px;
          box-sizing: border-box;
          margin: 20px 0;
          border-radius: 16px;
        }
        .movieDatas .movieData {
          width: 100%;
          display: flex;
          flex-direction: column;
          padding: 10px 0;
        }
        .movieDatas .movieData div {
          color: #fff;
          width: 90%;
          margin: 10px 0;
          padding-bottom: 7px;
          border-bottom: 1px solid #fff;
          font-size: 14px;
        }

        .homePage {
          margin: 20px 0;
          width: 100%;
          text-align: center;
        }
        .homePage:hover {
          transform: scale(1.1);
          color: gray;
          transition: 0.3s;
        }
        .overview {
          width: 400px;
          line-height: 22px;
          border: solid 2px #737373;
          padding: 20px 30px;
          border-radius: 18px;
          word-break: normal;
        }
      `}</style>
    </div>
  );
}

// movies/ 뒤에 붙는 값을 params라는 변수로 가져와서
// API 요청에 변수를 넣어 영화 상세정보를 가져옵니다.
export async function getServerSideProps({ params: { params } }: any) {
  const movieDetail = await (
    await axios.get(`http://localhost:3000/api/movies/${params[1]}`)
  ).data;
  return {
    props: {
      movieDetail,
    },
  };
}

components/layout.tsx

_app.tsx에 적용시켜 모든페이지에 공통된 레이아웃을 출력했습니다.

import NavBar from './NavBar';

// children Type 명시
export default function Layout({ children }: { children: JSX.Element }) {
  return (
    <>
      // 페이지 최상단에 NavBar과 하단에 Page를 출력
      <NavBar />
      <div>{children}</div>
    </>
  );
}

components/NavBar.tsx

import Link from 'next/link';
import { useRouter } from 'next/router';

export default function NavBar() {
  // useRouter을 이용해 현제 페이지에 맞는 카테고리 텍스트 에 색상을 부여
  const router = useRouter();
  return (
    <nav>
      <img src="/vercel.svg" alt="imgs" />
      <div>
        <Link href="/">
          <a className={router.pathname === '/' ? 'active' : ''}>Home</a>
        </Link>
        <Link href="/about">
          <a className={router.pathname === '/about' ? 'active' : ''}>Search</a>
        </Link>
      </div>
      <style jsx>{`
        nav {
          display: flex;
          gap: 10px;
          flex-direction: column;
          align-items: center;
          padding-top: 20px;
          padding-bottom: 10px;
          box-shadow: rgba(50, 50, 93, 0.25) 0px 50px 100px -20px,
            rgba(0, 0, 0, 0.3) 0px 30px 60px -30px;
        }
        img {
          max-width: 100px;
          margin-bottom: 5px;
        }
        nav a {
          font-weight: 600;
          font-size: 18px;
        }
        .active {
          color: tomato;
        }
        nav div {
          display: flex;
          gap: 10px;
        }
      `}</style>
    </nav>
  );
}

components/Seo.tsx

// HTML의 Head테그와 똑같습니다.
import Head from 'next/head';
// Page에 넣어 title를 props로 받아와 페이지마다 title를 변경, title props Type 명시.
export default function Seo({ title }: { title: string }) {
  return (
    <Head>
      <title>{title} | Next Movies</title>
    </Head>
  );
}

.env

API키를 숨기기 위해서 사용.

// 뭣모르고 뒤에 세미클론 붙였다가 한동안 애먹었었습니다.
MOVIE_API_KEY=API_KEY

next.config.js

중요한부분

source : 들어오는 요청 경로 패턴입니다.

destination : 변경할 경로입니다.

redirects : URL요청시 새페이지로 다시 라우팅.

rewrites : URL요청시 변경된 값만 표시.

/** @type {import('next').NextConfig} */

// .env에 숨긴 API_KEY
const API_KEY = process.env.MOVIE_API_KEY;

module.exports = {
  reactStrictMode: true,
  // 리더렉션(redirects)은 요청경로(source)를 다른경로(destination)로 연결해 줍니다
  async redirects() {
    return [
      {
        // source에서 /old-blog/:path* 를 요청시
        source: '/old-blog/:path*',
        // destination의 '/new-sexy-blog/:path*' 로 연결
        // '/old-blog/:path*' 뒤에 :path* 는 /old-blog/ 뒤에 붙은 값을 destination의 :path* 에 출력
        destination: '/new-sexy-blog/:path*',
        // 클라이언트/검색 엔진에 리디렉션을 영구적으로 캐시하는지 여부
        permanent: false,
      },
    ];
  },
  // rewrites를 사용하면 들어오는 요청 경로를 다른 대상 경로에 매핑할 수 있습니다(API 사용).
  async rewrites() {
    return [
      {
        // Page나 component에서 API를 source URL처럼 요청하면 destination의 URL을 반환해 줍니다
        source: '/api/movies',
        // .env에 숨긴 API_KEY
        destination: `https://api.themoviedb.org/3/movie/popular?api_key=${API_KEY}`,
      },
      {
        // /api/movies 뒤에 영화 id값을 :id로 받아와 destination에 추가해 상세데이터를 받아오도록 작성
        source: '/api/movies/:id',
        destination: `https://api.themoviedb.org/3/movie/:id?api_key=${API_KEY}`,
      },
    ];
  },
};

렌더링 화면

index.tsx

궁금한 영화 이미지를 클릭하면 onClick 이벤트를 통해 useRouter로 주소 url에 titleid값 을 넣어 http://localhost:3000/movies/:title/:id url로 페이지 라우팅을 합니다 그러면 pages/movies/[...params].tsx에서 pages/movies/ 뒤에 있는 title, id 값을 params라는 변수로 받아올수 있습니다.

[...params].tsx

사실 영화 이미지에 title 값을 넣을 필요는 없는데 그냥 내버려 뒀습니다.
Next.js는 디렉토리 구조만 잘 짜면 개발이 많이 편한것 같네요.

profile
혼자 공부해 보고 적어두는 블로그입니다 문제 있다고 생각되시는 부분이 있으면 피드백이라도 남겨주시면 감사하겠습니다

2개의 댓글

comment-user-thumbnail
2022년 12월 1일

이거 API 키 어디서 받아야하나요?

1개의 답글