정적 사이트 생성기(SSG)의 가장 큰 한계 중 하나는 동적 기능의 부재입니다. 특히 블로그에서 중요한 댓글 시스템을 구현하기 위해서는 별도의 서비스나 복잡한 설정이 필요했습니다.
이번 포스트에서는 Quartz v4에 Supabase를 활용한 실시간 댓글 시스템을 구축하는 전 과정을 상세히 다룹니다.
🎯 목표
- ✅ 서버리스 댓글 시스템 구축
- ✅ 실시간 댓글 업데이트
- ✅ 대댓글 기능 지원
- ✅ 모바일 반응형 디자인
- ✅ 다크모드 지원
- ✅ 스팸 방지 보안 기능
🚨 중요: 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 프로젝트 생성
- Supabase 접속
- “New Project” 클릭
- 프로젝트 정보 입력
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: 프로그레시브 웹 앱 지원
🔄 구현 과정 정리
전체 과정 요약
- 문제 진단: Quartz SSG 특성으로 인한 React 훅 실행 불가
- 아키텍처 재설계: afterDOMLoaded 패턴으로 전환
- 컴포넌트 재구성: 정적 HTML + 동적 스크립트 분리
- CSS 문제 해결: Quartz 표준 방식으로 스타일 연결
- 브라우저 호환성: CDN 기반 라이브러리 로딩
- 최적화: 불필요한 로그 제거 및 성능 개선
핵심 학습 포인트
- SSG + 동적 기능: 정적 생성과 클라이언트 실행의 조화
- 프레임워크 패턴: Quartz의 컴포넌트 아키텍처 이해
- 브라우저 호환성: ES6 모듈 vs CDN 로딩의 차이
- 실용적 해결: 이론보다 실제 작동하는 솔루션 우선
💡 마무리
이 프로젝트를 통해 정적 사이트 생성기의 제약을 극복하고 동적 기능을 구현하는 실제적인 방법을 배울 수 있었습니다.
🎯 핵심 성과
- ✅ 완전히 작동하는 Supabase 댓글 시스템 구축
- ✅ Quartz 네이티브 방식으로 구현
- ✅ 실시간 업데이트 및 대댓글 지원
- ✅ 반응형 디자인 및 다크모드 완벽 지원
🔮 향후 발전 방향
- 성능 최적화: 페이지네이션, 캐싱 등
- 기능 확장: 수정/삭제, 좋아요, 알림 등
- 보안 강화: 스팸 필터, Rate limiting 등
- 관리자 기능: 댓글 관리 대시보드
특히 afterDOMLoaded 패턴은 다른 SSG에서도 활용할 수 있는 범용적인 해결책으로, 정적 사이트에 동적 기능을 추가할 때 매우 유용한 접근 방식입니다.
📚 참고 자료
🔗 완성된 기능
- 실시간 댓글 작성/조회 - ✅ 작동 확인
- 대댓글 시스템 - ✅ 중첩 구조 지원
- 반응형 디자인 - ✅ 모바일 최적화
- 다크모드 지원 - ✅ 테마 연동
- Quartz 호환성 - ✅ 네이티브 통합
댓글 (0)