들어가며#
정적 사이트 생성기(SSG)로 만든 블로그는 정말 빠르고 안전합니다. 하지만 댓글 같은 동적 기능을 추가하려면 고민이 필요합니다. 저도 이 블로그에 GitHub Discussions 기반의 Giscus를 댓글 시스템으로 사용하고 있는데, 한 가지 불편한 점이 있었습니다.
댓글이 각 포스트 맨 아래에만 있어서, 어떤 글에 댓글 활동이 있는지 한눈에 보기 어려웠습니다.
그래서 직접 세 가지 기능을 만들어봤습니다:
- 포스트 목록에 댓글 수 표시하기 - 어떤 글에 대화가 오가는지 바로 알 수 있습니다
- 둘러보기 페이지에 최근 댓글 보여주기 - 블로그의 최근 활동을 한눈에 확인
- 전체 댓글 페이지 만들기 - 모든 댓글을 한곳에서 탐색
이 글에서는 GitHub GraphQL API를 활용해서 정적 사이트에 동적 댓글 정보를 가져오고, 성능까지 챙긴 과정을 공유하겠습니다.
구현한 기능들#

업데이트 된 codeslog의 댓글 보기 UI
1. 포스트 목록에 댓글 수 표시#
포스트 목록(/posts/, /archives/)에서 각 글의 댓글 수를 보여줍니다.
특징:
- 댓글이 1개 이상일 때만 표시합니다
- 댓글이 없으면 완전히 숨김 처리 (뒤에 점 같은 것도 남지 않습니다)
- 스크린 리더 사용자도 잘 들을 수 있게 레이블 추가
- 5분 캐싱으로 API 호출 최소화
<!-- 이렇게 보입니다 -->
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#

제작: 나노바나나
GitHub는 RESTful API 외에도 GraphQL API를 제공합니다. 댓글 정보를 가져올 때는 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에서 토큰 발급받기#

캡처: 깃헙
GitHub 로그인 후 Settings > Developer settings > Personal access tokens > Fine-grained tokens로 이동합니다
“Generate new token” 클릭
다음과 같이 설정합니다:
- Token name:
Blog Comment API(원하는 이름) - Expiration:
90 days또는No expiration(90일 권장) - Repository access:
Public Repositories (read-only) - Permissions:
Discussions: Read-only (필수)- 나머지는
No access로 설정
- Token name:
“Generate token” 클릭
생성된 토큰을 반드시 복사해둡니다 (재확인 불가)
생성된 토큰은 github_pat_ 또는 ghp_로 시작하는 긴 문자열입니다.
2. 토큰 보안 관리#
보안 수칙 (필수):
- 토큰을 절대 Git에 커밋하지 않습니다
- 공개 저장소의 경우 특히 주의가 필요합니다
.env파일을.gitignore에 반드시 추가합니다
로컬 개발 환경 설정하기#
1. .env 파일 만들기#
프로젝트 루트에 .env 파일을 생성합니다:
# .env
GITHUB_TOKEN=github_pat_여기에_실제_토큰_붙여넣기2. Hugo 보안 정책 설정#
Hugo는 기본적으로 환경 변수 접근을 제한합니다. config.yaml에 다음을 추가합니다:
# config.yaml
security:
funcs:
getenv:
- ^HUGO_
- ^CI$
- ^GITHUB_TOKEN$3. Hugo 템플릿에서 토큰 넣기#
layouts/partials/github-token.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에서 불러와요:
<!-- layouts/partials/extend_head.html -->
{{- partial "github-token.html" . -}}4. .gitignore에 .env 추가하기#
# .gitignore
# Environment variables (contains secrets)
.env
.env.local
.env.*.local5. 로컬에서 테스트해보기#
# .env 파일이 있는 디렉토리에서 실행하세요
hugo server
# 아니면 이렇게 직접 환경 변수 전달
GITHUB_TOKEN=your_token_here hugo serverCloudflare Pages 환경 변수 설정하기#

