노현진's Blog

[개발 기록] Next.js 목차 기능 구현 방법

Next.js 프로젝트에서 목차 기능 구현 방법에 대해 정리한 페이지입니다.

Posted
Preview Image
By HyunJinNo

Tags

TypeScript, React, Next.js, TOC, Development History, Intersection Observer

Environment

Next.js v16.1.4

1. 개요

Next.js 프로젝트에서 목차 기능 구현 방법에 대해 정리한 페이지입니다.

2. 목차 (Table of Contents, TOC) 구현하기

이번 글에서는 현재 보고 있는 블로그에서 사용하고 있는 목차 기능을 구현한 방법에 대해 설명합니다.

2.1. Step 1 - slug(id) 생성하기

slug란 주로 웹사이트 URL에서 포스트나 페이지의 내용을 간결하게 설명하는 고유한 문자열을 뜻하며, 주로 영문 키워드를 하이픈(-)으로 연결하여 사용합니다. Heading 태그에 slug(id)를 할당하여 특정 목차로 이동할 수 있는 기능을 구현할 수 있습니다.

다음 코드는 Heading 텍스트를 URL에 사용할 수 있는 slug(id) 형태로 변환하는 코드입니다.

typescript
1const id = text
2  .toLowerCase()
3  .replace(/\s+/g, "-")
4  .replace(/[^\w\-가-힣]/g, "");

위와 같은 코드를 통해 문단 제목이 다음과 같은 과정을 거쳐 slug(id)로 변환됩니다.

plaintext
12.3. QR 코드의 구조 (Structure)
232.3. qr 코드의 구조 (structure) // toLowerCase: 소문자 변환
452.3.-qr-코드의-구조-(structure) // replace(/\s+/g, "-"): 공백 문자를 "-"로 변환
6723-qr-코드의-구조-structure // replace(/[^\w\-가-힣]/g, ""): 특수 문자 제거

이 MDX 블로그에서 사용한 코드는 다음과 같습니다.

typescript
1/* @/mdx-components.tsx */
2
3/* ... */
4
5import type { MDXComponents } from "mdx/types";
6
7const components = {
8  /* ... */
9
10  h2: ({ children }) => (
11    <h2
12      className="mt-20 mb-5 border-b border-gray-200 pb-5 text-2xl font-semibold text-black dark:text-white"
13      id={children
14        .toLowerCase()
15        .replace(/\s+/g, "-")
16        .replace(/[^\w\--힣]/g, "")}
17    >
18      {children}
19    </h2>
20  ),
21  h3: ({ children }) => (
22    <h3
23      className="mt-10 mb-4 text-xl font-semibold text-black dark:text-white"
24      id={children
25        .toLowerCase()
26        .replace(/\s+/g, "-")
27        .replace(/[^\w\--힣]/g, "")}
28    >
29      {children}
30    </h3>
31  ),
32  h4: ({ children }) => (
33    <h4
34      className="mt-8 mb-4 text-lg font-semibold text-black dark:text-white"
35      id={children
36        .toLowerCase()
37        .replace(/\s+/g, "-")
38        .replace(/[^\w\--힣]/g, "")}
39    >
40      {children}
41    </h4>
42  ),
43  h5: ({ children }) => (
44    <h5
45      className="mt-6 mb-2 font-semibold text-black dark:text-white"
46      id={children
47        .toLowerCase()
48        .replace(/\s+/g, "-")
49        .replace(/[^\w\--힣]/g, "")}
50    >
51      {children}
52    </h5>
53  ),
54
55  /* ... */
56} satisfies MDXComponents;
57
58export function useMDXComponents(): MDXComponents {
59  return components;
60}

2.2. Step 2 - Heading 추출하기

목차를 생성하기 위해선 블로그 게시글에 사용된 Heading을 파싱하여 Heading 배열을 생성해야 합니다. 예를 들어, 다음과 같은 목차가 있을 때,

목차 예시

위와 같은 목차를 다음과 같은 트리 구조로 배열을 생성해야 합니다.

