youngminss-log

블로그 개발기 (ToC - Table of Contents)

Table of Contents
Rehype-Slug
youngmin's profile
Youngmin2024-12-07
Reading Time : 20 min
post-body-thumbnail

안녕하세요 youngminss 입니다. 이번 글에서는 Table of Content (이하, ToC) 를 블로그 상세 페이지에 추가한 과정을 소개하려 합니다.

글을 작성할 때는 목차를 두는데요, ToC 가 한국어로 번역하면 그 "목차"를 의미합니다.
ToC 를 글 상세 페이지에 배치하면 ToC 만 봐도 일차적으로 해당 글에서 소개할 소재들을 파악할 수 있다는 점필요할 때 빠르게 해당 섹션으로 이동이 가능할 수 있다는 점이 이점으로 생각이 들어 추가하게 되었습니다.


🤔 요구사항 분석

하나의 글에는 하나의 주 제목이 하나 존재할 수 있습니다. 한편 하나의 글 안에서도 목차를 나눌 수 있고 이를 여러 개의 부 제목으로 구분 지을 수 있습니다.

HTML에서 제목과 관련된 태그는 h Tag (= heading)입니다. 보통 중요도에 따라 h1 ~ h6 까지 계층을 구분할 수 있습니다. 글에서는 주 제목(h1) 을 시작으로 여러 하위 소제목(h2 ~ h6) 들로 계층을 둔다고 이해할 수 있습니다. 그래서 보통 다음과 같은 구조로 글을 보통 작성합니다.

글 구조 예시
## 소제목 1
 
내용...
 
## 소제목 2
 
내용...
 
### 소제목 2 의 소제목 1
 
내용...
 
## 소제목 3
 
내용...

우리에게 필요한 것은 HTML 로 변환된 글 내용에서 h2 ~ h6에 해당하는 블록의 정보가 필요합니다. h1 은 글의 제목에서 사용하니까요! (h1은 보통 페이지에서 1개만 사용되는 것을 "권장"한다.)

좀 더 구체화하자면 저의 경우는 h2, h3 의 정보만 필요할 것 같습니다. 그 이상의 계층으로 글을 작성하고 있지 않으며, 만약 그런 경우라면 목차의 깊이를 조절해서 h4 이상으로는 필요하지 않도록 구조를 수정하는 것이 맞는다고 생각합니다.

추출한 h2, h3 블록의 정보들을 기반으로 ToC 와 같이 그려주기만 하면 될 것 같습니다. 이 부분이 첫 번째 태스크가 될 것입니다.

그다음은 ToC 에서 필요한 기능을 정의해야 합니다. 제가 정의한 ToC 는 다음과 같이 작동해야 합니다.

  • ToC 의 아이템을 클릭하면 해당 섹션으로 이동 되어야 한다.
  • ToC 아이템을 클릭이나 스크롤을 통해 특정 부제목 섹션에 해당하는 ToC 아이템이 하이라이팅 되어야 한다.
  • 글 본문이 끝나는 영역 밑으로는 ToC 가 안보였으면 좋겠다.

아래부터는 어떤 과정으로 진행했는지 기술하겠습니다.



✅ 어떻게 목차 정보를 추출할 것인가 ?

일단, 본문 내 markdown에서 heading 처리를 한 콘텐츠가 렌더링 된 결과 예시를 확인하겠습니다.

image-1

markdown에서 "##" 로 작성한 것이 h2 태그로 렌더링 된 것을 확인할 수 있습니다. 우리는 콘텐츠 내 h2, h3 태그에 대한 정보를 추출하면 되겠습니다. h 태그들의 정보는 querySelector 를 사용해서 추출할 수 있습니다. 콘텐츠 내 모든 h2, h3 태그에 대한 정보가 필요한 것이기 때문에 Document.querySeletorAll selector 를 사용했습니다. 아래 코드 블록에서 line 7 이 그 부분입니다.

