본문 바로가기

OSSCA/2025

[OSSCA 2025] Githru MCP Server - Storyline Tool 개발

✨ 공부하며 성장하는 기록 공간입니다.
아직 부족한 점이 많고 배워야 할 것도 많지만, 그만큼 배우는 즐거움도 큽니다.
틀리거나 부족한 내용이 있다면 언제든지 편하게 지적해 주세요 😁

이 블로그는 오픈소스 프로젝트 githru-vscode-extension을 공부하고 기여한 내용을 정리한 공간입니다.
처음보다 성장해 있는 나 자신을 기대하며 꾸준히 기록해나가겠습니다!

 

서론

이전 포스팅에서는 MCP Server에서 MCP Host로 React Component를 어떻게 넘겨야 하는지를 확인하는 시간이였습니다.

https://dev-hiro.tistory.com/206

 

[OSSCA 2025] React Component를 활용한 MCP Tool 개발

✨ 공부하며 성장하는 기록 공간입니다. 아직 부족한 점이 많고 배워야 할 것도 많지만, 그만큼 배우는 즐거움도 큽니다. 틀리거나 부족한 내용이 있다면 언제든지 편하게 지적해 주세요 😁이

dev-hiro.tistory.com

당시에는 React Component의 소스코드와 Props 정보를 MCP Tool의 텍스트 응답으로 전달하고, Claude가 이를 이해할 수 있는지 테스트했습니다.

 

테스트 결과, Claude가 컴포넌트의 구조와 Props 정보를 해석할 수 있다는 것을 확인했습니다.

하지만 여기서 한 가지 아쉬운 점이 있었습니다.

 

React Component의 구조를 이해하는 것과 실제 컴포넌트를 화면에 렌더링하는 것은 별개의 문제라는 점입니다.

 

Githru는 Git 저장소의 메타데이터를 분석하고, 그 결과를 React 기반의 시각화 컴포넌트로 표현하는 프로젝트입니다.

따라서 단순히 컴포넌트의 소스코드를 전달하는 것이 아니라, Githru가 제공하는 시각화 결과 자체를 MCP Tool을 통해 보여주고 싶었습니다.

 

이번 포스팅에서는 Githru의 Analysis Engine과 NewViz Storyline Component를 MCP Server에 연동한 과정을 정리하려고 합니다.

 

Githru 동작원리

먼저 MCP Server와 연동하기 전에 Githru가 Git 데이터를 분석하고 시각화하는 구조를 이해할 필요가 있었습니다.

Githru의 핵심은 크게 두 가지로 나눌 수 있습니다.

  • Analysis Engine: Git 저장소의 변경 이력을 분석하고 시각화에 필요한 데이터를 생성
  • Visualization Component: 분석 결과를 전달받아 React와 D3를 이용해 시각화

전체적인 흐름은 다음과 같습니다.

Git Repository
      │
      ▼
Git Metadata
(Commit, Author, File Changes ...)
      │
      ▼
Analysis Engine
      │
      ▼
Githru Data Structure
      │
      ▼
React + D3 Component
      │
      ▼
Storyline Visualization

Git 메타데이터 분석

Git은 커밋마다 작성자, 변경 시점, 부모 커밋, 변경된 파일 등의 정보를 관리합니다.

Githru의 Analysis Engine은 이러한 정보를 수집하고, 개발 흐름을 분석하기 위한 데이터로 가공합니다.

 

여기서 Git의 커밋 이력은 단순한 트리가 아니라 DAG(Directed Acyclic Graph, 방향 비순환 그래프) 구조이고, 일반적으로 알고있는 트리 그림과 다르게, 일반적인 커밋은 부모 커밋을 참조하고, Merge Commit은 여러 부모 커밋을 가질 수 있기 때문입니다.

따라서 커밋의 연결 관계를 분석할 때는 단순한 선형 목록이 아니라 커밋 간의 관계를 고려해야 합니다.

 

여기에서 커밋 트리 탐색 알고리즘은 BFS/DFS를 통해 이루어집니다.

 

Git 시각화

Githru에서는 Analysis Engine에서 받은 가공한 데이터를 토대로 시각화 컴포넌트가 요구하는 방식으로 props로 전달하는 방식으로 이루어집니다. 특히 NewViz의 Storyline UI는 D3기반 시각화를 사용합니다.

 

D3(Data-Driven Documents)는 데이터를 기반으로 SVG 등의 그래픽 요소를 생성하고 조작할 수 있는 JavaScript 라이브러리로서, SVG는 XML 기반의 벡터 그래프 형식입니다.

 

