Summary

지금 이 글이 올라가 있는 블로그도 Quartz로 만들어졌다. Quartz는 Obsidian 노트를 웹으로 서빙하는 훌륭한 도구지만 UI가 마음에 들지 않았다. shadcn/ui 기반으로 다시 만들고, 장기적으로는 AI Agent까지 붙이고 싶었다. 그게 nuartz의 출발점이다. 처음엔 Quartz 코드를 복사해서 래핑하는 전략을 썼다가 구조적 문제를 발견했고, 플러그인을 해부하면서 실제로 재사용 가능한 것과 그렇지 않은 것을 구분하게 됐다. 이 글은 그 과정에서 고민한 것들의 기록이다.

아이러니

이 글 자체는 Quartz 위에서 작성되고 서빙된다. nuartz가 완성되면 이 글도 그 위에서 보이게 될 것이다.


목적

지금 운영 중인 블로그는 Quartz를 기반으로 한다. Quartz는 Obsidian 볼트를 그대로 웹사이트로 변환해주는 정적 사이트 생성기로, wikilink, callout, backlink, graph view까지 지원하는 완성도 높은 도구다.

그런데 쓰다 보니 두 가지가 계속 걸렸다.

첫째, UI가 마음에 안 든다. Quartz의 기본 디자인은 나쁘지 않지만, shadcn/ui 기반으로 직접 만들고 싶었다. 커스터마이징 여지도 생기고, React 생태계의 컴포넌트를 자유롭게 쓸 수 있으니까.

둘째, AI Agent를 붙이고 싶다. 단순히 글을 보여주는 것을 넘어서, 내 Obsidian 노트를 기반으로 질문에 답하고, 노트 간 연결을 탐색하고, 파일 기반 검색이 가능한 시스템을 만들고 싶었다. 결국 지식 관리 도구로 확장하고 싶었다.

그래서 목표는 이렇게 정리됐다:

Obsidian으로 노트 관리
    +
Next.js + shadcn/ui로 웹 서빙
    +
LangGraph + RAG으로 AI Agent 채팅

이 프로젝트 이름을 nuartz라고 붙였다. Next.js + Quartz.


첫 번째 접근: sync-quartz

아이디어

처음 떠올린 전략은 단순했다. Quartz를 그대로 쓰되, Next.js 위에서 돌아가도록 래핑하자.

upstream/quartz/ (Quartz 소스코드 clone)
    ↓ sync-quartz.ts 스크립트로 복사
packages/nuartz/quartz/ (복사된 Quartz 플러그인)
    ↓ nuartz 코드가 래핑
packages/nuartz/src/ (Next.js 통합 레이어)

sync-quartz.ts 스크립트가 Quartz 레포를 clone하고, 필요한 파일들을 복사해오는 방식이다. Quartz가 업데이트되면 스크립트를 다시 돌리면 된다는 아이디어였다.

// scripts/sync-quartz.ts
const SYNC_TARGETS = [
  { from: 'quartz/plugins', to: 'plugins' },
  { from: 'quartz/util', to: 'util' },
  { from: 'quartz/components', to: 'components' },
  // ...
]
 
// upstream Quartz에서 파일 복사
await cp(sourcePath, destPath, { recursive: true })

혼자 유지보수할 시간이 없으니, Quartz 개발자들이 업데이트하는 걸 가져오는 식으로 하자는 생각이었다.

왜 문제였나

실제로 만들어보니 구조적인 문제가 있었다.

"auto-sync"의 실체

sync-quartz 전략은 결국 파일 복사다.

- 진짜 auto-sync: 버전 의존성으로 관리
+ 실제 동작: Quartz 소스코드를 cp로 복사

Quartz 내부 구조가 바뀌면 복사해도 타입 에러, 런타임 에러가 난다. 결국 Quartz 내부 변화를 직접 추적해야 한다. 포크를 유지보수하는 것과 난이도가 비슷하다.

게다가 실제로 구현을 시작하자마자 막혔다. Quartz의 transformer 플러그인들이 inline script를 emit하는데, 이 스크립트들이 Quartz 자체 런타임을 가정하고 있어서 Next.js에서 그대로 쓸 수가 없었다.

결국 transformer wrapper는 전부 빈 배열을 반환하는 상태로 멈췄다:

// packages/nuartz/src/plugins/transformers/index.ts
export function getDefaultTransformers(): QuartzTransformerPluginInstance[] {
  // TODO: inline script 이슈 해결 후 구현
  return []  // ← 아무것도 안 함
}

README에는 ”✅ Full Obsidian Compatibility”라고 써있지만, wikilink도 callout도 아무것도 처리하지 않는 상태였다.


Quartz 플러그인 해부

막힌 이유를 제대로 파악하려고 Quartz 플러그인 구조를 직접 뜯어봤다.

플러그인 인터페이스

Quartz의 Transformer 플러그인은 4가지 메서드로 구성된다:

// quartz/plugins/types.ts
type QuartzTransformerPluginInstance = {
  name: string
  textTransform?: (ctx: BuildCtx, src: string) => string     // 파싱 전 텍스트 변환
  markdownPlugins?: (ctx: BuildCtx) => PluggableList          // remark 플러그인 목록 반환
  htmlPlugins?: (ctx: BuildCtx) => PluggableList              // rehype 플러그인 목록 반환
  externalResources?: (ctx: BuildCtx) => { js, css }         // 브라우저 런타임 리소스
}

핵심은 markdownPluginshtmlPlugins가 결국 remark/rehype 플러그인 배열을 반환한다는 점이다. Quartz는 unified 파이프라인 위에서 동작한다.

대부분은 npm 패키지 래퍼

플러그인들을 하나씩 열어보면 실체가 보인다.

GFM (GitHub Flavored Markdown):

// quartz/plugins/transformers/gfm.ts
markdownPlugins() {
  return [remarkGfm, smartypants]  // 그냥 npm 패키지 반환
}
htmlPlugins() {
  return [rehypeSlug, rehypeAutolinkHeadings]  // 그냥 npm 패키지 반환
}

Latex:

// quartz/plugins/transformers/latex.ts
import remarkMath from "remark-math"
import rehypeKatex from "rehype-katex"
 
markdownPlugins() { return [remarkMath] }
htmlPlugins() { return [[rehypeKatex, { output: "html" }]] }

SyntaxHighlighting:

// quartz/plugins/transformers/syntax.ts
import rehypePrettyCode from "rehype-pretty-code"
 
htmlPlugins() { return [[rehypePrettyCode, opts]] }

이 세 개는 Quartz 래퍼 없이 npm 패키지를 직접 써도 완전히 동일하다.

진짜 핵심: OFM

ObsidianFlavoredMarkdown (OFM) 만이 진짜 Quartz의 자산이다.

wikilink, callout, highlight(==text==), block reference(^id), tag 파싱 - 이 로직들을 Quartz 개발자들이 직접 구현했다.