ToC 데이터를 추출
useEffect(() => {
  // cc. 컨텐츠 본문 컴포넌트에 "post-body" 라는 이름의 id 값을 설정해놓았음
  const $postBody = document.getElementById("post-body");
 
  if ($postBody) {
    // step 1 : h2 ~ h3 태그까지 추출
    const hTagList = $postBody.querySelectorAll("h2, h3");
 
    // step 2 : ToC 리스트 만들기
    const postToCListTemp: IToCItem[] = [];
 
    hTagList.forEach((hTagItem) => {
      const { id: href, nodeName: level, textContent: text } = hTagItem;
 
      const scrollTop = window.scrollY || document.documentElement.scrollTop;
      const absoluteTop = hTagItem.getBoundingClientRect().top + scrollTop;
 
      postToCListTemp.push({
        level,
        text,
        href,
        absoluteTop: absoluteTop,
      });
    });
 
    if (postToCListTemp.length !== 0) {
      setPostToCList(postToCListTemp);
    }
  }
}, []);

line 10 부터는 각 NodeList 를 돌면서 Node 의 속성 중에 ToC 생성에 필요한 속성만 뽑아내서 ToC 아이템 리스트 데이터를 생성하는 과정입니다. line 13에서 각 h tag node에 대해 ToC 에 필요한 속성들을 뽑아냅니다. 각각의 속성에 대해 간단하게 설명을 하겠습니다.

  • id : h tag 의 id 입니다. id 에 해당하는 h tag 가 있는 섹션으로 이동 시 필요합니다.
  • nodeName : h tag 의 level (ex. h1, h2, h3 ...) 입니다. ToC 의 들여쓰기 계층을 표현할 때 필요합니다.
  • textContent : h tag 의 text 입니다. ToC 아이템에 표현될 콘텐츠에 필요합니다.

nodeName 이나 textContent 는 markdown에서 잘 작성만 했다면 존재하는 값 입니다. 하지만 id 에 대해서는 추가로 설명이 필요합니다.

위에서 예시로 첨부한 렌더링 된 결과에서는 h tag 에 id 속성이 없었는데 어떻게 추출할 수 있는 것일까요? 이를 위해서는 mdx rehype plugin 인 rehype-slug 을 활용했습니다. rehype-slugh tag 에 id 를 자동으로 삽입해 주는 rehype plugin 입니다. tag 내의 text 를 기반으로 id 속성을 추가해 줍니다. (cc. github-slugger)

아래 의존성 패키지를 추가해 줍니다.

npm install rehype-slug

그리고 MDX 컴포넌트의 options 속성에 rehype 의존성 배열 속성에 설치한 패키지를 추가해 줍니다. 저는 MDXRemote 컴포넌트를 사용하고 있고 다음과 같이 추가했습니다.

MDX 플러그인으로 rehype-slug 를 추가
import { TPost } from "@/types/post";
import { MDXRemote } from "next-mdx-remote/rsc";
import rehypeSlug from "rehype-slug";
 
...
 
const PostBody = ({ post }: { post: TPost }) => {
  return (
      <MDXRemote
		   ...
        source={post.content}
        options={{
          mdxOptions: {
            ...
            rehypePlugins: [
              rehypeSlug,
              ...
            ],
          },
        }}
      />
  );
};
 
export default PostBody;

렌더링 된 결과에서 h tag에 id 속성이 자동으로 추가된 것을 확인할 수 있습니다.

image-2

이렇게 ToC 에 필요한 id 정보까지 활용할 수 있게 됐습니다. 결과적으로 ToC 렌더링에 필요한 데이터를 완성했습니다.



✅ ToC 를 클릭하면 해당 섹션으로 이동

ToC 데이터를 통해 본인 스타일에 맞는 형태로 UI를 생성하면 됩니다. 저는 아래와 같은 UI 로 표현했습니다. (UI 에 대한 설명은 핵심이 아니라서 생략하겠습니다. 자세한 것은 repo 를 확인해 주세요 !)

