영화앱 8. 영화 메인페이지 만들기

jonyChoiGenius·2023년 2월 12일
0

ISG로 데이터 fetch하기

영화 메인페이지를 SSG를 이용해 만들고자 한다.

먼저 아래와 같이 데이터를 불러온다.

import React, { useEffect } from "react";
import tmdbApi, { requests } from "../services/tmdbApi";

interface iProps {
  moviesObject: { [key: string]: Movie };
}

const main = ({ moviesObject }: iProps) => {
  Object.entries(moviesObject).forEach((value) => {
    console.log(value);
  });

  return <div>main</div>;
};

export const getStaticProps = async () => {
  const requestsUrl = Object.entries(requests);
  const moviesObject = new Object();
  return await Promise.allSettled(
    requestsUrl.map((e) => {
      return tmdbApi.get(e[1]).then((res) => {
        moviesObject[e[0]] = res.data.results;
      });
    }),
  ).then(() => {
    return { props: { moviesObject } };
  });
};

export default main;

(allSettled 안의 map에서 return을 빼먹어서 문제를 찾느라 고생했다. ㅠㅠ)

참고로 타입스크립트에서 키의 값은 불확실하지만 키의 밸류를 알고 있을 때
type obj = {[k:string]:number}와 같은 식으로, 대괄호로 묶어 표현할 수 있다.

return값에 revalidate를 주면 일정 기간마다 생성된 데이터(json)의 값을 비교하여 HTML파일을 새로 빌딩한다.

return { props: { moviesObject }, revalidate: 4 * 24 * 60 * 60 };

revalidate값은 345,600으로 주었다. 4일에 해당하는 값이다. TMDB의 TopTrending이 1주일마다 갱신되는 점을 고려했다. 매일매일 갱신하는 것이 좋겠지만, revalidate값이 짧으면 SSG를 의도하여 Next.js를 사용한 이유가 없게 느껴졌다.

const main = ({ moviesObject }: iProps) => {
  const MoviesDataEntries = Object.entries(moviesObject);

  return (
    <div>
      {MoviesDataEntries.map((e) => {
        return <MovieRow id={e[0]} key={e[0]} title={e[0]} moviesData={e[1]} />;
      })}
    </div>
  );
};

만들어진 데이터를 위와 같이 MovieRow에 넘겨주면

간단하게 영화 목록이 나온다.

  // const MoviesDataEntries = Object.entries(moviesObject);
  const MoviesDataEntries: Array<[string, [Movie]]> = [
    ["요즘 뜨는 영화", moviesObject["fetchTrending"]],
    ["점수가 높은 영화", moviesObject["fetchTopRated"]],
    ["짜릿한 액션 영화", moviesObject["fetchActionMovies"]],
    ["즐거운 코미디 영화", moviesObject["fetchComedyMovies"]],
    ["으스스한 공포 영화", moviesObject["fetchHorrorMovies"]],
    ["놀라운 다큐멘터리", moviesObject["fetchDocumentaries"]],
  ];

영화 목록 타이틀을 한글패치 해주었다. (로맨스 영화에는 19금 영화가 너무 많아서 뺐다 ㅠㅠ)

디자인 하기

메인페이지의 가로 넓이는 부트스트랩을 이용해 간단하게 디자인 했다.

    <div className="container d-flex justify-content-center">
      <div className="col-12 col-lg-10">
        {MoviesDataEntries.map((e) => {
          return (
            <MovieRow id={e[0]} key={e[0]} title={e[0]} moviesData={e[1]} />
          );
        })}
      </div>
    </div>

줄과 줄 사이의 간격도 MovieRow의 제목 태그에 부트스트랩을 주었고,
{title ? <h2 className="mt-2 ">{title}</h2> : <></>}

이제 중요한건 MovieRowContent이다.
지나치게 디자인을 많이 바꾸면 SearchContent와 충돌이 일어난다.

기존에 높이를 0%~50% 혹은 0%~100%로 바꾸던 방식에서 opacity만 0에서 1로 바꾸는 방식으로 전환했다.
SearchContent에서도 height가 아닌 opacity를 바꿔주어서 간단하게 문제가 해결됐다.

import React, { useRef, useState } from "react";
import { useIntersection } from "../utils/useIntersection";

