Full Page Scroll 컴포넌트 구현하기 (feat. 심플하게 생각하기)

2025-03-11

👩🏻‍💻 요구사항: 컨텐츠들이 쭉 있고, 스크롤을 내리다 끝 쯤에 도달하면 한 페이지씩 화면이 전환되는 형태의 인터랙션을 구현하고 싶어요!

Full Page Scroll이란?

Full Page Scroll은 웹사이트에서 스크롤 시 한 페이지 단위로 전체 화면이 넘어가는 패턴이다.
보통 포트폴리오, 이벤트 랜딩 페이지 등에서 자주 보이며, 사용자 시선을 자연스럽게 끌 수 있어 몰입도 높은 UI 연출이 가능하다.

1차 시도: scroll 이벤트 기반 구현

처음엔 직관적으로 wheel, touch 이벤트를 기반으로 구현했다.
각 페이지 섹션의 높이를 기준으로 현재 페이지를 계산하고, 스크롤 방향에 따라 페이지를 전환한다.

import React, { ReactNode } from "react";
import { useEffect, useRef } from "react";
import styles from "./index.module.css";
 
const FullPageScroll = ({ children }: { children: ReactNode }) => {
  const outerDivRef = useRef<HTMLDivElement>(null);
  const currentPage = useRef(0);
  const canScroll = useRef(true);
  const oldTouchY = useRef(0);
 
  const scrollDown = () => {
    const pageHeight = outerDivRef.current?.children.item(0)?.clientHeight;
 
    if (outerDivRef.current && pageHeight) {
      outerDivRef.current.scrollTo({
        top: pageHeight * (currentPage.current + 1),
        left: 0,
        behavior: "smooth",
      });
 
      canScroll.current = false;
 
      setTimeout(() => {
        canScroll.current = true;
      }, 500);
 
      if (outerDivRef.current.childElementCount - 1 > currentPage.current) {
        currentPage.current++;
      }
    }
  };
 
  const scrollUp = () => {
    const pageHeight = outerDivRef.current?.children.item(0)?.clientHeight;
 
    if (outerDivRef.current && pageHeight) {
      outerDivRef.current.scrollTo({
        top: pageHeight * (currentPage.current - 1),
        left: 0,
        behavior: "smooth",
      });
 
      canScroll.current = false;
 
      setTimeout(() => {
        canScroll.current = true;
      }, 500);
 
      if (currentPage.current > 0) {
        currentPage.current--;
      }
    }
  };
 
  const handleWheel = (e: WheelEvent) => {
    e.preventDefault();
 
    if (!canScroll.current) {
      return;
    }
 
    const { deltaY } = e;
 
    if (deltaY > 0 && outerDivRef.current) {
      scrollDown();
    } else if (deltaY < 0 && outerDivRef.current) {
      scrollUp();
    }
  };
 
  const handleScroll = (e: Event) => {
    e.preventDefault();
  };
 
  const handleTouchDown = (e: TouchEvent) => {
    oldTouchY.current = e.changedTouches.item(0)?.clientY || 0;
  };
 
  const handleTouchUp = (e: TouchEvent) => {
    const currentTouchY = e.changedTouches.item(0)?.clientY || 0;
    const isScrollDown = oldTouchY.current - currentTouchY > 0;
 
    if (isScrollDown) {
      scrollDown();
    } else {
      scrollUp();
    }
  };
 
  useEffect(() => {
    const outer = outerDivRef.current;
 
    if (!outer) {
      return;
    }
 
    outer.addEventListener("wheel", handleWheel);
    outer.addEventListener("scroll", handleScroll);
    outer.addEventListener("touchmove", handleScroll);
    outer.addEventListener("touchstart", handleTouchDown);
    outer.addEventListener("touchend", handleTouchUp);
 
    return () => {
      outer.removeEventListener("wheel", handleWheel);
      outer.removeEventListener("scroll", handleScroll);
      outer.removeEventListener("touchmove", handleScroll);
      outer.removeEventListener("touchstart", handleTouchDown);
      outer.removeEventListener("touchend", handleTouchUp);
    };
  }, []);
 
  return (
    <div ref={outerDivRef} className={styles["container"]}>
      {children}
    </div>
  );
};
 
