Space
article thumbnail
반응형

프로젝트명

영화 웹 사이트 (Movies web)


기간 / 인원

23.12.04 ~ 12.11 (1주) / 1인


소개

Next.JS와 영화 무료 API를 활용하여 만든 영화 웹 사이트입니다.


배포 링크

🔗 : Movies-web (Vercel로 배포하였습니다.)

 

Home<!-- --> | Next Movies

 

movies-web-ebon.vercel.app


Github 링크

https://github.com/ghvhdh333/Movies-Web

 

GitHub - ghvhdh333/Movies-Web: Next.js로 영화 사이트 만들기

Next.js로 영화 사이트 만들기. Contribute to ghvhdh333/Movies-Web development by creating an account on GitHub.

github.com


기술 스택

[ FE ]

Next.JS, HTML, Styled-jsx(CSS)

 

[ Environment ]

VScode, Git, Github, Vercel

반응형

회고

사용자에게 API_KEY를 숨김

# 1

- API_KEY를 공개하여 API 호출을 통해 데이터를 불러옵니다.

- 단점 : 사용자에게 API_KEY가 공개되어, 보안상 위험을 받습니다.

// 기존 코드

// index.js
  const API_KEY = '~~~';
  const [movies, setMovies] = useState();
  useEffect(() => {
    (async () => {
      const { results } = await (
        await fetch(`https://api.themoviedb.org/3/movie/popular?api_key=${API_KEY}`)).json();
      setMovies(results);
    })();
  }, []);
  
export default function Home () {
	...
    return (
    	...
        {!movies && <h2 className="loading">Loading...</h2>}
        {movies?.map((movie) => (
        <Link href={`/info/${movie.id}`} key={movie.id}>
          <div className="movie">
            <img src={`https://image.tmdb.org/t/p/w500/${movie.poster_path}`} />
            <h4>{movie.original_title}</h4>
          </div>
        </Link>
     ))}
        ...
    )
}

 

# 2 (# 1 개선)

- env와 gitignore, rewrites를 활용하여 사용자에게 API_KEY를 비공개합니다.

rewrites()를 활용하면 유저가 보는 사이트 내에서는 API_KEY를 숨겨주지만,

   github에 업로드될 때 API_KEY도 같이 업로드되기에

   .env와 gitignore 활용하여 github에 업로드되지 않도록 구현하였습니다.

   아래와 같은 코드 작성 후 유저에게 API_KEY가 공개될 위험이 현저히 줄어들었습니다.

 

- 단점 : React.js의 작업이 끝나기 전까지 사용자가 loading 화면을 봅니다. (👇 아래 영상 참고 )

              (짧은 시간이지만 사용자는 loading 화면을 봅니다.)

 

// 변경한 코드

// index.js
const [movies, setMovies] = useState();
  useEffect(() => {
    (async () => {
      const { results } = await (
        await fetch(`/api/movies`)).json();
      setMovies(results);
    })();
  }, []);
...
  
  
// next.config.js
module.exports = {
  reactStrictMode: true,

  // url을 변경하여 보여준다. url을 숨길 수 있다. (API KEY 등을 숨겨야 하는 경우에 사용된다.)
  async rewrites() {
    return [
      {
        source: "/api/movies",
        destination: `https://api.themoviedb.org/3/movie/popular?api_key=${process.env.NEXT_PUBLIC_API_KEY}`,
      },
    ];
  },
};

// .env
NEXT_PUBLIC_API_KEY = ~~~

 

# 3 (#2 개선)

- SSR로 API를 호출하여 데이터를 불러옵니다. (Sever-Side-Rendering)

: 서버에서 작업이 다 끝나면 한 번에 화면을 보여줍니다.

  사용자가 loading 화면을 보지 않으며, 아래 코드를 줄일 수 있었습니다.

// .index.js
{!movies && <h2 className="loading">Loading...</h2>} // 코드 삭제함

 

- 단점 : 서버 작업의 지연시간만큼 사용자가 흰 화면만 보게 됩니다. (👇 아래 영상 참고 )

              (서버에서 작업이 다 끝나면 한 번에 화면을 보여주기에 loading 화면이 나오지 않습니다.)

 

// index.js

// getServerSideProps 함수로 인해 api의 응답받은 results를 받아온다.
export default function Home({ results }) {
	...
    {results?.map((movie) => (
        <Link href={`/info/${movie.id}`} key={movie.id}>
          <div className="movie"}>
            <img src={`https://image.tmdb.org/t/p/w500/${movie.poster_path}`} />
            <h4>{movie.original_title}</h4>
          </div>
        </Link>
     ))}
 	...
}

