코딩/React

Remix와 TMDB API 사용하여 영화 검색 사이트 만들기

드리프트 2022. 4. 16. 18:17
728x170
리믹스 프레임워크, Remix Framework, TMDB API 사용, Link prefetch 기능

 

 

안녕하세요?

 

제가 최근에 Remix Framework을 자주 사용하게 되는데요.

 

사용해본 결과 진짜 빠릅니다.

 

NextJS가 긴장해야 될 수준이고요.

 

참고로 개인 프로젝트에서 DB 데이터 260만 개의 Sqlite3 쿼리 써칭 결과 NextJS에서는 진짜 너무 오래 걸렸는데 Remix 프레임 워크에서는 정말 순식간에 로딩되었습니다.

 

그리고 Remix가 좋은 점이 HTML의 전통적인 사용 방식을 사용하고 있기 때문에 Remix를 쓰다 보면 웹 표준에 대해 더 자세히 알 수 있는 게 좋았습니다.

 

Remix의 좋은 점은 정말 여러 가지가 있는데요, 신생 프레임워크이기 때문에 아직은 유저가 별로 없지만 저는 NextJS를 대체할 수 있을 정도의 프레임워크라고 생각합니다.

 

이만 서론을 줄이고 본론으로 들어가 보겠습니다.

 

오늘은 Remix Framework으로 TMDB API를 사용하는 방법에 대해 알아보겠습니다.

 

TMDB API는 무료 영화 데이터베이스 API를 제공해 주는데요.

 

 

현재는 버전 3까지 나왔네요.

 

TMDB API는 API_KEY만 있으면 간단하게 fetch를 이용해서 JSON 형태의 자료를 받을 수 있습니다.

 

API_KEY는 TMDB 사이트에 가입해서 API를 클릭하면 쉽게 발급받을 수 있습니다.

 

그럼 TMDB의 API_KEY를 발급받았다고 가정하고 시작하겠습니다.

 

일단 기존 강좌에서 Remix 프로젝트를 TailwindCSS와 같이 구성한 적 있었는데요.

 

https://cpro95.tistory.com/673

 

Remix Framework 살펴보기

안녕하세요? 오늘은 Remix 프레임워크에 대해 알아볼까 합니다. React 생태계에서 가장 잘 나가는 프레임워크가 바로 Next.JS인데요. 저도 왠만하면 거의 모든 프로젝트는 모두 NextJS로 만듭니다. NextJ

cpro95.tistory.com

https://cpro95.tistory.com/674

 

Remix Framework에 TailwindCSS 적용하기

안녕하세요? 오늘은 Remix 프레임워크에 TailwindCSS를 적용시켜 보겠습니다. 먼저, 지난 시간까지 만든 remix-tutorial 폴더에서 다음과 같이 tailwindcss와 concurrently를 설치해 보겠습니다. npm i -D tailwin..

cpro95.tistory.com

 

위 두 개의 링크를 읽어보시고 진행하시면 준비는 다 된 겁니다.

 

이제 우리가 만들려고 하는 거는 TMDB를 이용한 영화 검색 사이트인데요.

 

위 스크린숏처럼 간단한 검색바에서 영화 제목만 클릭하면 관련 이름으로 된 영화 리스트를 제목과 함께 포스터를 나열하는 사이트입니다.

 

이번에는 TailwindCSS를 이용해서 좀 더 멋진 사이트를 만들었으니까 사실 코드가 조금 길어질 거 같습니다.

 

본격적인 코딩에 들어가 볼까요?

 

 

1. TMDB_API_KEY 설정하기

 

먼저. env 파일에 TMDB_API_KEY를 설정합시다.

 

DATABASE_URL="file:dev.db"
SESSION_SECRET="92cbdbc1234234234d7a31ddbba72dd8f55fbabc"
TMDB_API_KEY="d5c35e51c81488424234234234b19da7c1f572507a3d"

마지막에 있는 TMDB_API_KEY가 우리가 사용할 키입니다.

 

 

2. movies 라우팅 설정하기

 

영화 검색 페이지는 /movies 경로로 설정하려고 합니다.

 

