영화앱3: 랜딩 페이지 디자인

jonyChoiGenius·2023년 1월 29일
0

로그인과 프로필 기능을 구현할 수 있게 되었으니,
기존 프로젝트의 랜딩 페이지를 가져와 구현하고자 한다.

명세

랜딩 페이지 구성 방안은

  1. Static한 페이지를 만들고 인터랙션은 CSS 트랜지션으로 구현한다.
  2. 기존에 자동 스크롤을 통해 구현했던 것 역시 클래스명을 추가하고 CSS를 주는 것으로 구현한다.
  3. 애니메이션 시간동안 파이어베이스의 AuthCanged 이벤트가 fire되는지를 기다린 후, auth의 상태에 따라 Router를 push하거나 로그인 창으로 이동시키는 버튼을 만든다.

부트스트랩

부트스트랩을 먼저 적용해야 하는데,
https://blog.logrocket.com/handling-bootstrap-integration-next-js/
해당 글을 참조하여 _App.tsx에 추가할 수 있다.

하지만 일단 jsDelivr를 통한 CDN을 _document에 추가하는 방법으로 한다.

    } finally {
      sheet.seal();
    }
  }
  render() {
    return (
      <Html>
        <Head>
          <meta name="description" content="Generated by create next app" />
폰트는 구글웹폰트의 본고딕을 불러온다.
                    <link
            href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100;300;400;500;700;900&display=swap"
            rel="stylesheet"
          />
부트스트랩 CSS를 불러온다. 어트리뷰트 crossorigin을 crossOrigin으로 바꿔준다.
          <link
            href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css"
            rel="stylesheet"
            integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD"
            crossOrigin="anonymous"
          />
          <link rel="icon" href="/favicon.ico" />
        </Head>
        <body>
          <Main />
          <NextScript />
부트스트랩 스트립트를 불러온다.
          <script
            src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"
            integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN"
            crossOrigin="anonymous"
          ></script>
        </body>
      </Html>
    );
  }
}

스타일

GlobalStyle은 아래와 같이 배경색과 글자색, 폰트만 지정하도록 바꿔준다.

import { createGlobalStyle } from "styled-components";

const GlobalStyle = createGlobalStyle`
  html,
  body {
    font-family: "Noto Sans KR", sans-serif;
    background-color: #01192f;
    /* padding-top: 75px; */
    color: #ffff;
  }
`;

export default GlobalStyle;

랜딩페이지에 사용된 템플릿은 GrayScale이라는 StartBootstrap의 예제이다.

해당 예제의 CSS를 styles/GrayScale.tsx 라는 컴포넌트로 분리하고

import styled from "styled-components";

