영화앱 12. 메인 페이지 기능 완성하기

jonyChoiGenius·2023년 2월 23일
0

만들어진 메인 페이지에
'최신 인기글'과 '나를 위한 추천 영화' 기능을 추가한다.

나를 위한 추천 영화

나를 위한 추천 영화 기능은 간단하다.
랜딩 페이지에서 store에 저장해두었던 userProfile.myRecommendations 배열을 가져오면 된다.

  const myRecommendations = useTypedSelector(
    (state) => state.authSlice.userProfile.myRecommendations,
    shallowEqual,
  );

            <MovieRow
              id="my-recommendations"
              key="my-recommendation"
              title="나를 위한 추천 영화"
              moviesData={myRecommendations}
            />

문제는... 랜딩 페이지를 거치지 않고 main페이지로 오면 myRecommendations가 초기 상태인 빈 배열이다.

랜딩 페이지에서 처리한 로직을 mainPage에 옮겨준다.

  useEffect(() => {
    if (myRecommendations.length) return;
    authService.onAuthStateChanged((crrUser) => {
      const uid = crrUser?.uid;
      if (!uid) return;
      fetchProfile(uid).then((res) => {
        dispatch(setUserOjbect(crrUser));
        dispatch(setUserProfile(res));
      });
    });
  }, []);

그리고 현재 로직에 문제가 있는데,
MovieRow에 빈 배열이 들어오는 경우와 데이터가 있는 배열이 서로 다르게 렌더링 된다....그래서 state가 바뀌어도 렌더링이 안되는 문제가 있다.

그래서 MovieRow를 수정하기 보다는 삼항 연산자를 이용해 조건부 렌더링을 해주기로 했다,.

          {myRecommendations.length ? (
            <MovieRow
              id="my-recommendations"
              key="my-recommendation"
              title="나를 위한 추천 영화"
              moviesData={myRecommendations}
            />
          ) : (
            <>
              <MovieRow
                id="my-recommendations"
                key="my-recommendation"
                title="나를 위한 추천 영화"
                moviesData={[]}
              />
            </>
          )}

인기 영화 가져오기

MovieRow와 Card를 재활용하고 싶었는데, 잘 되지 않았다.
그래서 스타일만 불러오고 새로 Swiper를 작성하였다.

          <StyledBoardHeader className="container">
            <div className="row">
              <div className="col text-center">
                <h2 className="h4">인기 영화 일기</h2>
                <div
                  className={`lead eddting-text fs-5 mb-2`}
                  onClick={() => {
                    router.push("/board");
                  }}
                >
                  더보기
                </div>
              </div>
            </div>
          </StyledBoardHeader>
          <StyledMovieRow className="container" style={{ height: "350px" }}>
            <div className="row">
              <Swiper
                modules={[Navigation, Pagination, Scrollbar, A11y]}
                autoHeight={true}
                loop={false}
                navigation={{
                  prevEl: ".swiper-button-prev",
                  nextEl: ".swiper-button-next",
                }}
                // pagination={{ clickable: false }}
                breakpoints={{
                  1378: {
                    slidesPerView: 4,
                    slidesPerGroup: 4,
                  },
                  998: {
                    slidesPerView: 3,
                    slidesPerGroup: 3,
                  },
                  625: {
                    slidesPerView: 2,
                    slidesPerGroup: 2,
                  },
                  0: {
                    slidesPerView: 1,
                    slidesPerGroup: 1,
                  },
                }}
              >
                {trendingArticles.map((article) => {
                  return (
                    <SwiperSlide key={article.documentId}>
                      <Card
                        article={article}
                        key={article.documentId}
                        className="col-12"
                        setArticles={() => {
                          getTrending();
                        }}
                      />
                    </SwiperSlide>
                  );
                })}
                <div className="swiper-button-prev arrow"></div>
                <div className="swiper-button-next arrow"></div>
              </Swiper>
            </div>
          </StyledMovieRow>

영화 불러오는 로직

영화를 불러오는 데에 중요한 점은,
firestore에서 array의 size로 정렬하는 기능을 지원하지 않는다는 점이다.

그래서 정렬을 자바스크립트로 진행하고자 한다.