const MovieRowContent = ({ movie }) => {
  const [Loading, setIsLoading] = useState(1);
  const [isInView, setIsInView] = useState(false);
  const imgRef = useRef();
  useIntersection(imgRef, () => {
    setIsInView(true);
  });

  return (
    <figure ref={imgRef}>
      <div
        style={{
          width: "100%",
          paddingTop: "56.25%",
          backgroundImage: Loading
            ? `url('data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wEEEAGQAZABkAGQAakBkAHCAfQB9AHCAnECowJYAqMCcQOdA1IDBwMHA1IDnQV4A+gEMwPoBDMD6AV4CE0FLQYOBS0FLQYOBS0ITQdTCOMHOga9BzoI4wdTDS8KWgkuCS4KWg0vDzwMywwcDMsPPBJ1EIEQgRJ1Fz4WEhc+Hl8eXyjSEQGQAZABkAGQAakBkAHCAfQB9AHCAnECowJYAqMCcQOdA1IDBwMHA1IDnQV4A+gEMwPoBDMD6AV4CE0FLQYOBS0FLQYOBS0ITQdTCOMHOga9BzoI4wdTDS8KWgkuCS4KWg0vDzwMywwcDMsPPBJ1EIEQgRJ1Fz4WEhc+Hl8eXyjS/8IAEQgACQAQAwEiAAIRAQMRAf/EACgAAQEBAAAAAAAAAAAAAAAAAAEAAgEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEAMQAAAAykf/xAAUEAEAAAAAAAAAAAAAAAAAAAAg/9oACAEBAAE/AB//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/AH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/AH//2Q==')`
            : "",
          backgroundSize: "cover",
        }}
      >
        {/* 옵저빙되면 이미지 경로를 바꾸어 이미지의 로딩을 시작한다 */}
        <img
          alt={movie.title || movie.original_title}
          style={{
            cursor: "pointer",
            position: "absolute",
            top: "0",
            height: "100%",
            objectFit: "cover",
          }}
          className="row__poster "
          onLoad={() => setIsLoading(0)}
          src={
            isInView
              ? `https://image.tmdb.org/t/p/w780/${movie.backdrop_path}`
              : ""
          }
        />
                {/* 로딩이 완료되면 backdropFilter를 지우고, opacity는 글로벌 스타일을 따른다. */}
        <div
          className="overlay"
          style={
            Loading
              ? {
                  display: "block",
                  opacity: "1",
                  backdropFilter: "blur(10px)",
                }
              : {
                  backdropFilter: "blur(0px)",
                }
          }
        >
          <div
            className="description"
            style={{
              cursor: "pointer",
            }}
          >
            {movie.title || movie.original_title}
          </div>
        </div>
      </div>
    </figure>
  );
};

export default MovieRowContent;

이미지 프리로딩하기

지금까지 이미지 Lazy Loading을 통해 최적화를 했다면,
이번엔 Pre Loading을 통해 최적화를 해보자.

랜딩페이지에 애니메이션을 뒀던 이유도 Pre Laoding을 위해서였다.

getStaticProps에서 각 배열의 6개씩을 잘라 props로 내려준다.
(서버사이드에서 바로 파이어스토어에 넘겨주려고 했는데, 서버사이드에서는 firebase가 실행이 안되고 있을 것이 자명하기에...)

export const getStaticProps = async () => {
  const requestsUrl = Object.entries(requests);
  const moviesObject = new Object();
  return await Promise.allSettled(
    requestsUrl.map((e) => {
      return tmdbApi.get(e[1]).then((res) => {
        moviesObject[e[0]] = res.data.results;
      });
    }),
  ).then(() => {
    const validate = Math.floor(Date.now() / 1000);
    const preloadingData = [];
    Object.values(moviesObject).map((e) =>
      e.slice(0, 6).map((e) => {
        if (!e.backdrop_path) return "";
        const { backdrop_path } = e;
        return preloadingData.push(backdrop_path);
      }),
    );
    return {
      props: { moviesObject, preloadingData, validate },
      revalidate: 4 * 24 * 60 * 60,
    };
  });
};

preloadingData는 랜딩페이지에서 로딩할 이미지의 목록이고,
validate는 자료가 생성된 시간이다.(초)

이제 preloadingData를 firestore에 저장하자.

import { dbService } from "../../public/fbase";

export const patchPreload = async (
  validate: number,
  preloadData: Array<string>,
) => {
  return dbService
    .doc("preloading/data")
    .update({
      validate,
      preloadData,
    })
    .then((res) => Promise.resolve(res))
    .catch((err) => Promise.reject(err));
};

validate와 preloaddata를 업데이트하는 함수를 만들었다.

마지막으로 validate된 현재 validate된 시간이 다르면 업데이트를 해주면 되는데...
firestore는 요청을 많이 보낼수록 과금이 된다는 사실을 염두에 두자.
firestore에 저장된 시간과 비교하기 보다는 현재의 시간과 비교하여 요청을 보내도록 한다.

  useEffect(() => {
    const currentDate = Math.floor(Date.now() / 1000);
    // console.log(validate);
    // console.log(currentDate);
    // console.log(currentDate - validate);
    if (currentDate - validate <= 15) {
      patchPreload(validate, preloadingData);
    }
  }, []);