plaintext
1├── 1. 개요
2├── 2. QR 코드
3|   ├── 2.1. QR 코드란?
4|   ├── 2.2. QR 코드의 특징
5|   ├── 2.3. QR 코드의 구조
6|   |   ├── 2.3.1. 위치 탐지 패턴 (Finder Pattern)
7|   |   ├── 2.3.2. 정렬 패턴 (Alignment Pattern)
8|   |   └── (...)
9|   └── 2.4. React에서 QR 코드 생성하기
10└── 3. 참고 자료

이와 같은 트리 구조를 생성하기 위해선 재귀 컴포넌트를 사용해야 합니다. 먼저 다음과 같이 재귀 구조를 나타낼 수 있는 재귀 타입을 선언합니다.

typescript
1/* @/entities/toc/model/tocNode.ts */
2
3export interface TocNode {
4  tagName: string;
5  textContent: string;
6  id: string;
7  children: TocNode[];
8}

위의 코드를 설명하자면 다음과 같습니다.

  • tagName
    H2, H3 등, Heading의 태그 이름입니다.
  • textContent
    1. 개요과 같은 문단 제목입니다.
  • id
    Heading 태그에 할당된 slug(id) 값입니다.
  • children
    특정 목차의 하위 목차 목록입니다.

선언한 재귀 타입을 사용하여 TOC 트리 구조를 생성하는 코드는 다음과 같습니다.

typescript
1/* @/entities/toc/model/useTableOfContents.ts */
2
3"use client";
4
5import { usePathname } from "next/navigation";
6import { useEffect, useEffectEvent, useState } from "react";
7import { TocNode } from "./tocNode";
8
9export const useTableOfContents = () => {
10  const pathname = usePathname();
11  const [activeId, setActiveId] = useState("");
12  const [headingList, setHeadingList] = useState<TocNode[]>([]);
13
14  const updateHeadingList = useEffectEvent((arr: TocNode[]) => {
15    setHeadingList(arr);
16  });
17
18  useEffect(() => {
19    const observer = new IntersectionObserver(
20      (entries) => {
21        entries.forEach((entry) => {
22          if (entry.isIntersecting) {
23            setActiveId(entry.target.id);
24          }
25        });
26      },
27      { rootMargin: "0px 0px -80% 0px" },
28    );
29
30    const arr: TocNode[] = [];
31
32    for (const element of document.body.getElementsByTagName("main")[0]
33      .children) {
34      switch (element.tagName) {
35        case "H2":
36          arr.push({
37            tagName: element.tagName,
38            textContent: element.textContent,
39            id: element.id,
40            children: [],
41          });
42          observer.observe(element);
43          break;
44        case "H3":
45          arr[arr.length - 1].children.push({
46            tagName: element.tagName,
47            textContent: element.textContent,
48            id: element.id,
49            children: [],
50          });
51          observer.observe(element);
52          break;
53        case "H4":
54          arr[arr.length - 1].children[
55            arr[arr.length - 1].children.length - 1
56          ].children.push({
57            tagName: element.tagName,
58            textContent: element.textContent,
59            id: element.id,
60            children: [],
61          });
62          observer.observe(element);
63          break;
64        case "H5":
65          arr[arr.length - 1].children[
66            arr[arr.length - 1].children.length - 1
67          ].children[
68            arr[arr.length - 1].children[
69              arr[arr.length - 1].children.length - 1
70            ].children.length - 1
71          ].children.push({
72            tagName: element.tagName,
73            textContent: element.textContent,
74            id: element.id,
75            children: [],
76          });
77          observer.observe(element);
78          break;
79        default:
80          break;
81      }
82    }
83
84    updateHeadingList(arr);
85
86    return () => {
87      observer.disconnect();
88    };
89  }, [pathname]);
90
91  return { activeId, headingList };
92};

위의 코드를 설명하자면 다음과 같습니다.

  • const [headingList, setHeadingList] = useState<TocNode[]>([]);
    목차 목록을 나타내는 상태값입니다.
  • document.body.getElementsByTagName("main")[0].children
    이 블로그에서 블로그 게시물 내용은 HTML main 태그에 포함되어 있습니다. 따라서 Heading 목록을 가져오기 위해 먼저 main 태그 내 하위 태그 목록들을 순회해야 합니다.
  • switch (element.tagName)
    Heading 태그인 경우 트리 구조를 나타내는 배열인 arr에 추가됩니다.

