블로그 스크롤 스파이(TOC) 구현기

2023.11.20

Table of Contents

개인 블로그를 구현하고 내용을 확인하는 과정에서 어느 부분을 읽고 있는지 한눈에 볼 수 있는 기능을 구현하면 좋겠다는 생각을 가지게 되었습니다. 이미 react-notion-x에서 제공하는 기능인 Table of contents를 그대로 사용할 수 있었지만 모바일 환경에서는 아예 TOC를 보여주지 않는다는 점과 좀 더 이쁘게 커스터마이징하기 위해 직접 구현해 보기로 결정했습니다. Intersection observer를 활용하는 방법과 각 h 태그의 위치를 구하는 방법을 모두 활용해 보며 장단점을 찾게 되어 이에 대해 공유하고자 합니다.

TOC(Table of contents)

스크롤 스파이 기능을 바로 구현하고 싶었지만 표시할 곳이 없기에 침착하고 우선 목차를 추출하고 표시하는 것을 구현했습니다.
활용하고 있던 react-notion-x내에 포함된 notion-utilsgetPageTableOfContents를 참고해서 타입 불일치 문제와 불필요한 인자를 제거하고 구현했습니다.
노션 API로 받은 내용을 확인해 보면 header를 하나의 블록으로 처리하기에 어렵지 않게 구현할 수 있었으나 빈 header도 목차에 추가된다는 점을 개선하기 위해 렌더링 과정에서 조건문을 추가했습니다. 모바일 환경에서도 포스트 상단에서 목차를 통해 각 내용으로 접근이 가능하도록 했습니다.
결과물
결과물

스크롤 스파이

목차를 마음에 들게 표시할 수 있다면 이제 읽고 있는 위치에 맞게 내용을 활성화해주면 됩니다. activeId 상태를 등록하고 이를 통해 관리하는 방법을 택했지만 어떻게 이 상태를 변경할지에 대해 고민하는 시간을 가졌습니다.

Intersection Observer 활용

사실 엘리먼트의 스크롤 위치를 구하는 방법을 먼저 떠올렸지만 약간의 망설임이 있었습니다. scroll 이벤트 같은 경우에는 매 움직임마다 리스너가 동작하기 때문에 성능상 문제가 발생할 수 있었기 때문입니다.
디바운싱과 같은 최적화를 적용할 수 있지만 근본적으로 h 태그가 화면 특정 부분이 표시되면 그때 이벤트를 발생시키는 Intersection observer 방식으로 이러한 단점을 해결할 수 있을 거라고 생각했습니다.

구현 코드

import { Dispatch, SetStateAction, useEffect, useState } from "react"; const observerOption = { threshold: 0.4, rootMargin: "-70px 0px -90% 0px", }; const getIntersectionObserver = ( setState: Dispatch<SetStateAction<string>>, ) => { const observer = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting) { const target = entry.target as HTMLElement; if (target.dataset.id) { setState(target.dataset.id); } } }); }, observerOption); return observer; }; const useScrollSpy = () => { const [activeId, setActiveId] = useState(""); useEffect(() => { const observer = getIntersectionObserver(setActiveId); const headingElements = Array.from(document.querySelectorAll(".notion-h")); headingElements.map(header => { observer.observe(header); }); return () => { observer.disconnect(); }; }, []); return activeId; }; export default useScrollSpy;
UI와 로직을 분리해서 구현하고자 해서 커스텀 훅으로 로직을 구현했습니다.
DOM이 그려진 이후 동작해야 하기에 useEffect 내부에 옵저버를 등록하는 로직을 작성했습니다. 노션 페이지를 렌더링하는 곳에서 notion-h라는 class로 구분하고 있어 해당 엘리먼트들을 배열로 만드는 작업도 거쳤습니다. 페이지가 언마운트 될 때에는 등록된 옵저버들을 모두 해제하는 disconnect를 활용해서 Clean up했습니다.

문제점