예를 들어 다음과 같이 선과 원을 표현할 수 있습니다.

<svg xmlns="http://www.w3.org/2000/svg"
     width="300"
     height="100">
  <line
    x1="20"
    y1="50"
    x2="280"
    y2="50"
    stroke="blue"
    stroke-width="3"
  />
  <circle
    cx="150"
    cy="50"
    r="12"
    fill="orange"
  />
</svg>

svg는 해상도에 영향을 적게 받으며, 데이터에 따라 도형의 위치와 형태를 동적으로 생성할 수 있습니다.

 

여기에서 중요한 핵심 고민이 있습니다.

D3 컴포넌트를 그대로 SSR 하면 되지 않을까?

처음에는 해당 컴포넌트가 SVG이기에 그대로 서버 사이드에서 렌더링하여 넣으면 된다고 생각했습니다.

React는 react-dom/server를 통해 브라우저 없이도 컴포넌트를 HTML 문자열로 변환할 수 있기 때문입니다.

 

하지만 기존 컴포넌트의 구현 구조를 살펴보니, 단순히 renderToString()을 호출하는 것만으로는 원하는 SVG를 생성하기 어려웠습니다.

기존 컴포넌트의 문제점

기존 NewViz의 컴포넌트 FolderActivityFlow는 React의 useEffect와 D3를 활용하여 SVG를 생성하는 구조였습니다.

const svgRef = useRef<SVGSVGElement>(null);

useEffect(() => {
  const svg = d3
    .select(svgRef.current)
    .attr("width", 800)
    .attr("height", 400);

  svg.selectAll("*").remove();

  renderReleaseVisualization(
    svg,
    releaseContributorActivities
  );
}, [
  releaseGroups,
  releaseTopFolderPaths,
  renderReleaseVisualization
]);

return (
  <div>
    <svg ref={svgRef} />
    <div ref={tooltipRef} />
  </div>
);

여기서 중요한 부분은 실제 SVG 도형을 생성하는 코드가 useEffect 내부에 존재한다는 점입니다.

일반적인 브라우저 환경에서는 다음과 같은 순서로 동작합니다.

  1. React가 컴포넌트를 렌더링하고 DOM에 <svg> 요소를 생성합니다.
  2. React가 svgRef에 실제 DOM 요소를 연결합니다.
  3. 컴포넌트가 마운트된 이후 useEffect가 실행됩니다.
  4. D3가 <svg> 내부에 <g>, <rect>, <circle>, <path> 등의 요소를 추가합니다.

반면 서버 사이드 렌더링에서는 동작 방식이 다릅니다.

renderToString()은 HTML 문자열을 생성하지만, useEffect를 실행하거나 실제 브라우저 DOM을 마운트하지 않습니다.

따라서 기존 컴포넌트를 그대로 렌더링하면 JSX에서 선언한 빈 <svg> 요소만 HTML 문자열에 포함됩니다.

<div>
  <svg></svg>
  <div></div>
</div>

이 상태에서 SVG를 추출해 이미지로 변환하더라도, 실제 시각화에 필요한 도형은 포함되어 있지 않습니다.

또한 기존 D3 로직에는 브라우저 환경을 전제로 하는 코드가 존재했습니다.

  • d3.select()를 통한 실제 DOM 요소 선택 및 조작
  • selection.call(d3.axisBottom(...))을 통한 SVG 축 생성
  • tooltipRef.current를 이용한 툴팁 요소 접근
  • mouseover, event.pageX 등을 활용한 마우스 이벤트 처리

이러한 기능은 단순한 서버 사이드 렌더링만으로 동일하게 실행할 수 없습니다.

 

추가로 기존 컴포넌트의 SCSS import 역시 Node.js 실행 환경에서 별도의 로더나 빌드 설정이 없다면 문제가 될 수 있습니다.

 

즉, 기존 컴포넌트는 브라우저에서 마운트된 이후 D3가 SVG를 완성하는 구조였기 때문에, 서버에서 HTML 문자열만 생성하는 방식과 맞지 않았습니다.

대안 검토

이 문제를 해결하기 위해 고려할 수 있는 방법은 크게 세 가지였습니다.

방안 장점 단점
js-dom을 활용한 DOM 환경 구성 기존 D3 코드 재사용 가능 React Effect 실행 시점과 DOM 환경을 별도로 관리해야 함
Puppeteer를 활용한 브라우저 렌더링 기존 UI를 브라우저에서 렌더링하고 캡처 가능 Chromium 실행 및 배포 비용 증가
SVG 문자열 직접 생성 (채택) 브라우저 DOM 없이 서버에서 이미지 생성 가능 기존 D3 시각화 로직을 별도로 구현해야 함

