배경

Quartz v4의 기본 태그 시스템은 /tags/[tagname] 경로에서 해당 태그를 가진 글 목록을 보여주는 단순한 구조다. 글이 적을 때는 충분하지만, 태그가 수십 개로 늘어나면 문제가 생긴다.

  • 태그 간 계층 관계를 표현할 수 없다. cs/operating-systemcs/programming-language는 모두 cs 카테고리에 속하지만, 기본 태그 페이지에서는 이 관계가 드러나지 않는다.
  • 특정 분야의 글만 빠르게 훑어보려면 태그 페이지를 하나씩 돌아다녀야 한다.
  • 한 페이지에서 태그를 선택하고, 필터링된 결과를 즉시 확인하는 인터랙션이 없다.

목표는 명확했다. 계층적 태그 필터링이 가능한 인터랙티브 아티클 리스트를 만드는 것. Primary tag를 선택하면 하위 sub-tag가 노출되고, 아티클 목록이 실시간으로 필터링되는 구조다. 우아한형제들 기술 블로그의 태그 필터링 UI를 레퍼런스로 참고했다.

코드 블록 트리거 방식

Quartz는 마크다운을 HAST(HTML Abstract Syntax Tree)로 변환한 뒤 JSX 컴포넌트로 렌더링한다. 여기서 핵심 아이디어는 코드 블록의 language identifier를 트리거로 사용하는 것이다.

마크다운에 다음과 같은 코드 블록을 삽입하면 컴포넌트가 활성화된다:

```article-list
```

Quartz의 Rehype Pretty Code 플러그인이 이 코드 블록을 <pre data-language="article-list">로 변환하고, hasArticleListBlock() 함수가 HAST 트리를 순회하며 이 노드를 탐지한다.

quartz/components/l88_CustomTagArticleList.tsx
function hasArticleListBlock(tree: Root): boolean {
  let found = false
  visit(tree, "element", (node: Element) => {
    if (
      node.tagName === "pre" &&
      node.properties?.dataLanguage === "article-list" // [!code highlight]
    ) {
      found = true
    }
  })
  return found
}

이 함수가 true를 반환해야만 컴포넌트가 렌더링된다. falsenull을 반환하여 아무것도 출력하지 않는다.

이 방식의 장점은 별도의 설정 파일이나 frontmatter 옵션 없이, 마크다운 본문에 코드 블록 하나를 삽입하는 것만으로 동적 컴포넌트를 활성화할 수 있다는 것이다. 마크다운의 선언적 특성을 그대로 유지하면서 확장 가능한 패턴이다.

계층적 태그 구조

슬래시 네임스페이스

태그 계층은 슬래시(/)로 구분된 네임스페이스 규칙을 따른다.

  • cs/operating-system - primary tag는 cs, sub-tag는 cs/operating-system
  • dev/blog-customization - primary tag는 dev, sub-tag는 dev/blog-customization
  • unity - sub-tag 없이 primary tag만 존재

태그 계산 로직

빌드 시점에서 모든 페이지의 태그를 순회하며 세 가지 Map을 구축한다.

quartz/components/l88_CustomTagArticleList.tsx
// 1. 전체 태그별 글 개수
const tagCounts = new Map<string, number>()
// 2. Primary tag(첫 번째 세그먼트)별 글 개수
const primaryTagCounts = new Map<string, number>()
 
pages.forEach((page) => {
  const tags = page.frontmatter?.tags ?? []
  const seenPrimary = new Set<string>()
  tags.forEach((tag) => {
    tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1)
    const primary = tag.split("/")[0]
    if (!seenPrimary.has(primary)) {
      seenPrimary.add(primary)
      primaryTagCounts.set(primary, (primaryTagCounts.get(primary) || 0) + 1)
    }
  })
})
 
// 3. Primary tag별 하위 sub-tag 목록
const subTagsByPrimary = new Map<string, string[]>()
primaryTags.forEach((primary) => {
  const subs = [...tagCounts.keys()]
    .filter((tag) => tag.startsWith(primary + "/"))
    .sort((a, b) => (tagCounts.get(b) || 0) - (tagCounts.get(a) || 0))
  if (subs.length > 0) {
    subTagsByPrimary.set(primary, subs)
  }
})

seenPrimary Set은 한 페이지가 cs/operating-systemcs/programming-language 태그를 동시에 가질 때 cs의 카운트가 중복으로 올라가는 것을 방지한다.

표시 태그 필터링