// quartz/plugins/transformers/ofm.ts - 직접 구현한 정규식들
export const wikilinkRegex = new RegExp(
  /!?\[\[([^\[\]\|\#\\]+)?(#+[^\[\]\|\#\\]+)?(\\?\|[^\[\]\#]*)?\]\]/g
)
export const calloutRegex = new RegExp(/^\[\!([\w-]+)\|?(.+?)?\]([+-]?)/)
export const tagRegex = new RegExp(
  /(?<=^| )#((?:[-_\p{L}\p{Emoji}\p{M}\d])+(?:\/[-_\p{L}\p{Emoji}\p{M}\d]+)*)/gu
)

실제 callout 파싱도 직접 구현했다:

markdownPlugins(ctx) {
  plugins.push(() => {
    return (tree: Root, _file) => {
      visit(tree, "blockquote", (node) => {
        const match = firstLine.match(calloutRegex)
        if (match) {
          // blockquote를 callout HTML 구조로 변환
          node.data = {
            hProperties: {
              className: ["callout", calloutType],
              "data-callout": calloutType,
            }
          }
        }
      })
    }
  })
}

정리하면:

플러그인출처
Latexnpm 래퍼 (remark-math, rehype-katex)
SyntaxHighlightingnpm 래퍼 (rehype-pretty-code)
GFMnpm 래퍼 (remark-gfm, rehype-slug)
FrontMatternpm 래퍼 (gray-matter, remark-frontmatter)
OFMQuartz 자체 구현 ← 진짜 가치
TOC, Links부분 자체 구현

externalResources란?

inline script 이슈의 실체가 바로 externalResources다.

동작 방식

externalResources()는 플러그인이 브라우저에서 실행될 JS/CSS를 선언하는 메서드다.

// ofm.ts
externalResources() {
  return {
    js: [
      { script: calloutScript, loadTime: "afterDOMReady", contentType: "inline" },
      { script: checkboxScript, loadTime: "afterDOMReady", contentType: "inline" },
      { script: mermaidScript, loadTime: "afterDOMReady", moduleType: "module" },
    ]
  }
}

Quartz 빌드 파이프라인은 이걸 수집해서 생성된 HTML의 <head><script> 태그로 주입한다.

[플러그인 externalResources()]
    ↓ getStaticResourcesFromPlugins(ctx)
    ↓ emitter.emit(ctx, content, staticResources)
[생성된 HTML에 <script> 주입]
    ↓ 브라우저에서 실행
[callout 접기/펼치기, mermaid 렌더링 등]

왜 Next.js에서 안 되나

Quartz의 스크립트들은 Quartz 고유 런타임에 의존한다.

// callout.inline.ts
document.addEventListener("nav", setupCallout)
//                         ↑
//          Quartz SPA 라우터가 dispatch하는 커스텀 이벤트
//          Next.js에는 이 이벤트가 없음

Quartz는 자체 SPA 라우터(spa.inline.ts)가 있고, 페이지 이동 시마다 nav 이벤트를 dispatch한다. 스크립트들은 이 이벤트를 들어서 재초기화한다.

static site라서 필요한 것

Quartz는 완전한 .html 파일을 생성하는 정적 사이트 생성기다. React 런타임이 없으니 인터랙션을 <script> 태그로 직접 주입해야 한다.

Next.js에서는 같은 기능을 React 컴포넌트로 구현하면 된다.

// Quartz 방식
- document.addEventListener("nav", () => {
-   document.querySelectorAll(".callout-title").forEach(el => {
-     el.addEventListener("click", toggle)
-   })
- })
 
// Next.js 방식
+ function Callout({ type, title, children }) {
+   const [collapsed, setCollapsed] = useState(false)
+   return (
+     <div onClick={() => setCollapsed(!collapsed)}>{title}</div>
+   )
+ }

결국 externalResources()는 Quartz가 자체 런타임을 가지고 있기 때문에 필요한 개념이다. Next.js에서는 React 컴포넌트로 대체하면 그만이다.


더 나은 접근 방식

분석을 통해 명확해진 것:

  • Quartz 플러그인의 markdownPlugins() / htmlPlugins()재사용 가능하다
  • externalResources()무시하면 된다 (React로 대체)
  • 나머지 Latex, GFM, Syntax는 npm 패키지 직접 사용이 더 단순하다

OFM 플러그인을 직접 가져오는 방식:

import { ObsidianFlavoredMarkdown } from "../quartz/plugins/transformers/ofm"
 
const ofm = ObsidianFlavoredMarkdown({ wikilinks: true, callouts: true })
 
const processor = unified()
  .use(remarkParse)
  .use(ofm.markdownPlugins({ allSlugs: [] }))  // wikilink, callout, tag 파싱
  .use(remarkRehype)
  .use(ofm.htmlPlugins())                       // block reference, YouTube embed
  .use(rehypeStringify)

ctx.allSlugs는 broken wikilink 감지 옵션에서만 쓰이고, 나머지는 ctx 의존성이 거의 없다. 빈 배열로 넘겨도 동작한다.

sync-quartz는 이 목적으로는 유효하다

OFM 파일 하나만 복사해서 쓰는 용도라면 sync-quartz 전략은 여전히 의미가 있다. 문제는 Quartz 전체를 래핑하려 했던 초기 설계였다.

수정된 전략:
upstream/quartz/quartz/plugins/transformers/ofm.ts  ← 이것만 가져옴
나머지는 npm 패키지 직접 사용

AI 스택 선택

Next.js + shadcn/ui로 웹 서빙을 하고 나면 두 번째 목표인 AI Agent를 붙여야 한다.

Next.js가 맞는 이유

AI Agent 기능이 들어가는 순간 서버가 필요하다.

사용자 질문 → API Route → LLM 호출 → 스트리밍 응답

Quartz, Astro(static), GitHub Pages로는 불가능하다. Next.js의 API Routes / Server Actions가 자연스러운 선택이다.

Vercel AI SDK vs LangGraph

처음엔 Vercel AI SDK를 고려했다. Next.js와 통합이 제일 깔끔하고, streaming UI도 useChat 훅 하나로 된다.

그런데 실제 docs를 보니 “Skills/Harness” 개념이 다르다는 걸 알았다.

Vercel AI SDK의 "Skills"는 다른 개념

Vercel Skills = SKILL.md 파일 기반 “플러그인 프롬프트”

코드 에이전트(Cursor 같은 것)에 명령 파일을 로드하는 개념이다. 내가 원하는 graph-based skill routing이 아니다.

Vercel AI SDK의 실제 한계 (공식 docs 명시):

  • 체크포인팅 없음 → 직접 구현해야 함
  • Human-in-the-loop 없음
  • 문서에서 직접 “비결정적(non-deterministic)으로 설계됐다”고 인정

내가 원하는 harness 패턴은 LangGraph의 개념이다.

LangGraph JS vs Python

LangGraph JSLangGraph Python
성숙도v1.0.x, production 가능레퍼런스 구현, 가장 성숙
Next.js 통합동일 코드베이스별도 Python 서버 필요
체크포인팅PostgreSQL adapter 있음AsyncPostgresSaver (battle-tested)
deepagents harness없음✅ 있음
ML 생태계보통압도적
새 기능Python 이후 도착먼저 나옴

내가 내린 결론

AI 개발자로서 제대로 만들려면 LangGraph Python + deepagents harness가 맞다.

deepagents는 LangChain이 만든 agent harness 프레임워크로, planning → task decomposition → subagent spawn → skill routing이 전부 구현되어 있다. Python이 가진 ML 생태계(벡터 DB, 문서 로더, 임베딩)와 함께 쓰면 RAG 파이프라인도 훨씬 풍부하다.

Next.js와의 통합은 기존 portfolio-ai 패턴처럼 Python 서버를 별도로 띄우고 API로 호출하면 된다.


로드맵

우선순위를 명확히 했다. AI 기능보다 Next.js 앱을 먼저 완성하는 게 목표다.

Phase 1: Next.js 앱

마크다운 렌더링 (Quartz OFM 플러그인)
     +
shadcn/ui 컴포넌트
     +
Obsidian content → Next.js 정적 생성
     +
Vercel 배포
역할기술이유
콘텐츠 관리Velite파일 기반, 빌드 타임 인덱싱, 타입 안전
마크다운 파싱Quartz OFM markdownPlugins + htmlPluginsObsidian 호환성 유지
UIshadcn/ui목표 그대로
배포VercelPhase 2 AI 붙일 때도 그대로 사용

Phase 2: AI Agent

Phase 1이 완성된 후 LangGraph Python 서버를 추가한다.

nuartz (Next.js)
  └── app/api/chat/route.ts  ← Python 서버 호출

portfolio-ai (LangGraph Python)
  ├── skills/
  │   ├── rag_search.py       ← Obsidian 노트 검색
  │   ├── graph_traverse.py   ← 노트 간 연결 탐색
  │   └── summarize.py        ← 요약
  └── agent/
      └── nuartz_agent.py     ← deepagents harness

Next.js 구조는 건드리지 않고 /api/chat 라우트 하나만 추가하면 된다.


마치며

nuartz를 기획하면서 가장 크게 깨달은 것은, Quartz는 라이브러리가 아니라는 것이다.

Quartz는 완성도 높은 정적 사이트 생성기다. 내부를 뜯어서 다른 프레임워크에 끼워 맞추려는 시도는 처음부터 설계 불일치를 안고 가는 것이었다.

반면 OFM 플러그인 하나는 진짜 재사용 가능한 자산이다. wikilink와 callout 파싱 로직은 Quartz 개발자들이 공들여 만든 것이고, markdownPlugins / htmlPlugins 인터페이스 덕분에 unified 파이프라인에 깔끔하게 꽂을 수 있다.

결국 “Quartz를 통째로 가져오려는 욕심”을 내려놓고 “필요한 것만 가져오는 현실적인 전략”으로 방향을 바꿨다. 나머지는 npm 생태계가 이미 잘 해결해두었다.

  • Phase 1: Next.js + shadcn/ui + OFM 마크다운 렌더링 완성
  • Phase 2: LangGraph Python deepagents + RAG 파이프라인 구축
  • nuartz 위에서 이 글 다시 보기

참고자료