반응형 화면에서 GSAP 애니메이션 제어하기

hyejinJo·2025년 12월 15일

React

목록 보기
20/20
post-thumbnail

포트폴리오 프로젝트에서 GSAP의 ScrollTrigger를 사용하여 수평 스크롤 애니메이션을 구현했다. 이때 PC에서는 스크롤에 따라 프로젝트 카드들이 수평으로 이동하는 애니메이션이 동작하고, 모바일과 태블릿에서는 일반적인 세로 스크롤 레이아웃을 유지하도록 구현을 시도했지만, 생각한대로 실행되지 않았다.

1. Resize 이슈

레이아웃이 화면 크기에 따라 다르기 때문에, GSAP 애니메이션의 적용 여부도 화면 크기에 따라 달라져야 했다.

  • PC 화면: GSAP ScrollTrigger 애니메이션 적용 ✅
  • 모바일/태블릿: 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 를 직접 감지하지 못하고 있었던 것이다.



2. 해결: gsap.matchMedia()

하지만 이러한 문제는 GSAP 에서 제공하는 기능으로 쉽게 해결할 수 있었다..!

gsap.matchMedia() 란?

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 });
});

주요 특징:

  • 미디어 쿼리 조건에 따라 애니메이션을 자동으로 활성화/비활성화
  • 조건이 맞지 않게 되면 내부의 ScrollTrigger가 자동으로 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 기능이 포함된 반응형 레이아웃을 정상적으로 구현할 수 있었다🥹



3. 또 다른 문제 (feat.레이아웃 와장창)

하지만 예상치 못한 문제가 발생했는데... 내용은 아래와 같았다

문제 상황

  • PC → Mobile로 리사이즈: 정상적으로 동작
  • Mobile → PC로 리사이즈: 레이아웃이 깨지고 GSAP 애니메이션이 이상하게 동작

말 그대로 PC 화면에서 모바일로 리사이즈할 때는 애니메이션이 정상적으로 비활성화되었지만, 모바일에서 PC로 리사이즈할 때는 요소의 위치가 엉망이 되고 애니메이션이 제대로 동작하지 않는 현상이 발생하는 것이다🤬


원인 분석

AI 를 사용하여 분석해봤는데, 주요 원인은 GSAP 애니메이션이 요소에 인라인 스타일을 직접 적용하기 때문인것으로 확인됐다.
쉽게 말해, 문제의 흐름은 아래와 같이 흘러간다.

문제의 흐름:

  1. PC 화면에서 GSAP가 transform: translateX(...)와 같은 인라인 스타일을 요소에 적용이 됨
    (아래와 같은 형식으로!)

    <ul style="transform: translate3d(-XXXpx, 0px, 0px); ...">...</ul>
  2. 모바일로 전환될 때 matchMedia()가 애니메이션을 비활성화 (kill)

  3. 하지만 이미 적용된 인라인 스타일(transform, x 등)이 완전히 제거되지 않고 남아있음

  4. 모바일에서 PC로 다시 돌아올 때:

    • 이전에 남아있는 (모바일의)인라인 스타일
    • 새로운 애니메이션 계산값
    • CSS 스타일

    이 세 가지가 충돌하여 레이아웃이 깨지게된 것이다

gsap.matchMedia의 조건에 맞지 않을 때 revert() 가 자동으로 실행되면서 애니메이션을 되돌리지만, 스타일이 변경되기 전의 원래 인라인 상태로 완벽하게 되돌린다는 보장이 없기 때문에 이런 문제가 발생하게 되었다.



4. 진짜 최종 해결

ScrollTrigger.saveStyle()

GSAP는 이런 문제를 해결하기 위해 ScrollTrigger.saveStyles() 메서드를 제공한다고 한다.

ScrollTrigger.saveStyles('.my-projects-section, .my-projects-section *');

역할:

  1. 애니메이션 적용 전 요소의 원래 인라인 스타일을 기록
  2. matchMedia()가 애니메이션을 비활성화할 때 저장된 스타일로 자동 복원
  3. 스타일 충돌을 방지하고 레이아웃을 원상복구

사용 시점:

  • 컴포넌트가 마운트되고 DOM이 준비된 후
  • 애니메이션이 시작되기 에 한 번만 호출
  • 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;

주요 변경 사항

  1. ScrollTrigger.saveStyles() 추가

    useEffect(() => {
      if (typeof window !== 'undefined' && isLoaded) {
        ScrollTrigger.saveStyles('.my-projects-section, .my-projects-section *');
      }
    }, [isLoaded]);
    • DOM이 준비된 후 애니메이션 대상 요소의 원본 스타일을 저장
    • *를 포함하여 자식 요소까지 저장하여 안전하게 처리
    • isLoadedtrue가 된 후 실행되어 DOM이 완전히 준비된 상태에서 저장
  2. gsap.registerPlugin() 위치 변경

    • 컴포넌트 외부로 이동하여 한 번만 등록되도록 수정
    • matchMedia() 콜백 내부에서 중복 등록 제거
  3. invalidateOnRefresh: true 유지

    • 리사이즈 시 ScrollTrigger가 계산값을 재계산하도록 유지
    • saveStyles()와 함께 사용하면 더욱 안정적


결과

이제 아래 사항이 모두 해결된 모습을 확인할 수 있었다 ✨

  • GSAP 애니메이션 여부가 PC/Tablet/Mobile 사이즈별로 정상 작동
  • Mobile -> PC 로 resize 될 때 해당하는 화면 레이아웃대로 정상 복귀



참고

profile
Frontend💡

0개의 댓글