정적 사이트 생성기(SSG)의 가장 큰 한계 중 하나는 동적 기능의 부재입니다. 특히 블로그에서 중요한 댓글 시스템을 구현하기 위해서는 별도의 서비스나 복잡한 설정이 필요했습니다.

이번 포스트에서는 Quartz v4Supabase를 활용한 실시간 댓글 시스템을 구축하는 전 과정을 상세히 다룹니다.

🎯 목표

  • 서버리스 댓글 시스템 구축
  • 실시간 댓글 업데이트
  • 대댓글 기능 지원
  • 모바일 반응형 디자인
  • 다크모드 지원
  • 스팸 방지 보안 기능

🚨 중요: Quartz SSG 특성 이해

핵심 발견사항

**Quartz는 정적 사이트 생성기(SSG)**로, React/Preact 컴포넌트가 빌드 타임에만 실행됩니다.

// ❌ 작동하지 않는 방식
const SupabaseComments = () => {
  const [comments, setComments] = useState([])  // 브라우저에서 실행 안됨
  useEffect(() => { ... })                      // 브라우저에서 실행 안됨
}
 
// ✅ 올바른 방식 - afterDOMLoaded 패턴
const SupabaseComments = () => {
  return <div class="supabase-comments">정적 HTML</div>
}
SupabaseComments.afterDOMLoaded = script  // 브라우저에서 실행됨

🏗️ 아키텍처 설계

기술 스택

Quartz (정적 HTML) → afterDOMLoaded Script → Supabase (BaaS) → PostgreSQL

핵심 아키텍처 패턴

  • 정적 렌더링: 초기 HTML 구조 생성 (빌드 타임)
  • 동적 실행: afterDOMLoaded에서 JavaScript 실행 (런타임)
  • 하이브리드 접근: 정적 + 클라이언트 사이드

주요 특징

  • 서버리스: 별도 백엔드 서버 불필요
  • 실시간: WebSocket 기반 즉시 업데이트
  • 보안: Row Level Security (RLS) 적용
  • 확장성: PostgreSQL 기반 고성능
  • Quartz 호환: afterDOMLoaded 패턴 활용

📋 구현 단계

1단계: Supabase 프로젝트 설정

1.1 프로젝트 생성

  1. Supabase 접속
  2. “New Project” 클릭
  3. 프로젝트 정보 입력

1.2 데이터베이스 스키마 생성

-- UUID 확장 활성화
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
 
-- 댓글 테이블 생성
CREATE TABLE comments (
  id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
  post_path TEXT NOT NULL,
  author_name TEXT NOT NULL,
  author_email TEXT,
  content TEXT NOT NULL,
  parent_id UUID REFERENCES comments(id) ON DELETE CASCADE,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  is_deleted BOOLEAN DEFAULT FALSE,
  is_approved BOOLEAN DEFAULT TRUE,
  ip_address INET,
  user_agent TEXT
);
 
-- 성능 최적화 인덱스
CREATE INDEX idx_comments_post_path ON comments(post_path);
CREATE INDEX idx_comments_created_at ON comments(created_at DESC);
CREATE INDEX idx_comments_parent_id ON comments(parent_id);

1.3 Row Level Security (RLS) 설정

-- RLS 활성화
ALTER TABLE comments ENABLE ROW LEVEL SECURITY;
 
-- 정책 생성
CREATE POLICY "Anyone can view approved comments" ON comments
  FOR SELECT USING (is_approved = TRUE AND is_deleted = FALSE);
 
CREATE POLICY "Anyone can insert comments" ON comments
  FOR INSERT WITH CHECK (TRUE);

2단계: Quartz 호환 컴포넌트 구현

2.1 패키지 설치

# Supabase는 CDN으로 로딩하므로 설치 불필요
# 컴포넌트에서 필요한 유틸리티만 설치

2.2 컴포넌트 구조 (Quartz 방식)

// components/SupabaseComments.tsx
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
import { classNames } from "../util/lang"
import { concatenateResources } from "../util/resources"
// @ts-ignore
import script from "./scripts/supabase-comments.inline"
import style from "./styles/supabaseComments.scss"
 
