Framer Motion 페이지 전환 구현 중 발생한 이슈와 해결

2024-09-03 · 3 min read

개요

최근 블로그에 Framer Motion을 사용해 페이지 전환 효과를 추가하는 작업을 진행했습니다. 처음에는 간단하게 Layout 컴포넌트에 AnimatePresence를 추가하여 페이지 전환 시 애니메이션 효과를 적용하려고 했습니다.

그러나 예상치 못한 문제들이 발생했고, 이로 인해 다양한 시도를 하며 문제를 해결하는 과정이 필요했습니다. 여기서 발생한 이슈와 이를 해결하기 위해 적용한 방법을 공유하려 합니다.

기존 구현 방식

기존에는 각 페이지 컴포넌트에서 Layout 컴포넌트를 사용하여 공통된 레이아웃을 적용했습니다. 페이지에서 레이아웃을 분리함으로써 페이지 간 일관성을 유지하고, 각 페이지에 쉽게 동일한 구조를 적용할 수 있었습니다.

// .../pages/blog.tsx

const BlogPage = (pageProps: PageProps<DataType>) => {
  return (
    <Layout pageProps={pageProps}>
      <BlogScreen posts={pageProps.data.allMarkdownRemark.nodes} />
    </Layout>
  );
};

export default BlogPage;

Layout 컴포넌트에 Framer Motion의 AnimatePresence 컴포넌트를 추가하여 페이지 전환 효과를 구현하려 했습니다.

// .../layout/index.tsx

export const Layout = ({ children, pageProps }: LayoutProps) => {
  return (
    <>
      <Header />
      <Main>
        <AnimatePresence mode="wait" presenceAffectsLayout>
          <motion.div
            key={pageProps.location.pathname}
            initial={'initial'}
            animate={'enter'}
            exit={'exit'}
            variants={variants}
            transition={{ duration: pageTransitionDuration }}
          >
            {children}
          </motion.div>
        </AnimatePresence>
      </Main>
      <Footer />
    </>
  );
};

첫번째 문제: 레이아웃 언마운트 이슈

첫 번째 문제는 motion.div의 exit 애니메이션이 작동하지 않는 것이었습니다. 확인해보니 페이지 이동 시 Layout 컴포넌트가 언마운트되고 다시 마운트되는 과정에서 AnimatePresence도 함께 언마운트되고 있었던 것이 원인이었습니다.

이를 방지하기 위해서는 페이지 이동 시 Layout이 언마운트되지 않고 계속 유지되어야 했고, Gatsby Browser API 중 하나인 wrapPageElement를 사용해서 해결할 수 있었습니다.

wrapPageElement

This is useful for setting wrapper components around pages that won’t get unmounted on page changes. For setting context providers, use wrapRootElement.

// gatsby-browser.tsx

export const wrapPageElement = ({
  element,
  props,
}: WrapPageElementBrowserArgs) => {
  return <Layout pageProps={props}>{element}</Layout>;
};

이를 통해 motion.divexit 애니메이션이 동작하지 않는 문제가 해결되었고, 또 매 페이지마다 Layout 컴포넌트를 삽입해야 하는 번거로움이 사라졌습니다.

두번째 문제: Hydration failed 이슈

개발 환경에서는 문제가 없었지만, production 환경에서 Hydration에 실패한다는 경고가 발생했습니다.

Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:

- A server/client branch `if (typeof window !== 'undefined')`.
- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.
- Date formatting in a user's locale which doesn't match the server.
- External changing data without sending a snapshot of it along with the HTML.
- Invalid HTML tag nesting.

Gatsby에서는 초기 빌드 시 SSR을 통해 정적인 HTML을 생성합니다. 클라이언트에서는 이 정적 HTML을 React의 관리 하에 두에 재사용하는데, 이 과정을 Hydration이라고 합니다. 위 경고는 SSR한 HTML이 클라이언트에서 기대한 DOM 구조와 일치하지 않아 Hydration에 실패하고, 불필요한 렌더링이 발생한다는 내용이었습니다.

경고에는 구체적인 사례들까지 제시되어 있었지만, 제게 직접적으로 해당하는 내용은 없어서 다른 원인을 좀 더 살펴봐야 했습니다.

원인 1) gatsby-ssr 설정 누락

첫 번째 원인은 Gatsby SSR API의 wrapPageElement이 누락된 것이었습니다.

SSR 시에는 gatsby-browser의 wrapPageElement가 적용되지 않기 때문에 레이아웃이 빠진 채 HTML이 생성되는 문제가 있었고, 이를 해결하기 위해 gatsby-ssr에도 동일한 설정을 추가했습니다.

// gatsby-ssr.tsx

export const wrapPageElement = ({
  element,
  props,
}: WrapPageElementBrowserArgs) => {
  return <Layout pageProps={props}>{element}</Layout>;
};

원인 2) 인라인 <style> 태그

추가로 확인해보니, 컴포넌트에서 <style> 태그로 CSS를 직접 삽입하면서 문제가 발생했습니다. CSS를 <style>children으로 삽입한 것이, SSR 과정에서 HTML entity로 인코딩되면서 태그 내용의 불일치가 발생한 것입니다.

  • SSR된 HTML:
#post-list a&gt;div {...}
  • 클라이언트:
#post-list a>div {...}

이를 해결하기 위해 <style> 태그에 dangerouslySetInnerHTML을 사용하여 CSS를 삽입하는 방식으로 수정했습니다. 이 방법을 통해 클라이언트와 서버의 DOM이 일치하게 되었고, 최종적으로 Hydration 오류가 완전히 해결되었습니다.

결론

이번 작업에서는 Framer Motion을 이용한 페이지 전환 애니메이션 구현 중 발생한 문제들을 해결했습니다. Layout의 언마운트로 애니메이션이 중단되는 문제는 wrapPageElement API를 사용해 해결했고, production 환경에서 발생한 Hydration 오류는 SSR과 클라이언트 간의 DOM 불일치를 수정해 해결했습니다.

이 과정을 통해 Gatsby와 Framer Motion의 동작 방식에 대한 이해를 넓혔으며, 앞으로도 비슷한 문제에 대비할 수 있게 되었습니다. 이 글이 유사한 상황을 겪는 분들에게 도움이 되길 바랍니다.

Copyright 2022-2024.hanse-kimAll right reserved.