배경
Quartz v4의 기본 태그 시스템은 /tags/[tagname] 경로에서 해당 태그를 가진 글 목록을 보여주는 단순한 구조다. 글이 적을 때는 충분하지만, 태그가 수십 개로 늘어나면 문제가 생긴다.
- 태그 간 계층 관계를 표현할 수 없다.
cs/operating-system과cs/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 트리를 순회하며 이 노드를 탐지한다.
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를 반환해야만 컴포넌트가 렌더링된다. false면 null을 반환하여 아무것도 출력하지 않는다.
이 방식의 장점은 별도의 설정 파일이나 frontmatter 옵션 없이, 마크다운 본문에 코드 블록 하나를 삽입하는 것만으로 동적 컴포넌트를 활성화할 수 있다는 것이다. 마크다운의 선언적 특성을 그대로 유지하면서 확장 가능한 패턴이다.
계층적 태그 구조
슬래시 네임스페이스
태그 계층은 슬래시(/)로 구분된 네임스페이스 규칙을 따른다.
cs/operating-system- primary tag는cs, sub-tag는cs/operating-systemdev/blog-customization- primary tag는dev, sub-tag는dev/blog-customizationunity- sub-tag 없이 primary tag만 존재
태그 계산 로직
빌드 시점에서 모든 페이지의 태그를 순회하며 세 가지 Map을 구축한다.
// 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-system과 cs/programming-language 태그를 동시에 가질 때 cs의 카운트가 중복으로 올라가는 것을 방지한다.
표시 태그 필터링
한 페이지에 cs와 cs/operating-system이 모두 태그되어 있다면, 아티클 카드에 cs를 표시할 필요는 없다. 자식 태그가 존재하면 부모 태그를 숨기는 getDisplayTags() 함수가 이를 처리한다.
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 상태의 컨테이너에 렌더링한다.
<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 라우팅에서 페이지 전환 시마다 발생하는 이벤트다.
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 등 모든 하위 태그를 가진 글이 포함된다.
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 그룹의 표시/숨김을 처리하고, 페이지네이션을 초기화한다.
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)으로 한 번에 표시할 아티클 수를 제한한다. “더보기” 버튼을 누르면 visibleCount가 itemsPerPage만큼 증가하고, 모든 매칭 아티클이 표시되면 버튼이 사라진다.
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 등장 효과를 만든다.
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 + 클라이언트 하이드레이션 조합은 재사용 가능한 패턴이 된다.