여기서 js-dom을 사용하더라도 단순히 renderToString()만 호출해서 useEffect가 실행되는 것은 아니기에, React 컴포넌트를 실제 DOM에 마운트하는 과정이 추가로 필요합니다. 또한 Puppeteer는 실제 브라우저 환경에서 기존 UI를 렌더링할 수 있다는 장점이 있지만, MCP Server에서 Chromium을 실행해야 한다는 부담이 있습니다.

 

이번 PR에서는 최종적으로 브라우저 DOM에 의존하지 않고 SVG 문자열을 직접 생성하는 방식을 적용했습니다.

ServerSideFolderActivityFlow 구현

서버 환경에서 SVG를 생성하기 위해 MCP Server 내 ServerSideFolderActivityFlow 컴포넌트를 구현했습니다.

 

기존 FolderActivityFlow가 D3를 이용해 실제 DOM에 SVG 요소를 추가했다면, 서버용 컴포넌트는 createPureSVG()를 통해 SVG 문자열을 직접 생성합니다.

 

핵심적인 차이는 SVG 요소를 생성하는 시점입니다.

 

기존 방식은 React가 렌더링된 이후 useEffect에서 SVG를 구성하지만, 서버용 방식은 렌더링 과정에서 SVG 문자열을 생성합니다.

예를 들어 노드의 위치와 크기는 다음과 같이 계산합니다.

const xBandwidth = xRange / uniqueReleases.length;
const yBandwidth = yRange / releaseTopFolderPaths.length;

const radius =
  4 + (activity.changes / maxChanges) * 12;

svgElements += `
  <circle
    cx="${x}"
    cy="${y}"
    r="${radius}"
    fill="${color}"
    fill-opacity="0.8"
    stroke="#fff"
    stroke-width="2"
  />
`;

위 코드는 D3의 DOM 조작 없이 좌표와 반지름을 계산하고, SVG 요소를 문자열로 구성합니다.

 

이렇게 하면 renderToString() 실행 시점에 SVG 요소가 이미 구성되어 있으므로, 별도의 useEffect 실행 없이 시각화 결과를 HTML 문자열로 얻을 수 있습니다.

기존 D3 시각화와의 차이

SVG 문자열을 직접 생성하는 방식은 서버 환경에서 실행하기 간단하지만, 기존 시각화 로직을 완전히 동일하게 재현한 것은 아닙니다.

기존 FolderActivityFlow 서버용 ServerSideFolderActivityFlow
D3 scaleBand 및 paddingInner 데이터 개수에 따른 균등 분할
D3 scaleSqrt()를 활용한 노드 크기 변경량 비율에 따른 선형 반지름 계산
schemeCategory10 색상 팔레트 별도로 정의한 8개 색상 순환
릴리즈 내 노드 위치 분산 처리 해당 위치 분산 로직 미적용
D3 축, 툴팁, Hover 등 정적 SVG 요소 중심으로 구성

특히 노드의 크기를 계산하는 방식이 달라졌다는 점이 중요합니다.

 

기존 D3의 제곱근 스케일과 서버용의 선형 계산 방식은 같은 데이터를 입력하더라도 노드의 크기를 다르게 표현할 수 있습니다.

또한 동일한 릴리즈와 폴더 위치에 여러 노드가 존재할 경우, 서버용 구현에서는 노드가 겹칠 가능성이 있습니다.

 

즉, 이번 구현은 기존 UI를 픽셀 단위로 동일하게 재현한 것이 아니라, MCP Server에서 Storyline의 핵심 정보를 정적인 SVG로 표현하기 위한 구현에 가깝습니다.

 

추가로 직접 SVG 문자열을 생성하면서 다음과 같은 예외 상황도 고려할 필요가 있습니다.

  • 기여자 이름이나 폴더명에 &, < 등의 문자가 포함되면 SVG 파싱 문제가 발생할 수 있습니다.
  • 데이터가 비어 있는 경우 Math.max(...[])가 -Infinity를 반환할 수 있으므로 별도의 예외 처리가 필요합니다.
  • 브라우저용 컴포넌트와 서버용 컴포넌트가 분리되면서, 한쪽의 변경 사항이 다른 쪽에 자동으로 반영되지 않는 유지보수 문제가 발생합니다.

