byminseok.com

블로그 홈에 심전도 타임라인 만들기

이 블로그는 데스크탑⟨960px 이상⟩에서 스프레드 레이아웃이라는 2단 구조로 동작한다. 왼쪽에 목록, 오른쪽에 본문. 홈 화면도 마찬가지로, 왼쪽에 인트로 텍스트가 있고 오른쪽에 최근 글 목록이 뜬다. 문제는 모바일이었다. spread__rightdisplay: none이라 최근 글 목록이 아예 보이지 않았다. 홈에 들어와도 인트로 텍스트만 덩그러니.

모바일에서도 최근 콘텐츠에 접근할 수 있는 무언가가 필요했다. 그런데 그냥 글 목록을 밑에 붙이면 재미가 없으니까, 시각적으로 뭔가 있으면 좋겠다고 생각했다.

1. 아이디어 — GitHub 잔디에서 심전도로

처음 떠올린 건 GitHub의 contribution graph 같은 격자형 카운터였다. 최근 며칠간의 포스팅 빈도를 색 농도로 표현하는 식. 그런데 이건 블로그 톤이랑 안 맞았다. 너무 데이터 대시보드 같달까.

그러다 ECG 심전도 이미지를 봤는데, 이걸 타임라인으로 쓰면 어떨까 싶었다. 글이 있는 날은 심장이 뛰듯 피크가 솟고, 없는 날은 평탄한 기저선. 블로그가 살아있다는 느낌을 줄 수 있겠다고 생각했다.

ECG 심전도 — 이 라인을 타임라인으로 쓰면 어떨까

두 가지 방향이 있었다:

  • A. 장식용 심전도 + 아래에 글 목록: 심전도는 예쁜 그림일 뿐, 실제 기능은 아래 리스트가 담당
  • B. 데이터 드리븐 심전도: 각 피크가 실제 포스트를 나타내고, 클릭하면 그 글로 이동

B를 골랐다. 만드는 김에 인터랙티브하게 만들고 싶었다.

2. 설계

핵심 구조는 이렇다.

  • SVG viewBox="0 0 700 80" 안에 path 하나로 심전도 라인을 그린다
  • 기저선은 y=62, 피크 꼭대기는 y=14 근처
  • 각 날짜가 70px 폭을 차지 (700 ÷ 10일)
  • 글이 없는 날은 기저선에서 미세하게 출렁이는 노이즈
  • 글이 있는 날은 ECG QRS+T 파형으로 피크가 솟음
  • 피크 위에 44px HTML 오버레이 링크를 absolute로 올려서 터치 영역 확보
  • 호버하면 글 제목+날짜 툴팁, 클릭하면 해당 글로 이동

기존 Spread.js와 동일한 IIFE 모듈 패턴으로 Heartbeat.init()을 만들었다. Jekyll Liquid로 최근 10일간의 포스트 데이터를 JSON으로 생성하고, 이걸 JS에 넘겨서 SVG를 동적으로 그린다.

3. ECG 파형 — 직선에서 곡선으로

처음에는 SVG L⟨line-to⟩ 명령으로 피크를 그렸다. 결과물은… 심전도가 아니라 톱날이었다. 수직 스파이크가 위아래로 튀는 모양.

실제 ECG QRS 파형은 이런 순서다:

  1. Q 딥 — 기저선에서 살짝 아래로 하강
  2. R 피크 — 급격한 상승 (가장 높은 점)
  3. S 딥 — 다시 급하강
  4. T 웨이브 — 완만한 언덕 형태로 서서히 기저선 복귀

이걸 SVG 큐빅 베지어 커브⟨C 명령⟩로 구현했다.

function ecgPeak(cx, peakY) {
  var b = BASELINE;
  // Q dip: 기저선에서 살짝 아래로
  return ' C ' + (cx-8) + ',' + b + ' ' + (cx-6) + ',' + (b+1) + ' ' + (cx-4) + ',' + (b+4) +
  // R peak: 가파른 상승
         ' C ' + (cx-3) + ',' + (b+5) + ' ' + (cx-1.5) + ',' + (peakY+5) + ' ' + cx + ',' + peakY +
  // S dip: 가파른 하강
         ' C ' + (cx+1.5) + ',' + (peakY+5) + ' ' + (cx+3) + ',' + (b+5) + ' ' + (cx+4) + ',' + (b+4) +
  // T wave: 완만한 언덕
         ' C ' + (cx+5) + ',' + (b+2) + ' ' + (cx+7) + ',' + (b-8) + ' ' + (cx+10) + ',' + (b-7) +
  // baseline 복귀
         ' C ' + (cx+12) + ',' + (b-5) + ' ' + (cx+14) + ',' + (b-1) + ' ' + (cx+16) + ',' + b;
}

