본문 바로가기
✨FRONTEND/📍Next.js

[Next.js + GSAP] Failed to execute 'removeChild' on 'Node' 에러 원인과 해결

by 짱돌보리 2026. 1. 30.
728x90

🚨 Next.js + GSAP 사용 시 Failed to execute 'removeChild' on 'Node' 에러 원인과 해결

gsap의 섹션 고정 후 가로 스크롤 구현을 한 후,  다른 페이지(라우터 이동 시(router.push 혹은 next/link 태그) )로 이동할 때 에러가 발생했다.

 

처음엔 React 쪽 문제인가 했는데,

결론부터 말하면 GSAP 애니메이션과 Next.js 라우팅 타이밍 충돌 문제였다.

🔥 에러 원인

GSAP 애니메이션이 끝나기도 전에 페이지 전환(라우팅)이 일어나면서 발생하는 전형적인 충돌이다.

 

React는 페이지 이동 시 기존 DOM을 정리하려고 하고,GSAP은 동시에 DOM을 직접 조작하고 있다.

 

이 상태에서 DOM 구조가 꼬이면 React가

 

“이 노드… 내가 알던 자식이 아닌데?”
하면서 removeChild 에러를 던짐

 

특히 ScrollTrigger + pin: true 를 쓰면 이 문제가 더 잘 터진다.

 

무슨 소리나면...

 

나는 pin을 사용해 해당 섹션을 고정 중이었다.

const triggerRef = useRef<HTMLDivElement>(null)

useEffect(() => {
    if (!isClient || !triggerRef.current || !horizontalRef.current || isMobile)
      return

    const totalWidth = horizontalRef.current.scrollWidth
    const viewportWidth = window.innerWidth
    const scrollDistance = totalWidth - viewportWidth

    const ctx = gsap.context(() => {
      // 메인 타임라인: 섹션 고정 + 가로 스크롤 + 라인 애니메이션 동기화
      const tl = gsap.timeline({
        scrollTrigger: {
          trigger: triggerRef.current,
          pin: true,
          scrub: 1,
          start: 'top top',
          end: () => `+=${totalWidth}`,
          invalidateOnRefresh: true,
        },
      })

      tl.to(
        horizontalRef.current,
        {
          x: -scrollDistance,
          ease: 'none',
        },
        0,
      )

      tl.fromTo(
        lineRef.current,
        { scaleX: 0, transformOrigin: 'left center' },
        { scaleX: 1, ease: 'none' },
        0,
      )
    }, triggerRef)

    return () => ctx.revert()
  }, [isClient, isMobile])
<section
	ref={triggerRef}
    className="relative h-screen w-full overflow-hidden bg-slate-950"
>

 

 


 

🧨 진짜 문제의 핵심: ScrollTrigger.pin

1️⃣ GSAP의 Pin 동작 (DOM 강제 변경)

pin: true를 설정하면 GSAP은 DOM 구조를 직접 바꾼다.

 

원래 구조)

<section ref={sectionRef}>
  ...
</section>

 

GSAP 실행 후)

<div class="pin-spacer">
  <section ref={sectionRef}>
    ...
  </section>
</div>

 

 

아래 사진을 보면 GSAP이 pin-spacer라는 div를 임의로 만들고,
기존 section을 그 안으로 집어넣어 버린다.

2️⃣ React의 당혹감 🤯

React는 자신이 렌더링한 DOM 구조를 Virtual DOM으로 기억하고 있다.

 

React 입장에서는 구조가 이랬다 👇

부모
 └ section

 

그런데 실제 브라우저 DOM은 GSAP 때문에 이렇게 바뀐다 👇

부모
 └ pin-spacer
     └ section

 

이 상태에서 라우팅이 발생하면 React는

 

“자 이제 section을 지워야지”

하고 기존 부모에게 removeChild(section)을 시도함.

 

하지만 실제 부모는 이미 pin-spacer 로 바뀌어 있음

 

👉 "이건 내가 알던 자식이 아닌데?"
👉 removeChild 에러 발생하는 것!!!

✅ 해결 방법 1: gsap.context() + revert() (기본 중의 기본)

이미 ctx.revert()를 쓰고 있었지만,
페이지 이동 시 ScrollTrigger를 확실히 정리해주는 게 중요하다.

useEffect(() => {
  const ctx = gsap.context(() => {
    // GSAP 애니메이션 로직
  }, sectionRef);

  return () => {
    // 모든 ScrollTrigger 강제 제거
    ScrollTrigger.getAll().forEach(t => t.kill());
    ctx.revert();
  };
}, []);

 

👉 이건 필수 세팅
하지만 이거만으로는 pin 관련 문제를 완전히 못 잡는 경우도 있다.

✅ 해결 방법 2: pin 대상 바깥에 div 하나 더 감싸기 (✔ 실제 해결)

구조를 이렇게 바꿨다 👇

<div> {/* 고정되지 않는 상위 div */}
  <section ref={sectionRef}>
    ...
  </section>
</div>

 

왜 이게 해결이 됐을까?

 

GSAP은 이제 section 안에서만 DOM을 바꾼다.

div (React 기준 최상위)
 └ pin-spacer
     └ section

 

React 입장에서는

 

1. 최상위 div 구조는 절대 안 바뀜

2. 페이지 이동 시 그냥 div 통째로 제거하면 끝

3. GSAP이 안에서 무슨 짓(pin-spacer 생성)을 하든 React는 부모-자식 관계가 깨지지 않아서 에러가 안 나는 것 👍

 

결론: pin 쓸 땐 section을 최상위로 두지 말고, wrapper div를 두자!!!!!!