만일 validate와 현재 시간이 15초 이내인 경우,
해당 사용자가 접속하면서 html파일이 빌드된 것으로 판단하며 patchPreload를 실행한다.

1676204540
1676204593
53

yarn build, yarn start로 실행한 후 콘솔을 찍은 결과도 잘 작동한다.
위 로직은 수정했다. 본 글 최하단에 작성함.

이제 데이터를 db로부터 가져온다.

interface PreloadDataType {
  preloadData: Array<string>;
  validate: number;
}
export const fetchPreload = async (): Promise<PreloadDataType> => {
  return dbService
    .doc("preloading/data")
    .get()
    .then((Snapshot) => {
      const result = Snapshot.data() as PreloadDataType
      return result;
    })
    .catch((err) => err);
};

~~콘솔로 찍어보니 snapshot의 데이터가 객체 형식이라 위와 같이 타입을 덮어 씌웠다.

그리고 랜딩페이지의 Promise.allSettled에 아래와 같이 추가하였다. 이미지 객체를 만들어 이미지를 프리로딩 하는 방식인데, 스택 오버플로를 참조했다.~~

      Promise.allSettled([
        dispatch(authSlice.actions.setUserOjbect(currentUser)),
        dispatch(authSlice.actions.setUserProfile(profile)),
        //이 부분이 추가됨
        fetchPreload().then((res) => {
          res.preloadData.map((backdrop_path) => {
            const imageElement = new Image();
            imageElement.src = `https://image.tmdb.org/t/p/w780/${backdrop_path}`;
            return imageElement;
          });
        }),
      ]).then(() => (flag = -1));

아래 로직도 수정했다. 아래 리팩토링 문단 참조.

프리로딩 적용 전

프리로딩 적용 후

index.tsx에서 해당 사진들을 메모리 캐싱하고 있는 것을 확인할 수 있다.

(테스트 할 때에 캐시를 끄면 index.tsx, react-dom 이렇게 두 번 불러와서 테스트 하기가 까다로운 감이 있다. ㅠㅠ)

같은 방식으로 나의 추천영화를 캐싱하고 마무리

      Promise.allSettled([
        dispatch(authSlice.actions.setUserOjbect(currentUser)),
        dispatch(authSlice.actions.setUserProfile(profile)),
        fetchPreload().then((res) => {
          res.preloadData.map((backdrop_path) => {
            const imageElement = new Image();
            imageElement.src = `https://image.tmdb.org/t/p/w780/${backdrop_path}`;
            return imageElement;
          });
        }),
        //프로필이 있으면 프로필의 영화 정보에서 backdrop_path불러오기
        () => {
          if (!profile) return;
          profile.myRecommendations.map((movie) => {
            const imageElement = new Image();
            imageElement.src = `https://image.tmdb.org/t/p/w780/${movie.backdrop_path}`;
            return imageElement;
          });
        },
      ]).then(() => (flag = -1));

영화 데이터를 가져오는 부분도 수정했다.

리팩토링 (JSON 파일 생성하고 불러오기)

JSON 용량 최적화

next.js는 static 페이지를 렌더링하는데 필요한 데이터를 json 형식으로 저장한다.
참고로 현재 용량은 117kb

먼저 main.tsx를 렌더링할 때 필요한 정보는 id, title, backdropPath이다.
json에 저장된 불필요한 내용을 없애기 위해
MovieEssensial이라는 새로운 타입을 지정했다.

export interface MovieEssential {
  backdrop_path: string;
  title: string;
  id: number;
}

수정사항 1. MovieRowContent의 프롭 타입을 Movie에서 MovieEssential로 바꾸었다.

interface iProps {
  movie: MovieEssential;
  cardMode: boolean;
}

수정사항 2. MovieRow 역시 MoviesData의 프롭 타입을 MovieEssential로 바꾸었다.

interface iProps {
  title: string;
  id: string;
  movieList?: Array<Movie>;
이부분 수정
  moviesData?: Array<MovieEssential>;
  onResultClick?: (movie: Movie) => any;
  cardMode?: boolean;
}
function MovieRow({
  title,
  id,
  movieList,
  moviesData,
  onResultClick,
  cardMode = false,
}: iProps) {
이부분도 수정
  const [movies, setMovies] = useState<Array<MovieEssential> | Array<Movie>>(
    moviesData || movieList,
  );

수정사항 3. main page에 접속 시에 저장하는 movieObject 데이터를 기존 Move 객체에서 MovieEssential로 바꾸었다.

// getStaticProps 부분
      return tmdbApi.get(e[1]).then((res) => {
        movieEssentialArray = res.data.results.map((e: Movie) => {
          const { id, title, backdrop_path } = e;
          return { id, title, backdrop_path };
        });
        moviesObject[e[0]] = movieEssentialArray as Array<MovieEssential>;
      });
    }),