빈 날의 기저선도 L⟨직선⟩ 대신 Q⟨이차 베지어⟩로 바꿔서 미세한 출렁임을 줬다. seeded random으로 매번 같은 노이즈 패턴이 나오게 해서, 페이지를 새로고침해도 모양이 바뀌지 않는다.

큐빅 베지어로 바꾼 뒤 — 피크마다 동그라미가 있던 중간 버전

4. 기간 튜닝 — 14일에서 10일로

처음엔 최근 2주⟨14일⟩로 잡았는데, 포스팅이 특정 날에 몰려있다 보니 대부분의 날이 비어서 심전도가 거의 죽어있는 것처럼 보였다. 1주일로 줄여보니 이번엔 너무 짧아서 글이 아예 안 잡힐 수도 있었다.

결국 10일이 적당했다. 밀도가 어느 정도 유지되면서도 빈 날의 평탄한 기저선이 글 있는 날의 피크를 더 돋보이게 해줬다. 하단에 “최근 10일간 N개의 글”이라고 표시해서 맥락을 알려준다.

5. 터치 인터랙션 설계

데스크탑에서는 호버로 툴팁이 뜨고 클릭하면 아카이브 스프레드 레이아웃으로 이동한다⟨?read= 쿼리 파라미터⟩. 모바일에서는 호버가 없으니 다른 방식이 필요했다.

2탭 패턴을 적용했다

  1. 첫 번째 탭 → 툴팁이 뜨며 글 제목 미리보기 (3초 후 자동 해제)
  2. 두 번째 탭 → 실제로 그 글로 이동

이건 iOS의 링크 미리보기 패턴에서 가져왔다. 실수로 잘못된 글을 여는 걸 방지하면서도, 한 번 더 탭하면 바로 이동할 수 있다.

피크 위에 호버하면 글 제목과 날짜가 툴팁으로 뜬다

6. 툴팁 가장자리 보정

ECG 타임라인은 가로로 길게 펼쳐지니까, 양쪽 끝에 있는 피크의 툴팁이 화면 밖으로 넘어갈 수 있다. JS에서 각 툴팁의 위치를 계산한 뒤 컨테이너 밖으로 넘어가면 CSS 변수 --tooltip-offset으로 좌우 시프트한다

function adjustTooltips(links, container) {
  var cw = container.offsetWidth;
  for (var i = 0; i < links.length; i++) {
    var left = parseFloat(links[i].el.style.left);
    var halfTip = 90; // max-width 180px의 절반
    var offset = '-50%';
    if (left - halfTip < 0) {
      offset = 'calc(-50% + ' + (halfTip - left) + 'px)';
    } else if (left + halfTip > cw) {
      offset = 'calc(-50% - ' + (left + halfTip - cw) + 'px)';
    }
    links[i].el.style.setProperty('--tooltip-offset', offset);
  }
}

툴팁은 피크 아래쪽에 뜨도록 했다. 위로 올리면 심전도 라인을 가리기 때문이다.

7. 애니메이션

페이지 로드 시 인트로 타이핑 효과⟨기존⟩ 이후에 심전도 라인이 왼쪽에서 오른쪽으로 그려진다. stroke-dasharray/stroke-dashoffset 트릭이다:

var length = path.getTotalLength();
path.style.strokeDasharray = length;
path.style.strokeDashoffset = length;
// 0.9초 후 시작, 1.2초에 걸쳐 그려짐
path.style.transition = 'stroke-dashoffset 1.2s ease-in-out';
path.style.strokeDashoffset = '0';

라인 드로잉이 끝나면 0.5초 뒤에 HTML 오버레이 링크들이 순차적으로 fade-in된다⟨0.12초 간격⟩. prefers-reduced-motion: reduce가 설정되어 있으면 애니메이션 없이 즉시 표시된다.