한 페이지에 cscs/operating-system이 모두 태그되어 있다면, 아티클 카드에 cs를 표시할 필요는 없다. 자식 태그가 존재하면 부모 태그를 숨기는 getDisplayTags() 함수가 이를 처리한다.

quartz/components/l88_CustomTagArticleList.tsx
const getDisplayTags = (tags: string[]) =>
  tags.filter(
    (tag) => !tags.some((t) => t !== tag && t.startsWith(tag + "/")) // [!code highlight]
  )

tag + "/" 접두사를 가진 다른 태그가 존재하면 해당 태그는 필터링된다. 단순하지만 계층 구조에서 중복 표시를 효과적으로 제거한다.

SSG + 클라이언트 사이드 하이드레이션

SSG: Hidden div 렌더링

SSG(Static Site Generation) 단계에서는 모든 태그 버튼, sub-tag 그룹, 아티클 카드를 display: none 상태의 컨테이너에 렌더링한다.

quartz/components/l88_CustomTagArticleList.tsx
<div id="customTagArticleList-data" style="display:none" data-items-per-page={opts.itemsPerPage}>
  <div class="tagList">
    <span class="tag-select-button all selected" data-tag="all">
      All ({pages.length})
    </span>
    {primaryTags.map((tag) => (
      <span class="tag-select-button" data-tag={tag}>
        {tag} ({primaryTagCounts.get(tag)})
      </span>
    ))}
  </div>
  {/* sub-tag groups, article cards, see more button */}
</div>

HTML은 빌드 타임에 완전한 형태로 렌더링되므로 검색 엔진 크롤러가 모든 아티클 링크에 접근할 수 있다. SEO와 인터랙티브 UI를 동시에 확보하는 구조다.

클라이언트: DOM 재배치와 인터랙션

클라이언트 스크립트는 nav 이벤트를 리스닝한다. Quartz의 SPA 라우팅에서 페이지 전환 시마다 발생하는 이벤트다.

quartz/components/scripts/customtagarticlelist.inline.ts
document.addEventListener("nav", () => {
  const source = document.getElementById("customTagArticleList-data")
  const pre = document.querySelector("pre[data-language='article-list']")
  if (!source || !pre) return
 
  // SSR 렌더링된 hidden div를 코드 블록 위치로 이동
  const target = pre.closest("figure") || pre
  source.removeAttribute("style")
  source.id = "customTagArticleList"
  target.replaceWith(source) // [!code highlight]
  // ...
})

