들어가며

정적 사이트 생성기(SSG)로 만든 블로그는 정말 빠르고 안전합니다. 하지만 댓글 같은 동적 기능을 추가하려면 고민이 필요합니다. 저도 이 블로그에 GitHub Discussions 기반의 Giscus를 댓글 시스템으로 사용하고 있는데, 한 가지 불편한 점이 있었습니다.

댓글이 각 포스트 맨 아래에만 있어서, 어떤 글에 댓글 활동이 있는지 한눈에 보기 어려웠습니다.

그래서 직접 세 가지 기능을 만들어봤습니다:

  1. 포스트 목록에 댓글 수 표시하기 - 어떤 글에 대화가 오가는지 바로 알 수 있습니다
  2. 둘러보기 페이지에 최근 댓글 보여주기 - 블로그의 최근 활동을 한눈에 확인
  3. 전체 댓글 페이지 만들기 - 모든 댓글을 한곳에서 탐색

이 글에서는 GitHub GraphQL API를 활용해서 정적 사이트에 동적 댓글 정보를 가져오고, 성능까지 챙긴 과정을 공유하겠습니다.

구현한 기능들

업데이트 된 댓글 보기 인터페이스 - 글 목록, 최근 댓글, 전체 댓글 페이지를 작업했다.
업데이트 된 댓글 보기 인터페이스 - 글 목록, 최근 댓글, 전체 댓글 페이지를 작업했다.
업데이트 된 codeslog의 댓글 보기 UI

1. 포스트 목록에 댓글 수 표시

포스트 목록(/posts/, /archives/)에서 각 글의 댓글 수를 보여줍니다.

특징:

  • 댓글이 1개 이상일 때만 표시합니다
  • 댓글이 없으면 완전히 숨김 처리 (뒤에 점 같은 것도 남지 않습니다)
  • 스크린 리더 사용자도 잘 들을 수 있게 레이블 추가
  • 5분 캐싱으로 API 호출 최소화
html
<!-- 이렇게 보입니다 -->
2026년 1월 12일 · 5분 · Isaac · 3 댓글

2. 둘러보기 페이지에 최근 댓글

/explore/ 페이지에 최근 댓글을 카드 형태로 보여줍니다.

보여주는 정보:

  • 작성자 프로필 사진과 이름
  • 얼마나 전에 작성했는지 (예: “3분 전”, “2시간 전”)
  • 댓글 내용 미리보기 (최대 150자)
  • “게시글 보기” 링크 (클릭하면 댓글 위치로 바로 스크롤합니다!)

3. 전체 댓글 페이지

/explore/comments/ 주소로 블로그의 모든 댓글을 볼 수 있는 전용 페이지를 만들었습니다.

특징:

  • 최대 100개 discussion의 댓글을 가져옵니다
  • 최신 댓글이 위로 오도록 정렬
  • 최근 댓글과 동일한 디자인
  • 5분 캐싱 + stale-while-revalidate 패턴으로 빠르게 표시

기술 스택

Giscus: GitHub Discussions 기반 댓글 시스템

Giscus는 GitHub Discussions를 백엔드로 사용하는 오픈소스 댓글 시스템입니다.

장점:

  • GitHub 계정으로 간편하게 로그인
  • 마크다운 지원
  • 이모지 반응, 대댓글 모두 지원
  • 무료이며 광고 없음
  • 데이터가 GitHub에 저장되어 직접 소유 가능

단점:

  • 댓글 정보가 iframe 안에만 있어서 외부에서 접근 불가
  • 포스트 목록에 댓글 수를 보여주려면 별도 구현 필요

GitHub GraphQL API

GraphQL 쿼리 흐름을 보여주는 개념 일러스트 - 개발자 노트북에서 단일 GraphQL 쿼리를 GitHub 서버로 보내고 구조화된 데이터를 받아오는 모습
GraphQL 쿼리 흐름을 보여주는 개념 일러스트 - 개발자 노트북에서 단일 GraphQL 쿼리를 GitHub 서버로 보내고 구조화된 데이터를 받아오는 모습
제작: 나노바나나

GitHub는 RESTful API 외에도 GraphQL API를 제공합니다. 댓글 정보를 가져올 때는 GraphQL이 훨씬 효율적입니다.

