Ghost 블로그에 Obsidian 스타일 Knowledge Graph 추가하기: D3.js로 포스트 관계 시각화

Ghost 블로그에 Obsidian Graph View 같은 시각화 기능이 없어서 D3.js로 직접 만들었습니다. 포스트 간 링크, 태그 관계, 카테고리 클러스터를 인터랙티브하게 탐색할 수 있습니다.

1. 문제 상황

Obsidian의 Graph View가 부러웠다

Obsidian을 사용하다 보면 Graph View 기능에 매료됩니다. 노트들 간의 연결 관계가 한눈에 보이고, 클릭하면 해당 노트로 이동할 수 있죠.

문제: Ghost 블로그에는 이런 시각화 기능이 없다

Ghost는 훌륭한 블로그 플랫폼이지만, 포스트 간의 관계를 시각적으로 보여주는 기능은 제공하지 않습니다. 태그로 분류는 되지만, 어떤 글들이 서로 연결되어 있는지, 어떤 주제가 중심인지 파악하기 어렵습니다.

원하는 기능

  1. 포스트 간 링크 시각화 - 본문에서 다른 포스트를 참조하는 관계
  2. 태그 관계 시각화 - 포스트와 태그의 연결
  3. 카테고리 클러스터링 - 같은 태그를 공유하는 포스트들의 그룹
  4. 인터랙티브 - 드래그, 줌, 클릭으로 탐색 가능

2. 기술 선택

왜 D3.js인가?

네트워크 그래프 시각화 라이브러리는 여러 가지가 있습니다:

라이브러리 장점 단점
D3.js 완전한 커스터마이징, 풍부한 예제 학습 곡선 높음
Cytoscape.js 그래프 분석 기능 내장 스타일링 제한적
vis.js 쉬운 사용법 커스터마이징 한계
Sigma.js 대규모 그래프 최적화 문서 부족

D3.js를 선택한 이유:

  • Force Simulation - 노드가 자연스럽게 배치되는 물리 시뮬레이션
  • 완전한 제어권 - SVG 요소 하나하나 커스터마이징 가능
  • 풍부한 커뮤니티 - 수많은 예제와 튜토리얼

Ghost Content API

Ghost는 Content API를 제공하여 포스트, 태그, 작성자 정보를 JSON으로 가져올 수 있습니다:

// Content API 엔드포인트 예시
GET /ghost/api/content/posts/?key={api_key}&include=tags&limit=all
GET /ghost/api/content/tags/?key={api_key}&limit=all&include=count.posts

중요: Content API 키는 읽기 전용이므로 프론트엔드에 노출해도 안전합니다.


3. 구현 단계

Step 1: 기본 HTML 구조

먼저 그래프를 담을 컨테이너와 컨트롤 UI를 만듭니다:

<div id="graph-container">
  <!-- 필터 컨트롤 -->
  <div id="controls">
    <h3>Knowledge Graph</h3>
    <label>
      <input type="checkbox" id="show-post-links" checked>
      포스트 간 링크
    </label>
    <label>
      <input type="checkbox" id="show-tag-links" checked>
      태그 관계
    </label>
    <label>
      <input type="checkbox" id="show-category-links" checked>
      카테고리
    </label>
  </div>

  <!-- 범례 -->
  <div id="legend">
    <div class="legend-item">
      <div class="legend-color" style="background:#06B6D4"></div>
      포스트
    </div>
    <div class="legend-item">
      <div class="legend-color" style="background:#8B5CF6"></div>
      태그
    </div>
  </div>

  <!-- 정보 패널 -->
  <div id="info-panel"></div>

  <!-- SVG 그래프 -->
  <svg id="graph-svg"></svg>
</div>

Step 2: 스타일링

어두운 테마로 Obsidian 느낌을 살립니다:

#graph-container {
  width: 100%;
  height: 80vh;
  background: #0a0a0a;
  border-radius: 12px;
  position: relative;
  overflow: hidden;
}

#controls {
  position: absolute;
  top: 20px;
  left: 20px;
  background: rgba(15, 15, 15, 0.95);
  padding: 16px;
  border-radius: 12px;
  border: 1px solid rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(10px);  /* ← 글래스모피즘 효과 */
  z-index: 1000;
  color: white;
}

.node circle {
  cursor: pointer;
  stroke-width: 2px;
}

.node text {
  font-size: 10px;
  fill: white;
  pointer-events: none;  /* ← 텍스트 클릭 무시 */
}

