안녕하세요 youngminss 입니다. 이번 글에서는 Table of Content (이하, ToC) 를 블로그 상세 페이지에 추가한 과정을 소개하려 합니다.
글을 작성할 때는 목차를 두는데요, ToC 가 한국어로 번역하면 그 "목차"를 의미합니다.
ToC 를 글 상세 페이지에 배치하면 ToC 만 봐도 일차적으로 해당 글에서 소개할 소재들을 파악할 수 있다는 점과 필요할 때 빠르게 해당 섹션으로 이동이 가능할 수 있다는 점이 이점으로 생각이 들어 추가하게 되었습니다.
🤔 요구사항 분석
하나의 글에는 하나의 주 제목이 하나 존재할 수 있습니다. 한편 하나의 글 안에서도 목차를 나눌 수 있고 이를 여러 개의 부 제목으로 구분 지을 수 있습니다.
HTML에서 제목과 관련된 태그는 h Tag (= heading)입니다. 보통 중요도에 따라 h1 ~ h6 까지 계층을 구분할 수 있습니다. 글에서는 주 제목(h1) 을 시작으로 여러 하위 소제목(h2 ~ h6) 들로 계층을 둔다고 이해할 수 있습니다. 그래서 보통 다음과 같은 구조로 글을 보통 작성합니다.
좀 더 구체화하자면 저의 경우는 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 이 그 부분입니다.
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)
아래 의존성 패키지를 추가해 줍니다.
그리고 MDX 컴포넌트의 options 속성에 rehype 의존성 배열 속성에 설치한 패키지를 추가해 줍니다. 저는 MDXRemote 컴포넌트를 사용하고 있고 다음과 같이 추가했습니다.
렌더링 된 결과에서 h tag에 id 속성이 자동으로 추가된 것을 확인할 수 있습니다.
이렇게 ToC 에 필요한 id 정보까지 활용할 수 있게 됐습니다. 결과적으로 ToC 렌더링에 필요한 데이터를 완성했습니다.
✅ ToC 를 클릭하면 해당 섹션으로 이동
ToC 데이터를 통해 본인 스타일에 맞는 형태로 UI를 생성하면 됩니다. 저는 아래와 같은 UI 로 표현했습니다. (UI 에 대한 설명은 핵심이 아니라서 생략하겠습니다. 자세한 것은 repo 를 확인해 주세요 !)
다음 할 작업은 특정 ToC 아이템을 클릭하면 해당 영역으로 이동하는 기능이 필요합니다.
이전 단계에서 ToC 아이템에 필요한 데이터 중에 id 태그를 Next.js 의 태그 href 속성에 "#{id}" 형태로 주입해 주면 됩니다. 그리고 태그에 정의한 onClick 이벤트를 등록합니다.
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 에 문제가 없는 것이 더 중요하다고 생각해서 이 방식을 채택했습니다.
위 코드에서 두 개의 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 가 사라지는 효과를 주었습니다.
👋 글을 마치며
이번 글에서는 블로그와 콘텐츠 성 페이지에서 네비게이션 및 본문의 구성을 확인하는데 용이한 역할을 하는 ToC 를 구현하는 과정에 대해 소개했습니다. 평소에 타 블로그를 구경하면서 ToC 를 어떻게 구현했는지 궁금했는데요.
IntersectionObserver 같은 Web API 를 활용한 여러 사례들도 확인할 수 있었고, 이 방식으로 구현할 경우 빠르게 스크롤을 진행할 경우 제대로 감지하지 못하는 엣지 케이스도 확인해 볼 수 있었습니다. 이를 직접 스크롤 상태 값을 활용(with. throttle) 해서 각 ToC 섹션의 노출 여부를 판단하는 방식으로 해결하는 과정까지 진행해 볼 수 있었습니다. 😋