GitHub 잔디를 심전도로 바꾸는 Chrome Extension 만들기 Building ECGitHub — A Chrome Extension That Turns Contributions into an ECG
블로그에 심전도 타임라인을 만든 게 2월이었다. 최근 포스트를 ECG 파형으로 시각화하는 컴포넌트. 어제 로빈과 차를 마시다가 내가 만든 블로그를 보여줬더니 ‘어 이거 Github extenstion으로 만들어보면 어때요?’라는 아이디어를 주셨다. GitHub의 잔디밭(contribution graph)을 3D 아이소메트릭으로 바꿔주는 Isometric Contributions라는 Chrome Extension을 보여주시면서, 이것처럼 만들면 잔디밭을 심전도로 바꾸는 extension을 만들 수 있지 않을까?하는 아이디어를 나눴다. 다음 날 아침 나는 하네스 스킬을 쓰고, 딱 한 마디를 해보기로 했다.
/harness https://byminseok.com/2026-02-22/building-a-heartbeat-timeline-for-the-blog-home 이거를 GitHub contribution graph extension 로 만들어야 합니다.
하네스 스킬은 로빈(황민호)이 공유해주신 스킬을 사용했다. (LinkedIn 링크)
여기서부터는 Claude Code의 에이전트 팀이 일한 방식과 내가 직접 테스트해보면서 잡은 이슈들을 정리했다.
1. 하네스 설계 — 에이전트 팀으로 빌드하기
이 프로젝트는 Claude Code의 에이전트 팀 기능으로 빌드했다. 혼자 순차적으로 코딩하는 대신, 전문 에이전트 3명이 병렬로 작업하는 구조다.

