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


안녕하세요 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 처리를 한 콘텐츠가 렌더링 된 결과 예시를 확인하겠습니다.

markdown에서 "##" 로 작성한 것이 h2 태그로 렌더링 된 것을 확인할 수 있습니다. 우리는 콘텐츠 내 h2, h3 태그에 대한 정보를 추출하면 되겠습니다. h 태그들의 정보는 querySelector
를 사용해서 추출할 수 있습니다. 콘텐츠 내 모든 h2, h3 태그에 대한 정보가 필요한 것이기 때문에 Document.querySeletorAll selector 를 사용했습니다. 아래 코드 블록에서 line 7 이 그 부분입니다.
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-slug 는 h tag 에 id 를 자동으로 삽입해 주는 rehype plugin 입니다. tag 내의 text 를 기반으로 id 속성을 추가해 줍니다. (cc. github-slugger)
아래 의존성 패키지를 추가해 줍니다.
npm install rehype-slug
그리고 MDX 컴포넌트의 options 속성에 rehype 의존성 배열 속성에 설치한 패키지를 추가해 줍니다. 저는 MDXRemote 컴포넌트를 사용하고 있고 다음과 같이 추가했습니다.
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 속성이 자동으로 추가된 것을 확인할 수 있습니다.

이렇게 ToC 에 필요한 id 정보까지 활용할 수 있게 됐습니다. 결과적으로 ToC 렌더링에 필요한 데이터를 완성했습니다.
✅ ToC 를 클릭하면 해당 섹션으로 이동
ToC 데이터를 통해 본인 스타일에 맞는 형태로 UI를 생성하면 됩니다. 저는 아래와 같은 UI 로 표현했습니다. (UI 에 대한 설명은 핵심이 아니라서 생략하겠습니다. 자세한 것은 repo 를 확인해 주세요 !)

다음 할 작업은 특정 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 값이 추출된 것을 확인할 수 있습니다.

getElementById 으로 element 가 있다면 window.scrollTo 로 스크롤 하기 전에 Next.js 의 Link
태그 기본 작동 스크롤을 막기 위해 router.push 시 { scroll : false }
param 을 설정합니다. Link 태그의 기본 작동인 스크롤을 최상단으로 이동시키는 것을 방지하기 위함입니다. (cc. 참고)
제가 원한 동작은 ToC 아이템에 해당하는 영역으로 부드럽게 스크롤 되어 이동하길 원했습니다. 이를 위해 window 를 직접 조작해서 Header 바로 밑으로 이동할 영역의 시작점으로 이동시켰습니다.

✅ 특정 섹션에 해당하는 ToC 아이템 하이라이팅
다음으로 ToC 에서 내가 읽는 부분이 글의 어느 부분인지 인지시켜 주면 좋겠다고 생각하여 하이라이팅 기능을 추가로 넣었습니다. 이를 위해서는 다음과 같은 정보가 필요했습니다.
- 현재 스크롤 위치
- 현재 스크롤에 해당하는 ToC 아이템
스크롤 정보를 저는 state 에 담았습니다. Web API - Intersection Observer 를 사용하면 state 를 사용하지 않고도 쉽고 성능 측면에서도 불필요한 스크롤 state 를 관리할 필요가 없다는 장점이 있었지만, 스크롤을 빠르게 내리는 액션에서 대응이 안되는 이슈가 있는 것을 확인했습니다. (cc. 참고)
블로그에 글을 읽는 것 외에는 성능이 중요한 별다른 기능은 없었기에 UX 에 문제가 없는 것이 더 중요하다고 생각해서 이 방식을 채택했습니다.
"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 아이템에서 하이라이팅이 안 되는 엣지 케이스가 있었습니다.

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

✅ 글 본문이 영역을 벗어나면 ToC 숨기기
스크롤이 본문 영역 이상으로 벗어났을 경우에는 ToC 가 사라지 효과를 주고 싶었습니다. 이를 위해 스크롤 값이 변함에 따라 현재 스크롤이 본문 영역을 벗어났는지를 체크하는 핸들러를 추가했습니다.
저는 글 상세 하단에 공통으로 존재하는 댓글 섹션 의 노출 여부로 이를 판단했습니다. 약간의 offset (= 사용자 브라우저 height 의 1/2 크기) 을 주어서 댓글 영역이 절반 이상 보이기 시작하면 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;

👋 글을 마치며
이번 글에서는 블로그와 콘텐츠 성 페이지에서 네비게이션 및 본문의 구성을 확인하는데 용이한 역할을 하는 ToC 를 구현하는 과정에 대해 소개했습니다. 평소에 타 블로그를 구경하면서 ToC 를 어떻게 구현했는지 궁금했는데요.
IntersectionObserver 같은 Web API 를 활용한 여러 사례들도 확인할 수 있었고, 이 방식으로 구현할 경우 빠르게 스크롤을 진행할 경우 제대로 감지하지 못하는 엣지 케이스도 확인해 볼 수 있었습니다. 이를 직접 스크롤 상태 값을 활용(with. throttle) 해서 각 ToC 섹션의 노출 여부를 판단하는 방식으로 해결하는 과정까지 진행해 볼 수 있었습니다. 😋
ToC 구현체에 대해 더 궁금한 점은 repo 를 확인해 주세요 !
오류 제보나 피드백은 언제나 환영입니다. 🙂