뷰포트를 기준으로 rootMargin을 설정하는 일도 까다로웠지만 스크롤이 아래로 내려갈 때에는 문제없이 동작했습니다.
문제가 발생했던 부분은 스크롤을 빠르게 위로 올릴 때였습니다. 스크롤은 최상단으로 갔지만 activeId는 중간 어디쯤에 멈춰있는 현상을 확인했습니다. intersection observer 방법을 사용하는 다른 블로그들에서도 같은 문제를 확인할 수 있었습니다.
rootMargin
rootMargin
h 태그처럼 비교적 작은 크기를 가지는 대상을 비동기적으로 감시할 수 있지만 작은 rootMargin으로 인해 쓰로틀링이 적용된 것과 같이 동작하는 것이 원인이었습니다.
정확한 내부 로직이나 최적화를 확인할 수 없어 아쉬움이 남지만 한 viewport 안에 여러 개의 h 태그가 존재할 수 있는 블로그 글 특성상 한계를 느껴 다음 방법을 진행하게 되었습니다.

h 태그의 위치를 구하고 계산하여 활용

저는 이미 scrollY 값을 활용하는 스크롤 진행 바를 구현했습니다. framer-motion에서 제공하는 useScroll의 내부 로직을 확인해 보니 스크롤 이벤트 등록과 clean up 함수 또한 잘 설정되어 있어 또 같은 내용을 구현하기보다 이를 활용하였습니다.
이전에는 notion-h를 class로 가진 엘리먼트 그 자체를 배열로 만들었다면 이번에는 id, 위치를 객체로 만들어 ref에 배열로 담았습니다.
const hElementPositions = useRef<hElementPositionRefType[]>(); ... const getHTagPositions = () => { const pageTop = 201; //default page top hElementPositions.current = Array.from( document.querySelectorAll<HTMLElement>(".notion-h"), ) .filter(header => header.querySelector(".notion-h-title")?.textContent) .map(header => ({ id: header.dataset.id as string, top: header.offsetTop + pageTop, })); };

구현 코드

import { useMotionValueEvent, useScroll } from "framer-motion"; import { useRef, useState } from "react"; interface hElementPositionRefType { id: string; top: number; } const useScrollSpy = () => { const hElementPositions = useRef<hElementPositionRefType[]>(); const [activeId, setActiveId] = useState(""); const { scrollY } = useScroll(); const getHTagPositions = () => { ... }; useMotionValueEvent(scrollY, "change", latest => { getHTagPositions(); let headingId = ""; if (hElementPositions.current) { for (let i = 0; i < hElementPositions.current.length; i++) { if (latest >= hElementPositions.current[i].top) { headingId = hElementPositions.current[i].id; continue; } break; } setActiveId(headingId); } }); return activeId; }; export default useScrollSpy;
이벤트가 발생하면 배열을 순회하며 현재 scrollY(latest)가 엘리먼트의 위치보다 크거나 같은 경우 headingId를 바꾸고 작다면 for문을 빠져나와 상태를 변경하도록 구현했습니다.

문제점

useMotionValueEvent 내부에 getHTagPositions 함수가 이벤트마다 다시 계산되는 것을 확인할 수 있습니다. 이유는 블로그를 직접 만들며 react-notion-x의 이미지 렌더링에서 문제가 발생했는데요. content layout shift가 일어난다는 점입니다. 스켈레톤과 같은 최적화 기법으로 이를 해결할 수 있지만 라이브러리의 특성상 커스터마이징에 어려움을 겪어 스크롤이 일어날 때 위치를 다시 구해서 이를 해결하고자 했습니다.
notion image
시간을 측정해 본 결과 정상적인 범위 안에서 계산이 일어나는 경우 충분히 빠르다는 점을 확인하고 이런 방법을 채택했습니다.
결과적으로 이런 과정을 거치며 스스로도 만족할 수 있는 스크롤 스파이 기능을 구현할 수 있었습니다.
요란한 확인
요란한 확인

마치며

블로그를 직접 구현하며 모든 기능들에 관심을 가지고 만들었지만 특히 기억에 남는 내용을 작성해보았습니다. 간단할 수 있는 스크롤 스파이를 구현이지만 제가 겪었던 문제점들과 삽질을 누군가 경험하기 전에 확인한다면 시간을 아낄 수 있지 않을까 생각합니다. 그리고 다시 한번 느꼈지만 각 방법에는 장단점이 존재하고 어떤 선택을 하는지는 개발자의 판단이라는 부분에서 개발의 즐거움을 또 한번 느끼게 되었습니다.
목표로 하는 일이 잘 안풀리기도 하지만 끈기를 가지고 또 원인을 찾는 즐거움을 잊지 않는 개발자가 되어야 겠습니다.

Prev
함수형 프로그래밍 - Curry
Next
JS module 그리고 CJS와 ESM의 차이