8. 동그라미를 뺐다

처음에는 각 피크 위에 SVG <circle>을 그려서 클릭 포인트를 시각적으로 보여줬다. outline 스타일⟨속이 빈 원⟩로 만들었는데, 막상 보니 심전도의 깔끔한 라인 위에 동그라미가 얹혀있으니까 오히려 미관을 해쳤다.

고민이 됐다. 동그라미를 빼면 모바일에서 “여기 누를 수 있다”는 시각적 힌트가 사라지니까. 그런데 실제로는 44px 크기의 HTML 오버레이 <a> 태그가 SVG 위에 absolute로 떠 있어서, 동그라미가 보이든 안 보이든 터치 영역은 그대로다. 호버하거나 탭하면 툴팁이 뜨니까 인터랙션 가능하다는 건 자연스럽게 발견할 수 있다.

결국 SVG circle을 제거했다. 라인만 남기니 심전도 고유의 미니멀한 느낌이 훨씬 살았다.

최종 — 동그라미 없이 라인만 남긴 심전도 타임라인

9. 배포하자마자 터진 FOUC

완성된 코드를 GitHub Pages에 푸시하고 홈에 들어갔더니 심전도가 까맣게 나오고, 아래에 포스트 제목이 줄줄이 텍스트로 깔려 있었다. 새로고침하면 멀쩡한데, 처음 열 때만 그랬다.

FOUC⟨Flash of Unstyled Content⟩ 문제였다. 브라우저가 HTML을 파싱하면서 body 하단의 <script>에 도달하면 JS를 즉시 실행하는데, 이때 외부 CSS 파일이 아직 도착하지 않은 상태일 수 있다. CSS 없이 렌더링되면,

  • SVG path의 fill 기본값은 black → 테라코타 선 대신 까만 덩어리
  • 링크의 position: absoluteopacity: 0이 없음 → 문서 흐름에 끼어들며 보임
  • 툴팁의 visibility: hidden이 없음 → 제목+날짜 텍스트 그대로 노출

CSS가 캐시된 재방문자에게는 즉시 적용되니까 문제가 안 보였던 거고, 첫 방문이나 CDN 전파 직후에만 터지는 타이밍 버그였다.

수정은 간단했다. 핵심 숨김 처리를 CSS에만 의존하지 말고 JS에서 엘리먼트를 만들 때 인라인으로 넣어두면 된다:

// SVG path — CSS 없이도 검게 채워지지 않음
path.setAttribute('fill', 'none');

// 링크 — CSS 없이도 보이지 않고, 문서 흐름에 안 끼어듦
a.style.cssText = 'position:absolute;opacity:0;left:...;top:...';

CSS가 나중에 도착하면 같은 속성을 다시 설정하니까 충돌 없고, animateDotsopacity: 1로 바꾸는 시점에 비로소 보이게 된다. 외부 스타일시트에 의존하는 JS 컴포넌트를 만들 때는, “CSS가 아직 없다면?”을 한 번쯤 생각해봐야 한다는 교훈.

파일 구조

최종적으로 만들어진 파일들

  • _sass/_heartbeat.scss — 심전도 컴포넌트 스타일 (라인, 오버레이 링크, 툴팁, 하단 정보)
  • assets/js/heartbeat.js — IIFE 모듈. SVG path 생성, 좌표 변환, 애니메이션, 인터랙션
  • _layouts/home.html — Liquid로 10일 데이터 JSON 생성 + Heartbeat.init() 호출
  • assets/css/main.scss@import "heartbeat" 추가

날짜 데이터는 site.time 기준으로 Liquid에서 생성하므로, Jekyll 빌드⟨= git push⟩할 때마다 자동으로 갱신된다. 별도의 수동 관리가 필요 없다.


처음에 “모바일에서 최근 글을 보여줘야 하는데”로 시작한 문제가, 심전도라는 비유를 만나면서 기능적이면서도 시각적인 해결책이 됐다. SVG path 좌표를 손으로 찍으면서 “이 곡선이 왜 이상하지” 하고 씨름한 시간이 제일 길었는데, 결과적으로는 블로그에 성격을 하나 더 입힌 느낌이라 만족스럽다.