결과로 만들어진 JSON 파일은 18kb

JSON 파일을 생성하고 불러오기

본래 이 리팩토링은 위처럼 만들어진 json 파일을 그대로 불러와서 쓰는 것이었는데....
불러오는 법을 모르겠다. ㄷㄷㄷㄷㄷ

그래서 public 폴더에 json file을 만드는 것으로 대체했다.

먼저 main.tsx의 getStaticProps 부분이다.

import fs from "fs";
import path from "path";

const main = ({ moviesObject, preloadingData, validate }: iProps) => {
...
  // const dbValidate = useTypedSelector((state) => state.dbSlice.dbValidate);
  // useEffect(() => {
  //   if (dbValidate !== validate) {
  //     patchPreload(validate, preloadingData);
  //   }
  // }, []);
  
export const getStaticProps = async () => {
...
    const filePath = path.resolve(
      path.join(process.cwd(), "public", "preloadingData.json"),
    );
    fs.writeFileSync(filePath, JSON.stringify({ validate, preloadData: preloadingData }));
    return {
      props: { moviesObject, preloadingData, validate },
      revalidate: 4 * 24 * 60 * 60,
    };
  });
};

export default main;

db에 preloadingData를 삽입하는 부분이 사라지고
fs를 이용하여 public 폴더에 json file을 만들고 있다.

index.js 부분이다.

Promise.allSettled([
        dispatch(authSlice.actions.setUserOjbect(currentUser)),
        dispatch(authSlice.actions.setUserProfile(profile)),
        // fetchPreload()
        fetch("/preloadingData.json").then(async (data) => {
          const res = await data.json();
          dispatch(setDbValidate(res.validate));
          res.preloadData.map((backdrop_path) => {
            const imageElement = new Image();
            imageElement.src = `https://image.tmdb.org/t/p/w780/${backdrop_path}`;
            return imageElement;
          });
        }),

파이어스토어로부터 데이터를 불러오던 fetchPreload() 함수가 사라지고
fetch("/preloadingData.json") 으로 퍼블릭 폴더의 json 파일을 불러오도록 바뀌었다.

결과적으로 잘 동작한다.

이렇게 또 파이어스토어와의 통신 횟수를 하나 줄였다.

데이터 valid 여부 확인

위에서 가져오는 preloadData에 validate가 저장되어 있다.
이 validate를 redux에 저장하고,
문서가 빌드된 validate와 파이어스토어의 validate를 비교하여 다른 경우 업데이트하도록 하였다.

간단하게 dbSlice를 만든 후

import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { HYDRATE } from "next-redux-wrapper";

const dbSlice = createSlice({
  name: "dbSlice",
  initialState: {
    dbValidate: 0,
  },
  reducers: {
    setDbValidate(state, action: PayloadAction<number>) {
      state.dbValidate = action.payload;
    },
  },
  extraReducers: {
    [HYDRATE]: (state, action) => {
      return {
        ...state,
        ...action.payload,
      };
    },
  },
});

export default dbSlice;
export const { setDbValidate } = dbSlice.actions;

index.tsx의 fetchPreload에서 dispatch하도록 했다.

        fetchPreload().then((res) => {
          //이부분이 추가됨!
          dispatch(setDbValidate(res.validate));
          //
          res.preloadData.map((backdrop_path) => {
            const imageElement = new Image();
            imageElement.src = `https://image.tmdb.org/t/p/w780/${backdrop_path}`;
            return imageElement;
          });
        }),

이제 main.tsx에서 이를 조회하여 비교하면 된다.

const main = ({ moviesObject, preloadingData, validate }: iProps) => {
  const dbValidate = useTypedSelector((state) => state.dbSlice.dbValidate);
  useEffect(() => {
    if (dbValidate !== validate) {
      patchPreload(validate, preloadingData);
    }
  }, []);

SSG에서 받은 정보와 db에서 받은 정보를 비교하니 매우 정확한 비교가 가능했고,
yarn build, yarn start 를 해보아도 최초 접속시에만 적용되는 것을 확인할 수 있었다.

profile
천재가 되어버린 박제를 아시오?

0개의 댓글