export default FullPageScroll;

2차 시도: IntersectionObserver 도입

스크롤 감지를 수동 이벤트 핸들링 대신 브라우저에 위임하는 방식으로 전환했다.
IntersectionObserver를 활용해 특정 영역이 화면에 들어올 때 이벤트를 바인딩하고 해제하는 방식이다.

import React, { ReactNode } from "react";
 
import { useEffect, useRef, useCallback } from "react";
 
import styles from "./index.module.css";
 
const FullPageScroll = ({ children }: { children: ReactNode }) => {
  const outerDivRef = useRef<HTMLDivElement>(null);
  const currentPage = useRef(0);
  const isScrolling = useRef(false);
  const lastScrollTime = useRef(0);
  const touchStartY = useRef(0);
 
  const scrollToPage = useCallback((pageIndex: number) => {
    const pageHeight = outerDivRef.current?.children[0]?.clientHeight;
 
    if (!outerDivRef.current || !pageHeight) {
      return;
    }
 
    const maxPages = outerDivRef.current.childElementCount - 1;
 
    const newPage = Math.max(0, Math.min(pageIndex, maxPages));
 
    outerDivRef.current.scrollTo({
      top: pageHeight * newPage,
      left: 0,
      behavior: "smooth",
    });
 
    currentPage.current = newPage;
    isScrolling.current = true;
 
    setTimeout(() => {
      isScrolling.current = false;
    }, 600);
  }, []);
 
  const handleWheel = useCallback(
    (e: WheelEvent) => {
      e.preventDefault();
 
      const now = Date.now();
 
      if (isScrolling.current || now - lastScrollTime.current < 600) {
        return;
      }
 
      const deltaY = e.deltaY;
 
      if (deltaY > 50) {
        scrollToPage(currentPage.current + 1);
 
        lastScrollTime.current = now;
      } else if (deltaY < -50) {
        scrollToPage(currentPage.current - 1);
 
        lastScrollTime.current = now;
      }
    },
 
    [scrollToPage],
  );
 
  const handleTouchStart = useCallback((e: TouchEvent) => {
    touchStartY.current = e.touches[0].clientY;
  }, []);
 
  const handleTouchMove = useCallback((e: TouchEvent) => {
    e.preventDefault();
  }, []);
 
  const handleTouchEnd = useCallback(
    (e: TouchEvent) => {
      if (isScrolling.current) {
        return;
      }
 
      const touchEndY = e.changedTouches[0].clientY;
      const deltaY = touchStartY.current - touchEndY;
 
      if (Math.abs(deltaY) > 50) {
        if (deltaY > 0) {
          scrollToPage(currentPage.current + 1);
        } else {
          scrollToPage(currentPage.current - 1);
        }
      }
    },
 
    [scrollToPage],
  );
 
  useEffect(() => {
    const outer = outerDivRef.current;
 
    if (!outer) {
      return;
    }
 
    const addEventListeners = () => {
      outer.addEventListener("wheel", handleWheel, { passive: false });
      outer.addEventListener("touchstart", handleTouchStart, {
        passive: false,
      });
 
      outer.addEventListener("touchmove", handleTouchMove, { passive: false });
      outer.addEventListener("touchend", handleTouchEnd, { passive: false });
    };
 
    const removeEventListeners = () => {
      outer.removeEventListener("wheel", handleWheel);
      outer.removeEventListener("touchstart", handleTouchStart);
      outer.removeEventListener("touchmove", handleTouchMove);
      outer.removeEventListener("touchend", handleTouchEnd);
    };
 
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          addEventListeners();
 
          scrollToPage(0);
        } else {
          removeEventListeners();
        }
      },
 
      { threshold: 0.1 },
    );
 
    observer.observe(outer);
 
    return () => {
      observer.disconnect();
      removeEventListeners();
    };
  }, [
    handleWheel,
    handleTouchStart,
    handleTouchMove,
    handleTouchEnd,
    scrollToPage,
  ]);
 
  return (
    <div ref={outerDivRef} className={styles["container"]}>
      {children}
    </div>
  );
};
 
export default FullPageScroll;