const GrayScale = styled.div`
  :root {
    --bs-blue: #0d6efd;
    --bs-indigo: #6610f2;
...
export const GrayScale

index.tsx에 덮어 씌운다.

import GrayScale from "../styles/GrayScale";
export default function Home() {
  return (
    <GrayScale>
      <div>
        <header className="masthead">
          <div className="container px-4 px-lg-5 d-flex h-100 align-items-center justify-content-center masthead-text">
            <div className="d-flex justify-content-center">
              <div className="text-center">
                <h1 className="mx-auto my-0 text-uppercase">TEAL AND ORAGNE</h1>
                <h2
                  className="text-white-50 mx-auto mt-3 mb-5 after-1-secs"
                  // style="font-family: 'Noto Sans KR', sans-serif; font-weight: 400"
                >
                  여기에 명령이 들어가고
                  <br />- <i> 여기에 영화 제목이 들어간다.</i>
                </h2>
              <button type="button" className="btn btn-primary">
                구글로 로그인
              </button>
              </div>
            </div>
          </div>
        </header>
      </div>
    </GrayScale>
  );
}

잘 작동된다.

CSS 파일을 살펴보니 부트스트랩 5의 CSS 코드까지 들어있는 것 같아 masthead 부분만 따로 추출했다.

import styled from "styled-components";
const GrayScaleMasthead = styled.div`
  .masthead {
    position: relative;
    width: 100%;
...

덕분에 버튼 디자인이 망가졌지만, 부트스트랩의 기본 버튼인 btn-outline-light로 적용하니 봐줄만해졌다.

              <button type="button" className="btn btn-outline-light">
                구글로 로그인 하기
              </button>

토스트 알림창(react-toastify)

로그인 실패 등에 쓸 알람창을 만들 것인데,
직접 구현하려다 프로젝트가 너무 지연되는 것 같아 react-toastify 를 사용하기로 하였다.

_app.tsx에 ToastContainer를 문서에 있는 내용과 같이 넣어주었다.

경로에 유의하며 CSS와 ToastContainer를 임포트
import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

function App({ Component, pageProps }: AppProps) {
  const { store, props } = wrapper.useWrappedStore(pageProps);
  return (
    <Provider store={store}>
      <Head>
        <title>Create Next App</title>
      </Head>
      <GlobalStyle />
여기에 넣었음
      <ToastContainer />
      <Component {...props} />
    </Provider>
  );
}

export default App;

배경화면 스타일

현재 배경화면이 제대로 적용되지 않은 문제를 가지고 있는데, react 등에서는 웹팩으로 css 스타일을 압축하기 때문에 동적 url을 넣으면 제대로 동작하지 않을 수 있다. 스택 오버플로를 참조하자.

간단한 해결 방법은 컴포넌트 내부에 styled-components를 선언하여 컴포넌트에서 던져주는 변수(문자열)을 받아다 쓰는 것이다.

한편 이미지 로딩이 완료되면 fadein으로 이미지가 등장하도록 하기 위해 image tage를 배경으로 쓰는 방법-스택 오버플로를 참조했다.
이미지 태그를 분리하면 링크는 어트리뷰트로 넘겨주기 때문에 컴포넌트 내부에서 styled-components를 선언하지 않아도 된다는 장점이 있어 해당 방법을 채택했다.

  1. 이미지 파일이 커지도록 스타일을 주고
const BgImg = styled.img`
  pointer-events: none;
  position: absolute;
  width: 100vw;
  visibility: hidden;
  animation: fadein 1.5s;
  animation-delay: 1s;
  animation-fill-mode: forwards;
  @media (min-width: 780px) {
    width: 130vw;
  }
  @media (min-width: 1200px) {
    width: 100vw;
  }
`;
  1. 1초 후에 fade-in하는 애니메이션 GrayScaleMasthead 컴포넌트에 클래스로 주고
  .after-1-secs {
    visibility: hidden;
    animation: fadein 1.5s;
    animation-delay: 1s;
    animation-fill-mode: forwards;
  }
  @keyframes fadein {
    from {
      opacity: 0;
      visibility: hidden;
    }
    to {
      opacity: 1;
      visibility: visible;
    }
  }
  1. 위로 스크롤 하는 효과를 위해 tranform효과를 주는 클래스도 만들고
  .fade-away {
    animation: fadeout 1s;
    animation-fill-mode: forwards;
  }
  @keyframes fadeout {
    from {
      visibility: visible;
      transform: translateZ(0);
    }
    to {
      visibility: hidden;
      transform: translate3d(0, -2000px, 0);
    }
  }
  1. useEffect로 state를 변경해주면
  useEffect(() => {
    const randIdx = Math.ceil(Math.random() * (movieTitleList.length - 1));
    setImgUrl(backdropPathList[randIdx]);
    setQuetes(movieTitleList[randIdx]);
    setMovieName(movieQuotes[randIdx]);
    setTimeout(() => setHeadClassName("fade-away"), 3000);
  }, []);

잘 작동한다.

이제 해당 useEffect를 이용하여 로그인 여부 체크 등을 진행 후 routing을 진행하면 된다.

조건부 라우팅

로그인이 되었으나 프로필이 없는 경우 /profile/create 페이지로,
로그인도 됐고 프로필도 있는 경우 /main 페이지로 이동시킨다.

    const randIdx = Math.ceil(Math.random() * (movieTitleList.length - 1));
    setImgUrl(backdropPathList[randIdx]);
    setMovieName(movieTitleList[randIdx]);
    setQuetes(movieQuotes[randIdx]);
    authService.onAuthStateChanged(async (user) => {
      const currentUser = authService.currentUser;
      if (!currentUser) {
        setButtonDisplay("");
        return;
      }
      const uid = currentUser.uid;
      setCookie("uid", uid, 1);
      setButtonDisplay("d-none");
      let url = "/main";
      const profile = await fetchProfile(uid);
      if (!profile) url = "/profile/create";
      setTimeout(() => {
        setHeadClassName("up-lift");
        setTimeout(() => router.push(url), 1100);
      }, 1000);
      dispatch(authSlice.actions.setUserOjbect(currentUser));
      if (profile) dispatch(authSlice.actions.setUserProfile(profile));
    });
  }, []);

