본문 바로가기

React

React에서 스크롤 기반 Parallax 효과 구현하기

Parallax는 스크롤에 따라 배경과 콘텐츠의 이동 속도를 다르게 하여 깊이감을 주는 시각 효과입니다. React에서 성능을 고려해 구현하면 랜딩 페이지, 히어로 섹션, 갤러리 등에서 높은 몰입감을 제공할 수 있습니다.

1. 핵심 개념과 설계

기본 원리는 스크롤 위치에 비례한 오프셋을 계산해 요소의 transform 또는 backgroundPosition을 업데이트하는 것입니다. 성능을 위해 transform: translate3d와 requestAnimationFrame, passive 스크롤 리스너를 사용합니다. iOS에서 background-attachment: fixed가 제한적이므로 엘리먼트 기반 Parallax가 안전합니다.

2. CSS 배경 Parallax (간단하지만 모바일 제한)

배경 이미지를 빠르게 적용하는 방법입니다. 모바일(iOS Safari)에서 제한이 있으므로 대체 구현을 고려합니다.

import React from 'react';

export function HeroFixedBg() {
  return (
    <section
      style={{
        position: 'relative',
        minHeight: '60vh',
        backgroundImage: 'url(/images/hero.jpg)',
        backgroundSize: 'cover',
        backgroundRepeat: 'no-repeat',
        backgroundPosition: 'center',
        backgroundAttachment: 'fixed', // iOS에서 불안정
      }}
    >
      <div style={{ position: 'relative', zIndex: 1, padding: '4rem 2rem', color: '#fff' }}>
        <h1>Parallax Hero</h1>
        <p>스크롤에 깊이를 더합니다.</p>
      </div>
    </section>
  );
}

3. React 훅으로 엘리먼트 Parallax 구현

요소의 getBoundingClientRect().top을 기반으로 오프셋을 계산하고 transform을 적용합니다. requestAnimationFrame으로 페인트 타이밍에 맞춰 업데이트합니다.

import React, { useEffect, useRef, useState } from 'react';

function useParallax(speed = 0.3) {
  const ref = useRef(null);
  const [offset, setOffset] = useState(0);

  useEffect(() => {
    if (typeof window === 'undefined') return;
    let rafId = 0;

    const update = () => {
      const el = ref.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      // 화면 상단 기준 요소의 상대 위치를 속도에 맞게 변환
      const next = rect.top * speed;
      setOffset(next);
    };

    const onScroll = () => {
      cancelAnimationFrame(rafId);
      rafId = requestAnimationFrame(update);
    };

    // 초기 계산
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);

    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      cancelAnimationFrame(rafId);
    };
  }, [speed]);

  const style = {
    transform: `translate3d(0, ${offset}px, 0)`,
    willChange: 'transform',
  };

  return { ref, style };
}

export function Parallax({ children, speed = 0.3, className, style }) {
  const { ref, style: parallaxStyle } = useParallax(speed);
  return (
    <div ref={ref} className={className} style={{ ...style, ...parallaxStyle }}>
      {children}
    </div>
  );
}

// 사용 예시
export function ExampleSection() {
  return (
    <section style={{ padding: '6rem 2rem' }}>
      <Parallax speed={0.2}>
        <img src="/images/mountain.png" alt="산 풍경" style={{ width: '100%', display: 'block' }} />
      </Parallax>
      <Parallax speed={-0.1}>
        <h2 style={{ fontSize: '3rem' }}>앞쪽 텍스트가 더 빠르게 이동합니다</h2>
      </Parallax>
    </section>
  );
}

4. 성능 최적화 체크리스트

- transform 사용: top/left 변경 대신 translate3d로 레이아웃 재계산을 피합니다. - requestAnimationFrame: 스크롤 이벤트에서 직접 setState하지 말고 rAF로 배치합니다. - passive 리스너: 스크롤 성능을 향상합니다. - will-change: transform 힌트를 제공하되 필요한 요소에만 사용합니다. - 이미지 최적화: WebP/AVIF, 적절한 크기, lazy loading(loading="lazy"). - 뷰포트 내에서만 업데이트: 화면 밖이면 계산을 건너뜁니다.

// 뷰포트 체크 추가 (useParallax 내부 개선)
const update = () => {
  const el = ref.current;
  if (!el) return;
  const rect = el.getBoundingClientRect();
  const inView = rect.bottom >= 0 && rect.top <= window.innerHeight;
  if (!inView) return; // 화면 밖이면 스킵
  setOffset(rect.top * speed);
};