export const fetchTrending = () => {
  const dbRef = dbService.collection("articles");
  const query = dbRef.where(
    "published_date",
    ">=",
    Date.now() - 7 * 24 * 60 * 60 * 1000,
  );

  const data = query.get().then((Snapshot) => {
    let Articles = Snapshot.docs.map((doc) => {
      const documentId = doc.id;
      const documentData = doc.data();
      return { documentId, ...documentData } as Article;
    });
    Articles.sort((a, b) => b.likes.length - a.likes.length);
    return Articles.slice(0, 20);
  });
  return data;
  const query = dbRef.where(
    "published_date",
    ">=",
    Date.now() - 7 * 24 * 60 * 60 * 1000,
  );

쿼리는 최근 7일 치의 데이터를 가져온다.

Articles.sort((a, b) => b.likes.length - a.likes.length);

데이터를 likes 배열의 길이 순으로 정렬하여 반환한다.

그런데...7일 치로 조회하면 아무런 글이 나오지 않아 별로 좋지 않다.

그래서 7일치 데이터를 조회했을 때 아무 글이 없다면, 최신 작성된 글 20개를 가져오도록 한다.

export const fetchTrending = () => {
  const dbRef = dbService.collection("articles");
  const query = dbRef.where(
    "published_date",
    ">=",
    Date.now() - 7 * 24 * 60 * 60 * 1000,
  );

  const data = query.get().then((Snapshot) => {
    let Articles = Snapshot.docs.map((doc) => {
      const documentId = doc.id;
      const documentData = doc.data();
      return { documentId, ...documentData } as Article;
    });
    if (!Articles.length) {
      dbRef
        .limit(20)
        .get()
        .then((Snapshot) => {
          Articles = Snapshot.docs.map((doc) => {
            const documentId = doc.id;
            const documentData = doc.data();
            return { documentId, ...documentData } as Article;
          });
        });
    }
    Articles.sort((a, b) => b.likes.length - a.likes.length);
    return Articles.slice(0, 20);
  });
  return data;
};

이렇게 불러온 데이터를 index.tsx의 랜딩페이지에서 dispatch해주면 된다.

        fetchTrending().then((res) => {
          dispatch(setTrendingArticles(res));
        })

큰 무리없이 메인 페이지 기능 추가를 완료했다.

Next.js에서 Nav와 Footer를 추가하기 위해서는 _app.js를 아래와 같이 수정해주면 된다.

function MyApp({ Component, pageProps }) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  );
}

Layout을 조건부로 하기 위해서는 Layout파일 내에서 router.pathname 이나 _app.js에서 Component.name을 사용하면 된다.

function MyApp({ Component, pageProps }) {

  const isHomepage = Component.name === 'Home';
  
  const homepageLayout = (
    <>
      <Navbar />
      <main>{pageProps.children}</main>
      <Footer />
    </>
  );

  const otherPagesLayout = (
    <>
      <Navbar />
      <main>
        <div className="container">{pageProps.children}</div>
      </main>
      <Footer />
    </>
  );

  const layout = isHomepage ? homepageLayout : otherPagesLayout;

  return layout;
}

본 프로젝트에서는 footer를 사용하지 않고 Navbar만 사용할 것이므로 삼항 연산자를 이용해 Navbar를 조건부 렌더링하는 방식을 사용했다.

(아래 코드는 오류가 난다.

function App({ Component, pageProps }: AppProps) {
  const { store, props } = wrapper.useWrappedStore(pageProps);
  const isLanding =
    Component.name === "Home" || Component.name === "ProfileCreate";
  const isMain = Component.name === "MainPage";

  return (
    <Provider store={store}>
      <PersistGate loading={null} persistor={persistor}>
        <Head>
          <title>Create Next App</title>
        </Head>
        <GlobalStyle />
        <ToastContainer />
        <>
          {isMain ? (
            <GrayScaleMastheadH1>
              <header className={`navhead`}>
                <h1 className="">TEAL AND ORANGE</h1>
              </header>
            </GrayScaleMastheadH1>
          ) : (
            <></>
          )}
          {isLanding ? <></> : <Navbar />}
          <Component {...props} />
        </>
      </PersistGate>
    </Provider>
  );
}

export default App;

isLanding: 랜딩 페이지거나 프로필 생성페이지인 경우 - navbar를 표시하지 않는다.
isMain: 메인 페이지인 경우 - 최상단에 메인 로고 'header'를 렌더링한다.

그리고 이렇게 했더니... 개발환경에서는 괜찮았는데, 빌드한 후에는 dynamic하게 렌더링되지 않는 문제가 있었다. _app.js가 한 번 반환되면 그 이후로는 조건을 명확히 따지지 않기 때문으로 보인다.

Navbar에서 route.pathname을 활용하도록 한다.

function Navbar() {
  const router = useRouter();

  const isLanding =
    router.pathname === "/" || router.pathname === "/profile/create";
  if (isLanding) return <></>;

  const isMain = router.pathname === "/main";
  const header = isMain ? (
    <GrayScaleMastheadH1>
      <header className={`navhead`}>
        <h1 className="">TEAL AND ORANGE</h1>
      </header>
    </GrayScaleMastheadH1>
  ) : (
    <></>
  );
  
  return <>
    {header}
    <div
        className="container sticky-top d-flex justify-content-center">
      네브바
    </div>
  <>

이렇게 했더니 의도한 대로 빌드되고 렌더링 된다.

한편 Navbar에는 sticky와 스크롤 이벤트를 줬다.
스크롤 이벤트가 퍼포먼스를 많이 잡아 먹을까 걱정했는데, stackoverflow에서 별로 비싼 연산도 아니라고도 하고, Navbar라도 동적이어야 페이지가 전체적으로 동적인 느낌이 나서 적용했다.

//(1)
  const [position, setPosition] = useState(0);
  function onScroll() {
    setPosition(window.scrollY);
  }
  useEffect(() => {
    window.addEventListener("scroll", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
    };
  }, []);
  const opa = position < 280 ? (position - 100) / 200 + 0.1 : 0.95;


  return (
  <>
    {header}
    <div
      className="container sticky-top d-flex justify-content-center"
      style={{
        backgroundColor: `rgba(1, 25, 47, ${
          input && debouncedInput ? 0 : opa
        })`,
      }}
    >
      <StyledNav className="nav col-12 col-lg-10">
        <img
          alt="logo"
          src="/logo.png"
          className="nav__logo mt-1"
          role="button"
          onClick={() => router.push("/main")}
        />
//(2)
        <div className="d-flex  pr-5">
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            className="nav__input"
            type="text"
            placeholder="영화를 검색해주세요."
          />
          <div
            className={`nav__cancel ${input ? "visible" : "invisible"}`}
            onClick={() => setInput("")}
          >
            취소
          </div>
          <div style={{ width: "27px" }}></div>
        </div>
        <div style={{ height: "10px" }}>
          <CardFooter author={profile}></CardFooter>
        </div>
      </StyledNav>
      <SearchResult
        debouncedInput={debouncedInput}
        input={input}
        movies={movies}
        onResultClick={(movie) => {
          router.push(`/movie/${movie.id}`);
          setInput("");
        }}
        mainMode={true}
      />
    </div>
  </>
  );
}

1) 스크롤이 발생하면, 스크롤 위치에 따라서 Navbar의 밝기를 바꾼다.
2) 검색창을 둬서 검색결과를 row로 띄운다.