이러한 부분은 서버 전용 SVG 렌더링 방식을 선택하면서 생긴 기술적 트레이드오프입니다.


SVG를 PNG로 변환한 이유

서버 사이드 렌더링을 통해 SVG를 생성했다면, 다음으로는 이를 MCP Tool의 응답으로 반환해야 합니다.

그런데 여기서 또 다른 문제가 있었습니다.

 

당시 사용하던 Claude 환경에서는 SVG를 원하는 방식으로 표시할 수 없었습니다.(포스팅을 하는 현재는 완전하게 해결되었다라기보다, MCP Apps라는 기술을 통해 많은 부분의 한계를 해결할 수 있습니다.)

 

다만 이것이 MCP 프로토콜 자체의 제한인지, 당시 Claude Host 환경의 제한인지는 정확하게는 파악하기가 어려웠습니다. 따라서, 저는 MCP Host가 결과를 만들어 낼 수 있는 환경이 더욱 중요하다고 판단하였고, MCP Server는 응답값으로 ImageContent를 사용할 수 있기에 PNG로 변환하도록 결정하였습니다.

 

Sharp를 활용한 SVG → PNG 변환

Sharp는 Node.js 환경에서 이미지 리사이징, 포맷 변환, 압축 등의 기능을 제공하는 이미지 처리 라이브러리입니다.

따라서 서버 사이드 렌더링 결과에서 SVG 문자열을 추출한 뒤, Sharp를 이용해 PNG로 변환했습니다.

핵심 코드는 다음과 같습니다.

const htmlString = ReactDOMServer.renderToString(
  React.createElement(
    ServerSideFolderActivityFlow,
    {
      releaseGroups,
      // 기타 Props
    }
  )
);

const svgMatch = htmlString.match(
  /<svg[^>]*>[\s\S]*<\/svg>/
);

if (!svgMatch) {
  throw new Error("SVG not found in rendered HTML");
}

const pngBuffer = await sharp(
  Buffer.from(svgMatch[0])
)
  .png()
  .toBuffer();

이 과정을 단계별로 살펴보겠습니다.

 

첫 번째, React Component를 HTML 문자열로 변환합니다.

ReactDOMServer.renderToString(...)

앞서 구현한 서버용 컴포넌트는 렌더링 과정에서 SVG 마크업을 생성하므로, 반환된 HTML 문자열에 실제 SVG 도형이 포함됩니다.

 

두 번째, HTML에서 SVG 문자열을 추출합니다.

const svgMatch = htmlString.match(
  /<svg[^>]*>[\s\S]*<\/svg>/
);

정규식을 이용해 <svg> 시작 태그부터 </svg> 종료 태그까지의 문자열을 추출합니다.

여기서 [\s\S]*는 줄바꿈을 포함한 모든 문자를 대상으로 합니다.

 

다만 이 정규식은 Greedy 방식으로 동작합니다.

 

따라서 HTML에 SVG 요소가 여러 개 존재한다면 첫 번째 <svg>부터 마지막 </svg>까지 추출할 수 있습니다.

현재처럼 하나의 SVG를 처리하는 구조에서는 동작하지만, 여러 SVG를 다루게 된다면 별도의 파싱 방식이 필요할 수 있습니다.

 

세 번째, SVG 문자열을 Buffer로 변환합니다.

Buffer.from(svgMatch[0])

Node.js의 Buffer는 바이너리 데이터를 다루기 위한 자료구조입니다.

문자열 형태의 SVG를 Buffer로 변환하여 Sharp의 입력값으로 전달합니다.

 

네 번째, Sharp를 통해 PNG로 변환합니다.

const pngBuffer = await sharp(
  Buffer.from(svgMatch[0])
)
  .png()
  .toBuffer();

Sharp는 SVG를 해석하여 픽셀 기반 이미지로 래스터화한 뒤, PNG 형식의 Buffer를 반환합니다.

이 과정에서 SVG의 크기와 렌더링 해상도가 최종 이미지의 품질에 영향을 미칩니다.

또한 SVG 내부에 사용된 글꼴이나 이모지는 서버 환경의 폰트 지원 여부에 따라 다르게 표현될 수 있습니다.


MCP Tool 응답으로 이미지 반환하기

PNG 이미지 생성까지 완료했다면, 마지막으로 MCP Tool의 응답 형식에 맞게 반환해야 합니다.

MCP Tool은 실행 결과를 content 배열로 반환할 수 있으며, 이미지 데이터는 type: "image" 콘텐츠로 전달할 수 있습니다.