핵심은 replaceWith 호출이다. SSG에서 렌더링된 hidden div(#customTagArticleList-data)를 코드 블록이 있던 위치로 이동시킨다. Rehype Pretty Code가 <pre> 태그를 <figure>로 감싸기 때문에 closest("figure")로 래퍼를 찾아 대체한다.

태그 매칭 함수

matchesTag() 함수는 계층적 매칭을 수행한다. Primary tag cs를 선택하면 cs/operating-system, cs/programming-language 등 모든 하위 태그를 가진 글이 포함된다.

quartz/components/scripts/customtagarticlelist.inline.ts
function matchesTag(tags: string[], tag: string): boolean {
  if (tag === "all") return true
  return tags.some(
    (t) => t === tag || t.startsWith(tag + "/") // [!code highlight]
  )
}

startsWith(tag + "/") 패턴으로 정확한 접두사 매칭을 보장한다. cs를 선택했을 때 css가 포함되는 것을 / 구분자로 방지한다.

Primary tag와 Sub-tag 선택 동작

selectTag() 함수는 primary tag 선택 시 호출된다. Sub-tag 그룹의 표시/숨김을 처리하고, 페이지네이션을 초기화한다.

quartz/components/scripts/customtagarticlelist.inline.ts
function selectTag(tag: string) {
  activeTag = tag
  visibleCount = itemsPerPage
 
  // Primary tag에 해당하는 sub-tag 그룹만 표시
  subTagGroups.forEach((g) => {
    g.style.display = tag !== "all" && g.dataset.parent === tag ? "" : "none"
  })
  // Sub-tag 선택 상태 초기화
  container
    .querySelectorAll(".sub-tag-group .tag-select-button")
    .forEach((b) => b.classList.remove("selected"))
 
  updateDisplay()
}

Sub-tag를 클릭하면 activeTag가 해당 sub-tag의 전체 경로(예: cs/operating-system)로 바뀌어 정확한 매칭이 이루어진다. 이미 선택된 sub-tag를 다시 클릭하면 primary tag로 복귀하는 토글 동작도 구현되어 있다.

페이지네이션과 애니메이션

Incremental 로딩

itemsPerPage 옵션(기본값 5)으로 한 번에 표시할 아티클 수를 제한한다. “더보기” 버튼을 누르면 visibleCountitemsPerPage만큼 증가하고, 모든 매칭 아티클이 표시되면 버튼이 사라진다.

quartz/components/scripts/customtagarticlelist.inline.ts
if (seeMoreButton) {
  const handler = () => {
    visibleCount += itemsPerPage
    updateDisplay()
  }
  seeMoreButton.addEventListener("click", handler)
  window.addCleanup(() => seeMoreButton.removeEventListener("click", handler))
}

window.addCleanup()은 Quartz의 SPA 라우팅에서 페이지 전환 시 이벤트 리스너를 정리하기 위한 유틸리티다. 메모리 누수를 방지한다.

Staggered 애니메이션

updateDisplay() 함수는 각 카드에 순차적으로 animate 클래스를 추가하여 staggered 등장 효과를 만든다.

quartz/components/scripts/customtagarticlelist.inline.ts
function updateDisplay() {
  let matchIndex = 0
  pageCards.forEach((card) => {
    const tags: string[] = JSON.parse(card.dataset.tags || "[]")
    const matches = matchesTag(tags, activeTag)
 
    if (matches) {
      matchIndex++
      if (matchIndex <= visibleCount) {
        card.style.display = ""
        card.classList.remove("animate")
        setTimeout(() => card.classList.add("animate"), (matchIndex - 1) * 200) // [!code highlight]
      } else {
        card.style.display = "none"
        card.classList.remove("animate")
      }
    } else {
      card.style.display = "none"
      card.classList.remove("animate")
    }
  })
 
  // 마지막으로 보이는 카드의 하단 border 제거
  let lastVisible: HTMLElement | null = null
  pageCards.forEach((card) => {
    card.style.borderBottom = ""
    if (card.style.display !== "none") lastVisible = card
  })
  if (lastVisible) (lastVisible as HTMLElement).style.borderBottom = "none"
 
  // 더보기 버튼 - 모든 카드 애니메이션 이후 등장
  if (seeMoreButton) {
    const totalMatching = getMatchingCards().length
    const hasMore = totalMatching > visibleCount
    seeMoreButton.style.display = hasMore ? "block" : "none"
    seeMoreButton.classList.remove("animate")
    if (hasMore) {
      setTimeout(
        () => seeMoreButton.classList.add("animate"),
        Math.min(matchIndex, itemsPerPage) * 200,
      )
    }
  }
}

카드마다 (matchIndex - 1) * 200ms 딜레이로 animate 클래스를 추가한다. 첫 번째 카드는 즉시, 두 번째 카드는 200ms 후, 세 번째는 400ms 후에 등장한다. classList.remove("animate")setTimeout으로 다시 추가하는 패턴은 CSS 애니메이션을 re-trigger하기 위한 것이다.

“더보기” 버튼은 마지막 visible 카드의 애니메이션이 끝난 뒤에 등장하도록 Math.min(matchIndex, itemsPerPage) * 200만큼 딜레이된다.

마지막으로 보이는 카드의 borderBottom을 제거하여 리스트 하단이 깔끔하게 마무리되도록 한다.

마무리

이 컴포넌트의 핵심 설계 결정을 정리하면 다음과 같다.

  • 코드 블록 트리거 방식 - 마크다운에 ```article-list```을 삽입하는 것만으로 동적 컴포넌트를 활성화한다. frontmatter 설정이나 별도 파일 수정 없이, 마크다운의 선언적 특성을 유지하면서 확장할 수 있는 패턴이다.
  • SSG + 클라이언트 하이브리드 - 빌드 타임에 완전한 HTML을 렌더링하여 SEO를 확보하고, 클라이언트에서 DOM 재배치와 인터랙션을 처리한다. replaceWith로 hidden div를 코드 블록 위치에 삽입하는 방식이 이 구조의 핵심이다.
  • 슬래시 기반 계층적 태그 - startsWith(tag + "/") 패턴 하나로 primary-sub 태그 관계를 표현하고 필터링한다. 복잡한 태그 트리 구조 없이 문자열 규칙만으로 계층을 구현한 것이 특징이다.

Quartz에서 마크다운 기반으로 동적 UI를 삽입해야 할 때, 코드 블록 트리거 + SSG hidden div + 클라이언트 하이드레이션 조합은 재사용 가능한 패턴이 된다.