사용한 쿼리:

graphql
query($owner: String!, $repo: String!, $categoryId: ID!) {
  repository(owner: $owner, name: $repo) {
    discussions(first: 50, categoryId: $categoryId, orderBy: {field: UPDATED_AT, direction: DESC}) {
      nodes {
        title
        url
        comments(last: 10) {
          totalCount
          nodes {
            author {
              login
              url
              avatarUrl
            }
            body
            bodyText
            createdAt
            url
          }
        }
      }
    }
  }
}

GraphQL을 선택한 이유:

  • 필요한 데이터만 정확히 요청 가능 (불필요한 데이터 전송 제거)
  • 한 번의 요청으로 여러 리소스 조회 가능
  • RESTful API 대비 네트워크 비용 절감

GitHub Personal Access Token 만들기

GitHub API는 인증 없이도 사용할 수 있지만, 시간당 60회로 제한됩니다. Personal Access Token(PAT)을 사용하면 시간당 5000회로 늘어납니다.

1. GitHub에서 토큰 발급받기

깃헙 토큰 발급 페이지 화면
깃헙 토큰 발급 페이지 화면
캡처: 깃헙
  1. GitHub 로그인 후 Settings > Developer settings > Personal access tokens > Fine-grained tokens로 이동합니다

  2. “Generate new token” 클릭

  3. 다음과 같이 설정합니다:

    • Token name: Blog Comment API (원하는 이름)
    • Expiration: 90 days 또는 No expiration (90일 권장)
    • Repository access: Public Repositories (read-only)
    • Permissions:
      • Discussions: Read-only (필수)
      • 나머지는 No access로 설정
  4. “Generate token” 클릭

  5. 생성된 토큰을 반드시 복사해둡니다 (재확인 불가)

생성된 토큰은 github_pat_ 또는 ghp_로 시작하는 긴 문자열입니다.

2. 토큰 보안 관리

보안 수칙 (필수):

  • 토큰을 절대 Git에 커밋하지 않습니다
  • 공개 저장소의 경우 특히 주의가 필요합니다
  • .env 파일을 .gitignore에 반드시 추가합니다

로컬 개발 환경 설정하기

1. .env 파일 만들기

프로젝트 루트에 .env 파일을 생성합니다:

bash
# .env
GITHUB_TOKEN=github_pat_여기에_실제_토큰_붙여넣기

2. Hugo 보안 정책 설정

Hugo는 기본적으로 환경 변수 접근을 제한합니다. config.yaml에 다음을 추가합니다:

yaml
# config.yaml
security:
  funcs:
    getenv:
      - ^HUGO_
      - ^CI$
      - ^GITHUB_TOKEN$

3. Hugo 템플릿에서 토큰 넣기

layouts/partials/github-token.html 파일을 만듭니다:

html
{{- if getenv "GITHUB_TOKEN" -}}
<meta name="github-token" content="{{ getenv "GITHUB_TOKEN" }}">
{{- else if site.Params.githubToken -}}
<meta name="github-token" content="{{ site.Params.githubToken }}">
{{- end -}}

그리고 layouts/partials/extend_head.html에서 불러와요:

html
<!-- layouts/partials/extend_head.html -->
{{- partial "github-token.html" . -}}

4. .gitignore에 .env 추가하기

bash
# .gitignore
# Environment variables (contains secrets)
.env
.env.local
.env.*.local

5. 로컬에서 테스트해보기

bash
# .env 파일이 있는 디렉토리에서 실행하세요
hugo server

# 아니면 이렇게 직접 환경 변수 전달
GITHUB_TOKEN=your_token_here hugo server

Cloudflare Pages 환경 변수 설정하기

Cloudflare Pages Settings Page
Cloudflare Pages Settings Page
Cloudflare 환경 변수 설정 페이지

로컬에서 정상 작동이 확인되면 프로덕션 환경에도 설정합니다.

1. Cloudflare Pages 대시보드 접속

  1. Cloudflare 대시보드에 로그인
  2. Workers & Pages 섹션으로 이동
  3. 해당 사이트(프로젝트) 선택
  4. Settings 탭 클릭
  5. Environment variables 섹션으로 스크롤