프로필 정보 반영까지 다 끝난 이후 "up-lift"애니메이션을 주면서 라우팅을 해야하는데,
어떻게 시간설정을 할지 고민했다. 생각보다 처리가 빨리 끝나서 fade-in되기 전에 다 처리가 되버리는...
setTimeout은 1초, 라우팅까지의 타임아웃 1초를 줘서 2초 후에 이동되도록 했다.
글씨를 알아보기도 전에 이동하긴 하는데, 애니메이션간에 딜레이가 있으면 성능에 문제가 있어보여서, 애니메이션이 끝나는 족족 다음 애니매이션으로 넘어가는 타임아웃을 설정했다.
사실 제대로 랜딩페이지를 다루려면 스로틀이나 디바운스를 걸어서 요청의 상황에 따라 timeout시간을 유동적으로 가져가는 편이 좋다.
하지만 현재에는 요청의 수가 많지 않고 처리시간이 길지 않아 그렇게까지 로직을 구현할 필요는 없어 보인다.
dispatch 요청 두 개가 1초 내로 끝난다면 위 페이지는 정상적으로 작동할 것이다. (실제로는 두 디스패치 요청 모두에 await를 건 후 라우팅을 하는 것이 맞다.)

busy-waiting 조건부 라우팅 (feat.allSettled

수정 말이 나온김에 요청이 끝나지 않았으면 라우팅을 하는 대신 재귀적으로 자기 자신을 호출하는 방식으로 구현해보자.

      let flag = false;
      let routingTimeoutId;
      const routing = () => {
        if (flag) router.push(url);
        else {
          routingTimeoutId = setTimeout(routing, 1100);
        }
      };
      setTimeout(() => {
        setHeadClassName("up-lift");
        setTimeout(routing, 1100);
      }, 1000);
      try {
        await dispatch(authSlice.actions.setUserOjbect(currentUser));
        await dispatch(authSlice.actions.setUserProfile(profile));
        flag = true;
      } catch (error) {
        clearTimeout(routingTimeoutId);
        toast.error(error.message, {
          position: "bottom-left",
          autoClose: 2000,
          hideProgressBar: false,
          closeOnClick: true,
          pauseOnHover: true,
          draggable: true,
          progress: undefined,
          theme: "dark",
        });
      }

flag는 할 일이 끝났는지를 검사하는 플래그이다. 최초에는 false다.
routing()은 flag를 참조하여, 플래그가 true인 경우에 라우팅을 하고, 그렇지 않으면 1초 후 자기 자신을 재귀적으로 호출하는 함수이다.

setTimeout()으로 routing을 1초 후에 호출해준다.
그 사이에 await를 통해 dispatch 요청들을 처리한 후,
처리가 끝나면 flag를 true로 바꿔준다.

1초 뒤에 호출될 routing이 flag를 조회했을 때에 true이므로 자연스럽게 페이지를 이동할 것이며,
그렇지 않으면 1초 후에 다시 한번 자기 자신을 호출할 것이다.

중간에 요청이 실패하여 routing이 무한히 호출될 수 있다. 이 경우를 대비하여 routingTimeoutId를 clear해주고 에러 메시지를 띄운다.

이제 다른 페이지를 렌더링하기 전에 필요한 다양한 state들과 요청을 해당 try문 안에서 시도하면 된다.

또 수정 await는 순차적으로 실행되는데, 동시에 실행시키는 법이 없을까 고민하다가 Promise.allSettled()를 발견하였다.
사실 Promise.all에 관한 예시를 접한적이 있었으나 와닿지 않았는데, 실제 사용 예제 블로그들을 보니 단순하게 배열에다 함수들을 넣어주면 끝이었다.

Promise.all()은 배열의 작업들 중 하나라도 reject하면 그 자신도 reject하고 종료한다.
반면 Promise.allSettled()는 모든 작업이 프라미스를 반환하면, 그 결과를 배열로 반환하고 종료한다.
Promise.allSettled() 그 자체가 err을 catch해주는 역할도 하기에 유용하다고 생각하여 적용하기로 한다.
(추가로 찾아보니, try-catch문 안에 쓰는 경우 err.message가 배열 형태로 반환된다고 한다.)

Promise.allSettled()를 적용하면서 로직도 일부 수정했다.

  1. 위로 올라가는 애니메이션들을 삭제하고 fade-in, fade-out으로 교체했다. 애니메이션은 500ms이다.
  2. 애니메이션이 실행되고 라우터가 작동할 때까지의 시간이 500ms로 짧아졌기에, "애니메이션 추가 + 라우터 예약"을 하나의 로직으로 합쳤다.
  3. flag의 용도를 바꾸어 자신을 재귀적으로 호출하는 "애니메이션 추가 + 라우터 예약" 로직이 자신을 재귀적으로 호출한 횟수를 의미하도록 하였다. flag가 소정의 숫자를 넘어가면 서버가 응답이 없는 것으로 판단하고 에러 알람을 띄우고 종료한다. 기존 로직에는 애니메이션이 먼저 실행되고 busy-waiting을 하여 사용자가 빈 화면에서 기다리는 문제가 있었는데 그런 문제가 없어졌다.
  4. Promise.allSettled()가 완료되면 flag를 falsy한 값으로 바꾼다. flag가 falsy한 값이 되는 유일한 경우로, 이 때에 "애니메이션 추가 + 라우터 예약" 로직이 작동하게 된다.
      let flag = 1;
      let routingTimeoutId;
      const routing = () => {
        if (!flag) {
          setHeadClassName("up-lift");
          setTimeout(() => router.push(url), 510);
        } else if (flag > 10) {
          clearTimeout(routingTimeoutId);
          toastError("알 수 없는 에러가 발생하였습니다.");
        } else {
          flag += 1;
          routingTimeoutId = setTimeout(routing, 200);
        }
      };
      setTimeout(() => {
        routing();
      }, 2000);

      Promise.allSettled([
        dispatch(authSlice.actions.setUserOjbect(currentUser)),
        dispatch(authSlice.actions.setUserProfile(profile)),
      ]).then(() => (flag = -1));

이제 진짜진짜로 처리하고자 하는 비동기 함수들을 allSettled 배열 안에 추가해주면 된다. 랜딩페이지에서의 비동기를 처리할 수 있는 최소 대기시간은 2000ms, 최대 대기시간은 3800ms이며, allSettled가 비동기 함수들을 비동기적으로 처리하기 때문에 3800ms안에 처리될 수 있는 모든 요청들은 들어올 수 있다.

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

0개의 댓글