App Router에서 framer-motion 라이브러리의 exit animation이 제대로 동작하지 않는 문제

서동현·2025년 3월 9일
1

배경

포트폴리오 사이트를 개발하는 과정에서 다양한 애니메이션들을 구현하기 위해 framer-motion 라이브러리를 사용했다.

framer-motion 라이브러리란?

framer-motion 라이브러리는 React 기반의 애니메이션 라이브러리로, JSX에서 바로 애니메이션을 적용할 수 있어 React 컴포넌트와의 조합이 자연스럽고 애니메이션과 state와 쉽게 연동할 수 있어 동적인 UI를 만들기 편하다는 장점이 있다.

framer-motion의 AnimatePresence 컴포넌트를 사용하면 React에서 컴포넌트가 언마운트될 때 exit 애니메이션을 구현할 수 있게 도와준다. 그래서 이를 활용하여 포트폴리오 사이트에서 페이지 전환 애니메이션을 구현하고자 했다.


원인 분석

문제가 발생한 상황

포트폴리오 사이트는 Next.js 14.2.3 버전과 framer-motion 11.2.6 버전을 사용하고 있다.

// PageTransitionProvider.tsx

'use client';

import { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';

import { motion, AnimatePresence, Variants } from 'framer-motion';

import { text, translate } from './anim';

const anim = (variants: Variants) => {
  return {
    variants,
    initial: "initial",
    animate: "enter",
    exit: "exit"
  }
}

const curve = (initialPath: string, targetPath: string) => {
  return {
    initial: {
      d: initialPath
    },
    enter: {
      d: targetPath,
      transition: {duration: .75, delay: .35, ease: [0.76, 0, 0.24, 1]}
    },
    exit: {
      d: initialPath,
      transition: {duration: .75, ease: [0.76, 0, 0.24, 1]}
    }
  }
}

const SVG = ({
  height,
  width
}: {
  height: number,
  width: number,
}) => {
  const initialPath = `
    M0 300 
    Q${width / 2} 0 ${width} 300
    L${width} ${height + 300}
    Q${width / 2} ${height + 600} 0 ${height + 300}
    L0 0
  `;

  const targetPath = `
    M0 300
    Q${width / 2} 0 ${width} 300
    L${width} ${height}
    Q${width / 2} ${height} 0 ${height}
    L0 0
  `;

  return (
    <motion.svg {...anim(translate)} className='fixed left-0 top-0 pointer-events-none z-[300000]' style={{ width: '100vw', height: 'calc(100vh + 600px)' }}>
      <motion.path {...anim(curve(initialPath, targetPath))} fill='#000' />
    </motion.svg>
  )
}

export function PageTransitionProvider ({
  children
}: {
  children: React.ReactNode
}) {
  const pathname = usePathname();
  
  // 윈도우 사이즈를 저장하기 위한 state
  const [dimensions, setDimensions] = useState<{
    width: number,
    height: number,
  }>({
    width: -1,
    height: -1
  });

  // 윈도우 사이즈 변화에 유연하게 대응
  useEffect( () => {
    function resize(){
      setDimensions({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }

    resize();

    window.addEventListener("resize", resize);

    return () => {
        window.removeEventListener("resize", resize);
    }
  }, []);

  return (
    <AnimatePresence mode="wait" initial={true}>
      <motion.div key={pathname}>
        <motion.p className='fixed top-[40%] left-1/2 -translate-x-1/2 text-white text-4xl z-[300001] text-center' {...anim(text)}>
          {"sdh20282's portfolio"}
        </motion.p>
        {(dimensions.width !== -1 && dimensions.height !== -1) && <SVG key={pathname} {...dimensions}/>}
        {children}
      </motion.div>
    </AnimatePresence>
  );
};

PageTransitionProvider에서 AnimatePresence의 자식 컴포넌트인 motion.div의 key로 pathname을 주어 페이지가 변경될 때마다 exit 애니메이션을 구현하려 했다. 또한 PageTransitionProvider를 root layout에서 Provider의 형태로 사용하여 전체 페이지 변경에 대한 애니메이션을 적용하려 했다.

위 코드는 문제 없이 동작할 것처럼 보이지만...

이미지와 같이 루트 레이아웃 단에서 적용한 exit 애니메이션이 이전 페이지가 아닌 새로운 페이지에서 작동하는 문제가 발생했다.

문제의 원인

원인을 파악하기 위해 두가지 정도를 짚고 넘어갈 필요가 있었다.

AnimatePresence 컴포넌트의 동작 방식

AnimatePresence 컴포넌트는 직계 자식이 언마운트될 때 이를 잠시 보류하고, exit 애니메이션이 끝날 때까지 자식 컴포넌트들을 DOM에 유지한 후, exit 애니메이션이 끝난 뒤 자식 컴포넌트를 갱신하기 위해 재렌더링을 트리거한다. 여기서 AnimatePresence 컴포넌트는 직계 자식이 고유한 key 속성을 가지고 있어야 변경을 감지하고 처리할 수 있다.

실제로 framer-motion 깃허브에서 AnimatePresence 컴포넌트의 구현 내용을 살펴보면 다음과 같다.

// AnimatePresence/index.tsx

export const AnimatePresence = ({
  	// ...
	
 	// 현재 렌더링된 children을 저장
    const presentChildren = useMemo(() => onlyElements(children), [children])

	// 현재 남아 있는 컴포넌트들의 key를 저장
    const presentKeys =
        propagate && !isParentPresent ? [] : presentChildren.map(getChildKey)

    // ...
    
    // 자식 컴포넌트의 변경을 감지했을 때
    if (presentChildren !== diffedChildren) {
        // ...
      
      	// 모드에 따라 자식 컴포넌트의 언마운트를 보류여부를 결정
      	// 포트폴리오에서는 애니메이션을 2단계로 구분했기 때문에 mode="wait" 사용
        if (mode === "wait" && exitingChildren.length) {
            nextChildren = exitingChildren
        }

        // ...
    }

	// ...

    return (
        <>
            {renderedChildren.map((child) => {
          		// ...
          
                const onExit = () => {
                  	// ...

          			// 모든 자식의 exit 애니메이션이 끝났는지 검사
                    let isEveryExitComplete = true
                    exitComplete.forEach((isExitComplete) => {
                        if (!isExitComplete) isEveryExitComplete = false
                    })

                    if (isEveryExitComplete) {
                      	// 강제 재렌더링
                        forceRender?.()
                        
                        // ...
                    }
                }
					
                // ...
            })}
        </>
    )
}

다음으로는 app router에서 페이지 라우팅 방식을 확인할 필요가 있었다.

app router에서 페이지 라우팅 방식

app router에서는 OuterLayoutRouterInnerLayoutRouter 컴포넌트를 통해 페이지 라우팅을 관리한다. OuterLayoutRouter 컴포넌트에서는 LayoutRouterContext 컨텍스트에서 현재 페이지 라우팅과 관련된 정보를 가져오고, tree, cacheNode, segmentPath 등을 통해 렌더링할 UI를 결정하거나 캐시된 데이터를 관리하는 등 페이지 변경을 위한 준비를 한다. 그리고 InnerLayoutRouter 컴포넌트를 통해 실제로 새로운 UI를 렌더링한다.

여기서 중요한 점은, 페이지 변경이 발생할 때 OuterLayoutRouter 컴포넌트가 하위의 모든 레이아웃과 템플릿을 포함한 내용들을 새로운 경로에 맞게 '완전히' 교체해 버린다는 점이다. 여기서 OuterLayoutRouter 컴포넌트 자체에는 키가 존재하지 않기 때문에 AnimatePresence 컴포넌트로 직접 추적할 수 없고, key 값으로 전달한 pathname 역시 렌더링이 끝난 이후에 업데이트되기 때문에 앞서 보았던 문제가 발생한 상황과 같이 새로운 페이지로 내용이 전환된 이후 exit 애니메이션이 실행된다.


해결 과정

첫번째 시도

현재 문제가 되는 OuterLayoutRouter 컴포넌트는 LayoutRouterContext 컨텍스트에서 현재 페이지 라우팅과 관련된 정보를 가져온다고 했다.

// layout-router.tsx

// ...

export default function OuterLayoutRouter({
  // ...
  
  const context = useContext(LayoutRouterContext)
  if (!context) {
    throw new Error('invariant expected layout router to be mounted')
  }

  const { parentTree, parentCacheNode, parentSegmentPath, url } = context

  // ...

그렇다면 LayoutRouterContext를 직접 조작할 수 있다면 이를 해결할 수 있을 것처럼 보인다. 다행히도, 비공식적인 방법이지만 next/dist/shared/lib/app-router-context.shared-runtime 내부 모듈을 통해 LayoutRouterContext에 접근할 수 있다.

// frozen-router.tsx

import { useContext, useRef } from "react";
import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";

export function FrozenRouter({ children }: { children: React.ReactNode }) {
  const context = useContext(LayoutRouterContext)
  const frozen = useRef(context).current

  return <LayoutRouterContext.Provider value={frozen}>{children}</LayoutRouterContext.Provider>
}

위와 같이 LayoutRouterContext를 통해 받아온 현재 페이지의 상태를 useRef에 저장하고, 이전 페이지의 정보를 OuterLayoutRouter에 전달했다. 이를 통해 페이지 전환이 발생할 경우, exit 애니메이션이 실행되는 동안 이전 페이지를 렌더링하고 exit 애니메이션이 끝난 이후 AnimatePresence에서 forceRender를 호출하여 강제로 재렌더링을 시켜 다시 FrozenRouter가 호출되면 useRef에 저장된 새로운 페이지에 대한 정보를 OuterLayoutRouter에 전달하도록 처리했다.

다만 해당 방법은 OuterLayoutRouter 컴포넌트를 직접 건드리다보니 app 디렉토리의 loading.js 파일에서 사용되는 Suspense가 제대로 동작하지 않을 수 있다고 한다. 또한 페이지를 더 많이 렌더링하게 만들어 app-router에 부하를 줄 수 있다고 한다.

두번째 시도

앞서 만든 frozen-router를 포트폴리오 사이트에 적용하니 페이지 전환을 동결시키는 데에는 성공할 수 있었으나 컨텐츠가 제대로 렌더링되지 않는 문제가 발생했다.

루트 레이아웃에 적용한, 모든 페이지에서 공통으로 사용되는 정적인 컴포넌트인 Header와 Footer는 정상적으로 렌더링 되었으나, 동적으로 렌더링되는 페이지 컨텐츠는 렌더링되지 않았다.

// app/layout.tsx

import type { Metadata } from "next";

import { Providers } from "@/providers";
import { montserrat, rubik, nanumSquare, alliance } from "@/utils";
import { CommonNavbar, CommonMenu, CommonContact } from "@/layouts";

import "./globals.css";
import { PageTransitionProvider } from "./_providers/components";

export const metadata: Metadata = {
  title: {
    template: "%s - sdh20282",
    default: "sdh20282's Portfolio",
  },
  description: "sdh20282's portfolio",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={`${montserrat.variable} ${rubik.variable} ${nanumSquare.variable} ${alliance.variable} relative font-rubik overflow-x-hidden bg-[#232727]`}>
        <Providers>
          <CommonNavbar />
          <CommonMenu />
          {children}
          <CommonContact />
        </Providers>
      </body>
    </html>
  );
}

확인 결과, 현재 AnimatePresence mode="wait"을 사용 중인데 AnimatePresence mode="popLayout"을 적용할 경우 정상적으로 렌더링되는 것을 확인할 수 있었다.

따라서 FrozenRouterAnimatePresence 간의 렌더링 타이밍이 일치하지 않아서 해당 문제가 발생하는 것으로 추측했고, FrozenRouter에서 pathname을 기준으로 렌더링 타이밍을 일치시키기 위한 부분을 추가했다. 또한 보다 안정적으로 이전 내용을 유지하기 위해 usePreviousValue 커스텀 훅을 사용했다.

// use-previous-value.ts

export function usePreviousValue<T>(value: T): T | undefined {
  const prevValue = useRef<T>();
 
  useEffect(() => {
    prevValue.current = value;
    return () => {
      prevValue.current = undefined;
    };
  });
 
  return prevValue.current;
}


// frozen-router.tsx

export function FrozenRouter({ children }: { children: React.ReactNode }) {
  const context = useContext(LayoutRouterContext);
  const prevContext = usePreviousValue(context) || null;
 
  const pathname = usePathname();
  const prevPathname = usePreviousValue(pathname);
 
  const changed = pathname !== prevPathname && pathname !== undefined && prevPathname !== undefined;
  
  return (
    <LayoutRouterContext.Provider value={changed ? prevContext : context}>
      {children}
    </LayoutRouterContext.Provider>
  );
}

그 결과, 다음과 같이 exit 애니메이션이 의도한대로 동작하는 것을 확인할 수 있었다.

세번째 시도

개발 환경에서는 exit 애니메이션이 정상적으로 동작했으나 빌드 후 프로덕션 환경에서 포트폴리오 사이트를 실행했을 때 제대로 동작하지 않는 문제가 발생했다.

페이지 이동 시 개발 환경에서는 FrozenRouter가 한번만 호출되어 정상적으로 이전 페이지의 내용을 출력할 수 있었으나 프로덕션 환경에서는 FrozenRouter가 두 번 호출되어 FrozenRouter가 다음 페이지의 내용을 OuterLayoutRouter에 전달하는 것이 문제였다.

FrozenRouter에서 OuterLayoutRouter에 정보를 갱신하는 타이밍을 명확하게 하기 위해 별도의 상태를 통해 이를 관리하고자 했다.

// frozen-router.tsx

export function FrozenRouter({ children }: { children: React.ReactNode }) {
  const context = useContext(LayoutRouterContext);
  const prevContext = usePreviousValue(context) || null;
 
  const pathname = usePathname();
  const prevPathname = usePreviousValue(pathname);

  const [targetContext, setTargetContext] = useState<typeof context>(null);
  const [isAnimating, setIsAnimating] = useState(false);

  const changed = pathname !== prevPathname && pathname !== undefined && prevPathname !== undefined;
  
  useEffect(() => {
    if (pathname !== prevPathname) {
      setIsAnimating(true);
      setTargetContext(prevContext);
    }
  }, [pathname, prevPathname]);

  useEffect(() => {
    if (!isAnimating) return;

    const timer = setTimeout(() => {
      setIsAnimating(false);
    }, 750);

    return () => clearTimeout(timer);
  }, [isAnimating]);

  return (
    <LayoutRouterContext.Provider value={changed || isAnimating ? targetContext ?? prevContext : context}>
      {children}
    </LayoutRouterContext.Provider>
  );
}

프로덕션 환경에서도 exit 애니메이션이 의도한대로 동작하는 것을 확인할 수 있었다.


비고

현재 Next.js 15.2 버전에서 View Transitions에 관한 API가 실험적으로 지원된다고 한다. 아직은 이르지만 안정화된다면 이런 번거로운 방법 없이 exit 애니메이션이 동작할 수 있으리라 기대된다.


출처

framer-motion, AnimatePresence/index.tsx, github
App router issue with Framer Motion shared layout animations, Next.js issue
Next13.4 appDir does not render layout and template in the way the docs say it should, Next.js issue
Next.js, layout-router.tsx, github
Exit animation on NextJS 14 Framer Motion, stackoverflow
Next.js 15.2 React View Transitions (experimental), Next.js blog

profile
Frontend-Developer

0개의 댓글

관련 채용 정보