2. 환경 변수 추가하기

  1. “Add variable” 버튼 클릭

  2. 다음과 같이 입력합니다:

    • Variable name: GITHUB_TOKEN
    • Value: (앞서 복사한 토큰 붙여넣기)
    • Environment: ProductionPreview 모두 선택
  3. “Save” 클릭

3. 재배포하기

환경 변수는 기존 배포에 즉시 적용되지 않습니다. 다음 중 한 가지 방법으로 재배포합니다:

방법 1: Git 푸시

bash
git commit --allow-empty -m "Trigger rebuild for environment variables"
git push

방법 2: Cloudflare 대시보드

  • Deployments 탭에서 최신 배포 옆의 “Retry deployment” 클릭

4. 배포 확인하기

배포 완료 후 실제 사이트에서 다음을 확인합니다:

  • 포스트 목록에 댓글 수가 표시되는가?
  • 둘러보기 페이지에 최근 댓글이 보이는가?
  • 전체 댓글 페이지(/explore/comments/)가 정상 작동하는가?

브라우저 개발자 도구의 Network 탭에서 GitHub API 호출 성공 여부도 확인할 수 있습니다.

코드 구현 상세

JavaScript로 GitHub API 호출하기

static/js/comment-counts.js에서 댓글 수를 가져와요:

javascript
const GITHUB_TOKEN = document.querySelector('meta[name="github-token"]')?.content || '';

async function fetchCommentCounts() {
  const query = `
    query($owner: String!, $repo: String!, $categoryId: ID!) {
      repository(owner: $owner, name: $repo) {
        discussions(first: 50, categoryId: $categoryId) {
          nodes {
            title
            comments {
              totalCount
            }
          }
        }
      }
    }
  `;

  const headers = {
    'Content-Type': 'application/json',
  };

  if (GITHUB_TOKEN) {
    headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
  }

  const response = await fetch('https://api.github.com/graphql', {
    method: 'POST',
    headers: headers,
    body: JSON.stringify({
      query,
      variables: {
        owner: 'codeslog',
        repo: 'codeslog-comments',
        categoryId: 'DIC_kwDOP13p-c4Cv1Rv'
      }
    })
  });

  const result = await response.json();

  // 댓글 수를 giscus_term으로 매핑
  const counts = {};
  result.data.repository.discussions.nodes.forEach(discussion => {
    counts[discussion.title] = discussion.comments.totalCount;
  });

  return counts;
}

localStorage로 캐싱하기

API 호출을 줄이기 위해 5분 동안 캐싱합니다:

javascript
const CACHE_KEY = 'codeslog_comment_counts';
const CACHE_DURATION = 1000 * 60 * 5; // 5분

function getCachedData() {
  try {
    const cached = localStorage.getItem(CACHE_KEY);
    if (!cached) return null;

    const data = JSON.parse(cached);
    const now = Date.now();

    if (now - data.timestamp < CACHE_DURATION) {
      return data.counts;
    }
  } catch (e) {
    console.warn('Failed to read cache:', e);
  }
  return null;
}

function setCachedData(counts) {
  try {
    localStorage.setItem(CACHE_KEY, JSON.stringify({
      timestamp: Date.now(),
      counts: counts
    }));
  } catch (e) {
    console.warn('Failed to save cache:', e);
  }
}

Stale-While-Revalidate 패턴

캐시가 있으면 즉시 표시하고, 백그라운드에서 새 데이터를 가져와 업데이트합니다:

javascript
async function init() {
  // 캐시가 있으면 먼저 보여줘요
  const cached = getCachedData();
  if (cached) {
    updateCommentCounts(cached);

    // 백그라운드에서 새 데이터 가져오기
    fetchCommentCounts().then(counts => {
      setCachedData(counts);
      updateCommentCounts(counts);
    }).catch(error => {
      console.error('Background update failed:', error);
    });

    return;
  }

  // 캐시가 없으면 새로 가져와요
  try {
    const counts = await fetchCommentCounts();
    setCachedData(counts);
    updateCommentCounts(counts);
  } catch (error) {
    console.error('Failed to fetch:', error);
  }
}

