군만두의 IT 개발 일지

[Tistory Stats] GitHub 리드미에 티스토리 잔디 심기 본문

개발일지

[Tistory Stats] GitHub 리드미에 티스토리 잔디 심기

mandus 2026. 7. 5. 15:27

목차

     

    티스토리 발행 기록을 GitHub 프로필에 잔디로 표현하는 배지를 만들었습니다. 리드미에 <img> 태그 한 줄만 붙이면 티스토리 잔디를 확인할 수 있습니다. 만드는 과정에서 배운 점을 정리했습니다.

    ✨ 요약


    • 티스토리 발행 기록을 GitHub 프로필에 보여주는 잔디 배지를 만들었음. 리드미에 <img> 태그 한 줄만 붙이면 됨.
    • 핵심은 API가 JSON이 아니라 SVG 이미지 자체를 응답하는 것임. solved.ac 잔디를 리드미에 심어주는 mazandi에서 아이디어를 얻음.
    • GitHub가 리드미의 외부 이미지를 Camo 프록시로 캐싱하기 때문에, Content-Type과 Cache-Control 헤더, 외부 폰트 금지 같은 제약을 지켜야 했음. 배지는 최대 6시간 캐시되도록 설계함.

    ✨ 아이디어 : 이미지를 반환하는 API


    처음 티스토리 대시보드를 만들고 나니 욕심이 생겼습니다. 분석 결과를 매번 사이트에 들어와서 보는 게 아니라, GitHub 프로필에 상주시킬 수는 없을까 고민했습니다. solved.ac 스트릭을 리드미에 보여주는 mazandi처럼, 리드미에 아래 코드를 붙이면 티스토리 잔디가 보이게 만들고 싶었습니다.

    <img src="https://tistory-stats.vercel.app/api/badge?blog=블로그이름&theme=warm" />

    원리는 단순합니다. 리드미의 <img> 태그가 제 API를 호출하면, 서버리스 함수가 그 자리에서 RSS를 분석해 완성된 SVG 문자열을 이미지로 응답합니다. 응답 헤더의 Content-Type만 다를 뿐, 대시보드용 /api/rss와 데이터 수집 과정은 동일합니다.

    ▲ 잔디 배지 요청 흐름도

    ✨ GitHub Camo의 제약 조건


    구현 전에 알아야 할 것이 있었습니다. GitHub는 리드미의 외부 이미지를 직접 보여주지 않습니다.

     

    📌 Camo
    GitHub가 리드미 등에 포함된 외부 이미지를 대신 받아 자체 도메인(camo.githubusercontent.com)으로 서빙하는 이미지 프록시임. 사용자의 IP나 브라우저 정보가 외부 서버에 노출되는 것을 막고, HTTPS를 강제하기 위한 장치임. 원본 서버의 Cache-Control 헤더를 존중해 이미지를 캐싱함.

     

    Camo를 거치면서 생기는 제약은 세 가지였고, 각각 이렇게 대응했습니다.

    • Content-Type이 정확해야 함 : image/svg+xml을 명시하지 않으면 이미지로 렌더링되지 않음.
    • SVG 안에서 외부 리소스를 불러올 수 없음 : 웹폰트를 쓸 수 없어서 font-family는 시스템 폰트 계열로만 지정함.
    • 캐싱을 직접 설계해야 함 : 새 글이 배지에 반영되려면 캐시가 만료되어야 함. 너무 짧으면 서버리스 함수 호출량이 늘고, 너무 길면 잔디가 늦게 자람. s-maxage=21600(6시간)으로 타협함.
    // 이미지 타입 명시 + 6시간 캐시
    res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
    res.setHeader('Cache-Control', 's-maxage=21600, stale-while-revalidate=600');
    return res.status(200).send(svg);

    ✨ SVG 잔디 그리기


    배지의 잔디는 라이브러리 없이 직접 그렸습니다. 최근 26주를 주 단위 열로 배치하고, 날짜별 발행 수를 0~3단계 색상으로 칠하는 방식입니다. 그리드의 끝을 이번 주 토요일에 맞추면 모든 열이 일요일~토요일로 정렬됩니다.

    // 그리드의 끝을 "이번 주 토요일"로 맞춰 열이 항상 일~토로 정렬되게 한다
    const end = new Date(today.getTime() + (6 - today.getDay()) * DAY_MS);
    const start = new Date(end.getTime() - (WEEKS * 7 - 1) * DAY_MS);
    
    for (let w = 0; w < WEEKS; w++) {
      for (let d = 0; d < 7; d++) {
        if (cursor <= today) { // 미래 날짜 칸은 그리지 않는다
          const lv = level(heatmap.get(dateKey(cursor)) ?? 0);
          const fill = lv === 0 ? theme.empty : theme.grass[lv - 1];
          cells += `<rect x="${x}" y="${y}" width="12" height="12" rx="3" fill="${fill}"/>`;
        }
        cursor = new Date(cursor.getTime() + DAY_MS);
      }
    }

    테마는 쿼리 파라미터로 받되, 임의의 값이 스타일에 주입되지 않도록 화이트리스트 방식으로만 허용했습니다.

    // 테마는 정의된 것만 허용한다 (쿼리 파라미터를 통한 스타일 주입 방지)
    const THEMES = {
      warm: { bg: '#fffefb', grass: ['#f8c9b4', '#f0854f', '#d13c0a'], /* ... */ },
      dark: { bg: '#1c1a17', grass: ['#7c3a1d', '#c94f1a', '#ff6b35'], /* ... */ },
    };
    const theme = THEMES[themeName] ?? THEMES.warm; // 알 수 없는 값은 기본 테마로

    구현 중에 문제도 하나 있었습니다. RSS에서 읽은 블로그 제목을 SVG에 넣을 때, 엔티티 디코딩보다 XML 이스케이프를 먼저 해버리면 &amp;lt; 같은 이중 이스케이프가 생겨 제목이 깨진 문자로 표시됩니다. 디코딩을 먼저, 이스케이프를 나중에 하는 순서로 해결했습니다. 텍스트를 한 문서에서 꺼내 다른 문서에 넣을 때는 항상 인코딩 경계를 의식해야 한다는 걸 다시 배웠습니다.

    ✨ 실패해도 이미지를 반환하기


    이 API에서 가장 신경 쓴 부분은 오히려 실패 케이스입니다. RSS를 못 가져왔다고 JSON 에러를 반환하면, 리드미에는 깨진 이미지 아이콘이 뜹니다. 배지를 붙인 사람의 프로필이 남의 서비스 장애 때문에 지저분해지는 것입니다.

    그래서 실패 시에도 상태 코드 200과 함께 "잔디를 불러오지 못했습니다"라는 작은 안내 SVG를 반환하도록 했습니다. 대신 에러 상태가 오래 캐싱되면 복구 후에도 한동안 에러 배지가 보이므로, 에러 응답의 캐시는 5분으로 짧게 설정했습니다.

    // 실패 시에도 리드미가 깨져 보이지 않도록 "에러 상태 SVG"를 반환한다
    res.setHeader('Cache-Control', 's-maxage=300'); // 에러는 5분만 캐시 (금방 복구될 수 있으므로)
    return res.status(200).send(errorSvg);

    ✨ 사용 방법


    리드미에 아래 한 줄을 붙이면 됩니다. GitHub의 라이트/다크 모드에 맞춰 테마를 자동 전환하고 싶다면 <picture> 태그를 활용할 수 있습니다.

    <picture>
      <source media="(prefers-color-scheme: dark)"
              srcset="https://tistory-stats.vercel.app/api/badge?blog=블로그이름&theme=dark">
      <img src="https://tistory-stats.vercel.app/api/badge?blog=블로그이름&theme=warm" alt="Tistory 잔디">
    </picture>

    서비스 주소 : https://tistory-stats.vercel.app

    ▲ 리드미에 적용된 잔 배지 이미지

    ✨ 후기


    • "API의 응답이 꼭 JSON일 필요는 없다"는 당연한 사실을 처음으로 체감함. Content-Type을 바꾸는 것만으로 API가 이미지 생성기가 됨.
    • 성공 응답보다 실패 응답의 사용자 경험을 설계하는 데 더 오래 고민함. 내 서비스의 장애가 사용자의 프로필에 전이되지 않게 만드는 것, 이것도 일종의 장애 격리라고 생각함.
    • 다음 단계로는 커스텀 색상 테마 지원과 RSS 50개 제한을 넘어서는 히스토리 누적 방식을 고민 중임. 네이버 블로그와 velog에도 적용될 수 있도록 구현해보고 싶음.

    ✨ 참고자료


    1) GitHub Docs, "About anonymized URLs", 링크

    2) mazandi, solved.ac 스트릭 위젯, 링크

    3) MDN Web Docs, "picture element", 링크

    Comments