이렇게 하려면 Remix에서는 단순하게 app/routes/movis.tsx 파일을 만들거나 app/routes/movies/index.tsx 파일을 만들거나 하면 됩니다.

 

일단 두 번째 방식으로 만들려고 합니다.

 

app/routes/movies 폴더를 먼저 만들고 그 안에 index.tsx파일을 만들도록 합시다.

 

먼저, UI 부분입니다.

 

import { useEffect, useRef } from "react";
import {
  Form,
} from "remix";

export default function SearchMovies() {

  const inputRef = useRef<HTMLInputElement>(null);
  const formRef = useRef<HTMLFormElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  return (
    <div className="w-full mx-auto p-2">
      <h1 className="text-4xl font-bold py-4 px-2">Search the TMDB Movies</h1>
      <Form reloadDocument method="post" ref={formRef} replace className="p-2 ">
        <div className="mx-auto flex flex-cols">
          <input
            className="w-full sm:w-2/3 xl:w-1/3 dark:shadow-sm-light mr-1 block rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
            type="text"
            id="title"
            name="title"
            ref={inputRef}
          />
          <button
            className="rounded-sm bg-slate-400 py-2 px-3 text-gray-600 dark:bg-slate-600 dark:text-white"
            type="submit"
            
          >
            Search
          </button>
        </div>
      </Form>
      </div>
    </div>
  );
}

 

검색어를 넣는 SearchBar가 완성되었습니다.

 

Remix에서는 Form이라는 자체 form 태그를 지원해 주는데요.

 

Form으로 method="post"라고 설정하고 button submit을 하면 같은 페이지로 정보가 넘어갑니다.

 

위 스크린숏을 보시면 검색어에 "범죄도시"라고 넣고 Search 버튼을 눌렀을 때 서버로 보내는 Request 자료에 페이로드 부분이 우리가 원하는 데이터로 전달되는 걸 볼 수 있습니다.

 

리믹스 프레임워크는 서버사이드 코드 작성이 많은데요.

 

기존의 React나 NextJS에서 Search 폼을 할 때 클라이언트 사이드에서 작업하던 게 많았는데요.

 

리믹스는 자체적으로 Express 서버를 내장하고 있어 우리가 Form으로 submit한걸 서버에서 처리하게끔 해줍니다.

 

그러면 리믹스에서 제공해주는 Form을 submit 했을 때 처리하는 함수가 뭘까요?

 

바로 action 함수입니다.

 

예전에 loader, action 함수에 대해 알아본 게 있는데요.

 

https://cpro95.tistory.com/682

 

Remix Framework 서버사이드 loader, action 함수 알아보기

Remix Framework loader, action Function, 리믹스 프레임워크 서버사이드 loader, action 함수 안녕하세요? 지난 시간에 배웠던 리믹스 프레임워크의 다이내믹 라우팅에 더해서 오늘 시간에 배울 부분은 리믹

cpro95.tistory.com

 

위 글을 참고하시기 바랍니다.

 

이제 Form을 제출(submit)했을 때의 서버사이드 쪽 코드를 작성해 보겠습니다.

 

 

3. action 함수 만들기

 

import {
  ActionFunction,
  Form,
} from "remix";

import { getMovies, movieType } from "~/api/movies";

export const action: ActionFunction = async ({ request }) => {
  const body = await request.formData();
  console.log("body -->", Object.fromEntries(body));
  const title = body.get("title") as string;
  const data: movieType[] = await getMovies(title);
  return data;
};

action이라는 이름으로 함수를 만들어서 export 해주면 리믹스가 알아서 처리해 줍니다.

 

코드 내용을 잘 보시면 action 함수 자체는 async 함수이며, request의 formData에서 "title"이라는 값을 취하고 그 "title"이라는 값으로 getMovies(title) 함수를 이용해서 data를 가져옵니다.

 

data 타입이 movieType []이네요.

 

그리고 그 data를 return 해주면 됩니다.

 

참고로 action 함수는 무조건 객체를 리턴해야 합니다.

 

그럼 getMovies라는 함수를 만들어 볼까요?

 

/app/api/movies.ts 파일을 만들도록 합시다.

 

이 파일은 app/routes 폴더 밑에 있지 않고 app 폴더 바로 밑에 있는데요.

 