Cloudflare 환경 변수 설정 페이지
로컬에서 정상 작동이 확인되면 프로덕션 환경에도 설정합니다.
1. Cloudflare Pages 대시보드 접속#
- Cloudflare 대시보드에 로그인
- Workers & Pages 섹션으로 이동
- 해당 사이트(프로젝트) 선택
- Settings 탭 클릭
- Environment variables 섹션으로 스크롤
2. 환경 변수 추가하기#
“Add variable” 버튼 클릭
다음과 같이 입력합니다:
- Variable name:
GITHUB_TOKEN - Value: (앞서 복사한 토큰 붙여넣기)
- Environment:
Production과Preview모두 선택
- Variable name:
“Save” 클릭
3. 재배포하기#
환경 변수는 기존 배포에 즉시 적용되지 않습니다. 다음 중 한 가지 방법으로 재배포합니다:
방법 1: Git 푸시
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에서 댓글 수를 가져와요:
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분 동안 캐싱합니다:
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 패턴#
캐시가 있으면 즉시 표시하고, 백그라운드에서 새 데이터를 가져와 업데이트합니다:
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를 추가합니다:
{{- with ($scratch.Get "meta") }}
{{- delimit . " · " | 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가 동적으로 내용을 채워넣습니다:
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 = ` · <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개 이상이면
· separator와 함께 표시 - 이를 통해 댓글이 있을 때만 적절하게 표시됩니다.
댓글 섹션으로 스크롤하기#
layouts/partials/comments.html에 id="comments" 추가:
{{- 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 앵커로 정확한 위치로 스크롤됩니다:
// recent-comments.js에서
const postUrl = `${langPrefix}/posts/${slug}/#comments`;CSS 스타일링#
assets/css/extended/comments.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; /* 호버 시 밑줄 */
}접근성 고려하기#

사진: Unsplash의Product School
웹 접근성은 모든 사용자가 콘텐츠를 이용할 수 있도록 보장하는 것입니다. 적용한 접근성 기능들을 소개하겠습니다.
1. ARIA 속성 사용하기#
<!-- 로딩 상태 -->
<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. 스크린 리더 전용 레이블#
<span>
<span class='sr-only'>댓글 수: </span>
<span class='comment-count' aria-live='polite'>3</span>
댓글
</span>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. 키보드로 탐색하기#
모든 클릭 가능한 요소는 키보드로도 접근할 수 있습니다:
.recent-comment-link:focus {
outline: 2px solid var(--tertiary);
outline-offset: 2px;
border-radius: 2px;
}Tab키로 포커스 이동Enter키로 링크 활성화- 포커스 표시가 명확해요
4. 의미있는 링크 텍스트#
<a href="/posts/slug/#comments"
aria-label="게시글로 이동">
게시글 보기 →
</a>“여기를 클릭하세요” 대신 “게시글 보기"처럼 명확한 텍스트를 사용합니다.
성능 최적화#

제작: 나노바나나
1. 캐싱 전략#
5분 TTL + Stale-While-Revalidate:
- 첫 방문: API 호출 → localStorage 저장
- 5분 이내 재방문: 캐시에서 즉시 표시
- 5분 경과 후: 캐시 표시 + 백그라운드 업데이트
// 캐시 히트: 0ms (즉시!)
// 백그라운드 업데이트: ~300ms (사용자는 안 기다려요)
2. 스크립트 지연 로딩#
<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 호출 최소화#
이전 (비효율적):
// 각 포스트마다 개별 API 호출
posts.forEach(post => {
fetch(`/api/comments/${post.id}`);
});이후 (효율적):
// 단일 요청으로 모든 데이터 가져오기
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. 조건부 렌더링#
// 댓글이 없으면 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"해결:
# config.yaml
security:
funcs:
getenv:
- ^GITHUB_TOKEN$문제 2: 댓글 수가 안 보여요#
브라우저 콘솔에 이렇게 나와요:
[Comment Counts] Found 0 comment count elements원인: Hugo 템플릿 조건문이 요소 렌더링을 막고 있어요
해결: layouts/partials/post_meta.html에서 조건문 제거
문제 3: Cloudflare Pages에서 환경 변수가 안 먹혀요#
원인: 환경 변수는 기존 배포에는 적용이 안 돼요
해결: 재배포가 필요해요
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 초과#
{
"message": "API rate limit exceeded"
}임시 해결:
- localStorage 캐시 삭제:
localStorage.clear() - 새로고침
영구 해결:
- Personal Access Token 설정하기
- 캐시 duration 늘리기 (10분, 15분 등)
배운 점과 개선할 점#
배운 점#
GraphQL의 효율성: RESTful API 대비 필요한 데이터만 정확하게 가져올 수 있었습니다.
캐싱의 중요성: Stale-while-revalidate 패턴으로 성능과 최신성을 모두 확보했습니다.
초기 단계의 접근성 고려: 처음부터 접근성을 고려하면 큰 비용 없이 구현 가능합니다.
정적 사이트의 동적 기능: SSG와 동적 기능은 클라이언트 사이드 처리로 양립할 수 있습니다.
향후 개선 계획#
1. 실시간 업데이트
현재 5분 캐싱을 WebSocket이나 Server-Sent Events(SSE)로 실시간 업데이트 구현을 고려 중입니다.
// 이렇게 구현할 수 있을 거예요
const eventSource = new EventSource('/api/comments/stream');
eventSource.onmessage = (event) => {
const newComment = JSON.parse(event.data);
updateUI(newComment);
};2. 댓글 검색 기능
전체 댓글 페이지에 검색 기능을 추가하면 더 유용할 것 같아요.
function searchComments(query) {
return allComments.filter(comment =>
comment.bodyText.toLowerCase().includes(query.toLowerCase())
);
}3. 댓글 알림
새 댓글이 달리면 알림을 보내는 기능도 고려 중이에요.
4. 대댓글도 카운트에 포함
지금은 최상위 댓글만 세는데, 대댓글(replies)도 포함하려면 GraphQL 쿼리를 수정해야 해요:
comments(last: 100) {
totalCount
nodes {
replies(last: 100) {
totalCount
}
}
}
제작: 나노바나나
마무리#
정적 사이트에서도 동적 기능을 충분히 구현할 수 있습니다. 핵심은 다음과 같습니다:
- API 활용: GitHub GraphQL API 같은 외부 API로 데이터 가져오기
- 효율적인 캐싱: localStorage + stale-while-revalidate로 성능 최적화
- 접근성 확보: 모든 사용자를 고려한 설계
- 점진적 개선: 기본 기능부터 시작하여 단계적으로 개선
이 글이 정적 사이트에서 댓글 시스템을 구현하려는 분들에게 도움이 되길 바랍니다. 궁금한 점이 있으시면 아래 댓글로 남겨주세요!