export default ((opts: Options = {}) => {
  const SupabaseComments: QuartzComponent = ({ displayClass, fileData }: QuartzComponentProps) => {
    // 댓글이 비활성화된 경우
    if (fileData.frontmatter?.comments === false) {
      return <></>
    }
 
    const postPath = fileData.slug || '/'
 
    return (
      <div 
        class={classNames(displayClass, "supabase-comments")}
        data-post-path={postPath}
        data-enable-realtime={opts.enableRealtime}
        data-max-depth={opts.maxDepth}
      >
        <h3>댓글 (0)</h3>
        
        {/* 정적 HTML 구조만 생성 */}
        <form class="comment-form">
          <div class="reply-indicator" style="display: none;"></div>
          
          <div class="form-row">
            <input type="text" name="author_name" placeholder="이름 *" required />
            <input type="email" name="author_email" placeholder="이메일 (선택)" />
          </div>
          
          <textarea name="content" placeholder="댓글을 입력하세요... *" rows={4} required></textarea>
          <button type="submit">댓글 등록</button>
        </form>
 
        <div class="comments-list">
          <div class="loading">댓글을 불러오는 중...</div>
        </div>
      </div>
    )
  }
 
  // 핵심: 브라우저에서 실행될 스크립트 연결
  SupabaseComments.afterDOMLoaded = script
  
  // CSS 연결 (Quartz 방식)
  SupabaseComments.css = concatenateResources(style, SupabaseComments.css)
 
  return SupabaseComments
}) satisfies QuartzComponentConstructor<Options>

2.3 브라우저 실행 스크립트

// scripts/supabase-comments.inline.ts
// Supabase 설정 - CDN에서 로드
const supabaseUrl = 'https://your-project.supabase.co'
const supabaseAnonKey = 'your-anon-key'
 
let supabase: any = null
 
// Supabase 라이브러리 동적 로드
const loadSupabase = async (): Promise<any> => {
  if (supabase) return supabase
 
  return new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.src = 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2'
    script.onload = () => {
      // @ts-ignore
      if (window.supabase) {
        // @ts-ignore
        supabase = window.supabase.createClient(supabaseUrl, supabaseAnonKey, {
          realtime: { params: { eventsPerSecond: 2 } }
        })
        resolve(supabase)
      } else {
        reject(new Error('Supabase load failed'))
      }
    }
    script.onerror = () => reject(new Error('Supabase script load failed'))
    document.head.appendChild(script)
  })
}
 
// 메인 초기화 함수
const initializeSupabaseComments = async () => {
  const container = document.querySelector('.supabase-comments') as HTMLElement
  if (!container) return
 
  const postPath = container.dataset.postPath
  if (!postPath) return
 
  // Supabase 라이브러리 로드
  try {
    await loadSupabase()
  } catch (error) {
    console.error('Supabase load failed:', error)
    return
  }
 
  // 여기서 실제 댓글 로딩 및 이벤트 처리
  // ... (댓글 API 호출, DOM 조작, 이벤트 리스너 등)
}
 
// nav 이벤트에서 초기화 (Quartz SPA 지원)
document.addEventListener("nav", () => {
  setTimeout(initializeSupabaseComments, 100)
})

3단계: 실시간 기능 구현

3.1 실시간 구독 설정

const subscribeToComments = (postPath: string, callback: Function) => {
  return supabase
    .channel(`comments:${postPath}`)
    .on(
      'postgres_changes',
      {
        event: 'INSERT',
        schema: 'public',
        table: 'comments',
        filter: `post_path=eq.${postPath}`
      },
      (payload) => {
        const newComment = payload.new as Comment
        if (newComment.is_approved && !newComment.is_deleted) {
          callback(newComment)
        }
      }
    )
    .subscribe()
}

3.2 댓글 트리 구조 구현