image-3

다음 할 작업은 특정 ToC 아이템을 클릭하면 해당 영역으로 이동하는 기능이 필요합니다.
이전 단계에서 ToC 아이템에 필요한 데이터 중에 id 태그를 Next.js 의 태그 href 속성에 "#{id}" 형태로 주입해 주면 됩니다. 그리고 태그에 정의한 onClick 이벤트를 등록합니다.

섹션 내 이동을 위한 코드 블록
"use client";
 
...
 
import Link from "next/link";
import { useRouter } from "next/navigation";
import { MouseEventHandler ... } from "react";
 
...
 
const PostToC = ({ ... }) => {
  const router = useRouter();
 
  ...
 
  const handleToCItemClick: MouseEventHandler<HTMLAnchorElement> = (event) => {
    event.preventDefault();
 
    const decodedHashId = decodeURIComponent(event.currentTarget.hash);
    const scrollTargetId = decodedHashId.replace(/.*\#/, "");
    const $scrollTarget = document.getElementById(scrollTargetId);
    const $header = document.getElementById("blog-header");
 
    if ($scrollTarget) {
      // 1. change url hash
      router.push(decodedHashId, { scroll: false });
 
      // 2. smooth scroll target section
      window.scrollTo({
        top: $scrollTarget.offsetTop - ($header?.offsetHeight ?? 0),
        behavior: "smooth",
      });
    }
  };
 
  return (
      <>
        ...
		{postToCList.map((postToCItem, index) => {
	        const { level, text, href } = postToCItem;
 
            ...
            return (
                <Link
                  href={`#${href}`}
                  onClick={handleToCItemClick}
                >
                  {text}
                </Link>
            );
        })}
 
        ...
      </>
  );
};
 
export default PostToC;
 

onClick 에 등록한 handleToCItemClick 핸들러에서는 다음과 같은 작업을 합니다.

  • event 객체에 등록된 hash 가 있으면 이를 decode & refine 해서 element 를 찾을 수 있도록 id 로 변환합니다.
  • id 에 해당하는 element 의 offsetTop - 페이지 헤더의 offsetHeight 값 만큼 브라우저를 스크롤 합니다.

decode & refine 하는 과정이 필요한 이유는 hash 그대로 값을 사용하면 encode 된 값이기 때문에 원하는 형태의 id 를 사용하지 못하기 때문입니다. 실제로 브라우저에서 확인해 보면 아래 첨부한 이미지와 같이 decodedHashId (decode) -> scrollTargetId (refine) -> $scrollTarget 순으로 getElementById 연산에 필요한 id 값이 추출된 것을 확인할 수 있습니다.

image-4

getElementById 으로 element 가 있다면 window.scrollTo 로 스크롤 하기 전에 Next.js 의 Link 태그 기본 작동 스크롤을 막기 위해 router.push 시 { scroll : false } param 을 설정합니다. Link 태그의 기본 작동인 스크롤을 최상단으로 이동시키는 것을 방지하기 위함입니다. (cc. 참고)

제가 원한 동작은 ToC 아이템에 해당하는 영역으로 부드럽게 스크롤 되어 이동하길 원했습니다. 이를 위해 window 를 직접 조작해서 Header 바로 밑으로 이동할 영역의 시작점으로 이동시켰습니다.

video-1


✅ 특정 섹션에 해당하는 ToC 아이템 하이라이팅

다음으로 ToC 에서 내가 읽는 부분이 글의 어느 부분인지 인지시켜 주면 좋겠다고 생각하여 하이라이팅 기능을 추가로 넣었습니다. 이를 위해서는 다음과 같은 정보가 필요했습니다.

  • 현재 스크롤 위치
  • 현재 스크롤에 해당하는 ToC 아이템

스크롤 정보를 저는 state 에 담았습니다. Web API - Intersection Observer 를 사용하면 state 를 사용하지 않고도 쉽고 성능 측면에서도 불필요한 스크롤 state 를 관리할 필요가 없다는 장점이 있었지만, 스크롤을 빠르게 내리는 액션에서 대응이 안되는 이슈가 있는 것을 확인했습니다. (cc. 참고)

블로그에 글을 읽는 것 외에는 성능이 중요한 별다른 기능은 없었기에 UX 에 문제가 없는 것이 더 중요하다고 생각해서 이 방식을 채택했습니다.

ToC 하이라이팅을 위한 코드 블록
"use client";
 
import { throttle } from "@/functions/browser";
...
 
const PostToC = ({ ... }) => {
  const [postToCList, setPostToCList] = useState<IToCItem[]>([]);
  const isToCReady = postToCList.length !== 0;
 
  // 스크롤 state
  const [scrollY, setScrollY] = useState(isServerSide ? 0 : window.scrollY);
 
  // 하이라이팅 되어야하는 ToC 아이템 state
  const [highlightToCItem, setHighlightToCItem] = useState<IToCItem>();
 
  // 가장 최근 하이라이팅 된 ToC 아이템 state (🔎)
  const [lastHighlightToCItem, setLastHighlightToCItem] = useState<IToCItem>();
 
  ...
 
  // 1. 스크롤 state 업데이트 (with. throttle)
  useEffect(() => {
    const handleScroll = throttle(() => {
      setScrollY(window.scrollY);
    }, 200);
    handleScroll();
 
    if (!isServerSide) {
      window.addEventListener("scroll", handleScroll);
    }
 
    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, [isServerSide]);
 
 
  // 2. 현재 스크롤에 해당하는 ToC 아이템 state 업데이트
  useEffect(() => {
    const handleToCHighlight = () => {
      if (postToCList.length !== 0) {
        const candidateHighlightToCList = postToCList.filter((postToCItem) => {
          const { absoluteTop } = postToCItem;
 
          return (
            scrollY <= absoluteTop &&
            absoluteTop < scrollY + document.documentElement.clientHeight
          );
        });
 
        if (candidateHighlightToCList.length !== 0) {
          setLastHighlightToCItem(candidateHighlightToCList.at(0));
        }
 
        const isToCArea =
          scrollY + document.documentElement.clientHeight >
          (postToCList.at(0)?.absoluteTop ?? 0);
 
        setHighlightToCItem(
          isToCArea
            ? candidateHighlightToCList?.at(0) ?? lastHighlightToCItem
            : undefined,
        );
      }
    };
 
    handleToCHighlight();
  }, [isToCReady, scrollY]);
 
  return (
    <aside>
        <>
          {postToCList.map((postToCItem) => {
            const { text, href } = postToCItem;
 
			// 하이라이팅 TocItem 의 text 와 같을 경우 하이라이팅
            const isHighlight = text === highlightToCItem?.text;
 
            return (
              <div
				key={`toc-item-${text}`}
                className={`${isHighlight ? `font-semibold text-[var(--highlight)]` : ``}`}
              >
                <Link
                  ...
                >
                  {text}
                </Link>
              </div>
            );
          })}
        </>
    </aside>
  );
};
 
export default PostToC;

위 코드에서 두 개의 useEffect 가 존재합니다. 첫 번째 useEffect 에서는 throttle 를 주어 스크롤 정보(= 이하 scrollY) 를 업데이트합니다. 두 번째 useEffect 에서는 ToC 아이템 state 생성 시 계산했던 ToC 아이템들의 위치와 scrollY 값을 비교해서 하이라이팅 되어야하는 ToC 아이템 state 를 추출하는 과정입니다.



😢 만났던 이슈

scrollY 변화에 따라 두 번째 useEffect 가 실행이 되면 하이라이팅 되어야할 ToC 아이템 계산이 재실행되는데요, line 42 ~ 48 line 의 결과가 존재하지 않는 경우(= 현재 뷰포트 범위 안에 ToC 아이템이 하나도 존재하지 않는 경우) ToC 아이템에서 하이라이팅이 안 되는 엣지 케이스가 있었습니다.

video-2

lastHighlightToCItem 은 이 경우를 방지하기 위해 가장 최근에 하이라이팅 되었던 ToC 아이템을 state 로 저장해놨다가 위와 같은 상황에 사용하기위한 ToC 아이템 정보입니다.

video-3


✅ 글 본문이 영역을 벗어나면 ToC 숨기기

스크롤이 본문 영역 이상으로 벗어났을 경우에는 ToC 가 사라지 효과를 주고 싶었습니다. 이를 위해 스크롤 값이 변함에 따라 현재 스크롤이 본문 영역을 벗어났는지를 체크하는 핸들러를 추가했습니다.

저는 글 상세 하단에 공통으로 존재하는 댓글 섹션 의 노출 여부로 이를 판단했습니다. 약간의 offset (= 사용자 브라우저 height 의 1/2 크기) 을 주어서 댓글 영역이 절반 이상 보이기 시작하면 ToC 가 사라지는 효과를 주었습니다.

ToC 노출 제어를 위한 코드 블록
"use client";
 
...
 
const PostToC = ({ ... }) => {
  const [isPostToCEnd, setIsPostToCEnd] = useState<boolean>();
 
  ...
 
  // 2. 현재 스크롤에 해당하는 ToC 아이템 state 업데이트
  useEffect(() => {
    ...
 
	// ToC 영역(= 본문)을 넘었는지 (= 글 상세 하단 댓글 섹션의 노출 여부로 판단)
    const handlePostToCEnd = () => {
      const scrollTop = window.scrollY || document.documentElement.scrollTop;
      const $commentsSection = document.getElementById(
        "comments-giscus-container",
      );
 
      if ($commentsSection) {
        const commentsSectionAbsoluteTop =
          $commentsSection?.getBoundingClientRect().top + scrollTop;
        const offset = document.documentElement.clientHeight / 2;
 
        const isPostToCEnd =
          scrollY + document.documentElement.clientHeight >
          commentsSectionAbsoluteTop + offset;
 
        setIsPostToCEnd(isPostToCEnd);
      }
    };
 
	...
 
    handlePostToCEnd();
  }, [isToCReady, scrollY]);
 
  return (
    <aside
      className={`transition-opacity duration-300 ${isPostToCEnd ? `opacity-0` : `opacity-100`} ...`}
    >
        <>
          {postToCList.map((postToCItem) => {
            const { text, href } = postToCItem;
 
			// 하이라이팅 TocItem 의 text 와 같을 경우 하이라이팅
            const isHighlight = text === highlightToCItem?.text;
 
            return (
              <div
				key={`toc-item-${text}`}
                className={`${isHighlight ? `font-semibold text-[var(--highlight)]` : ``}`}
              >
                <Link
                  ...
                >
                  {text}
                </Link>
              </div>
            );
          })}
        </>
    </aside>
  );
};
 
export default PostToC;
video-4


👋 글을 마치며

이번 글에서는 블로그와 콘텐츠 성 페이지에서 네비게이션 및 본문의 구성을 확인하는데 용이한 역할을 하는 ToC 를 구현하는 과정에 대해 소개했습니다. 평소에 타 블로그를 구경하면서 ToC 를 어떻게 구현했는지 궁금했는데요.

IntersectionObserver 같은 Web API 를 활용한 여러 사례들도 확인할 수 있었고, 이 방식으로 구현할 경우 빠르게 스크롤을 진행할 경우 제대로 감지하지 못하는 엣지 케이스도 확인해 볼 수 있었습니다. 이를 직접 스크롤 상태 값을 활용(with. throttle) 해서 각 ToC 섹션의 노출 여부를 판단하는 방식으로 해결하는 과정까지 진행해 볼 수 있었습니다. 😋

ToC 구현체에 대해 더 궁금한 점은 repo 를 확인해 주세요 !

오류 제보나 피드백은 언제나 환영입니다. 🙂