routes 폴더 밑에서 라우팅 될 일이 없는 순수 서버사이드 유틸 함수이기 때문에 따로 api라는 폴더를 만드는 게 좋습니다.

 

export type movieType = {
  adult: boolean;
  backdrop_path: string;
  genre_ids: Array<number>;
  id: number;
  original_language: string;
  original_title: string;
  overview: string;
  popularity: number;
  poster_path: string;
  release_date: string;
  title: string;
  video: boolean;
  vote_average: number;
  vote_count: number;
};

export async function getMovies(title?: string | null) {
  const response = await fetch(
    `https://api.themoviedb.org/3/search/movie?api_key=${process.env.TMDB_API_KEY}&page=1&query=${title}&language=ko-KR`
  );
  const data = await response.json();
  const results: movieType[] = data.results;
  return results;
}

 

movieType는 보시면 아실 거고요.

 

getMovies 함수는 title이란 변수를 인자로 받고 TMDB API 서버에 Request를 하고 그 Response를 받아서 리턴해주는 함수입니다.

 

TMDB API의 사용법에 대해 알아보겠습니다.

 

아래 그림이 /search/movie의 API입니다.

 

실제 코드에서의 풀 경로입니다.

https://api.themoviedb.org/3/search/movie?api_key=d5c35e51c81488b19123123123123123da7c1f572507a3d&language=ko-KR&page=1&query=범죄도시

 

어떤 식으로 Response가 오는지 볼까요?

 

터미널에서 Curl을 이용해서 받아 봐도 되고 Firefox나 JSON형식을 예쁘게 보여주는 익스텐션이 있는 크롬에서도 볼 수 있습니다.

 

Firefox에서 본 원시 데이터입니다.

 

FireFox는 JSON을 예쁘게 포장해 주는데요.

 

위 스크린숏처럼 JSON을 누르면 다음과 같이 보기 좋게 나옵니다.

 

 

TMDB API는 page를 지정하게끔 되어 있는데요.

 

워낙 자료가 많기 때문입니다.

 

그래서 우리는 쿼리에 page=1이라고 넣었습니다.

 

리턴된 자료도 page: 1이라고 나와있네요.

 

그런데 우리가 필요한 건 바로 results라는 배열입니다.

 

그래서 getMovies 함수에서 보시면 리턴된 data의 results만 취하는 코드가 있습니다.

 

const results: movieType[] = data.results;

 

그러면 우리가 얻은 TMDB results 배열을 리믹스 코드에서 console.log 해볼까요?

 

action 함수에서 return data; 하기 전에 그 위에 console.log(data);라고 넣어줍시다.

 

 

 

터미널 상에서 console.log가 되고 있네요.

 

왜냐하면 action 함수는 서버사이드단에서 실행되기 때문입니다.

 

이제 action 함수에서 얻은 정보를 클라이언트 상에서 볼까요?

 