// 서버에서 동작한다.(SSR)
export async function getServerSideProps(){
  const { results } = await (await fetch(`http://localhost:3000/api/movies/popular`)).json();
  return {
    props: {
      results,
    },
  };
}

 

#4 (#3 에러 핸들링)

배포 단계에서 500에러가 계속 발생하여 CSR로 변경해서 데이터를 불러옴

export default function Home() {
const [homeData, setHomeData] = useState([]);

  const getHomeData = async () => {
    try {
      // 서버 api 호출
      const response = await axios.get(`/api/movies/popular`, {
        headers: {
          "Content-Type": "application/json",
          "ngrok-skip-browser-warning": "69420",
        },
      });
      setHomeData(response.data.results);
    } catch (error) {
      console.log(error);
    }
  };

  useEffect(() => {
    getHomeData();
  }, []);
  
  return (
    ...
  )
}

CSS를 Styled-jsx로 작성

Tailwind.CSS로 할 수 있었지만, 처음 보는 방법이기에 적용해보고 싶었습니다.

Styled-jsx는 자동적으로 랜덤 한 숫자를 붙여 class를 만들어주고, 페이지 별로 고유하게 작동하므로

다른 페이지에서 같은 class명을 사용하더라도 다른 className이 지정되어 CSS가 다르게 적용됩니다.

Tailwind.CSS와 비슷하게 JS파일 안에 작성이 가능하다는 장점이 있습니다.

 

사용해 본 결과

장점으로는 페이지 별로 적용되므로 Class명을 크게 신경 쓸 필요가 없고,

하나의 파일에서 작업할 수 있다는 장점이 있습니다.

 

단점으로는 JS파일에 CSS까지 같이 들어가다 보니 파일이 너무 커지고,

CSS가 자동완성이 되지 않기에 작성하기가 많이 불편하였습니다.

 

Styled-jsx를 사용하기보다는 Tailwind CSS를 사용할 것 같습니다.

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

// CSS를 style jsx 방법으로 작성하였다.
export default function NavBar(){
    const router = useRouter();
    return (
        <nav>
            <Link href='/'>
                <h1 className="title">Movies</h1>
            </Link>
            <div>
                <Link href="/">
                    <span className={router.pathname === "/" ? "active" : ""}>Home</span>
                </Link>
                <Link href="/about">
                    <span className={router.pathname === "/about" ? "active" : ""}>About</span>
                </Link>
            </div>
            <style jsx>{`
                nav {
                    display: flex;
                    justify-content: space-between;
                    align-items: center;
                    padding: 20px 30px 20px 30px;
                    box-shadow: rgba(50, 50, 93, 0.25) 0px 50px 100px -20px,
                                rgba(0, 0, 0, 0.3) 0px 30px 60px -30px;
                }
                .title  {
                    display: inline;
                    margin: 0;   
                }
                nav div {
                    display: flex;
                    gap: 20px;
                }
                nav span {
                        font-weight: 600;
                        font-size: 18px;
                }
                .active {
                        color: tomato;
                }
            `}</style>
        </nav>
    )
}

페이지 별로 Title 변경

# 1

- 페이지별로 하드코딩하여 Title 변경함

- 단점 : 현재는 페이지 수가 적기에 가능하지만,

              수 백, 수 천 개의 페이지가 있다면 페이지 별로 일일이 하드코딩으로 작성할 수 없으며, 비효율적입니다.

// 기존 방식

// index.js
<Head>
	<title>Home | Next Movies</title>
</Head>

// about.js
<Head>
	<title>About | Next Movies</title>
</Head>

 

 

# 2 (# 1 개선)

- router.pathname을 활용하여 현재 유저가 위치하고 사이트를 이용하여 title 제목을 변경하였습니다.

: router.pathname으로 현재 위치하고 있는 페이지의 정보를 가져와서 작성하였기에,

  이전 코드에 비해 훨씬 간결하고, 작업 효율도 증가하였습니다.

  (엣지 포인트로 Home 페이지는 '/'로 고정되어 있기에 if문을 통해 "Home"을 재할당 해주었습니다.)

// 변경한 방식

// components/Seo.js
import Head from "next/head";
import { useRouter }from "next/router";

// Title 변경하는 컴포넌트
// router.pathname을 사용하여 현재 유저가 위치하고 있는 page를 구할 수 있다.
export default function Seo() {
    const router = useRouter();
    let title = [...router.pathname.slice(1)]
        .map((el, index) => {
            return index === 0 ? el.toUpperCase() : el
        }).join('');
    
    if(title === "") {
        title = "Home";
    } 

    return (
        <Head>
            <title>{title} | Next Movies</title>
        </Head>
    )
}
// index.js
<Seo/>

