SPA에서 애니메이션은 컴포넌트와 생사를 같이해야 한다. 컴포넌트가 죽었는데 애니메이션이 살아 있으면, 그건 메모리 누수다.
증상 — 페이지를 오갈수록 느려진다
어웨어랩 마케팅 사이트에 GSAP 애니메이션을 넣었다. 히어로 섹션의 텍스트 페이드인, 스크롤 기반 패럴랙스, 카드 등장 애니메이션. 로컬에서 개발할 때는 문제가 없었는데, QA 중 이상한 리포트가 들어왔다 — "페이지를 여러 번 오가면 점점 느려진다."
Chrome DevTools의 Performance 탭을 열어보니, 라우팅할 때마다 GSAP의 글로벌 타임라인에 등록된 트윈 수가 계속 증가하고 있었다. 컴포넌트가 언마운트되어도 GSAP 트윈은 살아 있었다.
왜 SPA에서 위험한가
GSAP는 내부적으로 글로벌 레지스트리에 모든 트윈과 타임라인을 등록한다. MPA(전통적인 페이지 전환)에서는 페이지 이동 시 브라우저가 모든 것을 정리하므로 문제없다. 하지만 SPA에서는 브라우저가 페이지를 유지한 채 컴포넌트만 교체하기 때문에, 글로벌 레지스트리에 등록된 트윈이 누적된다.
10번 라우팅하면 같은 애니메이션이 10번 등록된다. 각각이 DOM 요소를 참조하고 있으므로 GC도 해당 요소를 정리하지 못한다. 메모리 사용량이 선형으로 증가하고, 결국 프레임 드롭으로 이어진다.
gsap.context()로 스코핑하기
GSAP 3.11부터 도입된 gsap.context()가 이 문제의 공식 해결책이다. context에 등록된 모든 트윈과 ScrollTrigger를 한 번에 정리할 수 있다.
import { useEffect, useRef } from 'react';
import gsap from 'gsap';
export function useGSAPContext(scope: React.RefObject<HTMLElement>) {
const ctx = useRef<gsap.Context | null>(null);
useEffect(() => {
ctx.current = gsap.context(() => {
// 이 안에서 생성되는 모든 트윈은 context에 자동 등록
}, scope.current!);
return () => {
// 언마운트 시 context 내 모든 트윈/ScrollTrigger 정리
ctx.current?.revert();
};
}, []);
return ctx;
}gsap.context()에 콜백과 스코프 엘리먼트를 넘기면, 콜백 안에서 생성되는 모든 트윈이 해당 context에 자동으로 등록된다. revert()를 호출하면 모든 트윈이 중지되고, DOM 변경이 원래대로 복원되고, ScrollTrigger가 제거된다.
GSAP 동적 import
GSAP는 번들 크기도 무시할 수 없다. 모든 페이지에서 로딩하지 않도록 동적 import로 전환했다.
import { useEffect, useState } from 'react';
export function useLazyGSAP() {
const [gsapModule, setGsapModule] = useState<typeof import('gsap') | null>(null);
useEffect(() => {
let cancelled = false;
import('gsap').then((mod) => {
if (!cancelled) setGsapModule(mod);
});
return () => { cancelled = true; };
}, []);
return gsapModule;
}이 훅은 GSAP가 필요한 컴포넌트에서만 임포트하게 한다. 메인 번들에서 GSAP를 분리하면서 초기 로딩 성능도 개선됐다.
prefers-reduced-motion 존중하기
모션 감소를 선호하는 사용자를 위해 미디어 쿼리를 체크했다.
export function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return reduced;
}prefers-reduced-motion: reduce가 설정된 경우 GSAP 애니메이션을 건너뛰거나 duration: 0으로 실행한다. 접근성과 성능을 동시에 챙기는 패턴이다.
전체 패턴: HeroSection 예시
function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
useEffect(() => {
if (reduced || !containerRef.current) return;
const ctx = gsap.context(() => {
gsap.from('.hero-title', {
y: 60,
opacity: 0,
duration: 1,
ease: 'power3.out',
});
ScrollTrigger.create({
trigger: '.hero-parallax',
start: 'top bottom',
end: 'bottom top',
scrub: true,
animation: gsap.to('.hero-parallax', { y: -100 }),
});
}, containerRef.current);
return () => ctx.revert();
}, [reduced]);
return <div ref={containerRef}>{/* ... */}</div>;
}메모리 안정화 확인
- 라우팅 10회 반복 후 GSAP 글로벌 타임라인 트윈 수: 증가하지 않음
- Chrome Memory 탭에서 힙 스냅샷 비교: 컴포넌트 언마운트 후 GSAP 관련 객체 GC 정상 수행
- Performance 탭 프레임 드롭: 발생하지 않음
- prefers-reduced-motion 설정 시: 애니메이션 건너뜀, 번들만 로딩
핵심 정리
SPA에서 GSAP를 쓸 때 gsap.context()와 revert()는 선택이 아니라 필수다. 이 두 가지를 빠뜨리면 메모리 누수는 시간 문제일 뿐이다.
- SPA에서 GSAP 트윈은 컴포넌트 언마운트 시 자동으로 정리되지 않는다
- gsap.context()로 스코핑하고, 언마운트 시 revert()를 호출하라
- 동적 import로 GSAP를 필요한 페이지에서만 로딩하라
- prefers-reduced-motion을 존중하라 — 접근성과 성능을 동시에 챙긴다
- 라우팅 반복 후 Memory 탭으로 누수 여부를 검증하라