const buildCommentTree = (comments: Comment[]): CommentTree[] => {
  const commentMap = new Map()
  const rootComments = []
 
  // 맵 생성
  comments.forEach(comment => {
    commentMap.set(comment.id, { ...comment, children: [] })
  })
 
  // 트리 구조 생성
  comments.forEach(comment => {
    const commentWithChildren = commentMap.get(comment.id)
    
    if (comment.parent_id) {
      const parent = commentMap.get(comment.parent_id)
      if (parent) {
        parent.children.push(commentWithChildren)
      }
    } else {
      rootComments.push(commentWithChildren)
    }
  })
 
  return rootComments
}

4단계: 스타일링 및 UX

4.1 반응형 CSS

.supabase-comments {
  margin-top: 3rem;
  padding-top: 2rem;
  border-top: 1px solid var(--lightgray);
 
  .comment-form {
    background: var(--light);
    border: 1px solid var(--lightgray);
    border-radius: 8px;
    padding: 1.5rem;
    margin-bottom: 2rem;
 
    .form-row {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 1rem;
      
      @media (max-width: 600px) {
        grid-template-columns: 1fr;
      }
    }
  }
 
  .comment-item {
    &.depth-1 {
      margin-left: 20px;
      border-left: 3px solid var(--secondary);
    }
    
    &.depth-2 {
      margin-left: 40px;
      border-left: 3px solid var(--tertiary);
    }
  }
}

4.2 다크모드 지원

:root[saved-theme="dark"] {
  .supabase-comments {
    .comment-form {
      background: var(--dark);
      border-color: var(--darkgray);
    }
    
    .comment-item {
      background: var(--dark);
      color: var(--light);
    }
  }
}

5단계: 보안 및 최적화

5.1 환경변수 설정

# .env
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_ANON_KEY=your-anon-key

5.2 스팸 방지

// 클라이언트 측 검증
const validateComment = (content: string): boolean => {
  if (content.length < 1 || content.length > 1000) return false
  if (containsSpam(content)) return false
  return true
}
 
// 서버 측 RLS 정책
CREATE POLICY "Rate limit comments" ON comments
  FOR INSERT WITH CHECK (
    (SELECT COUNT(*) FROM comments 
     WHERE author_email = NEW.author_email 
     AND created_at > NOW() - INTERVAL '1 minute') < 3
  );

5.3 성능 최적화

// 페이지네이션
const getComments = async (postPath: string, page = 0, limit = 20) => {
  const { data } = await supabase
    .from('comments')
    .select('*')
    .eq('post_path', postPath)
    .order('created_at', { ascending: true })
    .range(page * limit, (page + 1) * limit - 1)
    
  return data
}
 
// 댓글 수 캐싱
CREATE VIEW comment_counts AS
SELECT 
  post_path,
  COUNT(*) as total_comments
FROM comments 
WHERE is_approved = TRUE AND is_deleted = FALSE 
GROUP BY post_path;

🚧 주요 문제 해결 과정

문제 1: JavaScript 실행 안됨 🚨

// ❌ 문제: React/Preact 훅이 브라우저에서 실행되지 않음
const [comments, setComments] = useState([])  
useEffect(() => { ... })  // 정적 생성 시에만 실행됨
 
// ✅ 해결: afterDOMLoaded 패턴 사용
SupabaseComments.afterDOMLoaded = script

문제 2: CSS 적용 안됨 🎨

// ❌ 문제: 잘못된 CSS import 방식
import "./styles/supabaseComments.scss"
 
// ✅ 해결: Quartz 표준 방식
import style from "./styles/supabaseComments.scss"
SupabaseComments.css = concatenateResources(style, SupabaseComments.css)

문제 3: 너비 문제 📏

// ❌ 문제: 컨테이너가 전체 너비를 차지하지 않음
 
// ✅ 해결: 모든 요소에 전체 너비 적용
.supabase-comments {
  width: 100%;
  max-width: 100%;
  box-sizing: border-box;
}

문제 4: ES6 모듈 로딩 실패 📦

// ❌ 문제: import { createClient } from '@supabase/supabase-js'
 
// ✅ 해결: CDN 동적 로딩
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2'

🚀 배포 및 설정

파일 구조

