Project··4 min read·

익명이지만 자기 자신은 알아보는 댓글 — 디시 유동닉 방식 구현

로그인 없이 댓글을 받되, 같은 사람이 쓴 댓글인지 식별하고 본인만 삭제할 수 있게 하려면? 디시인사이드 유동닉의 원리(닉네임+비밀번호+IP 일부)를 가져와 IP 해시와 PBKDF2로 안전하게 구현한 기록.

익명이지만 자기 자신은 알아보는 댓글

블로그에 익명 댓글을 붙이면서 요구사항이 하나 있었다. 로그인은 시키기 싫지만, 그렇다고 완전 무법지대도 곤란하다. 같은 사람이 자작극하는 걸 어느 정도 막고, 본인이 쓴 댓글은 본인이 지울 수 있어야 했다.

이건 새로운 문제가 아니다. 디시인사이드의 "유동닉" 이 30년 가까이 검증한 패턴이다. 그 원리를 그대로 가져왔다.


디시 유동닉의 정체

디시 유동닉은 세 가지로 "로그인 없는 약한 신원"을 만든다.

요소역할
닉네임표시용 이름 (중복 허용, 신원 아님)
비밀번호그 댓글을 쓴 사람만 아는 것 → 수정/삭제 권한
IP 일부 (ㅇㅇ(118.235))그 사람만 가진 것 → 동일인 추정

핵심은 "계정"이라는 영속적 신원 대신, 댓글마다 일회용 자격증명을 붙이는 것이다. 로그인 없이도 "그 사람만 아는 비밀(비밀번호)"과 "그 사람만 가진 것(IP)"으로 동일인을 확률적으로 판별한다.

인증의 3요소 중 knowledge(아는 것)inherence/possession(가진 것)를 가볍게 흉내 낸 셈이다. 강한 보안은 아니지만, 댓글이라는 저가치 대상엔 충분하다.

1. 비밀번호 — "아는 것"으로 삭제 권한

댓글마다 비밀번호를 선택적으로 받는다. 단, 평문 저장은 절대 금지. 서버도 원본을 몰라야 한다. PBKDF2-SHA256으로 해시해 저장한다.

ts
// 저장 시: "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로 동일하게 유도한 뒤 상수 시간 비교로 검증한다. 단순 === 비교는 일치하는 글자 수에 따라 응답 시간이 미세하게 달라져(타이밍 공격) 비밀번호를 추측당할 수 있다.

ts
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는 개인정보라, 평문 노출도 평문 저장도 부담스럽다. 그래서 두 단계로 나눴다.

ts
// 비공개: 관리/도배 차단용 전체 해시
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 플래그로 숨김(소프트 삭제).
code
일반 사용자: 비밀번호 맞으면 삭제 가능
운영자:      Studio에서 is_hidden = true 로 숨김
그 외:       어떤 변조도 불가

한계 — 이건 "약한" 신원이다

분명히 해둘 점: 이 방식은 보안 경계가 아니다.

  • IP는 공유될 수 있다 (회사·학교·모바일 NAT → 다른 사람이 같은 IP). 그래서 동일인 식별은 "추정"이지 확정이 아니다.
  • 비밀번호를 안 걸면 본인 삭제가 안 된다 (의도된 동작).
  • 작정한 사람은 IP를 바꿔(VPN 등) 우회할 수 있다.

하지만 목적이 "은행 인증"이 아니라 "댓글 자작극·도배를 적당히 억제"이므로 이 정도면 충분하다. 과한 보안은 익명 댓글의 편의를 해친다. 요구에 맞는 강도를 고르는 게 설계다.


정리

  • 디시 유동닉 = 닉네임(표시) + 비밀번호(아는 것) + IP(가진 것) 로 만드는 로그인 없는 약한 신원
  • 비밀번호는 PBKDF2 해시 + 상수 시간 비교 — 평문 저장·타이밍 공격 모두 차단
  • IP는 salt 섞은 해시로만 저장, 공개 식별자는 글 단위로 격리해 추적 방지
  • 권한은 기본 차단 후 비밀번호 검증으로만 삭제 허용
  • 단, 이건 보안 경계가 아니라 편의를 위한 약한 식별임을 인지할 것
이 구조의 전체 그림(정적 사이트 + 서버리스)은 정적 블로그에 동적 기능 더하기에 정리했다.

Related Posts

같이 읽으면 좋은 글