이 패턴의 장점:

  • 즉시 표시: 캐시된 데이터를 즉시 표시하여 빠른 응답
  • 항상 최신: 백그라운드에서 자동 업데이트
  • 오류에 강함: 백그라운드 요청 실패 시에도 캐시된 데이터 유지

Hugo 템플릿 수정하기

layouts/partials/post_meta.html에 댓글 수 wrapper를 추가합니다:

html
{{- with ($scratch.Get "meta") }}
{{- delimit . "&nbsp;·&nbsp;" | safeHTML -}}
{{- end -}}

{{- if (.Param "giscus_term") -}}
<span class='comment-count-wrapper'
      data-giscus-term='{{ .Param "giscus_term" }}'
      data-lang='{{ .Lang }}'
      style='display:none;'>
</span>
{{- end -}}

JavaScript가 동적으로 내용을 채워넣습니다:

javascript
function updateCommentCounts(counts) {
  const elements = document.querySelectorAll('.comment-count-wrapper');

  elements.forEach(element => {
    const term = element.getAttribute('data-giscus-term');
    const lang = element.getAttribute('data-lang') || 'ko';
    const count = counts[term];

    if (count !== undefined && count > 0) {
      const commentLabel = lang === 'ko' ? '댓글' : 'comments';
      const commentLabelSR = lang === 'ko' ? '댓글 수' : 'comment count';

      // separator와 함께 출력
      element.innerHTML = `&nbsp;·&nbsp;<span><span class='sr-only'>${commentLabelSR}: </span><span class='comment-count' aria-live='polite'>${count}</span> ${commentLabel}</span>`;
      element.style.display = '';
    }
  });
}

핵심 포인트:

  • 댓글이 0개면 wrapper가 display:none 상태 유지
  • 댓글이 1개 이상이면 &nbsp;·&nbsp; separator와 함께 표시
  • 이를 통해 댓글이 있을 때만 적절하게 표시됩니다.

댓글 섹션으로 스크롤하기

layouts/partials/comments.htmlid="comments" 추가:

html
{{- if and (.Site.Params.comments) (ne .Params.comments false) (.Params.giscus_term) -}}
<div id="comments" class="giscus-container">
    <script src="https://giscus.app/client.js" ...>
    </script>
</div>
{{- end -}}

이제 #comments 앵커로 정확한 위치로 스크롤됩니다:

javascript
// recent-comments.js에서
const postUrl = `${langPrefix}/posts/${slug}/#comments`;

CSS 스타일링

assets/css/extended/comments.css:

css
/* 댓글 카드 스타일 */
.recent-comment-item {
  border: 1px solid var(--border);
  border-radius: 8px;
  overflow: hidden;
  transition: all 0.2s ease;
}