export default function SearchMovies() {
  const movies = useActionData<movieType[]>();

  console.log(movies);

 

리액트 컴포넌트에서 useActionData 함수를 이용하면 서버사이드에서 리턴한 action 함수의 데이터를 조회할 수 있습니다.

 

크롬 DevTools 안에서 본 데이터 모습입니다.

 

이제 이 데이터를 이용해서 화면에 뿌려주면 되는데요.

 

우리가 뿌려줄 데이터는 제목과 포스터 이미지입니다.

 

title과 poster_path가 되겠네요.

 

......
......
......
      </Form>
      <div className="p-3">
        {movies && <h1 className="text-2xl font-bold mb-4">Movies</h1>}
        <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
          {movies &&
            movies.map((movie: movieType) => (
              <Link key={movie.id} to={`${movie.id}`}>
                <h1 className="text-md font-semibold ml-2 h-12 mt-6">
                  {movie.title}
                </h1>
                {movie.poster_path && (
                  <img
                    className="p-2 hover:shadow-2xl hover:scale-105 cursor-pointer"
                    src={`https://image.tmdb.org/t/p/w342${movie.poster_path}`}
                  />
                )}
              </Link>
            ))}
        </div>
      </div>

 

Form 클로징 태그 바로 밑에 위와 같이 넣었습니다.

 

movies &&라는 코드를 썼기 때문에 movies가 있으면 나타나는 UI입니다.

 

우리의 movies를 배열이기 때문에 movies.map을 이용했고요.

 

TailwindCSS의 Grid를 이용해서 데이터를 쉽게 배열했습니다.

 

<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">

위 코드를 보시면 제일 작은 화면일 때는 cols-2이고 sm(small), md(medium), xl(extra-large) 일 때 각각 cols 숫자가 변하게 했습니다.

 

그리고 각각의 이미지에 대한 세부 사항으로의 링크는 Remix가 제공해주는 Link 태그를 이용했습니다.

 

참고로 Link 태그의 href는 to 프라퍼티를 쓰는데요.

 

to 프라퍼티는 string만 올 수 있습니다.

 

그런데 우리의 movie.id는 number 이기 때문에 위와 같이 스트링 리터럴 타입으로 지정한 겁니다.

 

만약 movie.id가 string이라면 단순하게 to={movie.id}라고 하면 됩니다.

 

그리고 poster_path인데요.

 

TMDB의 이미지 서버는 다음과 같습니다.

 

https://image.tmdb.org/t/p/크기/이미지_경로

 

여기서 크기가 중요한데요.

TMDB에서 제공해주는 이미지의 크기입니다.

 

저는 poster_sizes에서 w324를 썼습니다.

 

참고로 미개봉 영화는 영화 포스터가 없을 수 있기 때문에 poster_path가 null이 될 있으니 꼭 movie.poster_path && 형식으로 쓰셔야 합니다.

 

 

4. UX 개선하기

 

이제 Movies 메인 화면은 완성되었는데요.

 

만약 여러분이 작성한 사이트가 속도가 느려서 유저가 검색했을 때 한 참 기다려야 한다면 어떻게 해야 할까요?

 

바로 로딩 중이라는 표시를 해야 합니다.

 

Remix는 이 부분에 대해서도 많은 유틸리티 훅(Hook)을 제공해 주는데요.

 

간단히 사용할 훅이 바로 useTranstition 훅입니다.

 

먼저, 기존 Form 태그에서 reloadDocument을 빼야 합니다.

 

reloadDocument를 설정하면 Form의 action 처리 시작부터 전체 페이지를 reload 하기 때문에 useTransition 이 의미가 없어집니다.

 

<Form method="post" ref={formRef} replace className="p-2 ">

 

UI 코드입니다.

export default function SearchMovies() {
  const movies = useActionData<movieType[]>();

  console.log(movies);

  const transition = useTransition();
  let isSubmitting = transition.state === "submitting";

  const inputRef = useRef<HTMLInputElement>(null);
  const formRef = useRef<HTMLFormElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  useEffect(() => {
    if (isSubmitting) {
      formRef.current?.reset();
      inputRef.current?.focus();
    }
  }, [isSubmitting]);

 

useTransition 훅을 사용하려면 아무 이름으로 그 대상을 불러와야 되고, 그 변수를 이용해서 현재 브라우저 상태를 체크하는 방식입니다.

 

const transition = useTransition();
  let isSumitting = transition.state === "submitting";

위 코드 보시면 transition이라는 변수명으로 useTransition훅을 지정했고,

 

그다음으로 isSubmitting이라는 boolean 변수를 만들었습니다.

 

transition은 아래 그림처럼 location, state, submission, type을 제공해 주는데요.

우리가 사용할 거는 바로 state입니다.

state는 또 3개의 경우가 있는데요. 위 그림처럼 "idle", "submitting", "loading"입니다.

 

우리가 원하는 코드는 바로 submitting인데요.

 

왜냐하면 Form을 submitting 했을 때 서버에서 자료를 처리하고 있을 때라는 의미이기 때문입니다.

 

그러면 서버가 느린 경우 isSubmitting 변수는 항상 true가 되겠죠.

 

만약에 submitting이 완료되면 idle로 변하기 때문에 isSubmitting은 false가 되는 거죠.

 

그래서 마지막에 useEffect 코드에서 isSubmitting 변수에 따라서 처리해야 할 코드를 작성했는데요.

 

Form을 정리하고 포커스를 search 바에 지정하는 코드인 겁니다.

 

  useEffect(() => {
    if (isSubmitting) {
      formRef.current?.reset();
      inputRef.current?.focus();
    }
  }, [isSubmitting]);

그러면 Form이 submitting 중이라는 변수를 얻었으니까 이걸 UI에 적용해야겠죠.

 

제일 쉬운 게 그냥 화면에 Loading 중이라고 표시하는 겁니다.

 

....
....
      </Form>
      
      {isSubmitting && (
        <div
          className="p-4 mb-4 text-sm text-blue-700 bg-blue-100 rounded-lg dark:bg-blue-200 dark:text-blue-800"
          role="alert"
        >
          <span className="font-medium">Loading!</span>
        </div>
      )}
      
      <div className="p-3">
        {movies && <h1 className="text-2xl font-bold mb-4">Movies</h1>}
        
        ....
        ....

 

Form의 클로징 태그 밑에 Loading 중이라는 표시를 위해서 코드를 추가했습니다.

 

크롬 DevTools에서 네트워크 속도를 느린 3G라고 고른 후 테스트해볼까요?

 

Loading이라는 문구가 잘 표시되고 있네요.

 

그리고 Searching이라고 버튼 이름도 바뀌었네요.

 

다음과 같이 해주면 됩니다.

          <button
            className="rounded-sm bg-slate-400 py-2 px-4 text-gray-600 dark:bg-slate-600 dark:text-white"
            type="submit"
            disabled={isSubmitting}
          >
            {isSubmitting ? "Searching" : "Search"}
          </button>

 

어떤가요?

 

UI 부분도 정말 깔끔하지 않나요?

 

Form의 전송 및 기다림의 UI 부분은 제가 기존에 NextJS로 DB 260만 개 써칭 쿼리 해볼 때 절실히 느꼈었는데요.

 

Search 버튼을 눌렀을 때 아무것도 안 보이면 정말 사용자 입장에서는 답답하고 시간이 지체되면 바로 웹사이트를 꺼버리죠.

 

그래서 꼭 로딩 UI 부분을 신경 써야 합니다.

 

 

5. 무비 상세 페이지 만들기

 

이제 영화 포스터를 클릭했을 때 이동하는 상세 페이지를 만들도록 하겠습니다.

 

<Link key={movie.id} to={`${movie.id}`}>

위 코드처럼 Link 태그의 to 부분은 단순하게 movie.id입니다.

 

그래서 다이내믹 라우팅으로 만들어야 하는데요.

 

app/routes/movies 폴더에 $movieId.tsx이란 이름으로 파일을 만들도록 합시다.

 

파일 이름에 $ 표시가 붙은 이유는 이 파일의 라우팅은 다이내믹 라우팅이라는 뜻이죠.

 

import { useParams } from "remix";

export default function MovieDetail() {
  const params = useParams();
  return <div>MovieDetail : {params.movieId}</div>;
}

다이내믹 라우팅에서 보시면 useParams 훅을 이용해서 params 정보를 가져옵니다.

 

그리고 params 정보에서 movieId라는 정보를 가져오는데요.

 

movieId라는 이름인 이유는 파일명이 $movieId이기 때문입니다.

 

위 스크린숏을 보시면 movies 경로 뒤에 숫자가 11이라고 있네요.

 

이 11이라는 스트링이 바로 params의 movieId가 되는 겁니다.

 

그럼 우리가 메인 페이지에서 영화 정보의 id를 상세페이지로 넘겼는데요.

 

영화의 상세 정보를 id를 이용해서 획득해야겠죠.

 

TMDB API를 이용해서 getMovieById라는 API 함수를 만들도록 하겠습니다.

 

app/api/movies.ts 파일에 아래 내용을 추가합니다.

export async function getMovieById(movieId: string) {
  const response = await fetch(
    `https://api.themoviedb.org/3/movie/${movieId}?api_key=${process.env.TMDB_API_KEY}&language=ko-KR`
  );

  const data = await response.json();
  return data;
}

id를 이용해서 영화 상세 정보를 가져오는 함수입니다.

 

그럼 이 함수를 영화 상세 페이지에 적용해 보겠습니다.

 

다시 $movieId.tsx파일입니다.

 

import { Link, LoaderFunction, useLoaderData } from "remix";
import invariant from "tiny-invariant";
import { getMovieById } from "~/api/movies";

export const loader: LoaderFunction = async ({ params }) => {
  invariant(params.movieId, "expected params.movieId");
  
  const data = await getMovieById(params.movieId);
  return data;
};

export default function MovieId() {
  const movie = useLoaderData();
  console.log(movie);
  
  return <div>MovieDetail</div>;
}

리믹스의 loader함수를 사용했습니다.

 

loader함수는 리믹스 페이지가 처음 실행될 때 실행되는 서버사이드 함수인데요.

 

loader 함수에서 getMovieId라는 우리가 만든 api 함수를 이용해서 영화 상세 정보를 얻고 그걸 return 합니다.

 

loader 함수는 {request, params}를 기본 제공해 주는데요.

 

그래서 아까처럼 클라이언트사에서는 useParams 훅을 사용했었는데 여기 loader함수에서는 그냥 params을 불러오면 됩니다.

 

그리고 처음 보는 invariant 함수가 있는데요.

 

invariant는 tiny-invariant를 설치하면 됩니다.

 

왜 invariant함수를 썼냐면 타입 스크립트 에러 방지를 위해 넣은 겁니다.

 

타입 스크립트는 params.movieId가 없을 경우가 있다고 판단하기 때문인데요.

 

invariant는 코딩하는 디벨로퍼가 볼 때 params.movieId가 꼭 있으니까 신경 쓰지 말라는 뜻입니다.

 

자, 그러면 loader 함수에서 얻은 서버사이드 정보를 return 했는데요.

 

클라이언트에서는 어떻게 불러올까요?

 

바로 useLoaderData 훅입니다.

 

  const movie = useLoaderData();
  console.log(movie);

크롬 DevTools 상에 console.log 된 정보입니다.

 

이제 이 정보를 멋지게 보여주면 영화 상세 페이지는 끝납니다.

 

UI 부분이기 때문에 전체 코드를 보여드리도록 하겠습니다.

 

import { Link, LoaderFunction, useLoaderData } from "remix";
import invariant from "tiny-invariant";
import { getMovieById } from "~/api/movies";

export const loader: LoaderFunction = async ({ params }) => {
  invariant(params.movieId, "expected params.movieId");
  const data = await getMovieById(params.movieId);

  console.log("Prefetching Movie... -->", data.title);

  return data;
};
export default function MovieId() {
  const movie = useLoaderData();
  // console.log(data);
  return (
    <div>
      <div className="w-full h-40 sm:h-72 md:h-80 lg:h-96 overflow-hidden relative">
        <div className="w-full h-full flex flex-col absolute justify-between items-start">
          <Link
            to="/movies"
            className="text-white bg-slate-600/60 px-4 text-2xl hover:underline"
          >
            Go Back
          </Link>
          <div className="px-4 mb-2 text-4xl font-bold text-white bg-slate-700/60">
            {movie.original_title !== movie.title
              ? `${movie.original_title}(${movie.title})`
              : movie.title}
          </div>
        </div>

        <img
          src={`https://image.tmdb.org/t/p/w780${movie.backdrop_path}`}
          className="w-full h-auto object-contain"
          style={{ marginTop: -50 }}
        />
      </div>
      <div className="px-6 py-4 text-2xl">{movie.tagline}</div>
      <div className="px-6">
        {movie.genres &&
          movie.genres.map(({ id, name }: { id: number; name: string }) => (
            <span
              key={id}
              className="bg-indigo-100 text-indigo-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-indigo-200 dark:text-indigo-900"
            >
              {name}
            </span>
          ))}
      </div>
      <div className="px-6 py-2 text-xl leading-10">{movie.overview}</div>
      <div className="px-6 py-2">
        <span className="bg-pink-100 text-pink-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-pink-200 dark:text-pink-900">
          Release : {movie.release_date}
        </span>
      </div>
      <div className="px-6 py-2">
        <span className="bg-green-100 text-green-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-green-200 dark:text-green-900">
          Rating: {movie.vote_average}
        </span>
      </div>
      <div className="px-6 ">
        {movie.production_companies &&
          movie.production_companies.map(
            ({
              id,
              name,
              origin_country,
            }: {
              id: number;
              name: string;
              origin_country: string;
            }) => (
              <span
                key={id}
                className="bg-gray-100 text-gray-800 text-xs font-semibold mr-2 px-2.5 py-0.5 rounded dark:bg-gray-700 dark:text-gray-300"
              >
                {name}({origin_country})
              </span>
            )
          )}
      </div>
    </div>
  );
}

 

어떤까요?

 

정말 멋지지 않나요?

 

UI 코드의 CSS는 FlowBite의 CSS를 이용했습니다. 참고 바랍니다.

 

 

6. meta 함수 사용하기

 

리믹스에는 meta함수가 있는데요.

 

이것도 서버사이드 함수입니다.

 

어떤 역할을 하냐면 바로 브라우저의 title과 description을 설정해 줍니다.

 

app/movies/index.tsx 파일에 아래와 같이 코드를 추가해 봅시다.

 

export const meta: MetaFunction = (props) => {
  return {
    title: "TMDB 영화 검색",
    description: "리믹스와, TMDB API를 이용한 영화 검색 사이트입니다.",
  };
};

그리고 현재 페이지의 source code 정보를 보시면

위 그림과 같이 title과 meta 태그의 description 부분이 설정되어 있습니다.

 

리믹스의 meta함수는 동적인 data도 처리할 수 있는데요.

 

바로 loader함수에서 리턴한 data를 meta함수도 사용할 수 있습니다.

 

app/routes/movies/$movieId.tsx파일에 다음과 같이 코드를 추가해 볼까요?

 

export const meta: MetaFunction = (props) => {
  return {
    title: props.data.title,
  };
};

 

loader 함수에서 리턴한 data를 meta함수의 props에서 가져올 수 있습니다.

 

props.data가 그 값인데요.

 

위 코드처럼 작성하면 브라우저의 title이 영화 제목으로 변하게 되는 거죠.

 

아래 스크린숏을 참고 바랍니다.

 

 

 

7. 리믹스의 강력한 기능 prefetch

 

마지막으로 살펴볼 기능은 리믹스 프레임워크의 prefetch인데요.

 

영화 검색 페이지에서 검색한 영화 리스트를 쭉 보여주는 코드가 있을 겁니다.

 

거기에 Link 태그 부분에 prefetch를 설정할 수 있는데요.

 

일단 다음과 같이 설정해 봅시다.

 

<Link key={movie.id} to={`${movie.id}`} prefetch="intent">
....
</Link>

 

리믹스에서는 Link 태그에 prefetch 항목을 지원합니다.

 

여기에는 "none", "intent", "render"가 들어갈 수 있는데요.

 

먼저, intent 가 무슨 역할을 하는지 살펴보겠습니다.

 

$movieId.tsx 파일에서 loader 함수 부분에 아래처럼 console.log 코드를 추가해 보겠습니다.

 

export const loader: LoaderFunction = async ({ params }) => {
  invariant(params.movieId, "expected params.movieId");
  const data = await getMovieById(params.movieId);

  console.log("Prefetching Movie... -->", data.title);

  return data;
};

 

이제 처음 페이지에서 영화 이름을 검색해 봅시다.

 

스타워즈라고 치고 검색해 볼까요?

 

 

그리고 마우스를 여러 포스터로 바꿔서 이동해 봅시다.

 

콘솔 창을 볼까요?

 

 

콘솔 창에 "Prefetching Movie... --> ㅇㅇㅇㅇㅇㅇ"라고 계속 쓰이고 있습니다.

 

무슨 일이 벌어진 걸까요?

 

바로 말 그대로 prefetch인데요.

 

리믹스는 prefetch가 intent일 때는 Link 태그 위를 마우스가 호버(hover)했을 때 그 경로를 따라가서 loader 함수를 실행한다는 뜻입니다.

 

이러면 사용자가 마우스를 호버(hover) 했을 때는 벌써 리믹스가 그다음 페이지의 데이터를 확보했다는 뜻이 되는 겁니다.

 

React 디벨로퍼로써 이 기능은 정말 막강한데요.

 

이래서 Remix가 그렇게 빠른 UI를 보여주는 게 아닐까 싶습니다.

 

실제 클릭해 보면 정말 빠른 속도로 상세 페이지가 나오는 걸 볼 수 있을 겁니다.

 

그러면 prefetch="render"는 뭘까요?

 

prefetch="render"는 모든 Link 태그의 loader함수를 실행하게 됩니다.

 

마우스가 호버(hover) 상태의 Link만이 아니라 그 화면에 있는 모든 Link가 해당되는 거죠.

 

이 방법은 비추천인데요.

 

현재 화면에 보여줄게 몇 개 없을 경우에는 충분히 사용할 수 있는 옵션이지만 우리의 경우처럼 영화가 10개 이상 넘어갈 때는 상당히 비효율적일 수 있기 때문입니다.

 

일단 Link 태그의 prefetch 기능은 정말 강력한 기능인데요.

 

꼭 사용하시길 바랍니다.

 

오늘은 Remix 프레임워크를 이용해서 TMDB API를 사용해서 영화 검색 사이트를 만들어 봤는데요.

 

여러분도 한번 사용해 보시면 Remix의 강력한 속도에 반할 거라고 생각합니다.

 

참고로 movies/index.tsx 파일의 전체 코드를 아래와 같이 보여드리도록 하겠습니다.

 

import { useEffect, useRef } from "react";
import {
  ActionFunction,
  Form,
  Link,
  LoaderFunction,
  MetaFunction,
  useActionData,
  useTransition,
} from "remix";
import { getMovies, movieType } from "~/api/movies";

export const action: ActionFunction = async ({ request }) => {
  const body = await request.formData();
  console.log("body -->", Object.fromEntries(body));
  const title = body.get("title") as string;
  const data: movieType[] = await getMovies(title);
  console.log(data);
  return data;
};

export const meta: MetaFunction = (props) => {
  return {
    title: "TMDB 영화 검색",
    description: "리믹스와, TMDB API를 이용한 영화 검색 사이트입니다.",
  };
};

export default function SearchMovies() {
  const movies = useActionData<movieType[]>();

  const transition = useTransition();

  let isSubmitting = transition.state === "submitting";

  const inputRef = useRef<HTMLInputElement>(null);
  const formRef = useRef<HTMLFormElement>(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

  useEffect(() => {
    if (isSubmitting) {
      formRef.current?.reset();
      inputRef.current?.focus();
    }
  }, [isSubmitting]);

  return (
    <div className="w-full mx-auto p-2">
      <h1 className="text-4xl font-bold py-4 px-2">Search the TMDB Movies</h1>
      <Form method="post" ref={formRef} replace className="p-2 ">
        <div className="mx-auto flex flex-cols">
          <input
            className="w-full sm:w-2/3 xl:w-1/3 dark:shadow-sm-light mr-1 block rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
            type="text"
            id="title"
            name="title"
            ref={inputRef}
          />
          <button
            className="rounded-sm bg-slate-400 py-2 px-4 text-gray-600 dark:bg-slate-600 dark:text-white"
            type="submit"
            disabled={isSubmitting}
          >
            {isSubmitting ? "Searching" : "Search"}
          </button>
        </div>
      </Form>
      {isSubmitting && (
        <div
          className="p-4 mb-4 text-sm text-blue-700 bg-blue-100 rounded-lg dark:bg-blue-200 dark:text-blue-800"
          role="alert"
        >
          <span className="font-medium">Loading!</span>
        </div>
      )}
      <div className="p-3">
        {movies && <h1 className="text-2xl font-bold mb-4">Movies</h1>}
        <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
          {movies &&
            movies.map((movie: movieType) => (
              <Link key={movie.id} to={`${movie.id}`} prefetch="intent">
                <h1 className="text-md font-semibold ml-2 h-12 mt-6">
                  {movie.title}
                </h1>
                {movie.poster_path && (
                  <img
                    className="p-2 hover:shadow-2xl hover:scale-105 cursor-pointer"
                    src={`https://image.tmdb.org/t/p/w342${movie.poster_path}`}
                  />
                )}
              </Link>
            ))}
        </div>
      </div>
    </div>
  );
}

 

 

그리드형