개발/프로젝트

스크롤 기반 싱글 페이지 라우팅 구현하기

xuwon 2025. 4. 4. 18:35

포트폴리오 페이지를 만들려는데, 스크롤 기반으로 동작되도록 하고 싶었다.

일단 필요한 기능은

1. 스크롤 시 경로 변경
2. 메뉴 클릭 시 해당 섹션으로 이동
3. 특정 페이지(메인, 마지막)에선 메뉴 숨김

const mainRef = useRef<HTMLElement | null>(null); // 타입 명시
const aboutMeRef = useRef<HTMLElement | null>(null);
const projectRef = useRef<HTMLElement | null>(null);
const experienceRef = useRef<HTMLElement | null>(null);
const contactRef = useRef<HTMLElement | null>(null);

useRef로 각 세션의 DOM을 저장한다.
이걸 이용해서 scrollntoView()로 해당 섹션으로 스크롤 하거나, IntersectionObserver로 감시할 수 있다.


useRef

리액트 컴포넌트에서 DOM 요소나 어떤 값을 기억하고 싶을 때 사용하는 훅

const myRef = useRef(null);

myRef.current를 통해 실제 DOM 요소나 값에 접근이 가능하다.
또한 컴포넌트가 리렌더링되어도 값을 유지한다!
그리고 값이 바뀌어도 리렌더링 X

const boxRef = useRef(null);

useEffect(() => {
  boxRef.current.scrollIntoView({ behavior: "smooth" });
}, []);

이렇게 사용할 수 있는데,
boxRef를 특정 HTML 요소에 연결해서 사용한다.

그러면 처음 마운트될 때, boxRef가 연결된 DOM 요소로 부드럽게 스크롤을 이동하라는 뜻이 된다.

useRef는 너무 잘 까먹게 되는 것 같다...


그리고 스크롤 시 경로를 바꾸는 기능을 구현해보자.
IntersectionObserver을 사용하면 된다.

IntersectionObserver는 어떤 섹션이 화면 안에 보이는지 감지하는 함수이다.

useEffect(() => {
  const observer = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) { // 요소가 화면에 보인다면?
        const id = entry.target.id; // 보이는 요소의 id를 가져옴
        if (location.pathname !== `/${id}`) { // 경로가 같지 않다면
          navigate(`/${id}`, { replace: true }); // 경로 이동
          // replace: true -> 브라우저 히스토리가 쌓이지 않게 함.
        }
      }
    }
  }, { threshold: 0.6 }); // 0.6만큼 보일 때 경로 변경

  const sections = [
      mainRef.current,
      aboutMeRef.current,
      projectRef.current,
      experienceRef.current,
      contactRef.current,
  ];
  
  sections.forEach((sec) => sec && observer.observe(sec));
  // 각 세션을 observer가 감시!

  return () => observer.disconnect(); // 메모리 낭비 방지
}, [navigate, location.pathname]);

여기서 entries는 감시 중인 섹션의 정보가 담긴 배열이다.
그럼 entry는 각 섹션의 정보가 될테고, 보이는 섹션의 id를 가져와서 경로를 변경해 주는 것이다.


다음으로 경로가 바뀔 시 (메뉴 선택) 스크롤 이동이 가능하도록 하는 것이다.

useEffect(() => {
  const scrollToSection = () => {
    switch (location.pathname) {
      case "/":
        mainRef.current?.scrollIntoView({ behavior: "smooth" });
        break;
      case "/aboutMe":
        aboutMeRef.current?.scrollIntoView({ behavior: "smooth" });
        break;
      ...
    }
  };

  scrollToSection();
}, [location]);

location.pathname이 바뀔 때마다 scrollIntoView()를 사용해서 부드럽게 이동하도록 한다.

scrollIntoView()브라우저 화면에서 어떤 요소가 자동으로 보이도록 스크롤해주는 함수이다.


그리고 특정 경로에서 메뉴를 숨겨야 하니까, location.pathname으로 조건문 처리하면 된다.

{location.pathname !== "/" && location.pathname !== "/contact" && (
  <Navbar />
)}

경로가 '/' 또는 '/contact'일 땐 메뉴가 안 보이도록 숨겼다.


<section
  ref={mainRef}
  id=""
  style={{ height: "100vh" }}
>
  <Main />
</section>

각 섹션은 이렇게 되어 있다.

IntersectionObserver를 위한 id 속성이 있고, 스크롤 하기 위한 ref 속성이 있다.