
포트폴리오 프로젝트에서 GSAP의 ScrollTrigger를 사용하여 수평 스크롤 애니메이션을 구현했다. 이때 PC에서는 스크롤에 따라 프로젝트 카드들이 수평으로 이동하는 애니메이션이 동작하고, 모바일과 태블릿에서는 일반적인 세로 스크롤 레이아웃을 유지하도록 구현을 시도했지만, 생각한대로 실행되지 않았다.
레이아웃이 화면 크기에 따라 다르기 때문에, GSAP 애니메이션의 적용 여부도 화면 크기에 따라 달라져야 했다.
위 환경에서 화면 너비가 resize 될때마다 GSAP 애니메이션을 동적으로 활성화 또는 비활성화를 해야하는데, 기존 코드가 정상동작하지 않아 새로고침 없이는 반응형으로 구현되지 않는 문제가 발생했다.
'use client';
...
const SideProjects = () => {
const sectionRef = useRef<HTMLElement | null>(null);
const wrapperRef = useRef<HTMLUListElement | null>(null);
const { isMobile, isLoaded } = useIsMobile();
useGSAP(() => {
if (!isLoaded || isMobile) return;
if (!sectionRef.current || !wrapperRef.current) return;
gsap.registerPlugin(ScrollTrigger);
const section = sectionRef.current;
const wrapper = wrapperRef.current;
const sections = gsap.utils.toArray<HTMLLIElement>('.my-projects-section');
if (sections.length === 0) return;
const gap = 70;
const boxWidth = 800;
const viewportWidth = window.innerWidth;
const wrapperRect = wrapper.getBoundingClientRect();
const wrapperLeft = wrapperRect.left;
const centerOffset = (viewportWidth - boxWidth) / 2 - wrapperLeft;
gsap.set(wrapper, { x: centerOffset });
const totalSections = sections.length;
const scrollDistance = (boxWidth + gap) * (totalSections - 1);
const snapPoints = sections.map((_, index) => index / (totalSections - 1));
const animation = gsap.to(wrapper, {
x: centerOffset - scrollDistance,
ease: 'none',
scrollTrigger: {
trigger: section,
start: 'top top',
end: () => `+=${scrollDistance + viewportWidth}`,
pin: true,
scrub: 0.5,
snap: {
snapTo: (progress) => {
// 가장 가까운 snap 포인트 찾기
let closest = snapPoints[0];
let minDistance = Math.abs(progress - snapPoints[0]);
snapPoints.forEach((point) => {
const distance = Math.abs(progress - point);
if (distance < minDistance) {
minDistance = distance;
closest = point;
}
});
return closest;
},
duration: { min: 0.1, max: 0.3 }, // 빠른 snap
delay: 0,
},
invalidateOnRefresh: true,
},
});
ScrollTrigger.refresh();
return () => {
animation.kill();
ScrollTrigger.getAll().forEach((trigger) => {
if (trigger.vars.trigger === section) {
trigger.kill();
}
});
};
}, [isLoaded, isMobile]);
return (
<S.SideProjects id='my-projects' ref={sectionRef}>
<Inner>
<Title text={`MY ✨ \nPROJECTS`} />
</Inner>
<S.SideProjectsInner ref={wrapperRef}>
{sideProjectsList.map((item, index) => (
<S.SideProjectsInnerBox key={`${item.title}-${index}`} className='my-projects-section'>
...
</S.SideProjectsInnerBox>
))}
</S.SideProjectsInner>
</S.SideProjects>
);
};
export default SideProjects;
useIsMobile.ts
모바일 여부(isMobile)와 초기 로드 완료 상태(isLoaded)를 반환하는 커스텀 훅이다.
import { useState, useEffect } from 'react';
import { BREAKPOINT } from '../../../_constant/breakpoint';
export const useIsMobile = (breakpoint: number = BREAKPOINT) => {
const [isMobile, setIsMobile] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const checkIsMobile = () => {
const mobile = window.innerWidth <= breakpoint;
setIsMobile(mobile);
setIsLoaded(true);
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
}, []);
return { isMobile, isLoaded };
};
기존 코드에서는 useIsMobile 를 사용했는데, 정상적으로 실행되지 않았다. 그 이유를 찾아보니, 커스텀훅의 경우 React 상태(isMobile) 변경에만 의존하기 때문에 GSAP이 resize 를 직접 감지하지 못하고 있었던 것이다.
하지만 이러한 문제는 GSAP 에서 제공하는 기능으로 쉽게 해결할 수 있었다..!
GSAP 에서는 미디어 쿼리에 따라 조건부로 애니메이션을 활성화/비활성화할 수 있는 matchMedia() 메서드를 제공해준다.
const mm = gsap.matchMedia();
mm.add('(min-width: 1081px)', () => {
// PC 화면에서만 실행되는 애니메이션
gsap.to(element, { x: 100 });
});
mm.add('(max-width: 1080px)', () => {
// 모바일/태블릿에서만 실행되는 애니메이션
gsap.to(element, { y: 100 });
});
주요 특징:
kill되고 revert 가 실행됨mm.revert()를 호출하면 모든 matchMedia 인스턴스가 정리됨'use client';
import { BREAKPOINT } from '@/app/_constant/breakpoint';
const SideProjects = () => {
const sectionRef = useRef<HTMLElement | null>(null);
const wrapperRef = useRef<HTMLUListElement | null>(null);
useGSAP(() => {
if (!isLoaded) return;
if (!sectionRef.current || !wrapperRef.current) return;
const mm = gsap.matchMedia();
// PC 화면에서만 ScrollTrigger 애니메이션 적용
mm.add(`(min-width: ${BREAKPOINT + 1}px)`, () => {
gsap.registerPlugin(ScrollTrigger);
const section = sectionRef.current;
const wrapper = wrapperRef.current;
const sections = gsap.utils.toArray<HTMLLIElement>('.my-projects-section');
if (!section || !wrapper || sections.length === 0) return;
const gap = 70;
const boxWidth = 800;
const viewportWidth = window.innerWidth;
const wrapperRect = wrapper.getBoundingClientRect();
const wrapperLeft = wrapperRect.left;
const centerOffset = (viewportWidth - boxWidth) / 2 - wrapperLeft;
gsap.set(wrapper, { x: centerOffset });
const totalSections = sections.length;
const scrollDistance = (boxWidth + gap) * (totalSections - 1);
const snapPoints = sections.map((_, index) => index / (totalSections - 1));
const animation = gsap.to(wrapper, {
x: centerOffset - scrollDistance,
ease: 'none',
scrollTrigger: {
trigger: section,
start: 'top top',
end: () => `+=${scrollDistance + viewportWidth}`,
pin: true,
scrub: 0.5,
snap: {
snapTo: (progress) => {
let closest = snapPoints[0];
let minDistance = Math.abs(progress - snapPoints[0]);
snapPoints.forEach((point) => {
const distance = Math.abs(progress - point);
if (distance < minDistance) {
minDistance = distance;
closest = point;
}
});
return closest;
},
duration: { min: 0.1, max: 0.3 },
delay: 0,
},
invalidateOnRefresh: true,
},
});
ScrollTrigger.refresh();
return () => {
animation.kill();
ScrollTrigger.getAll().forEach((trigger) => {
if (trigger.vars.trigger === section) {
trigger.kill();
}
});
};
});
return () => mm.revert();
}, [isLoaded]);
return (
<S.SideProjects id='my-projects' ref={sectionRef}>
{/* ... JSX 내용 ... */}
</S.SideProjects>
);
};
export default SideProjects;
위 내용을 바탕으로 matchMedia() 를 사용한 후, 화면 크기에 따라 자동으로 애니메이션이 적용되고 제거되면서 GSAP 기능이 포함된 반응형 레이아웃을 정상적으로 구현할 수 있었다🥹
하지만 예상치 못한 문제가 발생했는데... 내용은 아래와 같았다
말 그대로 PC 화면에서 모바일로 리사이즈할 때는 애니메이션이 정상적으로 비활성화되었지만, 모바일에서 PC로 리사이즈할 때는 요소의 위치가 엉망이 되고 애니메이션이 제대로 동작하지 않는 현상이 발생하는 것이다🤬
AI 를 사용하여 분석해봤는데, 주요 원인은 GSAP 애니메이션이 요소에 인라인 스타일을 직접 적용하기 때문인것으로 확인됐다.
쉽게 말해, 문제의 흐름은 아래와 같이 흘러간다.
문제의 흐름:
PC 화면에서 GSAP가 transform: translateX(...)와 같은 인라인 스타일을 요소에 적용이 됨
(아래와 같은 형식으로!)
<ul style="transform: translate3d(-XXXpx, 0px, 0px); ...">...</ul>
모바일로 전환될 때 matchMedia()가 애니메이션을 비활성화 (kill)
하지만 이미 적용된 인라인 스타일(transform, x 등)이 완전히 제거되지 않고 남아있음
모바일에서 PC로 다시 돌아올 때:
이 세 가지가 충돌하여 레이아웃이 깨지게된 것이다
즉 gsap.matchMedia의 조건에 맞지 않을 때 revert() 가 자동으로 실행되면서 애니메이션을 되돌리지만, 스타일이 변경되기 전의 원래 인라인 상태로 완벽하게 되돌린다는 보장이 없기 때문에 이런 문제가 발생하게 되었다.
GSAP는 이런 문제를 해결하기 위해 ScrollTrigger.saveStyles() 메서드를 제공한다고 한다.
ScrollTrigger.saveStyles('.my-projects-section, .my-projects-section *');
역할:
matchMedia()가 애니메이션을 비활성화할 때 저장된 스타일로 자동 복원사용 시점:
useEffect를 사용하여 적절한 타이밍에 호출위 내용을 토대로 아래와 같이 코드를 수정해봤다. 🔽
'use client';
import { BREAKPOINT } from '@/app/_constant/breakpoint';
// GSAP ScrollTrigger 플러그인 등록
if (typeof window !== 'undefined') {
gsap.registerPlugin(ScrollTrigger);
}
const SideProjects = () => {
const sectionRef = useRef<HTMLElement | null>(null);
const wrapperRef = useRef<HTMLUListElement | null>(null);
// 애니메이션 대상 요소의 원본 스타일을 저장
useEffect(() => {
if (typeof window !== 'undefined' && isLoaded) {
ScrollTrigger.saveStyles('.my-projects-section, .my-projects-section *');
}
}, [isLoaded]);
useGSAP(() => {
if (!isLoaded) return;
if (!sectionRef.current || !wrapperRef.current) return;
const mm = gsap.matchMedia();
mm.add(`(min-width: ${BREAKPOINT + 1}px)`, () => {
const section = sectionRef.current;
const wrapper = wrapperRef.current;
const sections = gsap.utils.toArray<HTMLLIElement>('.my-projects-section');
if (!section || !wrapper || sections.length === 0) return;
...
const animation = gsap.to(wrapper, {
...
});
ScrollTrigger.refresh();
return () => {
animation.kill();
ScrollTrigger.getAll().forEach((trigger) => {
if (trigger.vars.trigger === section) {
trigger.kill();
}
});
};
});
return () => mm.revert();
}, [isLoaded]);
return (
<S.SideProjects id='my-projects' ref={sectionRef}>
{/* ... JSX 내용 ... */}
</S.SideProjects>
);
};
export default SideProjects;
ScrollTrigger.saveStyles() 추가
useEffect(() => {
if (typeof window !== 'undefined' && isLoaded) {
ScrollTrigger.saveStyles('.my-projects-section, .my-projects-section *');
}
}, [isLoaded]);
*를 포함하여 자식 요소까지 저장하여 안전하게 처리isLoaded가 true가 된 후 실행되어 DOM이 완전히 준비된 상태에서 저장gsap.registerPlugin() 위치 변경
matchMedia() 콜백 내부에서 중복 등록 제거invalidateOnRefresh: true 유지
saveStyles()와 함께 사용하면 더욱 안정적이제 아래 사항이 모두 해결된 모습을 확인할 수 있었다 ✨