SupabaseNext.js익명 인증HMAC미들웨어

회원가입 없는 익명 사이트가 "이미 동의했습니다"를 아는 방법

2026년 7월 13일4 분 읽기

익명 커뮤니티 사이드 프로젝트를 운영하면서 직접 유저로도 써보는데, 어느 날 어제 동의했던 방 개설 제안에 다시 동의 버튼을 눌렀더니 이렇게 나왔습니다.

이미 동의했습니다.

순간 스스로 만든 서비스인데도 이런 생각이 들었어요. "익명인데... 어떻게 내가 한 걸 알지? 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 제약으로 중복 차단
     → 같은 재료로 방마다 다른 결정론적 닉네임 생성
  1. IP 대신 익명 세션 — 정확하고, 개인정보를 저장하지 않음
  2. 원본 ID 대신 HMAC 해시 — 유출돼도 역산 불가, 클라이언트 비노출
  3. 중복 방지는 DB unique 제약 — 레이스 컨디션 없는 가장 단단한 방법
  4. 식별과 신원의 분리 — "같은 브라우저"는 알지만 "누구"인지는 모름
  5. 쿠키 기반의 한계는 의도된 트레이드오프 — 완벽한 부정 방지는 익명성과 양립 불가

"익명인데 어떻게 알지?"의 답은 결국 이겁니다 — 당신이 누군지는 모르지만, 당신의 브라우저와는 구면이거든요.

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.