익명이지만 자기 자신은 알아보는 댓글 — 디시 유동닉 방식 구현
로그인 없이 댓글을 받되, 같은 사람이 쓴 댓글인지 식별하고 본인만 삭제할 수 있게 하려면? 디시인사이드 유동닉의 원리(닉네임+비밀번호+IP 일부)를 가져와 IP 해시와 PBKDF2로 안전하게 구현한 기록.
익명이지만 자기 자신은 알아보는 댓글
블로그에 익명 댓글을 붙이면서 요구사항이 하나 있었다. 로그인은 시키기 싫지만, 그렇다고 완전 무법지대도 곤란하다. 같은 사람이 자작극하는 걸 어느 정도 막고, 본인이 쓴 댓글은 본인이 지울 수 있어야 했다.
이건 새로운 문제가 아니다. 디시인사이드의 "유동닉" 이 30년 가까이 검증한 패턴이다. 그 원리를 그대로 가져왔다.
디시 유동닉의 정체
디시 유동닉은 세 가지로 "로그인 없는 약한 신원"을 만든다.
| 요소 | 역할 |
|---|---|
| 닉네임 | 표시용 이름 (중복 허용, 신원 아님) |
| 비밀번호 | 그 댓글을 쓴 사람만 아는 것 → 수정/삭제 권한 |
IP 일부 (ㅇㅇ(118.235)) | 그 사람만 가진 것 → 동일인 추정 |
핵심은 "계정"이라는 영속적 신원 대신, 댓글마다 일회용 자격증명을 붙이는 것이다. 로그인 없이도 "그 사람만 아는 비밀(비밀번호)"과 "그 사람만 가진 것(IP)"으로 동일인을 확률적으로 판별한다.
인증의 3요소 중 knowledge(아는 것)와 inherence/possession(가진 것)를 가볍게 흉내 낸 셈이다. 강한 보안은 아니지만, 댓글이라는 저가치 대상엔 충분하다.
1. 비밀번호 — "아는 것"으로 삭제 권한
댓글마다 비밀번호를 선택적으로 받는다. 단, 평문 저장은 절대 금지. 서버도 원본을 몰라야 한다. PBKDF2-SHA256으로 해시해 저장한다.
// 저장 시: "pbkdf2$<iter>$<saltHex>$<hashHex>" 형식
async function hashPassword(password: string): Promise<string> {
const iterations = 100_000;
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, 256,
);
return `pbkdf2$${iterations}$${toHex(salt)}$${toHex(bits)}`;
}삭제할 때는 저장된 salt/iterations로 동일하게 유도한 뒤 상수 시간 비교로 검증한다. 단순 === 비교는 일치하는 글자 수에 따라 응답 시간이 미세하게 달라져(타이밍 공격) 비밀번호를 추측당할 수 있다.
let diff = 0;
for (let i = 0; i < actual.length; i++) {
diff |= actual.charCodeAt(i) ^ expected.charCodeAt(i);
}
return diff === 0; // 길이가 같으면 항상 같은 시간왜 bcrypt가 아니라 PBKDF2? Edge 런타임(Deno)의 WebCrypto에 기본 내장되어 외부 의존성이 없기 때문이다. 댓글 비밀번호 수준엔 100k 반복 PBKDF2로 충분하다.
2. IP — "가진 것"으로 동일인 추정
디시는 IP 앞 두 옥텟을 그대로 노출한다(ㅇㅇ(118.235)). 하지만 IP는 개인정보라, 평문 노출도 평문 저장도 부담스럽다. 그래서 두 단계로 나눴다.
// 비공개: 관리/도배 차단용 전체 해시
const ipHash = await sha256Hex(ip + IP_SALT);
// 공개: 글 단위 식별자 (해시 앞 6자리)
const anonId = (await sha256Hex(ip + slug + IP_SALT)).slice(0, 6);ip_hash: DB에만 저장, 응답에 절대 포함 안 함. 도배 차단·관리용.anon_id:홍길동 (a3f9k2)처럼 공개. 같은 글에서 같은 IP면 같은 ID → "같은 사람이네" 식별.
핵심은 anon_id에 `slug`를 섞은 것이다. 글이 다르면 같은 IP라도 ID가 완전히 달라진다. → 같은 글 안에서만 동일인이 보이고, 글을 넘나드는 추적은 불가능하다. 디시의 IP 노출보다 프라이버시가 강하다.
salt의 역할: 해시에 비밀 salt를 섞지 않으면, IP는 경우의 수가 적어(IPv4 약 43억) 무지개표로 역산될 수 있다. salt가 있으면 평문 IP 복원이 사실상 불가능하다.
3. 삭제는 되지만 함부로는 안 되게
권한 설계는 "처음엔 막고, 필요한 것만 연다" 원칙을 따랐다.
- UPDATE/DELETE 엔드포인트를 처음엔 만들지 않음 + RLS 정책도 없음 → 클라이언트가 어떤 키로도 직접 변조 불가.
- 이후 "비밀번호 검증 후 본인 삭제"만 열었다. 비밀번호 없이 쓴 댓글은 본인 삭제 불가(403), 운영자만 처리.
- 부적절한 댓글은
is_hidden플래그로 숨김(소프트 삭제).
일반 사용자: 비밀번호 맞으면 삭제 가능
운영자: Studio에서 is_hidden = true 로 숨김
그 외: 어떤 변조도 불가한계 — 이건 "약한" 신원이다
분명히 해둘 점: 이 방식은 보안 경계가 아니다.
- IP는 공유될 수 있다 (회사·학교·모바일 NAT → 다른 사람이 같은 IP). 그래서 동일인 식별은 "추정"이지 확정이 아니다.
- 비밀번호를 안 걸면 본인 삭제가 안 된다 (의도된 동작).
- 작정한 사람은 IP를 바꿔(VPN 등) 우회할 수 있다.
하지만 목적이 "은행 인증"이 아니라 "댓글 자작극·도배를 적당히 억제"이므로 이 정도면 충분하다. 과한 보안은 익명 댓글의 편의를 해친다. 요구에 맞는 강도를 고르는 게 설계다.
정리
- 디시 유동닉 = 닉네임(표시) + 비밀번호(아는 것) + IP(가진 것) 로 만드는 로그인 없는 약한 신원
- 비밀번호는 PBKDF2 해시 + 상수 시간 비교 — 평문 저장·타이밍 공격 모두 차단
- IP는 salt 섞은 해시로만 저장, 공개 식별자는 글 단위로 격리해 추적 방지
- 권한은 기본 차단 후 비밀번호 검증으로만 삭제 허용
- 단, 이건 보안 경계가 아니라 편의를 위한 약한 식별임을 인지할 것
이 구조의 전체 그림(정적 사이트 + 서버리스)은 정적 블로그에 동적 기능 더하기에 정리했다.
Related Posts
같이 읽으면 좋은 글
정적 블로그에 동적 기능 더하기 — GitHub Pages + Supabase Edge Function
서버가 없는 GitHub Pages 정적 블로그에 댓글·조회수·이모지 리액션·방명록을 붙였다. Supabase Edge Function을 백엔드로 쓰고, 익명이지만 식별 가능한 댓글·본인 제외 조회수·무료 운영까지 실제로 구축하며 내린 결정들을 정리했다.
인증과 인가 — 세션-쿠키 방식과 JWT 토큰 방식
HTTP는 무상태인데 어떻게 로그인 상태를 유지하는가. 인증과 인가의 차이, 서버가 상태를 갖는 세션-쿠키 방식과 토큰에 정보를 담는 JWT 방식의 트레이드오프, 그리고 토큰을 어디에 둘지를 정리했다.
Polling, SSE, WebSocket — 클러스터 설치 상태에 무엇을 골랐나
실시간 알람은 SSE를 골랐는데, 클러스터 설치 상태 화면에서는 Short Polling을 골랐다. 같은 '서버 → 클라이언트 실시간' 문제인데 왜 선택이 달랐는지 정리했다.