.link {
  stroke-opacity: 0.5;
}

Step 3: Ghost API에서 데이터 가져오기

const GHOST_URL = 'https://your-blog.com';
const API_KEY = 'your-content-api-key';

let allPosts = [];
let allTags = [];

async function fetchData() {
  try {
    // 포스트와 태그를 병렬로 가져오기
    const [postsRes, tagsRes] = await Promise.all([
      fetch(`${GHOST_URL}/ghost/api/content/posts/?key=${API_KEY}&include=tags&limit=all&fields=id,title,slug,html,published_at`),
      fetch(`${GHOST_URL}/ghost/api/content/tags/?key=${API_KEY}&limit=all&include=count.posts`)
    ]);

    const postsData = await postsRes.json();
    const tagsData = await tagsRes.json();

    allPosts = postsData.posts || [];
    // 포스트가 있는 태그만 필터링
    allTags = (tagsData.tags || []).filter(t => t.count && t.count.posts > 0);

    buildGraph();
  } catch (error) {
    console.error('API 호출 실패:', error);
  }
}

핵심 포인트:

  • include=tags - 각 포스트의 태그 정보 포함
  • include=count.posts - 각 태그의 포스트 수 포함
  • limit=all - 페이지네이션 없이 전체 가져오기

Step 4: 포스트 간 링크 분석

본문에서 다른 포스트로의 내부 링크를 추출합니다:

function extractPostLinks(post) {
  const result = [];

  // HTML 파싱
  const doc = new DOMParser().parseFromString(post.html || '', 'text/html');

  // 모든 앵커 태그 검사
  doc.querySelectorAll('a').forEach(anchor => {
    const href = anchor.getAttribute('href') || '';

    // 내부 링크인지 확인
    if (href.includes(GHOST_URL)) {
      // URL에서 slug 추출
      const slug = href.split('/').filter(Boolean).pop();

      // 해당 slug의 포스트 찾기
      const targetPost = allPosts.find(p => p.slug === slug);

      if (targetPost && targetPost.id !== post.id) {
        result.push(targetPost.id);
      }
    }
  });

  // 중복 제거
  return [...new Set(result)];
}

Step 5: 노드와 링크 데이터 구조 생성

D3.js Force Simulation에 맞는 데이터 구조를 만듭니다:

let nodes = [];
let links = [];

function buildGraph() {
  nodes = [];
  links = [];

  // 1. 포스트 노드 생성
  allPosts.forEach(post => {
    nodes.push({
      id: 'post-' + post.id,
      label: post.title,
      type: 'post',
      data: post,
      r: 8  // 노드 반지름
    });
  });

  // 2. 태그 노드 생성
  allTags.forEach(tag => {
    nodes.push({
      id: 'tag-' + tag.id,
      label: tag.name,
      type: 'tag',
      data: tag,
      r: 6  // 태그는 조금 작게
    });
  });

  // 3. 포스트 간 링크 (본문 참조)
  allPosts.forEach(post => {
    const linkedPostIds = extractPostLinks(post);
    linkedPostIds.forEach(targetId => {
      links.push({
        source: 'post-' + post.id,
        target: 'post-' + targetId,
        type: 'post-link'
      });
    });
  });

  // 4. 포스트-태그 링크
  allPosts.forEach(post => {
    if (post.tags && post.tags.length > 0) {
      post.tags.forEach(tag => {
        links.push({
          source: 'post-' + post.id,
          target: 'tag-' + tag.id,
          type: 'tag-link'
        });
      });
    }
  });

  // 5. 카테고리 링크 (같은 태그 공유)
  allTags.forEach(tag => {
    const postsWithTag = allPosts.filter(post =>
      post.tags && post.tags.some(t => t.id === tag.id)
    );

    // 같은 태그를 가진 포스트들끼리 연결
    for (let i = 0; i < postsWithTag.length; i++) {
      for (let j = i + 1; j < postsWithTag.length; j++) {
        links.push({
          source: 'post-' + postsWithTag[i].id,
          target: 'post-' + postsWithTag[j].id,
          type: 'category-link'
        });
      }
    }
  });

  render();
}

Step 6: D3.js Force Simulation 설정

핵심인 Force Simulation을 구성합니다:

const svg = d3.select('#graph-svg');
const g = svg.append('g');  // 줌/패닝용 그룹

// 줌 설정
svg.call(d3.zoom()
  .scaleExtent([0.1, 4])
  .on('zoom', e => g.attr('transform', e.transform))
);