위의 코드를 통해 TOC 트리 구조를 생성한 예시는 다음과 같습니다.

typescript
1[
2  {
3    tagName: "H2",
4    textContent: "1. 개요",
5    id: "1-개요",
6    children: [],
7  },
8  {
9    tagName: "H2",
10    textContent: "2. QR 코드",
11    id: "2-qr-코드",
12    children: [
13      {
14        tagName: "H3",
15        textContent: "2.1. QR 코드란?",
16        id: "21-qr-코드란",
17        children: [],
18      },
19      {
20        tagName: "H3",
21        textContent: "2.2. QR 코드의 특징",
22        id: "22-qr-코드의-특징",
23        children: [],
24      },
25      {
26        tagName: "H3",
27        textContent: "2.3. QR 코드의 구조",
28        id: "23-qr-코드의-구조",
29        children: [
30          {
31            tagName: "H4",
32            textContent: "2.3.1. 위치 탐지 패턴 (Finder Pattern)",
33            id: "231-위치-탐지-패턴-finder-pattern",
34            children: [],
35          },
36          {
37            tagName: "H4",
38            textContent: "2.3.2. 정렬 패턴 (Alignment Pattern)",
39            id: "232-정렬-패턴-alignment-pattern",
40            children: [],
41          },
42          {
43            tagName: "H4",
44            textContent: "2.3.3. 타이밍 패턴 (Timing Pattern)",
45            id: "233-타이밍-패턴-timing-pattern",
46            children: [],
47          },
48          {
49            tagName: "H4",
50            textContent: "2.3.4. 버전 정보",
51            id: "234-버전-정보",
52            children: [],
53          },
54          {
55            tagName: "H4",
56            textContent: "2.3.5. 포맷 정보 (Format Information)",
57            id: "235-포맷-정보-format-information",
58            children: [],
59          },
60          {
61            tagName: "H4",
62            textContent: "2.3.6. 데이터 영역",
63            id: "236-데이터-영역",
64            children: [],
65          },
66          {
67            tagName: "H4",
68            textContent: "2.3.7. 여백",
69            id: "237-여백",
70            children: [],
71          },
72        ],
73      },
74      {
75        tagName: "H3",
76        textContent: "2.4. React에서 QR 코드 생성하기",
77        id: "24-react에서-qr-코드-생성하기",
78        children: [
79          {
80            tagName: "H4",
81            textContent: "2.4.1. qrcode",
82            id: "241-qrcode",
83            children: [],
84          },
85          {
86            tagName: "H4",
87            textContent: "2.4.2. qrcode.react",
88            id: "242-qrcodereact",
89            children: [],
90          },
91        ],
92      },
93    ],
94  },
95  {
96    tagName: "H2",
97    textContent: "3. 참고 자료",
98    id: "3-참고-자료",
99    children: [],
100  },
101];

2.3. Step 3 - Intersection Observer 적용하기

현재 active 목차 위치를 표시하기 위해선 Intersection Observer를 활용할 수 있습니다. 먼저 다음과 같이 현재 보고 있는 목차를 감지하는 observer를 선언합니다.

typescript
1const observer = new IntersectionObserver(
2  (entries) => {
3    entries.forEach((entry) => {
4      if (entry.isIntersecting) {
5        setActiveId(entry.target.id);
6      }
7    });
8  },
9  { rootMargin: "0px 0px -80% 0px" },
10);

여기서 { rootMargin: "0px 0px -80% 0px" }은 뷰포트의 관찰 영역을 아래에서 80% 줄이겠다는 의미입니다. 즉, 다음과 같이 상단 20%에 들어오는 목차를 active 목차로 표시하게 됩니다.

plaintext
1┌─────────────────┐
2│     감지 영역     │  ← 위 20% 부분에 들어오는 목차 감지
3├─────────────────┤
4│                 │
5│     (제외)       │ ← 아래 80% 제외
6│                 │
7└─────────────────┘

이후 Heading에 해당하는 목차들에 대해 Intersection Observer를 적용하여 activeId 값을 변경하게 됩니다.