// about.js
<Seo/>

영화 아이템 클릭 시 영화 정보 사이트로 이동

# 1 (선택)

- useState, useEffect를 사용하여 API를 호출하여 데이터를 불러온다.

: moive item을 클릭하면 URL에 id값이 동적으로 들어간다.

   그리고, /info/[id] 페이지로 이동하고, router로 영화의 id값을 가져온 뒤, API를 호출하여 id에 맞는 영화 정보를 가져온다.

 

- 장점 : 필요한 데이터만을 불러올 수 있다.

- 단점 : API 호출 수가 증가한다. (사용자가 많아질수록 사용 횟수가 점점 증가하고, 그만큼 많은 비용이 발생한다.)

 

- 선택 이유 : 가져올 정보가 많아 모든 정보를 URL query를 통해 전달받기엔 적합하지 않다고 생각했습니다.

// index.js
export default function Popular({result}) {
  return (
     ...
      {result?.map((movie) => (
        <Link href={`/info/${movie.id}`} key={movie.id}>
          <div className="movie">
            <img src={`https://image.tmdb.org/t/p/w500/${movie.poster_path}`} />
            <h4>{movie.original_title}</h4>
          </div>
        </Link>
     ))}
    ...
  );
}

export async function getServerSideProps(){
  const { results } = await (await fetch(`http://localhost:3000/api/movies/popular`)).json();
  return {
    props: {
      results,
    },
  };
}

 

// info/[id].js
export default function Info() {
  const router = useRouter();
  const { id } = router.query;

  const [movie, setMovie] = useState([]);

  useEffect(() => {
    if(id){
      (async () => {
        const data = await (
          await fetch(`/api/moive/info/${id}`)).json();
        setMovie(data);
      })();
    }
  }, [id]);
 
 return (
     <div className='container'>
      <Seo />
        {movie.length !== 0 && id ? 
        <>
            <img src={`https://image.tmdb.org/t/p/w500/${movie.poster_path}`} />
            <section className="movie_detail">
                <div className="detail_items">
                    <div className="detail_box">Title</div>
                    <span className="detail_text">{movie.title}</span>
                </div>
                <div className="detail_items">
                    <div className="detail_box">Movie ID</div>
                    <span className="detail_text">{movie.id}</span>
                </div>
                <div className="detail_items">
                    <div className="detail_box">Language</div>
                    <span className="detail_text">{movie.original_language}</span>
                </div>
                <div className="detail_items">
                    <div className="detail_box">Release Date</div>
                    <span className="detail_text">{movie.release_date}</span>
                </div>
                <div className="detail_items">
                    <div className="detail_box">Runtime</div>
                    <span className="detail_text">{movie.runtime} min</span>
                </div>
                <div className="detail_items">
                    <div className="detail_box">Vote Average</div>
                    <span className="detail_text vote_average">{movie.vote_average}</span>
                    <span> / 10</span>
                </div>
                <div className="detail_items">
                    <div className="detail_box">Vote Count</div>
                    <span className="detail_text">{movie.vote_count} 🙋‍♂️</span>
                </div>
                <div>
                    <div className="detail_box">Overview</div>
                    <div className="detail_overview">{movie.overview}</div>
                </div>
            </section>
        </>
      : <h2 className="loading">Loading...</h2>
    }
    </div>
  );
}

 

# 2 (다른 방법 시도)

- URL query를 사용하여 데이터를 넘겨준다. (URL에 정보를 숨겨서 보낼 수 있다.)

: movie list를 클릭하면 onClick 이벤트가 발생하여 해당 이벤트 발생으로 인해 URL 쿼리에 정보를 담고,

  info/[id] 페이지로 이동하고, router로 데이터를 가져온다.

  

- 장점 : API 호출 수를 줄일 수 있다. (= 비용을 아낄 수 있다.)

- 단점 : 1. 직접 URL를 입력하여 /info/[id]로 접속하면, router.query에 값이 없어 화면을 보여주지 못한다.

              2. 개인적인 생각으로는 유저가 폼 박스 등을 submit 하였을 때나 URL에 담을 데이터양이 작을 때 사용하면 좋을 것 같다.