[리더] → TeamCreate
│
├─ analyst (Explore) — GitHub DOM 분석
│
├─ ecg-dev (커스텀) — ECG 렌더링 엔진 + 통계 + 범례
│
└─ ext-dev (커스텀) — Extension 셸 + Popup + 통합
파이프라인 + 팬아웃 패턴이다. analyst가 먼저 GitHub DOM 구조를 분석하고, 그 결과를 기반으로 ecg-dev와 ext-dev가 병렬로 작업한다.
3명의 에이전트가 약 40분 만에 Extension 전체를 빌드했다. 파일 14개. 물론 이건 시작일 뿐이었다.
2. heartbeat.js 적응 — 10일에서 365일로
원본 heartbeat.js는 최근 10일의 포스트를 시각화한다. viewBox 700×80, 날짜당 70px. GitHub는 52주(365일)다. 단순히 스케일만 바꾸면 피크가 빽빽하게 붙어서 알아볼 수가 없다.
이전 블로그 글에서 겪었던 문제와 같은 구조다. 당시에는 14일→10일로 표시 범위를 줄여서 해결했다. “대부분 빈 날이면 flat해 보이니까” 적절한 밀도를 찾는 게 핵심이었다.
GitHub에서는 표시 범위를 줄일 수 없다 — 1년치 전체를 보여줘야 한다. 그래서 반대 방향으로 접근했다. 날짜 수를 줄이는 대신, 피크를 공간에 맞게 스케일링하는 방식.
주간 단위 압축을 적용했다. 365일을 52주로 묶고, 각 주의 최대 contribution level로 피크 높이를 결정한다. level 0은 평탄한 기저선, level 4는 최대 피크.
var LEVEL_PEAK_FACTORS = [0, 0.25, 0.5, 0.75, 1.0];
var peakY = baseline - peakRange * LEVEL_PEAK_FACTORS[maxLevel];
피크의 가로 폭도 문제였다. 원본 heartbeat.js에서 피크 하나의 QRS+T 파형은 24px 고정(cx-8부터 cx+16까지). 블로그에서는 70px/day니까 넉넉했다. 하지만 주간 모드에서 한 주의 폭은 약 14px — 피크가 옆 주를 침범한다.
ecgPeak 함수에 scale 파라미터를 추가해서, 유닛 폭에 맞게 베지어 커브의 x좌표를 동적으로 축소했다.
var DEFAULT_PEAK_WIDTH = 24; // 원본 피크 폭
function ecgPeak(cx, peakY, baseline, scale) {
var s = scale || 1.0;
// Q dip — x좌표만 scale 적용, y는 유지
var d = ' C ' + (cx - 8 * s) + ',' + b + ' ' + ...
}
function calcPeakScale(unitW) {
var ideal = (unitW * 0.8) / DEFAULT_PEAK_WIDTH;
return Math.max(0.05, Math.min(1.2, ideal));
}
주간 모드에서 scale은 0.46 — 피크가 11px로 축소되어 14px 슬롯의 80%를 차지한다. QRS+T 형태를 유지하면서 겹치지 않는 최적값이다.
3. GitHub DOM과의 싸움
첫 번째 빌드를 로드했더니 아무것도 안 바뀌었다. 원인은 DOM 선택자.
에이전트가 svg.js-calendar-graph-svg와 rect[data-date]를 사용해서 코드를 작성했는데, 현재 GitHub은 SVG가 아니라 HTML table을 쓰고 있었다.
<!-- 우리 코드가 찾던 것 (deprecated) -->
<svg class="js-calendar-graph-svg">
<rect data-date="2026-02-22" data-level="4" />
</svg>
<!-- 실제 GitHub DOM (2026 현재) -->
<table class="ContributionCalendar-grid js-calendar-graph-table">
<td class="ContributionCalendar-day" data-date="2026-02-22" data-level="4">
</td>
</table>
td.ContributionCalendar-day[data-date]로 선택자를 수정했다. contribution count는 tool-tip 커스텀 엘리먼트의 텍스트에서 정규식으로 추출한다.
4. 버그 사냥 — CSS 우선순위, 토글, 테마
첫 렌더링은 됐지만 버그가 쏟아졌다. 하나씩 잡은 과정.
색상 테마가 안 바뀌는 문제
Popup에서 Blue, Purple을 선택해도 ECG 라인이 항상 초록색이었다. 원인은 CSS 우선순위.
createEcgSvg에서 path.setAttribute('stroke', lineColor) — SVG attribute로 색상을 설정하고 있었는데, CSS에서 .ecg-line { stroke: var(--ecg-line-color, #39d353) } — CSS property가 SVG attribute보다 우선순위가 높다. 그리고 --ecg-line-color 변수를 아무 데서도 세팅하지 않았다.
해결: ECG 컨테이너에 CSS 변수를 직접 세팅.
container.style.setProperty('--ecg-line-color', lineColor);
이 한 줄로 ECG 라인, 피크 dot, 통계 패널 값, 범례까지 전부 테마 색상을 따르게 됐다. CSS 변수의 cascade 덕분에.
토글이 Both로만 보이는 문제
ECG 모드를 선택해도 원본 잔디밭이 계속 보였다. .js-calendar-graph만 숨기고 있었는데, 이 element는 contribution table만 감싸는 요소다. <h2> (contribution count 텍스트), 연도 탭 등은 이 element 바깥의 sibling이라 그대로 노출됐다.
해결: .js-yearly-contributions (전체 contribution 섹션)을 숨기도록 변경. ECG 컨테이너는 이 섹션 바깥에 배치해서, 섹션을 숨겨도 ECG는 보이게.
Stats 텍스트가 안 보이는 문제
Light mode에서 Streaks 텍스트가 하얀색이었다. Light mode override CSS가 .ecg-stats-block에만 걸려있고, 실제 color를 상속하는 부모 .ecg-stats-panel에는 안 걸려 있었다. CSS 변수 scope 문제.
5. 연도 전환 — 죽었다 살아나는 Observer
GitHub 프로필에서 2026, 2025, 2024 연도를 클릭하면 contribution 데이터가 바뀐다. 처음에는 ECG가 전혀 반응하지 않았다.
원인은 MutationObserver의 target 소실이다. .js-calendar-graph에 observer를 걸어놨는데, GitHub이 연도를 바꿀 때 이 element 자체를 교체한다. Observer가 달려있던 element가 DOM에서 빠지면서 observer도 같이 죽는다.
처음에는 상위 element(.js-yearly-contributions)에 observer를 걸어봤다. 하지만 이것도 같은 문제 — GitHub이 섹션 전체를 교체할 수 있다.
결국 document.body에 단일 persistent observer를 걸고, .js-calendar-graph element의 identity를 추적하는 방식으로 해결했다.
let knownGraphEl = null;
domObserver = new MutationObserver(() => {
const currentGraph = getGraph();
if (currentGraph && !knownGraphEl) {
// 그래프가 처음 나타남 (lazy load)
scheduleRender();
}
if (currentGraph !== knownGraphEl) {
// element가 교체됨 (연도 전환)
if (currentMode === 'ecg') {
// 즉시 숨김 — 잔디밭이 잠깐 보이는 것 방지
getContributionSection()?.classList.add('ecgithub-hidden');
}
scheduleRender();
}
});
이 접근의 핵심은 element reference 비교 (currentGraph !== knownGraphEl)다. DOM 내용이 바뀌었는지가 아니라, element 자체가 새 객체인지를 본다. GitHub이 어떤 방식으로 교체하든 — innerHTML, replaceChild, turbo navigation — 모두 잡아낸다.
연도 전환 시 잔디밭이 잠깐 보이는 flash 문제도 있었다. 새 section에는 ecgithub-hidden class가 없으니까 300ms debounce 동안 노출된다. observer callback에서 즉시 class를 추가해서 해결.
6. 살아있는 심전도 — Pulse Dot
여기까지 만들고 보니 뭔가 빠져있었다. 심전도 모니터는 지금 이 순간 심장이 뛰고 있는지를 보여주는 거다. 정적인 그래프가 아니라.
마지막 활동이 있는 피크 위치에 pulsing dot을 넣었다. 라인 끝(12월)이 아니라, 실제로 contribution이 있는 마지막 주에.
var lastPeak = peaks[peaks.length - 1];
var endpoint = lastPeak
? { x: lastPeak.cx, y: lastPeak.cy, alive: true }
: { x: W, y: baseline, alive: false };
이렇게 하면:
- “in last year” view → 최근 피크 꼭대기에서 pulse — “지금 뛰고 있다”
- 2026년 view → 3월 근처에서 pulse, 이후는 flatline — “여기까지 살아있다”
- 2024년 view → 마지막 활동에서 pulse — “그 해의 마지막 심장박동”
Dot은 라인 그리기 애니메이션이 끝난 후에 fade in된다. 심전도 모니터에서 펜이 지나간 자리에 점이 찍히는 느낌.
@keyframes ecg-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.2; transform: scale(0.4); }
}
7. 완성된 구조
ecgithub/
├── manifest.json # Manifest V3
├── src/
│ ├── ecg-engine.js # ECG 렌더링 (adaptive scaling, pulse dot)
│ ├── ecg-stats.js # 통계 (Total, Best Day, Streaks)
│ ├── ecg-legend.js # 범례 (ECG 미니 파형)
│ ├── ecg-export.js # PNG 내보내기
│ ├── ecg-styles.css # 스타일 (light/dark, reduced-motion)
│ ├── content.js # Content Script (observer, view toggle)
│ ├── popup.html/js/css # 설정 UI (on/off, 테마, 통계 토글)
├── icons/ # 아이콘
├── store/ # Chrome Web Store 등록용 에셋
├── LICENSE # MIT
└── README.md
빌드 방식에 대해
이 extension은 코드 한 줄 한 줄 직접 타이핑한 게 아니다. 하네스를 설계하고, 에이전트 팀을 조율하고, 결과물을 검증하고, 버그를 잡는 방식으로 만들었다.
에이전트가 첫 빌드를 40분 만에 뽑아냈다. 하지만 진짜 작업은 그 이후였다. CSS 우선순위 버그, DOM element 교체 감지, 피크 스케일링 — QA 에이전트를 만들어서 작업하면 더 빨리 되었을 수도 있지만, 이번에는 내가 직접 문제를 정확히 진단하고, 왜 안 되는지를 설명하고, 방향을 제시하는 역할을 했다.
기획자로서 코드를 읽고 구조를 이해하는 건 할 수 있다. 베지어 커브를 처음부터 짜는 건 내 영역이 아니다. 하지만 “70px/day에서 설계된 24px 피크가 14px/week에서 겹친다”는 걸 알아채고, 블로그에서의 경험을 연결해서 해결 방향을 제시하는 건 — 그건 코드를 치는 것과는 다른 종류의 엔지니어링이다.
GitHub: github.com/theorakim/ecgithub Chrome Web Store: Coming soon