return {
  type: "image",
  data: pngBuffer.toString("base64"),
  mimeType: "image/png",
  annotations: {
    audience: ["user"],
    priority: 0.9
  }
};

위 객체는 MCP Tool의 content 배열에 포함되는 이미지 콘텐츠입니다.

MCP Tool의 전체 응답 형태로 표현하면 다음과 같습니다.

return {
  content: [
    {
      type: "text",
      text: "Storyline 분석이 완료되었습니다."
    },
    {
      type: "image",
      data: pngBuffer.toString("base64"),
      mimeType: "image/png"
    }
  ]
};

여기서 핵심은 pngBuffer.toString("base64")입니다.

왜 Base64로 인코딩할까?

MCP는 JSON-RPC 기반으로 메시지를 주고받습니다.

하지만 PNG 파일의 원본 바이너리 데이터를 JSON 문자열에 그대로 포함할 수는 없습니다.

따라서 바이너리 데이터를 문자열로 표현하기 위한 인코딩 방식이 필요합니다.

Base64는 바이너리 데이터를 ASCII 문자로 표현할 수 있도록 변환하는 인코딩 방식입니다.

PNG Buffer
    │
    ▼
Base64 Encoding
    │
    ▼
JSON-RPC Message
    │
    ▼
MCP Host
    │
    ▼
Image Display

이렇게 하면 MCP Tool의 JSON 응답에 이미지 데이터를 포함할 수 있습니다.

 

다만 Base64는 압축 방식이 아닙니다.

 

일반적으로 원본 바이너리 데이터보다 인코딩된 문자열의 크기가 약 33% 증가합니다.

따라서 이미지의 크기가 커질수록 MCP 응답의 데이터 크기도 증가할 수 있습니다.

=> 추후 개선 포인트!!

 

결과

stroyline MCP 결과

이렇게 실제로 구현을 완료할 수 있었습니다!

 

정적 이미지 방식의 한계

이번 방식으로 Storyline의 시각화 결과를 MCP Host에 전달할 수 있게 되었지만, 기존 React Component가 제공하던 모든 기능을 유지한 것은 아닙니다.

대표적으로 다음과 같은 인터랙션은 PNG 이미지에 포함되지 않습니다.

  • 노드 Hover 시 나타나는 툴팁
  • 노드 클릭 이벤트
  • 확대 및 축소
  • 사용자 입력에 따른 동적인 화면 변경

이는 PNG가 정적인 이미지이기 때문입니다.

즉, 이번 작업의 목적은 React Component를 MCP Host에서 직접 실행하는 것이 아니라, Githru가 분석한 결과를 서버에서 시각화하고, 이를 MCP Host가 표시할 수 있는 이미지 형태로 전달하는 것이었습니다.

 

마무리

이번 작업에서는 Githru의 Analysis Engine과 NewViz Storyline Component를 MCP Server에 연동했습니다.

 

처음에는 기존 React Component를 renderToString()으로 변환하면 쉽게 해결할 수 있을 것이라고 생각했습니다.

하지만 실제로는 기존 컴포넌트가 useEffect와 D3의 DOM 조작에 의존하고 있었기 때문에, 서버 환경에서 그대로 렌더링할 수 없었습니다.

 

이를 해결하기 위해 서버 전용 컴포넌트를 구현하고, SVG 문자열을 직접 생성하는 방식을 적용했습니다.

이후 생성된 SVG를 Sharp를 통해 PNG로 변환하고, Base64로 인코딩하여 MCP Tool의 이미지 콘텐츠로 반환했습니다.

 

이 과정에서 단순히 React Component를 서버에서 실행하는 것뿐 아니라, 실행 환경의 차이와 MCP Host가 처리할 수 있는 콘텐츠 형식까지 함께 고려해야 한다는 점을 알게 되었습니다.

 

다만 이번 방식은 정적인 이미지 출력에 한정되며, 기존 D3 시각화와 동일한 표현이나 인터랙션을 완전히 제공하지는 못합니다.

또한 브라우저용 컴포넌트와 서버용 컴포넌트가 분리되면서 유지보수해야 할 코드가 늘어났다는 점도 향후 개선해야 할 부분입니다.

 

결과적으로 이번 PR은 React Component 자체를 MCP Host로 전달하는 방식에서 한 단계 더 나아가, 서버에서 시각화 결과를 생성하고 MCP Tool의 표준 이미지 콘텐츠로 제공하는 방법을 구현한 작업이었습니다.

 

참조