# 정적 사이트에서 동적 댓글 시스템 만들기: Giscus + GraphQL API

> Hugo 정적 블로그에 댓글 수 표시, 최근 댓글, 전체 댓글 페이지를 구현한 과정을 공유합니다. GitHub GraphQL API와 캐싱 전략으로 성능과 UX를 모두 잡은 방법을 알아보세요.

**Published:** 2026-01-16 | **Updated:** 2026-01-16

---


## 들어가며

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

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

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

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

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

## 구현한 기능들

{{< img src="images/contents/comment-features-overview.png" alt="업데이트 된 댓글 보기 인터페이스 - 글 목록, 최근 댓글, 전체 댓글 페이지를 작업했다." caption="업데이트 된 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](https://giscus.app/)는 GitHub Discussions를 백엔드로 사용하는 오픈소스 댓글 시스템입니다.

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

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

### GitHub GraphQL API

{{< img src="images/contents/graphql-api-illustration.png" alt="GraphQL 쿼리 흐름을 보여주는 개념 일러스트 - 개발자 노트북에서 단일 GraphQL 쿼리를 GitHub 서버로 보내고 구조화된 데이터를 받아오는 모습" caption="제작: 나노바나나" >}}

GitHub는 RESTful API 외에도 [GraphQL API](https://docs.github.com/en/graphql)를 제공합니다. 댓글 정보를 가져올 때는 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에서 토큰 발급받기

{{< img src="images/contents/github-token-security.png" alt="깃헙 토큰 발급 페이지 화면" caption="캡처: 깃헙" >}}

1. GitHub 로그인 후 [Settings > Developer settings > Personal access tokens > Fine-grained tokens](https://github.com/settings/personal-access-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 환경 변수 설정하기

{{< img src="images/contents/cloudflare-deployment.png" alt="Cloudflare Pages Settings Page" caption="Cloudflare 환경 변수 설정 페이지" >}}

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

### 1. Cloudflare Pages 대시보드 접속

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

### 2. 환경 변수 추가하기

1. **"Add variable"** 버튼 클릭
2. 다음과 같이 입력합니다:
   - **Variable name**: `GITHUB_TOKEN`
   - **Value**: (앞서 복사한 토큰 붙여넣기)
   - **Environment**: `Production`과 `Preview` 모두 선택

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.html`에 `id="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;  /* 호버 시 밑줄 */
}
```

## 접근성 고려하기

{{< img src="images/contents/web-accessibility.jpg" alt="다양한 사람들이 컴퓨터를 사용하는 모습" caption="사진: <a href='https://unsplash.com/ko/사진/앉아서-노트북을-사용하는-사람들-XZkk5xT8Xrk' target='_blank' title='새 창에서 열림'>Unsplash</a>의<a href='https://unsplash.com/ko/@productschool' target='_blank' title='새 창에서 열림'>Product School</a>" >}}

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

### 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>
```

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

## 성능 최적화

{{< img src="images/contents/caching-performance.png" alt="stale-while-revalidate 캐싱 패턴을 보여주는 인포그래픽 - 사용자 요청이 캐시를 빠르게 히트하고 백그라운드에서 새로고침이 동시에 일어나는 흐름" caption="제작: 나노바나나" >}}

### 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
    }
  }
}
```

{{< img src="images/contents/static-site-dynamic-features.png" alt="정적 웹사이트와 동적 기능이 완벽하게 통합된 모습 - 빠른 속도와 상호작용성을 동시에 구현" caption="제작: 나노바나나" >}}

## 마무리

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

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

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

## 참고 자료

- [Giscus 공식 사이트](https://giscus.app/)
- [GitHub GraphQL API 문서](https://docs.github.com/en/graphql)
- [GitHub Personal Access Tokens 가이드](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token)
- [Hugo Security Policy 문서](https://gohugo.io/about/security-model/)
- [Cloudflare Pages 환경 변수 문서](https://developers.cloudflare.com/pages/configuration/build-configuration/)
- [Stale-While-Revalidate 패턴](https://web.dev/stale-while-revalidate/)
- [ARIA 접근성 가이드](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA)