5. Framer Motion으로 빠르게 구현

Framer Motion의 useScroll/useTransform을 사용하면 매핑 로직을 간단히 처리할 수 있습니다.

import { motion, useScroll, useTransform } from 'framer-motion';
import React, { useRef } from 'react';

export function MotionParallax() {
  const ref = useRef(null);
  const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'end start'] });
  // 스크롤 진행도(0~1)를 -50px~50px으로 매핑
  const y = useTransform(scrollYProgress, [0, 1], [-50, 50]);

  return (
    <section ref={ref} style={{ height: '120vh', position: 'relative' }}>
      <motion.img
        src="/images/clouds.png"
        alt="구름 레이어"
        style={{ position: 'absolute', top: '20%', left: 0, right: 0, margin: '0 auto', y }}
      />
      <motion.h2 style={{ y: useTransform(scrollYProgress, [0, 1], [30, -30]) }}>
        자연스러운 Parallax</motion.h2>
    </section>
  );
}

6. 모바일/이미지 최적화

- iOS에서 background-attachment: fixed 대신 엘리먼트 Parallax를 사용합니다. - 큰 이미지 레이어는 속도를 낮추고 스크롤 구간을 제한합니다. - CSS contain: paint로 독립 페인트 영역을 만들면 교차 영향이 줄어듭니다.

// 큰 레이어에 페인트 contain 적용
const layerStyle = {
  contain: 'paint',
  willChange: 'transform',
};

7. 접근성과 SEO 고려

- prefers-reduced-motion 존중: 모션을 최소화합니다. - 의미 있는 콘텐츠는 Parallax에 묶지 말고, 시맨틱 구조(h1~h3, p)와 대체 텍스트를 제공합니다. - CLS 방지: 이미지에 고정 크기 또는 aspect-ratio를 지정합니다.

// 사용자의 모션 선호도에 따라 속도 조정
function useReducedMotionSpeed(base = 0.3) {
  const [speed, setSpeed] = React.useState(base);
  React.useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    const apply = () => setSpeed(mq.matches ? 0 : base);
    apply();
    mq.addEventListener('change', apply);
    return () => mq.removeEventListener('change', apply);
  }, [base]);
  return speed;
}

8. 흔한 문제와 해결

- 요소가 튀는 현상: 레이아웃이 바뀌지 않도록 transform만 변경합니다. - Nested 스크롤 컨테이너: window가 아닌 컨테이너에 스크롤 리스너를 달고 rect/top 기준을 컨테이너로 보정합니다. - 초기 깜빡임: 마운트 시 한 번 update를 호출해 초기 오프셋을 설정합니다.

9. 재사용 가능한 Parallax 컴포넌트 패턴

속도(speed), 방향(axis), 범위(clamp)를 prop으로 받아 다양한 레이어에 재사용합니다.

import React, { useEffect, useRef, useState } from 'react';

function useParallaxAdvanced({ speed = 0.3, axis = 'y', clamp = [-80, 80] }) {
  const ref = useRef(null);
  const [offset, setOffset] = useState(0);

  useEffect(() => {
    let rafId = 0;
    const update = () => {
      const el = ref.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const base = axis === 'y' ? rect.top : rect.left;
      const next = Math.max(clamp[0], Math.min(clamp[1], base * speed));
      setOffset(next);
    };
    const onScroll = () => {
      cancelAnimationFrame(rafId);
      rafId = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      cancelAnimationFrame(rafId);
    };
  }, [speed, axis, clamp]);

  const style =
    axis === 'y'
      ? { transform: `translate3d(0, ${offset}px, 0)`, willChange: 'transform' }
      : { transform: `translate3d(${offset}px, 0, 0)`, willChange: 'transform' };

  return { ref, style };
}

export function ParallaxLayer({ children, options }) {
  const { ref, style } = useParallaxAdvanced(options || {});
  return (
    <div ref={ref} style={style}>{children}</div>
  );
}

위 패턴으로 이미지, 텍스트, 버튼 등 다양한 레이어를 쉽게 구성할 수 있습니다.

10. 마무리

React에서 스크롤 기반 Parallax를 구현할 때는 rAF, transform, passive 리스너로 성능을 챙기고, 모바일/접근성 고려를 더하면 실무에서도 안정적으로 적용할 수 있습니다. Framer Motion을 사용하면 복잡한 맵핑을 간결하게 처리할 수 있어 팀 생산성도 높아집니다.