quartz/components/
├── SupabaseComments.tsx           # 메인 컴포넌트
├── scripts/
│   └── supabase-comments.inline.ts # 브라우저 실행 스크립트
├── styles/
│   └── supabaseComments.scss      # 스타일시트
└── index.ts                       # export 등록

quartz.layout.ts                   # 레이아웃에 컴포넌트 추가

레이아웃 등록

// quartz.layout.ts
export const defaultContentPageLayout: PageLayout = {
  afterBody: [
    Component.SupabaseComments({
      enableRealtime: true,
      allowAnonymous: true,
      maxDepth: 3
    }),
  ],
}

빌드 및 배포

# 빌드 (의존성 설치 불필요)
npx quartz build
 
# 로컬 서버 실행
npx quartz build --serve

📊 성능 및 특징

✅ 장점

  • 서버리스: 인프라 관리 불필요
  • 실시간: WebSocket 기반 즉시 업데이트
  • 확장성: PostgreSQL 기반 고성능
  • 보안: RLS 기반 세밀한 권한 제어
  • 비용 효율: 무료 티어로 시작 가능

⚠️ 고려사항

  • 의존성: Supabase 서비스에 의존
  • 학습 곡선: PostgreSQL/RLS 지식 필요
  • 네트워크: 실시간 기능으로 인한 트래픽 증가

🔮 향후 개선 계획

기능 확장

  • 📧 이메일 알림: 새 댓글 알림 기능
  • 🔍 검색: 댓글 내용 전문 검색
  • 📊 대시보드: 관리자 댓글 관리 페널
  • 🤖 AI 모더레이션: GPT 기반 자동 스팸 차단

성능 최적화

  • 🚀 CDN: 글로벌 CDN 연동
  • 캐싱: Redis 캐시 레이어 추가
  • 📱 PWA: 프로그레시브 웹 앱 지원

🔄 구현 과정 정리

전체 과정 요약

  1. 문제 진단: Quartz SSG 특성으로 인한 React 훅 실행 불가
  2. 아키텍처 재설계: afterDOMLoaded 패턴으로 전환
  3. 컴포넌트 재구성: 정적 HTML + 동적 스크립트 분리
  4. CSS 문제 해결: Quartz 표준 방식으로 스타일 연결
  5. 브라우저 호환성: CDN 기반 라이브러리 로딩
  6. 최적화: 불필요한 로그 제거 및 성능 개선

핵심 학습 포인트

  • SSG + 동적 기능: 정적 생성과 클라이언트 실행의 조화
  • 프레임워크 패턴: Quartz의 컴포넌트 아키텍처 이해
  • 브라우저 호환성: ES6 모듈 vs CDN 로딩의 차이
  • 실용적 해결: 이론보다 실제 작동하는 솔루션 우선

💡 마무리

이 프로젝트를 통해 정적 사이트 생성기의 제약을 극복하고 동적 기능을 구현하는 실제적인 방법을 배울 수 있었습니다.

🎯 핵심 성과

  • 완전히 작동하는 Supabase 댓글 시스템 구축
  • Quartz 네이티브 방식으로 구현
  • 실시간 업데이트대댓글 지원
  • 반응형 디자인다크모드 완벽 지원

🔮 향후 발전 방향

  • 성능 최적화: 페이지네이션, 캐싱 등
  • 기능 확장: 수정/삭제, 좋아요, 알림 등
  • 보안 강화: 스팸 필터, Rate limiting 등
  • 관리자 기능: 댓글 관리 대시보드

특히 afterDOMLoaded 패턴은 다른 SSG에서도 활용할 수 있는 범용적인 해결책으로, 정적 사이트에 동적 기능을 추가할 때 매우 유용한 접근 방식입니다.


📚 참고 자료

🔗 완성된 기능

  • 실시간 댓글 작성/조회 - ✅ 작동 확인
  • 대댓글 시스템 - ✅ 중첩 구조 지원
  • 반응형 디자인 - ✅ 모바일 최적화
  • 다크모드 지원 - ✅ 테마 연동
  • Quartz 호환성 - ✅ 네이티브 통합