많은 기능이 "이 사람이 자격이 있는가"만 알면 되는데, 구현은 대개 "이 사람이 누구인가"를 저장하는 방식으로 한다. 익명 자격 증명은 이 둘을 분리한다. 권한은 검증하되 식별자는 남기지 않는다.
적용 대상은 생각보다 많다. 익명 설문의 1인 1표, 무료 체험 남용 방지, 유료 회원 전용 콘텐츠, 신고 기능의 중복 방지, 봇 차단. 전부 "자격 확인"이지 "신원 확인"이 아니다.
이 접근을 처음 검토한 건 익명 피드백 기능을 만들 때였다. 익명이라고 해 놓고 서버에는 사용자 ID를 저장하고 있었다. 중복 제출을 막아야 했으니까. 그런데 그건 익명이 아니다. 관리자가 조회하면 누가 썼는지 다 보인다. 진짜 익명이면서 중복도 막으려면 다른 구조가 필요하다.
무엇을 증명할 것인가
| 요구 | 저장하면 안 되는 것 | 증명할 것 |
|---|---|---|
| 1인 1회 참여 | 참여자 목록 | "미사용 토큰을 하나 가지고 있다" |
| 유료 회원 확인 | 어떤 회원이 접근했는지 | "유효한 회원 자격 증명을 보유한다" |
| 성인 여부 확인 | 생년월일 | "기준 연령 이상이다" |
| 중복 신고 방지 | 신고자 신원 | "이 대상에 대해 아직 신고하지 않았다" |
| 봇 차단 | 장치 지문 | "비용을 지불했거나 챌린지를 통과했다" |
가장 실용적인 수단 — 블라인드 서명
복잡한 증명 시스템을 쓰지 않고도 익명성과 검증을 동시에 얻을 수 있는 고전적 방법이 블라인드 서명이다. 개념은 단순하다. 사용자가 토큰을 봉투에 넣어 서버에 주고, 서버는 내용을 보지 않고 봉투째 서명한다. 사용자는 봉투를 벗겨 서명된 토큰을 얻는다.
// 흐름 요약 (라이브러리 사용을 전제로 한 개념 코드)
// 1) 클라이언트: 무작위 토큰 생성 후 블라인딩
const token = randomBytes(32);
const { blinded, unblinder } = blind(token, serverPublicKey);
// 2) 서버: 자격 확인 후 블라인드 서명 (token 값은 모름)
if (!user.isEligible()) throw new Error('not eligible');
if (await hasIssued(user.id)) throw new Error('already issued');
await markIssued(user.id); // "발급했다"만 기록
const blindSig = signBlinded(blinded, serverPrivateKey);
// 3) 클라이언트: 언블라인딩 → 서명된 토큰 획득
const signature = unblind(blindSig, unblinder);
// 4) 사용 시점 (다른 세션, 로그인 불필요)
// 서버는 서명 유효성과 미사용 여부만 확인
if (!verify(token, signature, serverPublicKey)) reject();
if (await isSpent(hash(token))) reject();
await markSpent(hash(token)); // 사용된 토큰 해시만 저장
서버가 가진 정보는 두 가지뿐이다. "사용자 A에게 발급했다"와 "토큰 해시 X가 사용됐다". 이 둘을 연결할 방법이 없다. 이것이 익명성의 근거다.
연결 가능성(linkability)이라는 함정
암호학적으로 완벽해도 운영에서 연결이 드러나는 경우가 많다. 실무에서 더 자주 문제가 되는 건 이쪽이다.
비용 기반 증명 — 스팸에 대한 다른 접근
자격을 부여할 근거가 없는 완전 익명 서비스에서는 비용을 부과하는 방식이 쓰인다. 계정 생성이나 요청에 한계비용을 만들어 대량 생성을 억제하는 것이다.
| 비용 형태 | 장점 | 단점 |
|---|---|---|
| 소액 결제 | 계산이 명확, 효과가 크다 | 결제 수단 자체가 신원 근접 정보, 접근성 저하 |
| 작업 증명 | 신원 불필요 | 저사양 기기에 불리, 공격자는 비용을 감당할 수 있음 |
| 챌린지(캡차 등) | 구현이 쉽다 | 접근성 문제, 자동 해결 비용 하락 |
| 초대·평판 | 품질이 높다 | 폐쇄적, 초기 확산 어려움 |
결제를 쓴다면 결제와 계정을 분리하는 장치가 필요하다. 그렇지 않으면 익명성이 결제 사업자 쪽으로 옮겨 갈 뿐이다. 앞서 본 블라인드 서명 구조를 결제 영수증에 적용하면 이 분리가 가능해진다.
직접 구현하지 말아야 할 것들
암호 프리미티브는 직접 구현하면 거의 반드시 틀린다. 특히 블라인드 서명과 영지식 증명은 미묘한 구현 오류가 보안을 통째로 무너뜨린다.
- 검증된 라이브러리를 사용하고, 버전과 취약점 공지를 추적한다.
- 키 관리 계획을 먼저 세운다 — 서명 키가 유출되면 토큰을 무한 생성할 수 있다.
- 키 회전 절차를 설계에 포함한다. 회전 시 기존 토큰의 유효 기간을 어떻게 할 것인가.
- 재생 공격 방지를 반드시 포함한다 — 사용된 토큰 기록은 빠르고 확실해야 한다(경쟁 조건 주의).
- 만료를 넣는다. 영원히 유효한 토큰은 운영상 위험하다.
-- 사용된 토큰 기록은 경쟁 조건에 강해야 한다
CREATE TABLE spent_tokens (
token_hash bytea PRIMARY KEY, -- 유니크 제약이 곧 중복 방지
spent_at timestamptz NOT NULL DEFAULT now(),
epoch int NOT NULL -- 키 회전 세대
);
-- 삽입이 성공하면 사용 처리, 충돌하면 이미 사용된 토큰
INSERT INTO spent_tokens(token_hash, epoch) VALUES ($1, $2)
ON CONFLICT (token_hash) DO NOTHING
RETURNING token_hash;
-- 반환이 없으면 재사용 시도 → 거부
-- 만료된 세대는 주기적으로 정리
DELETE FROM spent_tokens WHERE epoch < $current_epoch - 2;
언제 이 복잡성을 감수할 가치가 있나
- 프라이버시가 제품의 핵심 약속인 서비스
- 내부 조회로 신원이 드러나면 안 되는 기능(제보·신고·설문)
- 규제상 개인정보 최소 수집이 요구되는 영역
- 신뢰가 곧 제품 가치인 경우
- 사용자가 이미 로그인해 있고 그 사실이 문제되지 않는 기능
- 참여자 수가 적어 익명 집합이 작은 경우
- 운영·감사 요구로 어차피 추적이 필요한 경우
- 단순 중복 방지면 충분한 경우
마지막 판단 기준은 이것이다. "우리 회사 직원이 DB를 열어 봤을 때 누가 무엇을 했는지 알 수 있어야 하는가?" 알아서는 안 된다면 익명 자격 증명이 필요하고, 알아도 된다면 일반적인 인증으로 충분하다.
자주 묻는 질문
익명 자격 증명이 정확히 무엇인가요?
사용자의 신원을 저장하지 않고 특정 자격만 검증하는 인증 방식입니다. "누구인가" 대신 "유효한 자격이 있는가"만 확인하며, 발급 기록과 사용 기록이 서로 연결되지 않도록 설계합니다.
블라인드 서명은 어떻게 익명성을 보장하나요?
서버가 토큰의 실제 값을 보지 못한 상태로 서명하기 때문입니다. 나중에 토큰이 제시돼도 서버는 그것이 누구에게 발급한 서명인지 알 수 없으며, 발급 기록과 사용 기록 사이에 연결고리가 없습니다.
암호 기술만 잘 쓰면 익명성이 보장되나요?
아닙니다. 발급과 사용의 시간 간격, IP 주소, 세션 정보, 로그, 그리고 익명 집합의 크기가 실제 익명성을 좌우합니다. 참여자가 소수라면 어떤 기술을 써도 추론이 가능하므로 설계 단계에서 이를 고려해야 합니다.
영지식 증명을 직접 구현해야 하나요?
권하지 않습니다. 대부분의 요구는 블라인드 서명이나 일회성 토큰처럼 더 단순한 수단으로 충분히 달성됩니다. 어떤 경우에도 암호 프리미티브는 검증된 라이브러리를 사용하고 키 관리와 회전 절차를 먼저 설계하세요.
토큰 재사용은 어떻게 막나요?
사용된 토큰의 해시를 유니크 제약이 있는 테이블에 기록하고, 삽입 성공 여부로 사용 가능 여부를 판단하면 경쟁 조건에 안전합니다. 토큰에 만료와 키 세대를 두어 오래된 기록을 정리할 수 있게 설계하세요.
결제로 스팸을 막으면 익명성이 깨지지 않나요?
결제와 계정을 직접 연결하면 그렇습니다. 결제 영수증에 블라인드 서명 구조를 적용해 "유효한 결제가 있었다"는 사실만 증명하고 어떤 계정과 연결되는지는 서버가 알 수 없게 만들면 분리가 가능합니다.

댓글 0