function render() {
  g.selectAll('*').remove();

  const width = container.clientWidth;
  const height = container.clientHeight;

  // Force Simulation 생성
  const simulation = d3.forceSimulation(nodes)
    .force('link', d3.forceLink(links)
      .id(d => d.id)
      .distance(80)  // 링크 거리
    )
    .force('charge', d3.forceManyBody()
      .strength(-200)  // 노드 간 반발력 (음수)
    )
    .force('center', d3.forceCenter(width / 2, height / 2))
    .force('collision', d3.forceCollide()
      .radius(d => d.r * 2)  // 충돌 방지 반경
    );

  // ... 렌더링 코드
}

Force 설명:

  • forceLink - 연결된 노드들을 가깝게 유지
  • forceManyBody - 모든 노드 간 반발력 (겹침 방지)
  • forceCenter - 그래프를 화면 중앙으로
  • forceCollide - 노드 간 충돌 방지

Step 7: 링크 렌더링

// 링크 그리기
const link = g.append('g')
  .selectAll('line')
  .data(links)
  .join('line')
  .attr('class', 'link')
  .attr('stroke', d => {
    // 링크 타입별 색상
    if (d.type === 'post-link') return '#10B981';   // 초록 - 직접 참조
    if (d.type === 'tag-link') return '#F59E0B';    // 주황 - 태그 연결
    return '#333';                                   // 회색 - 카테고리
  })
  .attr('stroke-width', d => d.type === 'post-link' ? 2 : 1);

Step 8: 노드 렌더링

// 노드 그룹 생성
const node = g.append('g')
  .selectAll('g')
  .data(nodes)
  .join('g')
  .attr('class', 'node')
  .call(drag(simulation));  // 드래그 기능 추가

// 원 그리기
node.append('circle')
  .attr('r', d => d.r)
  .attr('fill', d => d.type === 'post' ? '#06B6D4' : '#8B5CF6')
  .attr('stroke', d => d.type === 'post' ? '#0891b2' : '#7C3AED');

// 라벨 추가
node.append('text')
  .attr('dx', 12)
  .attr('dy', 4)
  .text(d => d.label.length > 25 ? d.label.slice(0, 25) + '...' : d.label);

Step 9: 드래그 기능 구현

function drag(simulation) {
  function dragstarted(event, d) {
    if (!event.active) simulation.alphaTarget(0.3).restart();
    d.fx = d.x;  // 고정 위치 설정
    d.fy = d.y;
  }

  function dragged(event, d) {
    d.fx = event.x;
    d.fy = event.y;
  }

  function dragended(event, d) {
    if (!event.active) simulation.alphaTarget(0);
    d.fx = null;  // 고정 해제
    d.fy = null;
  }

  return d3.drag()
    .on('start', dragstarted)
    .on('drag', dragged)
    .on('end', dragended);
}

드래그 동작:

  1. 드래그 시작 - 시뮬레이션 재활성화, 노드 위치 고정
  2. 드래그 중 - 마우스 따라 위치 업데이트
  3. 드래그 끝 - 시뮬레이션 안정화, 고정 해제

Step 10: 정보 패널 (안전한 DOM 조작)

XSS 방지를 위해 innerHTML 대신 DOM 메서드를 사용합니다:

function showInfo(node) {
  const panel = document.getElementById('info-panel');
  panel.style.display = 'block';

  // 기존 내용 제거
  while (panel.firstChild) {
    panel.removeChild(panel.firstChild);
  }

  // 제목 추가
  const title = document.createElement('h4');
  title.textContent = node.label;  // ← textContent 사용 (XSS 방지)
  panel.appendChild(title);

  if (node.type === 'post') {
    // 발행일
    const date = document.createElement('p');
    date.textContent = '발행일: ' + new Date(node.data.published_at).toLocaleDateString('ko-KR');
    panel.appendChild(date);

    // 링크
    const linkP = document.createElement('p');
    const anchor = document.createElement('a');
    anchor.href = GHOST_URL + '/' + node.data.slug;
    anchor.target = '_blank';
    anchor.textContent = '글 보기 →';
    linkP.appendChild(anchor);
    panel.appendChild(linkP);

    // 태그
    if (node.data.tags && node.data.tags.length) {
      const tagsP = document.createElement('p');
      node.data.tags.forEach(tag => {
        const chip = document.createElement('span');
        chip.className = 'tag-chip';
        chip.textContent = tag.name;
        tagsP.appendChild(chip);
      });
      panel.appendChild(tagsP);
    }
  }
}