typescript
1observer.observe(element);

2.4. Step 4 - 목차 UI 구현하기

생성된 TOC 트리 구조를 활용하여 특정 목차 및 하위 목차 목록을 나타내는 UI를 구현하자면 다음과 같습니다.

typescript
1/* @/entities/toc/ui/TocList.tsx */
2
3import { TocNode } from "../model/tocNode";
4
5interface TocListProps {
6  activeId: string;
7  tocNode: TocNode;
8}
9
10export const TocList = ({ activeId, tocNode }: TocListProps) => {
11  const containsActive = (tocNode: TocNode): boolean => {
12    if (activeId === tocNode.id) {
13      return true;
14    }
15
16    return tocNode.children.some((child) => containsActive(child));
17  };
18
19  return (
20    <li key={tocNode.id} className="flex flex-col gap-2.5">
21      <a
22        className={[
23          tocNode.tagName === "H2" && "pl-4",
24          tocNode.tagName === "H3" && "pl-7",
25          tocNode.tagName === "H4" && "pl-10",
26          tocNode.tagName === "H5" && "pl-13",
27          activeId === tocNode.id &&
28            "text-custom-blue border-custom-blue -ml-px border-l font-medium dark:border-blue-300 dark:text-blue-300",
29          "hover:text-custom-blue truncate hover:dark:text-blue-300",
30        ].join(" ")}
31        href={`#${tocNode.id}`}
32        onClick={(e) => {
33          e.preventDefault();
34
35          const element = document.getElementById(tocNode.id);
36
37          if (element) {
38            // 요소의 위치를 얻어서 위로 스크롤 조정
39            window.scrollTo({
40              top: element.offsetTop - 30,
41              behavior: "smooth",
42            });
43
44            window.history.pushState(null, "", `#${tocNode.id}`);
45          }
46        }}
47      >
48        {tocNode.textContent}
49      </a>
50      {tocNode.children.length !== 0 && containsActive(tocNode) && (
51        <ul className={["flex flex-col gap-2.5"].join(" ")}>
52          {tocNode.children.map((child) => (
53            <TocList key={child.id} activeId={activeId} tocNode={child} />
54          ))}
55        </ul>
56      )}
57    </li>
58  );
59};

위의 코드를 설명하자면 다음과 같습니다.

  • containsActive
    현재 보고 있는 목차가, 자기 자신 또는 하위 목차에 해당하면 하위 목차를 펼쳐 보일 수 있도록 구현한 부분입니다.
  • window.scrollTo({ top: element.offsetTop - 30, behavior: "smooth", });
    특정 목차 위치로 스크롤하는 부분입니다.
  • window.history.pushState
    브라우저의 세션 기록 스택에 항목을 추가하는 코드입니다.

위의 TocList.tsx 컴포넌트를 활용하여 전체 목차를 나타내는 UI를 구현하자면 다음과 같습니다.

typescript
1/* @/entities/toc/ui/TableOfContents.tsx */
2
3"use client";
4
5import { usePathname } from "next/navigation";
6import { useTableOfContents } from "../model/useTableOfContents";
7import { TocList } from "./TocList";
8
9export const TableOfContents = () => {
10  const pathname = usePathname();
11  const { activeId, headingList } = useTableOfContents();
12
13  if (!pathname.startsWith("/posts")) {
14    return null;
15  }
16
17  return (
18    <section className="animate-fade-up sticky top-12 flex w-full flex-col gap-4 border-l border-gray-200 pb-4">
19      <h2 className="pl-4 font-medium text-[#585858]">Contents</h2>
20      <nav className="text-custom-gray text-sm">
21        <ul className="flex flex-col gap-2.5">
22          {headingList.map((heading) => (
23            <TocList key={heading.id} activeId={activeId} tocNode={heading} />
24          ))}
25        </ul>
26      </nav>
27    </section>
28  );
29};

2.5. 구현 결과

최종 구현 결과는 다음과 같습니다.

Caution

영상에서 목차가 부드럽게 펼쳐지는 애니메이션은 현재 블로그에서는 삭제하였습니다.

3. 참고 자료

© HyunJinNo. Some rights reserved.