// index.js
export default function Home({ results }) {
  const router = useRouter();
  const onClick = (id, title, language, release_date, runtime, vote_average, vote_count, overview) => {
    router.push(
      {
        pathname : `/info/${id}`,
          query : {
            id,
            title,
            language, 
            release_date,
            runtime,
            vote_average,
            vote_count,
            overview,
          },
        }, 
        `/info/${id}`
      )
    };

  return (
	...
      {results?.map((movie) => (
        <Link href={`/info/${movie.id}`} key={movie.id}>
          <div className="movie" onClick={() => onClick(
            movie.id,
            movie.original_title,
            movie.original_language,
            movie.release_date,
            movie.runtime,
            movie.vote_average,
            movie.vote_count,
            movie.overview, )}>
            <img src={`https://image.tmdb.org/t/p/w500/${movie.poster_path}`} />
            <h4>{movie.original_title}</h4>
          </div>
        </Link>
     ))}
     ...
// info/[id]
// 간단히 요점만 적어보았습니다.
export default function Info() {
	const router = useRouter();
    return (
    	<div>
        	<h1>{router.query.title}</h1>
            <h4>{router.query.id}</h4>
            <h4>{router.query.language}</h4>
            <h4>{router.query.release_date}</h4>
            <h4>{router.query.runtime}</h4>
            <h4>{router.query.vote_average}</h4>
            <h4>{router.query.vote_count}</h4>
            <h4>{router.query.overview}</h4>
        </div>
    )
}

 

# 3 (# 1 + # 2)

- URL query를 사용하여 데이터를 넘겨주며, 새로고침 시 API를 호출하여 데이터를 가져온다. (#1, 2의 장점 + 단점보완)

: 1. 영화 아이템을 클릭하여 /info/[id]에 접속한 경우 ( === # 2)

   - movie item을 클릭하면 onClick 이벤트가 발생하여 URL query에 정보를 담고,

      /info/[id] 페이지로 이동한 뒤, router로 데이터를 가져온다.

  2. 새로고침 또는 직접 URL에 접속한 경우

   - useState, useEffect 등을 사용하여 기존의 API를 호출 방식처럼 데이터를 불러온다.

       : movie item을 클릭하면 URL에 id값이 동적으로 들어간다.

그리고, /info/[id] 페이지로 이동하고, router로 영화의 id값을 가져온 뒤, API를 호출하여 id에 맞는 영화 정보를 가져온다.

 

- 장점 : API 호출 수를 줄일 수 있다. (비용 절감), 필요한 데이터만을 불러올 수 있다.

- 단점 : #1과 #2의 단점을 상쇄시킵니다.


배포 문제

- React에서 배포했던 것처럼 AWS의 S3로 배포하려고 했으나, 예상치 못한 문제가 발생하여 배포하지 못하고 있습니다.

   스스로 생각해 본 결과, AWS(S3)는 정적 웹 사이트 호스팅인데

   위의 코드에서 /info/[id]와 같이 동적 라우팅이 있어서 배포가 정상적으로 되지 않은 것 같습니다.

   해당 문제를 최대한 빨리 해결하고, 배포할 수 있도록 노력하겠습니다.

-> 해당 문제를 해결하고 Vercel로 배포하였습니다.


느낀 점

- 첫 Next.JS 사용

:  이번 미니 프로젝트를 통해 Next.Js를 처음 사용해 봤습니다.

   하지만 React와 크게 다르지 않았으며, 라우팅 방식과 CSR, SSR인지, 프레임워크와 라이브러리인지 여부의 차이였으며, 

   CSR, SSR와 프레임워크, 라이브러리의 차이에 대해 더욱 이해할 수 있었던 시간이었습니다.

 

- CSR, SSR에 대한 생각

: React(CSR), Next.js(SSR & CSR)라고 생각하고 있는데, SSR이 사용자 입장에서 더욱 좋은 것 같다.

단, 웹, 앱 등을 만드는 곳이 기업이므로, 내가 만약 웹, 앱을 만드는 기업인데 SSR과 CSR 중 하나만 사용해서 만들어라고 한다면

검색엔진에 엄청 유리해야 되는 프로젝트가 아니고서야 SSR보다는 CSR를 사용해서 작업할 것 같다.

왜냐하면 CSR이 SSR에 비해 검색엔진에 불리하지만, SSR은 서버에서 렌더링 후 유저에게 보여주는 형식이기에

서버의 사용량이 CSR보다 많다. 서버의 사용량이 곧 회사의 돈(비용)이기 때문이라는 생각이 들었다.

(물론, Next.js나 React에서 두 가지(CSR, SSR)를 섞어서 사용하는 것이 제일 좋을 것 같다.)

반응형
profile

Space

@Space_zero

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!