// 노드 클릭 이벤트
node.on('click', (event, d) => showInfo(d));

Step 11: 필터 기능

체크박스로 관계 유형을 필터링합니다:

function getFilteredLinks() {
  const showPostLinks = document.getElementById('show-post-links').checked;
  const showTagLinks = document.getElementById('show-tag-links').checked;
  const showCategoryLinks = document.getElementById('show-category-links').checked;

  return links.filter(link => {
    if (link.type === 'post-link' && !showPostLinks) return false;
    if (link.type === 'tag-link' && !showTagLinks) return false;
    if (link.type === 'category-link' && !showCategoryLinks) return false;
    return true;
  });
}

// 체크박스 이벤트 리스너
document.querySelectorAll('#controls input').forEach(checkbox => {
  checkbox.addEventListener('change', render);
});

Step 12: Ghost 페이지에 추가하기

Ghost Admin API를 사용해 페이지를 생성합니다. Ghost는 Lexical 포맷을 사용합니다:

// Lexical 포맷으로 HTML 카드 생성
const lexical = {
  root: {
    children: [
      {
        type: "html",
        version: 1,
        html: graphHTML  // 위에서 만든 전체 HTML/CSS/JS
      }
    ],
    direction: null,
    format: "",
    indent: 0,
    type: "root",
    version: 1
  }
};

// Ghost Admin API로 페이지 생성
const pageData = {
  pages: [{
    title: 'Knowledge Graph',
    slug: 'knowledge-graph',
    status: 'published',
    lexical: JSON.stringify(lexical),
    visibility: 'public'
  }]
};

4. 핵심 개념 정리

개념 설명 사용 위치
Force Simulation 물리 기반 노드 배치 알고리즘 그래프 레이아웃
forceLink 연결된 노드를 가깝게 유지 관계 표현
forceManyBody 노드 간 반발력 겹침 방지
forceCollide 충돌 감지 및 방지 노드 분리
Content API Ghost 읽기 전용 API 데이터 조회
Lexical Ghost 에디터 JSON 포맷 페이지 생성

5. 베스트 프랙티스

성능 최적화 체크리스트

  • [ ] limit=all 대신 페이지네이션 고려 (100개 이상 포스트 시)
  • [ ] 노드 라벨 길이 제한 (25자)
  • [ ] 카테고리 링크는 선택적으로 표시 (너무 많아지면 복잡)
  • [ ] 디바운싱으로 리렌더링 최적화

보안 체크리스트

  • [ ] Content API 키만 사용 (Admin API 키 노출 금지)
  • [ ] innerHTML 대신 textContent/DOM 메서드 사용
  • [ ] 외부 URL은 target="_blank" + rel="noopener" 추가

UX 개선 사항

  • [ ] 로딩 인디케이터 표시
  • [ ] 에러 발생 시 사용자 피드백
  • [ ] 모바일 반응형 (터치 이벤트)
  • [ ] 키보드 네비게이션

6. FAQ

Q: Content API 키를 프론트엔드에 노출해도 안전한가요?

A: 네, Content API 키는 읽기 전용입니다. 포스트, 태그, 작성자 정보만 조회할 수 있고, 글 작성/수정/삭제는 불가능합니다. Ghost 공식 문서에서도 프론트엔드 사용을 권장합니다.

Q: 포스트가 많아지면 성능 문제가 있나요?

A: D3.js Force Simulation은 수백 개 노드까지는 무리 없이 처리합니다. 1,000개 이상이면 WebGL 기반 라이브러리(Sigma.js)를 고려하세요.

Q: Obsidian처럼 노트 내부 링크([[note]])도 파싱할 수 있나요?

A: Ghost 본문은 HTML이므로 [[note]] 문법은 지원하지 않습니다. 대신 일반 하이퍼링크(<a href="...">)를 파싱합니다.

Q: 태그 없이 포스트만 표시할 수 있나요?

A: 네, 필터 체크박스에서 "태그 관계"를 해제하면 포스트 간 링크만 표시됩니다.

Q: 그래프를 이미지로 저장할 수 있나요?

A: SVG를 PNG로 변환하는 라이브러리(svg-to-png, html2canvas)를 추가하면 가능합니다.


7. 참고 자료


8. 다음 단계

이 Knowledge Graph를 더 발전시킬 수 있는 방향:

  • 확대/축소/복귀 UI
  • 노드 검색
  • 시간축 필터