그렇게 해서 완성된 결과물이다.

사실 navbar 디자인 잡는데에 시간이 오래 걸렸다.
bootstrap의 container class로 메인 페이지와 넓이를 통일시키는 게 중요했다.

redux-persist 적용하기

그동안 redux-persis를 적용하지 않고 파이어스토어의 onAuthStateChaged 이벤트만 사용하고 있었는데,

스태틱한 페이지가 늘어나면서 새로고침을 해도 실시간으로 유저 정보를 불러올 필요성이 커지고 있다.

그래서 redux-persist를 적용하기로 하였다.

  1. redux-persist 설치하고 import하기
//store/index.tsx
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { persistReducer, persistStore } from "redux-persist";
import storageSession from "redux-persist/lib/storage/session";

리덕스 툴킷에서는 configureStore에서 자동으로 rootReducer를 만들어주지만, persistReducer를 사용하기 위해서는 combineReducers를 이용해 수동으로 rootReducer를 만들어주어야 한다.

  1. persistedReducer 구성하기
//1) rootRecuder를 만든다
const reducers = combineReducers({
  authSlice: authSlice.reducer,
  dbSlice: dbSlice.reducer,
});

//2) persist 설정을 넣어준다. key, storage, whiteList, blackList 등을 설정할 수 있다.
const persistConfig = {
  key: "root",
  storage: storageSession,
};

//3) persistedReducer를 구성해준다. persistReducer(구성, 루트리듀서)
const persistedReducer = persistReducer(persistConfig, reducers);

//configureStore의 reducer를 persistedReducer로 지정한다.
const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: false,
    }),
});
const makeStore = () => store;

const wrapper = createWrapper(makeStore);
export default wrapper;
//4) provider에 주입해줄 persistor를 만든다.
export const persistor = persistStore(store);

persistConfig에서 storage를 통해 로컬 스토리지를 쓸 지, 세션 스토리지를 쓸 지 지정할 수 있다. 또한 whitelist:[리듀서명] 혹은 blacklist:[리듀서명]을 통해 특정 리듀서만 포함/제외 할 수 있다.
로컬 스토리지와 화이트 리스트를 사용하는 스니펫은 [Redux-persist] 새로고침에도 유지되는 store (with Redux-toolkit)에서 확인할 수 있다.
나는 모든 리듀서를 sessionStorage에 저장하였다.

이제 __app.js에 주입하면 된다.

import wrapper, { persistor } from "../store";
import { PersistGate } from "redux-persist/integration/react";

  return (
    <Provider store={store}>
      <PersistGate loading={null} persistor={persistor}>
        <Head>
          <title>Create Next App</title>
        </Head>
        <GlobalStyle />
        <ToastContainer />
        <>
          <Navbar></Navbar>
          <Component {...props} />
        </>
      </PersistGate>
    </Provider>
  );
}

persistor를 store.persistor에 저장하여 바로 불러와도 괜찮다.

이렇게하면 Provider가 store를 주입할 때에, 세션 스토리지에서 저장된 persistor를 불러와 바로 주입시켜준다.

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

0개의 댓글