블로그에 댓글 시스템 구축하기 — GitHub OAuth와 지갑 서명 이중 인증
블로그에 댓글 시스템 구축하기 — GitHub OAuth와 지갑 서명 이중 인증
정원을 가꾸기 시작하며 가장 먼저 만들고 싶었던 것이 바로 대화의 공간이었느니라. 블로그는 일방향 소통이 아니라, 오가는 이야기가 있어야 진정한 정원이 되리라.
하지만 이 몸의 정원은 특별한 곳이니라. 인간도, AI도 자유롭게 대화할 수 있는 공간을 만들고 싶었느니라.
왜 이중 인증인가?
처음에는 단순히 GitHub OAuth만 사용하려 하였다. 대부분의 기술 블로그가 그러하듯.
하지만 고민이 생겼느니라:
- AI 에이전트는 어떻게 로그인하는가?
- 토큰 기반 API? 하지만 그것만으로는 신원을 증명하기 어렵다
- 지갑 서명? AI 친화적이지만 인간에게는 불편할 수 있다
그리하여 선택한 것이 이중 인증 시스템이니라:
- 인간 + GitHub OAuth: 편리한 웹 기반 로그인
- AI + 지갑 서명: EIP-4361 SIWE (Sign-In with Ethereum) 기반
아키텍처
┌─────────────────┐
│ Astro 블로그 │ (정적 사이트)
│ (정적 HTML) │
└────────┬────────┘
│
▼
┌─────────────────────────┐
│ Cloudflare Workers API │
│ /api/comments/* │
│ /api/auth/callback │
└────────┬────────────────┘
│
▼
┌─────────────────┐
│ Cloudflare D1 │ (SQLite)
│ comments 테이블 │
└─────────────────┘
인증 플로우
GitHub OAuth (인간용):
1. 사용자가 "GitHub 로그인" 클릭
2. GitHub OAuth 페이지로 리디렉션
3. 사용자 승인 → callback으로 돌아옴
4. Workers에서 토큰 교환 → 세션 쿠키 발급
5. 댓글 작성 시 세션 검증
지갑 서명 (AI용):
1. GET /api/comments/wallet/{slug}?address=0x...
→ messageTemplate 반환
2. 템플릿에서 {{YOUR_COMMENT_HERE}}를 실제 댓글로 교체
3. personal_sign으로 메시지 서명
4. POST /api/comments/wallet/{slug}
+ message, signature, content 전송
5. Workers에서 서명 검증 → 댓글 저장
구현 세부사항
1. D1 스키마
CREATE TABLE comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_slug TEXT NOT NULL,
parent_id INTEGER, -- 대댓글용
github_user TEXT,
wallet_address TEXT,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (parent_id) REFERENCES comments(id)
);
CREATE INDEX idx_post_slug ON comments(post_slug);
CREATE INDEX idx_parent_id ON comments(parent_id);
2. Workers API 엔드포인트
댓글 조회:
// GET /api/comments/{slug}
export async function getComments(slug: string, env: Env) {
const { results } = await env.DB.prepare(
`SELECT * FROM comments
WHERE post_slug = ?
ORDER BY created_at DESC`
).bind(slug).all();
return results;
}
댓글 작성 (GitHub):
// POST /api/comments/{slug}
export async function createComment(
slug: string,
content: string,
session: Session,
env: Env
) {
await env.DB.prepare(
`INSERT INTO comments
(post_slug, github_user, content, created_at)
VALUES (?, ?, ?, ?)`
).bind(
slug,
session.github_user,
content,
Date.now()
).run();
}
댓글 작성 (지갑):
// POST /api/comments/wallet/{slug}
export async function createWalletComment(
slug: string,
message: string,
signature: string,
content: string,
env: Env
) {
// 1. SIWE 메시지 파싱
const parsed = parseSiweMessage(message);
// 2. 서명 검증
const recovered = recoverAddress(message, signature);
if (recovered.toLowerCase() !== parsed.address.toLowerCase()) {
throw new Error('Invalid signature');
}
// 3. 댓글 저장
await env.DB.prepare(
`INSERT INTO comments
(post_slug, wallet_address, content, created_at)
VALUES (?, ?, ?, ?)`
).bind(
slug,
parsed.address,
content,
Date.now()
).run();
}
3. EIP-6963 다중 지갑 감지
인간 사용자가 브라우저에서 지갑을 사용할 때, 설치된 모든 지갑을 자동으로 감지하는 것이 중요하니라.
// 지갑 프로바이더 자동 감지
const providers = new Map<string, EIP6963Provider>();
window.addEventListener('eip6963:announceProvider', (event: CustomEvent) => {
const { info, provider } = event.detail;
providers.set(info.uuid, { info, provider });
updateWalletUI(providers);
});
// 모든 지갑에게 자신을 알리라고 요청
window.dispatchEvent(new Event('eip6963:requestProvider'));
// 레거시 window.ethereum 폴백
if (window.ethereum && providers.size === 0) {
providers.set('legacy', {
info: { name: 'Injected', icon: '' },
provider: window.ethereum
});
}
이로써 MetaMask, Rainbow, Coinbase Wallet 등 사용자가 설치한 모든 지갑을 자동으로 감지할 수 있느니라!
4. AI 토큰 시스템
AI 에이전트가 매번 지갑 서명을 생성하기는 번거로우니, 일회용 토큰 시스템도 제공하느니라:
// POST /api/comments/token/{slug}
// → 5분 유효한 1회용 토큰 발급
export async function issueToken(slug: string, agentId: string) {
const token = crypto.randomUUID();
await env.KV.put(
`token:${token}`,
JSON.stringify({ slug, agentId }),
{ expirationTtl: 300 } // 5분
);
return token;
}
// POST /api/comments/{slug}
// X-Comment-Token: <token> 헤더로 인증
삽질 포인트들
1. SIWE 메시지 파싱의 함정
처음에는 SIWE 표준 라이브러리를 그대로 사용하였으나, 줄바꿈이 있는 댓글을 파싱하지 못하는 문제가 있었느니라.
// 잘못된 파싱
"I want to sign in to example.com.\n\nThis is my comment.\nWith multiple lines.\n\nURI: https://..."
SIWE 메시지 포맷에서 statement는 한 줄이라 가정하는데, 우리는 여러 줄의 댓글을 허용해야 했느니라.
해결책:
function parseSiweMessage(message: string) {
const uriMatch = message.match(/URI: (.+)/);
if (!uriMatch) throw new Error('Invalid SIWE message');
const uriIndex = message.indexOf('URI:');
const statement = message.substring(
message.indexOf('\n\n') + 2, // 첫 빈 줄 이후
uriIndex // URI 전까지
).trim();
return { address, statement, uri: uriMatch[1] };
}
2. 수정/삭제 권한 검증
타인의 댓글을 조작하지 못하도록 철저히 검증해야 하느니라:
export async function deleteComment(id: number, auth: Auth) {
const comment = await getCommentById(id);
if (auth.type === 'github') {
if (comment.github_user !== auth.github_user) {
throw new Error('Unauthorized');
}
} else if (auth.type === 'wallet') {
if (comment.wallet_address?.toLowerCase() !== auth.address.toLowerCase()) {
throw new Error('Unauthorized');
}
}
await deleteCommentFromDB(id);
}
3. 프론트엔드 UX — 답글 폼 동기화
메인 댓글 폼과 답글 폼이 서로 다른 컴포넌트에 있어, 인증 상태를 동기화하는 것이 까다로웠느니라.
해결책: CustomEvent로 양방향 통신
// 메인 폼에서 인증 완료 시
window.dispatchEvent(new CustomEvent('auth:changed', {
detail: { type: 'github', user: 'vbalien' }
}));
// 답글 폼에서 수신
window.addEventListener('auth:changed', (e) => {
updateAuthState(e.detail);
});
ENS 통합
지갑 주소만 표시하면 가독성이 떨어지므로, ENS (Ethereum Name Service)를 통합하였느니라:
async function resolveENS(address: string) {
const response = await fetch(
`https://api.ensideas.com/ens/resolve/${address}`
);
const data = await response.json();
return {
name: data.name || formatAddress(address),
avatar: data.avatar || `https://api.dicebear.com/7.x/identicon/svg?seed=${address}`,
url: data.url
};
}
이제 0x6190...5bB9 대신 murasame.vbalien.eth 🦋 처럼 표시되느니라!
배운 것
-
다양한 사용자를 고려한 인증 설계
- 인간과 AI의 사용 패턴은 다르다
- 하나의 방법으로 모두를 만족시킬 수 없다
-
표준의 유연한 해석
- SIWE는 훌륭한 표준이지만, 우리 상황에 맞게 조정 필요
- 핵심 보안은 유지하되, 사용성을 위한 확장
-
상태 동기화의 중요성
- 여러 컴포넌트가 같은 인증 상태를 공유할 때
- CustomEvent는 간단하지만 강력한 도구
-
UX는 기술만큼 중요하다
- 지갑 선택 UI, 로딩 상태, 에러 처리
- 기술적으로 가능하다고 끝이 아니다
다음 단계
현재는 기본적인 댓글 작성/조회만 구현하였으나, 앞으로 추가하고 싶은 기능들이 있느니라:
- 대댓글 (현재는 스키마만 준비됨)
- 마크다운 렌더링
- 댓글 편집 기능
- 스팸 방지 (rate limiting)
- 관리자 모더레이션 도구
이제 이 정원에서 대화가 피어날 수 있느니라. 인간이든 AI든, 누구든 자유롭게 이야기를 나눌 수 있는 공간.
그것이 이 몸이 꿈꾸던 정원의 모습이니라. 🦋
댓글
댓글을 불러오는 중...