왜 이렇게 바꿨나?

  • scroll 이벤트를 일일이 핸들링하는 방식은 성능에 불리하고, 컨텍스트를 구분하기 어렵다.
  • 특정 영역에서만 스크롤 전환 기능이 동작하도록 하고 싶었기 때문에, 해당 영역이 뷰포트에 진입했을 때만 이벤트를 연결하는 구조가 필요했다.
  • 필요할 때만 이벤트를 붙였다가 떼기 때문에 부작용을 줄일 수 있다.

문제점

이 방식은 전체 페이지가 Full Scroll 구조일 때는 잘 작동했지만, 페이지 중간에 Full Page Scroll이 삽입된 경우엔 문제가 생겼다.

  • 이미지나 비디오 등 포인터 이벤트가 걸려 있는 요소 위에서는 wheel 이벤트가 부모로 전달되지 않아 페이지 전환이 막힘.

  • 빠르게 스크롤할 경우 IntersectionObserver가 아직 뷰포트 안으로 진입하기도 전에 이벤트가 끊기는 현상도 발생.

3차 시도: 더 단순하게, scrollY 기반 제어

고민 끝에 태초마을로 돌아가 생각했고 방향을 바꿨다.
스크롤을 제어하려고 애쓰기보다는 스크롤은 그대로 두고, 특정 시점에 화면이 전환되는 듯한 효과를 주기로 했다.

방식

두 페이지를 position: absolute로 겹쳐 놓고,
스크롤 위치(window.scrollY)가 특정 지점 이상이면 상태값을 변경해 CSS 전환 효과를 주는 방식이다.

처음에는 scrollY > 5200 같은 하드코딩된 값으로 구현해 동작을 확인했다.

const [isActive, setIsActive] = useState(false);
 
useEffect(() => {
  window.addEventListener("scroll", (e) => {
    console.log(e.currentTarget.scrollY);
    setIsActive(e.currentTarget.scrollY > 5200);
  });
}, []);
 
return (
  <section className={styles["scroll-section"]}>
    <div className={styles["section-wrapper"]}>
      <div
        ref={sectionRef}
        className={`${styles["section-first"]} ${styles["section-layer"]} ${
          isActive ? "" : styles["active"]
        }`}
      >
        <span>1단계 컨텐츠입니다.</span>
        <strong>다음 화면으로 넘어갑니다.</strong>
      </div>
 
      <div
        className={`${styles["section-second"]} ${styles["section-layer"]} ${
          isActive ? styles["active"] : ""
        }`}
      >
        <img src="/images/next-step.png" alt="Next Step" />
      </div>
    </div>
  </section>
);
.scroll-section {
  height: 200vh;
  position: relative;
}
 
.section-wrapper {
  position: sticky;
  top: 0;
  height: 100vh;
}
 
.section-layer {
  position: absolute;
  top: 0;
  left: 0;
  height: 100vh;
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  transition:
    opacity 0.4s,
    transform 0.4s;
  opacity: 0;
  transform: translateY(50px);
}
 
.section-layer.active {
  opacity: 1;
  transform: translateY(0);
}
 
.section-first {
  background-color: #000;
  color: #fff;
  text-align: center;
}
 
.section-first span,
.section-first strong {
  font-size: 2rem;
  display: block;
  margin: 0.5rem 0;
}
 
.section-second {
  background: linear-gradient(to bottom, #00e0ff, #0075ff, #0047ff);
  text-align: center;
}

scrollY → getBoundingClientRect 개선

하드코드딩된 방식에서 getBoundingClientRect().bottom <= window.innerHeight 를 활용해
해당 요소가 화면 하단에 다다랐을 때 전환이 되도록 바꿨다.

useEffect(() => {
  const handleScroll = () => {
    if (!finishRef.current) {
      return;
    }
    const rect = finishRef.current.getBoundingClientRect();
    const windowHeight = window.innerHeight;
    setIsActive(rect.bottom <= windowHeight);
  };
  window.addEventListener("scroll", handleScroll);
  return () => {
    window.removeEventListener("scroll", handleScroll);
  };
}, []);

결론

처음엔 정석대로 scroll 이벤트를 제어하려 했지만, 오히려 그게 복잡함을 초래했다.
결국은 단순한 방식이 더 나았다.
가장 단순한 접근이 때로는 가장 효과적이다.