Altcha self-host vs Cloudflare Turnstile: 인테이크 폼 스팸 방어 선택
gaebari.com/apply 인테이크 폼이 reCAPTCHA, Cloudflare Turnstile 대신 self-hosted Altcha PoW를 선택한 이유와 구체적인 구현 결정.
인테이크 폼에 스팸 방어가 필요한 이유는 단순히 노이즈 때문이 아니다.
gaebari.com/apply 제출 한 건은 다음 이벤트 체인을 트리거한다:
폼 제출 → Altcha PoW 검증 → DB insert
→ NATS 이벤트 발행 (gaebari.intake.submitted)
→ Talk #gaebari-ops 푸시 알림 (운영자 모바일 PWA)
→ Resend 자동 응답 이메일 발송
스팸 제출이 들어오면 운영자 휴대폰이 울리고 Resend 발송 쿼터가 소비된다. Talk 푸시는 신호로서의 가치가 있다. "인테이크가 들어왔다"는 것을 즉시 알기 위해서다. 스팸이 섞이면 그 신호가 노이즈가 된다. 단순한 수신함 노이즈가 아니라 실질적인 비용과 모바일 인터럽션이다.
세 가지 옵션을 검토했다.
옵션 1: reCAPTCHA (Google)
Google JavaScript를 페이지에 삽입해야 한다. 사용자 행동 데이터가 Google로 흘러간다.
한국 사용자를 대상으로 하는 서비스에서 Google 데이터 경로는 규제 민감성이 있다. GDPR 맥락에서 Google reCAPTCHA는 여러 차례 검토 대상이 됐다. 한국 개인정보보호법(PIPA) 맥락에서도 제3자 데이터 이전에 해당할 수 있다.
이미지 그리드 풀기 방식은 접근성 문제가 있다. 시각 장애인 사용자, 인지 장애 사용자에게 불편하다. 모바일에서 작은 이미지 그리드를 탭하는 UX도 좋지 않다.
vendor lock-in: Google이 API 정책을 바꾸거나 서비스를 변경하면 즉각 영향을 받는다.
옵션 2: Cloudflare Turnstile
reCAPTCHA보다 낫다. 이미지 퍼즐이 없다. Cloudflare의 브라우저 신호 분석으로 대부분의 경우 사용자에게 비가시적으로 처리된다. 무료 티어가 있다.
단, 모든 인테이크 제출이 Cloudflare 인프라를 경유한다. 사용자 IP와 브라우저 핑거프린트가 Cloudflare 서버로 전달된다. Cloudflare를 CDN으로 사용하지 않는 경우 데이터 경로가 추가되는 것이다.
Turnstile collects certain information to verify the visitor is human, including IP addresses, browser characteristics, and behavioral signals. This data is processed by Cloudflare's infrastructure.
JavaScript 번들을 삽입해야 하는 것도 마찬가지다. 서드파티 스크립트가 페이지에 들어온다. Content Security Policy 설정이 복잡해진다.
옵션 3: Altcha PoW (MIT, self-host)
Proof-of-Work 방식이다. 클라이언트 브라우저가 제출 전에 CPU 연산 문제를 푼다. 문제의 답(PoW solution)이 폼 데이터와 함께 서버로 전송되고, 서버가 HMAC으로 검증한다.
서드파티 JavaScript 없다. 외부 API 호출 없다. 서버 사이드 altcha-lib HMAC 검증만 있다. MIT 라이선스로 서버에 직접 올린다.
Form submit → Altcha PoW verify → DB insert → NATS publish (gaebari.intake.submitted) → Talk #gaebari-ops push notification (operator mobile PWA) → Resend auto-reply email. Spam is not just inbox noise. It triggers mobile interruptions and consumes Resend quota.
결정: Altcha
결정 기준을 정리하면:
| 기준 | reCAPTCHA | Turnstile | Altcha |
|---|---|---|---|
| Vendor lock-in | Cloudflare | 없음 | |
| 데이터 경로 | Google 서버 | Cloudflare 서버 | 자체 서버 |
| 서드파티 JS | 있음 | 있음 | 없음 |
| 접근성 | 이미지 그리드 문제 | 양호 | PoW, 이미지 없음 |
| 모바일 UX | 그리드 탭 | 비가시적 | CPU 수백 ms |
| 라이선스 | 독점 | 독점 | MIT |
인테이크 폼은 고객당 보통 한 번 제출한다. 제출 빈도가 낮기 때문에 모바일에서 수백 ms의 PoW 연산 비용은 수용 가능하다. 반면 채팅 입력창이나 댓글 폼처럼 반복 제출이 잦은 엔드포인트라면 다른 메커니즘이 맞다.
한국 비즈니스로서 한국 사용자 데이터를 Google이나 Cloudflare 경로 없이 처리하고 싶었다. self-host가 그 요건을 충족하는 유일한 옵션이다.
“Data is a toxic asset. We need to start thinking about it as such, and treat it as we would any other source of toxicity.”
이 관점은 인테이크 폼 설계에 직접 적용된다. 클라이언트 IP를 평문으로 저장하지 않고 sha256 해시만 보관하는 것, 폼 필드에 불필요한 식별자를 넣지 않는 것, 외부 captcha 서버로 행동 데이터를 보내지 않는 것. 모두 "데이터를 자산으로 보지 않고 부채로 본다"는 같은 원칙에서 나온다.
구현 세부사항
구현은 두 레이어다.
레이어 1: Altcha PoW
GET /api/altcha/challenge가 HMAC 서명된 challenge를 내려준다.
const ALTCHA_MAX_NUMBER = 50000;
const challenge = await createChallenge({
hmacKey: ALTCHA_HMAC_KEY || "dev-only-insecure-key",
maxNumber: ALTCHA_MAX_NUMBER,
expires: new Date(Date.now() + 10 * 60 * 1000), // 10분 TTL
});ALTCHA_HMAC_KEY는 Vault에서 관리되고 rotation 가능하다. challenge TTL은 10분이다. 만료된 challenge로는 통과되지 않는다.
POST /api/apply에서 altcha-lib의 verifySolution()으로 서버 사이드 HMAC 검증을 수행한다. 검증 실패 시 400 응답, 이후 단계(DB insert, NATS publish)로 진행하지 않는다.
레이어 2: IP 해시 rate limit
Altcha만으로는 부족하다. PoW를 자동화로 푸는 botnet을 막으려면 IP 기반 제한이 필요하다.
IP는 sha256(client_ip)로 해시해서 저장한다. IP 원문은 DB에 없다. rate limit 쿼리는 PostgreSQL gaebari.gaebari_intake 테이블에서 동일 ip_hash의 최근 제출 수를 카운트한다:
// 1분 이내 5건 초과 → 429
SELECT COUNT(*)::text AS cnt
FROM gaebari.gaebari_intake
WHERE ip_hash = $1
AND created_at > NOW() - INTERVAL '1 minute'
// 1시간 이내 20건 초과 → 429
SELECT COUNT(*)::text AS cnt
FROM gaebari.gaebari_intake
WHERE ip_hash = $1
AND created_at > NOW() - INTERVAL '1 hour'DB 오류 시 fail-open이다. rate limit 쿼리가 실패해도 실제 제출을 막지 않는다. 인테이크 폼에서 정상 사용자를 막는 것이 스팸 일부를 통과시키는 것보다 비용이 크다는 판단이다.
Calibration
ALTCHA_MAX_NUMBER (현재 50,000)는 튜닝 가능하다.
- rejection ratio < 0.1% 지속: 값을 올린다 (PoW를 더 어렵게. botnet이 풀기 너무 쉬운 상태)
- rejection ratio > 5% 지속: 값을 내린다 (정상 사용자 모바일 UX 보호. 너무 느린 상태)
- 조정 없음 구간: 0.1% ~ 5%
rejection은 altcha_failed 로그 이벤트로 기록된다. PoW solution이 없거나 HMAC 검증 실패인 경우가 모두 카운트된다.
현재 maxNumber=50,000은 최신 랩탑 기준 약 1-2초, 모바일(최신 기기) 기준 수백 ms 연산량이다. 구형 저사양 모바일에서는 더 걸릴 수 있다. 이 트레이드오프를 결정할 데이터는 실제 제출량이 쌓인 이후에 확보된다.
Failure modes: Altcha가 다운되면
self-host의 단점은 자명하다. 운영자가 직접 책임진다. Altcha 라이브러리에 버그가 있거나, HMAC 키가 잘못 설정되거나, challenge 발급 엔드포인트가 5xx를 내면, 정상 사용자도 폼을 제출할 수 없다. 외부 의존을 없앤 대신 가용성 책임이 100% 운영자 측에 있다.
이 가능성에 대비한 처리 경로는 명시적이다.
경로 A: challenge 발급 실패 (GET /api/altcha/challenge 5xx). Vault에서 ALTCHA_HMAC_KEY 로딩 실패, DB 연결 실패, Node 런타임 OOM 등이 원인일 수 있다. 이 경로에서는 폼이 challenge를 받지 못하므로 제출 자체가 시작되지 않는다. 클라이언트 측 UI는 "잠시 후 다시 시도해주세요" 메시지를 노출한다. 사용자 입장에선 명확한 실패 신호이지만, 외부 captcha 같은 통제 불가 실패와 달리 운영자가 즉시 대응할 수 있는 영역이다.
경로 B: verify 실패 (POST /api/apply 400). challenge는 정상 발급됐는데 검증이 실패한 경우. HMAC 키 rotation 직후 이전 challenge가 접수되거나, 클라이언트 측 PoW solver 버그로 잘못된 number를 보내거나, 시계 skew로 expires가 깨진 경우 등이 해당한다. 400 응답과 altcha_failed 이벤트로 기록된다. rejection ratio 모니터링이 이 경로의 신호를 잡아낸다.
경로 C: 라이브러리 자체 결함. altcha-lib 의존성 업그레이드 후 검증 로직 변경, 또는 보안 취약점 공시. 대응은 stable 버전 핀 + dependabot 알림 + 정기 리뷰. 외부 vendor의 정책 변경 위험은 없지만 라이브러리 자체의 보안 업데이트는 운영자가 추적해야 한다.
fail-open vs fail-closed 결정은 경로별로 다르다. challenge 발급 실패(A)는 fail-closed. 사용자에게 명시적 실패를 보여주고 재시도를 유도한다. 검증 실패(B)는 fail-closed. 의심스러운 제출을 통과시키지 않는다. 반면 IP rate-limit 쿼리 실패(DB 오류)는 fail-open. 정상 사용자를 막는 비용이 스팸 일부를 통과시키는 비용보다 크다는 판단이다. 이 비대칭은 의도적이다.
HMAC 키 로테이션: 운영 절차
ALTCHA_HMAC_KEY는 정기 로테이션이 필요한 비밀이다. 키가 노출되면 공격자가 유효한 PoW solution을 사전 계산해 발급할 수 있고, 그러면 PoW 자체의 의미가 사라진다.
로테이션 흐름은 단순하다.
1. 새 키 생성. Vault에 새 시크릿 버전을 추가한다. kv put secret/gaebari/altcha hmac_key=<random_64_bytes>. 이 시점에서는 Next.js 앱이 아직 새 키를 모른다.
2. 환경 변수 갱신 + 롤링 재시작. Kubernetes Deployment의 ALTCHA_HMAC_KEY env가 새 Vault 시크릿을 참조하도록 변경하고, 롤링 재시작을 트리거한다. 새 Pod가 새 키로 challenge를 발급하기 시작한다.
3. 기존 challenge의 grace period. 기존 키로 발급된 challenge에는 10분 TTL이 박혀 있다. 롤링 재시작 후 10분이 지나면 모든 in-flight challenge가 만료된다. 그 사이에 새 키로 발급되어 검증되는 challenge가 점진적으로 늘어난다.
4. 검증 윈도우. 검증 로직은 현재 키로만 HMAC 검증한다. 따라서 롤링 재시작 직후 ~10분간은 이전 키로 발급된 challenge가 400으로 거부된다. 사용자 입장에서는 "유효한 PoW를 풀었는데 거부됐다"는 인상이 생길 수 있다. 이 윈도우를 줄이려면 dual-key 검증을 구현할 수도 있지만, 단순성을 위해 현재는 single-key + 짧은 transient 거부를 받아들인다.
로테이션 빈도는 분기에 1회가 디폴트다. 키 노출 의심 시 즉시 로테이션이 추가 트리거다.
NATS 이벤트 fan-out: 인테이크 처리 체인 상세
POST /api/apply 검증 통과 후 발행되는 gaebari.intake.submitted 이벤트는 단일 producer, 다중 consumer 패턴이다.
이벤트 페이로드는 의도적으로 미니멀하다. {intake_id, created_at, source} 정도다. 폼 본문(이름/이메일/요청 내용)은 페이로드에 포함되지 않는다. 컨슈머는 intake_id로 PostgreSQL gaebari.gaebari_intake에서 필요한 필드만 다시 읽는다. 이 분리의 이유는 두 가지다. NATS JetStream의 메시지 보존 윈도우에 PII가 누적되지 않도록 하고, 컨슈머가 "이벤트가 왔다"는 신호와 "본문 데이터가 필요하다"는 별도 결정을 내릴 수 있도록 한다.
현재 등록된 컨슈머는 두 개다. Talk 푸시 디스패처는 운영자 모바일 PWA로 알림을 보낸다. 본문은 읽지 않고 "새 인테이크 N건 누적"이라는 카운터 메시지만 사용한다. Resend 자동 응답 워커는 본문을 읽어 신청자 이메일로 자동 응답을 발송한다.
향후 컨슈머를 추가할 때(예: 분석 파이프라인, 슬롯 매칭 큐) 같은 NATS subject를 구독하기만 하면 된다. producer 측 변경은 필요 없다. 이 디커플링이 self-host 인프라가 외부 SaaS 의존을 늘리지 않으면서도 확장될 수 있는 경로다.
이 패턴은 Altcha 통과 후의 처리 책임 분리를 분명하게 만든다. 인테이크 API는 검증 + DB 기록 + 이벤트 발행까지만 책임진다. 알림 발송 실패, 자동 응답 메일 큐 적체, 분석 파이프라인 지연 등은 컨슈머 측 운영 문제로 격리된다. 어느 컨슈머가 일시적으로 다운되어도 인테이크 폼 자체는 계속 작동하고, 큐에 누적된 이벤트는 컨슈머 복구 후 재처리된다. 만약 알림 발송을 인테이크 API 안에서 동기 처리했다면, Resend가 5xx를 내는 순간 정상 신청자도 폼 제출 실패를 경험하는 구조가 됐을 것이다.
JetStream의 메시지 보존은 7일이다. 이 기간 안에 컨슈머가 복구되면 누락된 이벤트는 자동으로 재처리된다. 7일을 초과하는 다운타임은 별도 백필 절차가 필요하지만, 운영 경험상 그 시점에는 인테이크 다운 자체가 더 큰 문제로 인식될 가능성이 높다. 이 한계는 알고 있는 한계로 남겨둔다. 백필 도구는 PostgreSQL gaebari.gaebari_intake 테이블의 timestamp 범위 쿼리로 직접 재발행할 수 있도록 별도 admin 스크립트로 분리되어 있다.
트레이드오프 정직하게
PoW는 사용자에게 CPU 비용을 전가한다. 수백 ms는 작지만 0은 아니다.
고빈도 엔드포인트에서는 이 비용이 UX에 영향을 준다. 댓글창, 실시간 검색, 반복 제출이 많은 폼에서는 PoW가 맞지 않는다. 인테이크 폼은 고객당 1회 제출이 기준이므로 이 트레이드오프는 right side에 있다.
서드파티 JS 없음, 데이터 경로 통제, MIT 라이선스. 이 세 가지가 Altcha를 선택한 실질적인 이유다. "스팸을 막는다"는 목적만이면 Turnstile로도 충분했을 것이다. 외부 의존성을 최소화하는 것이 추가 목표였기 때문에 self-host를 택했다.
Altcha is an open-source, self-hosted CAPTCHA alternative that uses Proof of Work to protect against spam. No external API calls, no third-party JavaScript, MIT licensed.
FAQ
자주 묻는 질문
Altcha는 접근성 문제가 없나요?
이미지 그리드나 오디오 퍼즐이 없습니다. PoW는 JavaScript를 실행할 수 있는 브라우저라면 자동으로 처리됩니다. JavaScript가 비활성화된 환경에서는 작동하지 않지만, 이는 reCAPTCHA도 마찬가지입니다.
ALTCHA_HMAC_KEY가 노출되면 어떻게 되나요?
키가 노출되면 공격자가 유효한 PoW solution을 생성할 수 있습니다. Vault에서 관리하며 rotation 가능합니다. 키를 교체하면 이전 키로 발급된 challenge는 즉시 무효화됩니다.
rate limit은 정상 클라이언트에게도 적용되나요?
1분 5회, 1시간 20회입니다. 정상적인 인테이크 제출 패턴에서는 이 한도를 넘을 수 없습니다. 자동화 테스트나 개발 환경에서 반복 제출 시 주의가 필요합니다.
스팸이 통과되면 어떻게 되나요?
NATS 이벤트가 발행되고 Talk 알림과 Resend 이메일이 발송됩니다. 스팸 패턴이 확인되면 ip_hash 기반 block을 수동으로 추가하거나 ALTCHA_MAX_NUMBER를 올려 PoW 난이도를 높입니다.
Altcha 오픈소스 프로젝트: altcha.org