.recent-comment-item:hover {
  border-color: var(--primary);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

/* 링크 명도 대비 개선 */
.recent-comment-link {
  color: var(--primary);  /* 더 진한 색상 */
  font-weight: 600;  /* 더 두꺼운 글씨 */
  text-decoration: none;
}

.recent-comment-link:hover {
  color: var(--tertiary);
  text-decoration: underline;  /* 호버 시 밑줄 */
}

접근성 고려하기

다양한 사람들이 컴퓨터를 사용하는 모습
다양한 사람들이 컴퓨터를 사용하는 모습
사진: UnsplashProduct School

웹 접근성은 모든 사용자가 콘텐츠를 이용할 수 있도록 보장하는 것입니다. 적용한 접근성 기능들을 소개하겠습니다.

1. ARIA 속성 사용하기

html
<!-- 로딩 상태 -->
<div class="recent-comments-loading"
     aria-live="polite"
     aria-busy="true">
  댓글을 불러오는 중...
</div>

<!-- 댓글 목록 -->
<ul class="recent-comments-list" role="list">
  <!-- 댓글 항목들 -->
</ul>

<!-- 오류 상태 -->
<div class="recent-comments-error"
     role="alert"
     style="display: none;">
  댓글을 불러오는 데 실패했습니다.
</div>

ARIA 속성 설명:

  • aria-live="polite": 스크린 리더가 현재 읽기 완료 후 변경사항을 알립니다
  • aria-busy="true": 로딩 중임을 알립니다
  • role="list": 리스트임을 명시합니다 (CSS에서 list-style: none 사용 시 필요)
  • role="alert": 중요한 메시지임을 즉시 알립니다

2. 스크린 리더 전용 레이블

html
<span>
  <span class='sr-only'>댓글 수: </span>
  <span class='comment-count' aria-live='polite'>3</span>
  댓글
</span>

CSS:

css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

스크린 리더는 “댓글 수: 3 댓글"로 읽지만, 화면에는 “3 댓글"만 표시됩니다.

3. 키보드로 탐색하기

모든 클릭 가능한 요소는 키보드로도 접근할 수 있습니다:

css
.recent-comment-link:focus {
  outline: 2px solid var(--tertiary);
  outline-offset: 2px;
  border-radius: 2px;
}
  • Tab 키로 포커스 이동
  • Enter 키로 링크 활성화
  • 포커스 표시가 명확해요

4. 의미있는 링크 텍스트

html
<a href="/posts/slug/#comments"
   aria-label="게시글로 이동">
  게시글 보기 →
</a>

“여기를 클릭하세요” 대신 “게시글 보기"처럼 명확한 텍스트를 사용합니다.

성능 최적화

stale-while-revalidate 캐싱 패턴을 보여주는 인포그래픽 - 사용자 요청이 캐시를 빠르게 히트하고 백그라운드에서 새로고침이 동시에 일어나는 흐름
stale-while-revalidate 캐싱 패턴을 보여주는 인포그래픽 - 사용자 요청이 캐시를 빠르게 히트하고 백그라운드에서 새로고침이 동시에 일어나는 흐름
제작: 나노바나나

1. 캐싱 전략

5분 TTL + Stale-While-Revalidate:

  • 첫 방문: API 호출 → localStorage 저장
  • 5분 이내 재방문: 캐시에서 즉시 표시
  • 5분 경과 후: 캐시 표시 + 백그라운드 업데이트
javascript
// 캐시 히트: 0ms (즉시!)
// 백그라운드 업데이트: ~300ms (사용자는 안 기다려요)

2. 스크립트 지연 로딩

html
<script src="/js/comment-counts.js" defer></script>
<script src="/js/recent-comments.js" defer></script>
<script src="/js/all-comments.js" defer></script>

defer 속성:

  • HTML 파싱을 차단하지 않습니다
  • 페이지 로드 속도 향상
  • DOMContentLoaded 전 실행 보장

3. API 호출 최소화

이전 (비효율적):

javascript
// 각 포스트마다 개별 API 호출
posts.forEach(post => {
  fetch(`/api/comments/${post.id}`);
});

이후 (효율적):

javascript
// 단일 요청으로 모든 데이터 가져오기
const allDiscussions = await fetchAllDiscussions();
const countMap = buildCountMap(allDiscussions);

GraphQL 덕분에 한 번의 요청으로 50개 discussion의 댓글 정보를 모두 가져올 수 있습니다.

4. Rate Limit 관리

GitHub API rate limit:

  • 인증 없음: 60 requests/hour
  • Personal Access Token: 5000 requests/hour

5분 캐싱으로 시간당 최대 12회 호출:

  • 60 requests/hour ÷ 12 = 5 페이지뷰/5분
  • PAT 사용하면 여유가 엄청 많아요

5. 조건부 렌더링

javascript
// 댓글이 없으면 DOM 조작하지 않습니다
if (count !== undefined && count > 0) {
  element.innerHTML = `...`;
  element.style.display = '';
}

불필요한 DOM 업데이트를 방지하여 브라우저 리플로우를 줄입니다.

성능 측정 결과

실제 측정 결과입니다 (Chrome DevTools):

첫 페이지 로드:

  • HTML 다운로드: ~150ms
  • JavaScript 실행: ~50ms
  • API 호출: ~300ms
  • Total Time to Interactive: ~500ms

캐시된 두 번째 방문:

  • HTML 다운로드: ~100ms (CDN 캐시)
  • JavaScript 실행: ~50ms
  • localStorage 읽기: ~1ms
  • Total Time to Interactive: ~150ms

백그라운드 업데이트:

  • 사용자는 안 기다려도 돼요
  • 다음 페이지 로드 시 최신 데이터가 보여요

트러블슈팅

문제 1: Hugo 빌드 오류

Error: access denied: "GITHUB_TOKEN" is not whitelisted in policy "security.funcs.getenv"

해결:

yaml
# config.yaml
security:
  funcs:
    getenv:
      - ^GITHUB_TOKEN$

문제 2: 댓글 수가 안 보여요

브라우저 콘솔에 이렇게 나와요:

[Comment Counts] Found 0 comment count elements

원인: Hugo 템플릿 조건문이 요소 렌더링을 막고 있어요

해결: layouts/partials/post_meta.html에서 조건문 제거

문제 3: Cloudflare Pages에서 환경 변수가 안 먹혀요

원인: 환경 변수는 기존 배포에는 적용이 안 돼요

해결: 재배포가 필요해요

bash
git commit --allow-empty -m "Trigger rebuild"
git push

문제 4: CORS 오류

GitHub GraphQL API는 CORS를 지원해서 문제가 없을 거예요. 만약 오류가 나면:

확인할 것:

  • API endpoint가 https://api.github.com/graphql인지 확인
  • Content-Type: application/json 헤더가 있는지
  • 브라우저가 최신 버전인지

문제 5: Rate Limit 초과

json
{
  "message": "API rate limit exceeded"
}

임시 해결:

  • localStorage 캐시 삭제: localStorage.clear()
  • 새로고침

영구 해결:

  • Personal Access Token 설정하기
  • 캐시 duration 늘리기 (10분, 15분 등)

배운 점과 개선할 점

배운 점

  1. GraphQL의 효율성: RESTful API 대비 필요한 데이터만 정확하게 가져올 수 있었습니다.

  2. 캐싱의 중요성: Stale-while-revalidate 패턴으로 성능과 최신성을 모두 확보했습니다.

  3. 초기 단계의 접근성 고려: 처음부터 접근성을 고려하면 큰 비용 없이 구현 가능합니다.

  4. 정적 사이트의 동적 기능: SSG와 동적 기능은 클라이언트 사이드 처리로 양립할 수 있습니다.

향후 개선 계획

1. 실시간 업데이트

현재 5분 캐싱을 WebSocket이나 Server-Sent Events(SSE)로 실시간 업데이트 구현을 고려 중입니다.

javascript
// 이렇게 구현할 수 있을 거예요
const eventSource = new EventSource('/api/comments/stream');
eventSource.onmessage = (event) => {
  const newComment = JSON.parse(event.data);
  updateUI(newComment);
};

2. 댓글 검색 기능

전체 댓글 페이지에 검색 기능을 추가하면 더 유용할 것 같아요.

javascript
function searchComments(query) {
  return allComments.filter(comment =>
    comment.bodyText.toLowerCase().includes(query.toLowerCase())
  );
}

3. 댓글 알림

새 댓글이 달리면 알림을 보내는 기능도 고려 중이에요.

4. 대댓글도 카운트에 포함

지금은 최상위 댓글만 세는데, 대댓글(replies)도 포함하려면 GraphQL 쿼리를 수정해야 해요:

graphql
comments(last: 100) {
  totalCount
  nodes {
    replies(last: 100) {
      totalCount
    }
  }
}
정적 웹사이트와 동적 기능이 완벽하게 통합된 모습 - 빠른 속도와 상호작용성을 동시에 구현
정적 웹사이트와 동적 기능이 완벽하게 통합된 모습 - 빠른 속도와 상호작용성을 동시에 구현
제작: 나노바나나

마무리

정적 사이트에서도 동적 기능을 충분히 구현할 수 있습니다. 핵심은 다음과 같습니다:

  1. API 활용: GitHub GraphQL API 같은 외부 API로 데이터 가져오기
  2. 효율적인 캐싱: localStorage + stale-while-revalidate로 성능 최적화
  3. 접근성 확보: 모든 사용자를 고려한 설계
  4. 점진적 개선: 기본 기능부터 시작하여 단계적으로 개선

이 글이 정적 사이트에서 댓글 시스템을 구현하려는 분들에게 도움이 되길 바랍니다. 궁금한 점이 있으시면 아래 댓글로 남겨주세요!

참고 자료