회원가입 없는 익명 사이트가 "이미 동의했습니다"를 아는 방법
익명 커뮤니티 사이드 프로젝트를 운영하면서 직접 유저로도 써보는데, 어느 날 어제 동의했던 방 개설 제안에 다시 동의 버튼을 눌렀더니 이렇게 나왔습니다.
이미 동의했습니다.
순간 스스로 만든 서비스인데도 이런 생각이 들었어요. "익명인데... 어떻게 내가 한 걸 알지? IP인가?"
정답은 IP가 아닙니다. 이 글은 회원가입도 로그인도 없는 사이트가 어떻게 "당신, 어제 왔었죠"를 아는지 — 그리고 그러면서도 어떻게 여전히 익명일 수 있는지를 실제 구현 코드로 풀어봅니다.
왜 이 문제가 어려운가
익명 사이트에도 "한 사람 = 한 표" 같은 제약이 필요한 순간이 옵니다. 제 프로젝트에서는 게시판(방) 개설이 유저 30명의 동의로 결정되는데, 한 사람이 30번 누를 수 있으면 이 장치는 무의미해집니다.
그런데 회원가입이 없으니 쓸 수 있는 카드가 마땅치 않습니다:
| 방법 | 문제 |
|---|---|
| IP 주소 | 같은 와이파이(회사·카페·가족)가 전부 한 명 취급, 반대로 모바일은 IP가 수시로 바뀜. 게다가 IP는 그 자체로 개인정보라 저장하는 순간 익명성이 약해짐 |
| 브라우저 핑거프린팅 | 프라이버시 침해 논란 + 갈수록 브라우저가 막는 추세 |
| 전화번호 인증 | 그 순간 익명 사이트가 아니게 됨 |
남는 답은 하나입니다. "익명이지만 세션은 있는" 상태를 만드는 것.
Step 1 — 모든 방문자에게 익명 세션 발급하기
Supabase Auth에는 signInAnonymously()라는 기능이 있습니다. 이메일도 비밀번호도 없이, 랜덤 UUID 하나짜리 유저를 만들어 세션 쿠키를 발급합니다.
이걸 Next.js 미들웨어(제 프로젝트에서는 proxy.ts)에 넣어서 모든 방문자가 첫 요청에서 자동으로 세션을 갖게 했습니다:
// src/proxy.ts (핵심만 발췌)
export async function proxy(request: NextRequest) {
const supabase = createServerClient(/* 쿠키 연동 설정 */);
// 익명 세션이 없으면 발급 — device_hash 파생의 기반이 되는 세션 쿠키를 항상 보장
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
await supabase.auth.signInAnonymously();
}
return response;
}
유저 입장에서는 아무 일도 일어나지 않습니다. 가입 화면도, 약관 동의도 없습니다. 하지만 브라우저 쿠키에는 이제 "이 브라우저"를 나타내는 익명 세션이 하나 생겼습니다.
Step 2 — auth.uid()를 그대로 쓰지 않고 해싱하기
이제 세션의 auth.uid()로 중복을 걸러내면 될 것 같지만, 여기서 한 단계 더 들어갑니다. UUID를 DB에 그대로 저장하지 않고, 서버만 아는 비밀값(pepper)으로 HMAC 해싱합니다:
// src/lib/device-hash.ts
export function deriveDeviceHash(userId: string): string {
const pepper = process.env.DEVICE_HASH_PEPPER;
return createHmac("sha256", pepper).update(userId).digest("hex");
}
왜 이렇게까지 하냐면:
- DB가 유출돼도 device_hash에서 원래 세션 ID를 역산할 수 없습니다 (pepper는 DB가 아니라 서버 환경변수에만 존재)
- 클라이언트에는 device_hash가 절대 내려가지 않습니다
- 그러면서도 서버는 "같은 브라우저인지"를 일관되게 판별할 수 있습니다
Step 3 — 중복 방지는 DB unique 제약으로
동의(투표) 기록 테이블은 이렇게 생겼습니다:
create table topic_votes (
id uuid primary key default gen_random_uuid(),
topic_id uuid not null references topics(id) on delete cascade,
device_hash text not null,
created_at timestamptz not null default now(),
unique (topic_id, device_hash) -- ← 핵심
);
(방 ID, device_hash) 조합에 unique 제약이 걸려 있어서, 같은 브라우저가 같은 방에 두 번 동의하면 DB가 INSERT 자체를 거부합니다. API는 이 충돌을 받아서 409와 함께 "이미 동의했습니다"를 돌려주고요. 애플리케이션 코드에서 "먼저 조회하고 없으면 넣기" 같은 레이스 컨디션에 취약한 로직을 짤 필요가 없습니다 — 정합성은 DB 제약이 지키는 게 제일 단단합니다.
제가 어제 동의한 걸 오늘도 사이트가 아는 이유가 이제 설명됩니다. 쿠키가 브라우저에 남아 있으니, 같은 세션 → 같은 device_hash → unique 충돌. IP는 아무 관련이 없고, 실제로 이 프로젝트는 IP를 어디에도 저장하지 않습니다.
덤 — 같은 재료로 만드는 "방마다 다른 닉네임"
이 device_hash는 닉네임 생성에도 재활용됩니다. (방 ID + device_hash)를 시드로 형용사·명사를 결정론적으로 뽑으면:
// src/lib/nickname.ts
const digest = createHash("sha1").update(`${topicId}:${deviceHash}`).digest();
return `${ADJECTIVES[digest[0] % 12]} ${NOUNS[digest[1] % 12]}`;
// → "격분한 너구리", "수줍은 택시기사" ...
- 같은 방 안에서는 항상 같은 닉네임 → 대화의 연속성 유지
- 다른 방에서는 시드가 달라져 전혀 다른 닉네임 → 방을 넘나드는 추적 불가
DB에 닉네임 매핑 테이블을 둘 필요도 없습니다. 필요할 때마다 같은 수식으로 다시 계산하면 되니까요.
그래서 이게 "익명"이 맞나?
맞습니다. 포인트는 식별 가능(identifiable)과 신원 노출(identified)의 분리입니다.
서버가 아는 것: "해시 a3f9...가 이 방에 동의했다."
서버가 모르는 것: 그 해시가 누구인지 — 이름, 이메일, 전화번호, IP 전부 없습니다.
즉 시스템은 "같은 브라우저"라는 사실만 알고, "어떤 사람"인지는 알 수 없는 구조입니다. 익명성과 최소한의 질서(중복 방지, 도배 방지, 신고 시 제재)를 동시에 잡는 절충점이죠.
한계 — 그리고 그게 의도인 이유
이 구조의 약점은 명확합니다. 세션이 쿠키에 있으니, 쿠키를 버리면 새 사람이 됩니다.
- 시크릿 창을 열면 → 새 익명 세션
- 쿠키를 지우면 → 새 익명 세션
- 다른 브라우저·기기를 쓰면 → 새 익명 세션
즉 마음먹은 사람은 한 제안에 여러 번 동의할 수 있습니다. 이걸 막으려면 전화번호 인증이나 핑거프린팅이 필요한데, 그 순간 익명 사이트가 아니게 됩니다. 그래서 이 프로젝트에서 "30명 동의"는 부정 불가능한 투표가 아니라 가벼운 허들로 설계했고, 대신 device_hash 기준 레이트리밋(액션별 60초 5회)이 기계적인 도배를 막는 1차 방어선을 맡습니다.
익명성과 부정 방지는 트레이드오프 관계입니다. 어느 쪽을 얼마나 포기할지는 서비스의 성격이 정하는 것이고, 익명 커뮤니티라면 익명성 쪽에 추를 두는 게 맞다고 판단했습니다.
정리
전체 구조를 한눈에:
방문 → 미들웨어가 익명 세션 자동 발급 (쿠키)
→ 액션 시 서버가 HMAC(pepper, auth.uid())로 device_hash 계산
→ (방 ID, device_hash) unique 제약으로 중복 차단
→ 같은 재료로 방마다 다른 결정론적 닉네임 생성
- IP 대신 익명 세션 — 정확하고, 개인정보를 저장하지 않음
- 원본 ID 대신 HMAC 해시 — 유출돼도 역산 불가, 클라이언트 비노출
- 중복 방지는 DB unique 제약 — 레이스 컨디션 없는 가장 단단한 방법
- 식별과 신원의 분리 — "같은 브라우저"는 알지만 "누구"인지는 모름
- 쿠키 기반의 한계는 의도된 트레이드오프 — 완벽한 부정 방지는 익명성과 양립 불가
"익명인데 어떻게 알지?"의 답은 결국 이겁니다 — 당신이 누군지는 모르지만, 당신의 브라우저와는 구면이거든요.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.