728x90

내부 DNS는 인프라 하는 사람들에게 오랫동안 애매하게 방치된 영역이었다. 공개 DNS는 Cloudflare나 Route53 같은 걸 쓰고, 사내망은 CoreDNS나 BIND, 클라우드 안에서는 각 벤더의 Private Hosted Zone... 이렇게 서너 개 시스템이 각자 놀고 있는 경우가 대부분이다. 2026년 7월 GA된 Cloudflare Internal DNS는 바로 이 파편화를 하나의 제어 평면으로 묶겠다는 이야기다. 실무자 입장에서 이게 진짜 쓸만한 건지, 어디서 삽질하게 될지를 정리해봤다.

1. 왜 지금 이게 화제인가 — 내부 DNS 파편화라는 오래된 통증

혼합 환경을 굴려본 사람이라면 이 그림이 익숙할 거다.

  • 공개 도메인(example.com)은 외부 DNS 제공자
  • 사내망(*.corp.internal)은 온프레미스 CoreDNS 또는 BIND
  • AWS 안쪽은 Route53 Private Hosted Zone
  • GCP는 Cloud DNS의 private zone
  • 보안 정책은 또 별도 방화벽/프록시 계층에서

문제는 이 시스템들이 공통 제어 평면을 공유하지 않는다는 점이다. 특히 Split-horizon DNS(같은 호스트명을 내부/외부 사용자에게 다르게 응답하는 구성)를 하면 지옥문이 열린다. 내부용 뷰와 외부용 뷰를 각각 별도 시스템에서 관리하다가, 어느 한쪽만 레코드를 수정하는 순간 두 시스템이 어긋나기(drift) 시작한다. 그리고 그 drift는 꼭 금요일 저녁에 장애로 터진다.

Cloudflare가 미는 메시지는 명확하다. "공개 DNS, 내부 DNS, Zero Trust 정책을 같은 글로벌 네트워크와 하나의 API에서 다루자." 원문에 따르면 Enterprise 고객은 Cloudflare Gateway에 추가 비용 없이 포함된다고 한다. 이 부분은 계약 형태에 따라 다를 수 있으니 영업/공식 문서 확인은 필요하다.

2. 동작 원리 — Gateway Resolver + Internal Authoritative DNS

구조를 단순화하면 두 개의 컴포넌트로 나뉜다.

  • Gateway Resolver: 재귀 해석(recursive)과 정책 평가를 담당. 2020년에 나온 그 Gateway 맞고, 공개 해석은 1.1.1.1 인프라를 그대로 탄다. 표현식(expression) 기반으로 쿼리를 필터링하거나 다른 업스트림으로 리다이렉트할 수 있다.
  • Internal Authoritative DNS: 내부 존의 권한 있는(authoritative) 레코드를 서빙. Cloudflare가 10년 넘게 굴려온 그 authoritative 플랫폼 위에 올라간다.

여기서 다루는 객체가 세 가지인데, 이걸 이해하면 절반은 끝난다.

  • Internal Zones: 내부 리소스의 실제 레코드(앱, 서비스 엔드포인트, DB 등)를 담는 존
  • DNS Views: 특정 사용자/디바이스 집합이 봐야 할 "해석 컨텍스트". Split-horizon이 별도 시스템 없이 되는 핵심.
  • Resolver Policies: Gateway 안에 존재하며, 매칭되는 쿼리를 특정 View로 라우팅

중요한 개념이 Zone reference다. intranet.local 같은 공용 존을 한 번만 정의하고 여러 View에서 참조(reference)한다. 기존 Split-horizon이 강요하던 "레코드 복붙 → drift 발생" 패턴을 DRY하게 바꾸는 게 이 참조 구조다. 개인적으로 이 부분이 제일 실용적이라고 본다. CoreDNS로 뷰 나눌 때 존 파일 두 벌 관리하다가 한쪽만 고쳐서 사고 나는 걸 실제로 몇 번 봤기 때문이다.

쿼리 한 방이 흘러가는 경로

클라이언트 쿼리가 들어오면 이렇게 처리된다.

  1. 먼저 Gateway Resolver에 도착해서 정책이 평가된다.
  2. Resolver policy가 매칭되고 내부 View를 가리키면 → Internal Authoritative DNS로 라우팅, 해당 View의 존에서 응답.
  3. 정책이 차단(block)하면 → resolver 단에서 드롭.
  4. 둘 다 아니면 → 공개 경로로, 1.1.1.1이 공개 DNS 계층에서 해석.

여기서 편한 게, View는 내부에서 이름을 못 찾으면 공개 해석으로 폴백(fallback)할 수 있다. 즉 클라이언트는 이게 내부 이름인지 공개 이름인지 몰라도 되고, 하나의 리졸버가 둘 다 처리한다. 이걸 CoreDNS로 하려면 forwardfallthrough 조합을 손으로 짜야 하는데, 그걸 정책으로 추상화한 셈이다.

변경 전파 경로

레코드 변경은 대시보드든 Terraform이든 직접 API 호출이든 전부 동일한 DNS Records API를 탄다. 쓰기 경로가 하나라 감사(audit)와 추론이 단순해진다. 변경은 코어 데이터센터에 저장·검증된 뒤 글로벌 네트워크로 복제되고, 영향받은 캐시 엔트리가 무효화되어 TTL 만료를 기다리지 않고 몇 초 안에 반영된다고 한다. 이건 온프레미스 BIND에서 TTL 걸어놓고 "왜 아직 옛날 값 나와?" 하던 경험을 아는 사람에겐 꽤 매력적인 부분이다.

3. 기존 솔루션과 비교 — CoreDNS / Route53 PHZ / Pi-hole

실무에서 자주 쓰는 것들과 성격을 비교해보면 이렇다. (수치성 벤치마크는 원문에 없어 생략, 성격 위주로만 정리한다.)

항목Cloudflare Internal DNSCoreDNSRoute53 Private Hosted ZonePi-hole
주 용도내부/공개 통합 + Zero Trust 정책K8s/자체 내부 DNSAWS VPC 내부 DNS가정/소규모 광고차단 DNS
제어 평면Cloudflare 단일 평면직접 운영AWS 콘솔/API직접 운영
Split-horizonView + Zone reference로 네이티브view 플러그인/수동 구성VPC 단위로 사실상 horizon사실상 미지원
Zero Trust 연동Gateway 정책과 통합없음(별도 계층)없음없음
클라우드 종속없음(어느 연결이든)없음AWS 락인없음
운영 부담매니지드본인이 다 짊어짐매니지드(AWS 한정)직접 운영

정리하면, CoreDNS는 K8s 클러스터 내부용으로는 여전히 최고지만 조직 전체의 사용자·디바이스 정책까지 다루지는 않는다. Route53 PHZ는 AWS 안에서는 훌륭하지만 온프레미스와 다른 클라우드를 아우르는 순간 벽에 부딪힌다. Cloudflare Internal DNS의 차별점은 "DNS 해석 + 누가 접근 가능한가"를 같은 정책 엔진에서 결정한다는 지점이다. Pi-hole은 애초에 결이 다르니 비교표에 넣긴 했지만 엔터프라이즈 대안은 아니다.

4. 실전 구성 — Split-horizon DNS 세 단계로 세우기

원문 기준으로 설정은 크게 세 단계다. 존 생성 → 뷰 생성 → 리졸버 정책 정의. API로 가보자.

4-1. 내부 존과 첫 레코드 생성

# 1) 내부 존 생성
curl -X POST "https://api.cloudflare.com/client/v4/accounts/zones" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "account": { "id": "'"$ACCOUNT_ID"'" },
    "name": "corp.internal",
    "type": "internal"
  }'

기대 출력(예시):

{
  "success": true,
  "errors": [],
  "messages": [],
  "result": {
    "id": "023e105f4ecef8ad9ca31a8372d0c353",
    "name": "corp.internal",
    "type": "internal",
    "status": "active"
  }
}
# 2) 내부 A 레코드 추가 (예: DB 엔드포인트)
curl -X POST "https://api.cloudflare.com/client/v4/zones/023e105f4ecef8ad9ca31a8372d0c353/dns_records" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "A",
    "name": "db.corp.internal",
    "content": "10.0.1.50",
    "ttl": 300
  }'

4-2. DNS View 생성 후 존 연결

curl -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/internal_dns/views" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "production-view",
    "zones": ["023e105f4ecef8ad9ca31a8372d0c353"]
  }'

기대 출력(예시):

{
  "success": true,
  "result": {
    "id": "9f7b3c1a55d24e6f8a0b1c2d3e4f5a6b",
    "name": "production-view",
    "zones": ["023e105f4ecef8ad9ca31a8372d0c353"]
  }
}

4-3. Gateway Resolver Policy로 라우팅

마지막은 Zero Trust 대시보드에서 Gateway location을 만들고, 조건을 정한 뒤 해석 방식(resolution method)을 Internal DNS View로 지정, 위에서 만든 production-view를 선택하면 된다. 여기서부터 정책에 매칭되는 쿼리는 내부 존에서 해석된다.

4-4. Terraform으로 IaC 관리

Terraform 지원이 있고, Terraform 역시 동일한 DNS Records API를 타기 때문에 전파 경로가 대시보드/직접 API와 완전히 같다. 즉 IaC로 바꿔도 별도 반영 경로를 따로 신경 쓸 필요가 없다. 아래는 개념 예시이며, 실제 리소스명·인자는 공식 프로바이더 문서 확인이 필요하다.

# 개념 예시 (실제 스키마는 provider 문서 확인 필요)
resource "cloudflare_dns_record" "db_internal" {
  zone_id = "023e105f4ecef8ad9ca31a8372d0c353"
  type    = "A"
  name    = "db.corp.internal"
  content = "10.0.1.50"
  ttl     = 300
}

연결 방식은 뭐든 된다

Gateway Resolver로 DNS 트래픽이 흐르기만 하면 된다. Cloudflare One Client(구 WARP), DoH, DoT, 표준 53번 포트 DNS, PAC 파일, Cloudflare WAN 모두 지원한다. 특히 Cloudflare WAN을 쓰면 연결된 네트워크의 모든 디바이스가 개별 클라이언트 설치 없이 내부 호스트명을 해석할 수 있다고 한다. 레거시 장비가 깔린 지점망/공장망처럼 에이전트 설치가 곤란한 환경에서 이 부분이 꽤 유용할 것으로 보인다.

5. 운영 관점 — 트레이드오프와 흔한 함정

흔한 함정 1: 폴백을 믿고 존을 어설프게 나눴을 때

View가 내부에서 못 찾으면 공개로 폴백한다는 게 편하긴 한데, 이게 함정이 될 수도 있다. 내부 존에 존재해야 할 레코드가 오타나 누락으로 빠지면, 차단되지 않고 조용히 공개 해석으로 넘어가 엉뚱한 외부 IP를 물어오는 경우가 생긴다. 이런 건 에러가 안 나서 더 위험하다. 배포 후 내부 이름은 반드시 dig로 응답 소스를 검증하는 습관을 들이는 게 좋다.

dig db.corp.internal +short
# 기대: 10.0.1.50
# 만약 공인 IP가 나온다면 → 폴백으로 새어나간 것. 존/뷰/정책 매칭 재점검

흔한 함정 2: 존 타입/이름 충돌 에러

같은 이름의 존을 중복 생성하거나, 이미 공개 존으로 등록된 이름을 내부 존으로 다시 만들려 할 때 대략 이런 형태의 응답을 만나게 된다(에러 형식은 실제와 다를 수 있으니 참고용):

{
  "success": false,
  "errors": [
    {
      "code": 1061,
      "message": "zone with name 'corp.internal' already exists"
    }
  ],
  "result": null
}

이럴 땐 새로 만들지 말고 기존 존을 여러 View에서 참조(Zone reference)하는 방향으로 가야 한다. Split-horizon을 한다고 존을 복제 생성하는 순간 Cloudflare가 굳이 없애려던 drift를 스스로 다시 만드는 꼴이다.

흔한 함정 3: 정책이 매칭 안 돼서 전부 공개로 나가는 경우

Resolver Policy의 location 조건(소스 IP 범위, 디바이스 상태 등)이 실제 클라이언트와 안 맞으면, 쿼리는 정책에 걸리지 않고 그냥 공개 경로로 간다. "내부 이름이 하나도 안 잡혀요" 증상의 대부분은 존/레코드 문제가 아니라 정책 매칭 조건 문제다. Gateway의 DNS 로그(단일 대시보드)를 열어서 해당 쿼리가 어떤 정책에 매칭됐는지부터 확인하는 게 순서다. 이게 로그가 한 군데 모인다는 통합 제어 평면의 실질적 이점이기도 하다.

트레이드오프

  • 종속성: 결국 Cloudflare 제어 평면에 DNS 해석 경로가 묶인다. 멀티 벤더 전략을 중시하는 조직은 이 점을 감안해야 한다.
  • Enterprise 중심: 원문상 Enterprise + Gateway 사용자가 대상이다. 소규모 팀이 가볍게 쓰기엔 결이 안 맞을 수 있다.
  • 클러스터 내부 DNS는 여전히 CoreDNS: K8s 내부의 서비스 디스커버리까지 이걸로 대체하는 그림은 아니다. 조직 레벨 이름 해석과 클러스터 내부 해석은 계층이 다르다.

6. 정리 — 누가 언제 써야 하나

한 줄 요약: 공개 DNS·내부 DNS·Zero Trust 정책이 각자 따로 놀아서 drift와 장애에 시달려온 조직이라면, 이 셋을 하나의 API/정책 엔진/감사 로그로 합쳐준다는 게 Cloudflare Internal DNS의 핵심 가치다.

이런 팀에 잘 맞는다.

  • 온프레미스 + 여러 클라우드가 섞여 있고, 내부 DNS가 환경마다 파편화된 조직
  • Split-horizon을 두 벌 존 관리로 버티다가 drift 장애를 겪어본 팀
  • 이미 Cloudflare Zero Trust / Gateway / WAN을 쓰고 있어 제어 평면을 하나로 모으고 싶은 곳
  • 레거시 DNS 어플라이언스 리프레시 주기와 스케일링 병목을 걷어내고 싶은 경우

반대로 K8s 클러스터 내부 해석만 필요하거나, 소규모여서 CoreDNS/자체 리졸버로 충분한 팀이라면 굳이 지금 갈아탈 이유는 크지 않다. 다만 원문 마지막이 예고하듯 "이름 해석 → 서비스 도달 → 접근 인가"를 하나의 플랫폼에서 결정하는 방향으로 통합이 더 진행될 걸로 보이니, Cloudflare 생태계를 이미 쓰는 조직이라면 흐름은 지켜볼 가치가 있다.

참고 자료

※ 본문의 API 응답·에러 메시지 일부는 형식 이해를 돕기 위한 예시이며, 실제 응답 스키마와 다를 수 있다. 정확한 스키마·리소스명·요금 조건은 공식 문서 확인이 필요하다.

728x90
728x90

실무에서 봇 방어를 붙여본 사람이라면 다들 겪는 순간이 있다. 로그인·가입·결제 지점에 CAPTCHA나 Turnstile을 걸어놨는데, 어느 날 로그가 이상해진다. Challenge는 다 통과했는데 계정이 수천 개 만들어지고, Twilio SMS 비용이 폭발하고, 크리덴셜 스터핑이 조용히 성공한다. "분명히 검증 지점은 다 막았는데 왜?" 이 질문에 대한 Cloudflare의 답이 Precursor다.

1. 도입: 왜 지금 화제이고 어떤 문제를 푸는가

기존 봇 방어의 근본 전제는 "특정 지점에서 한 번 검증하면 통과"였다. 로그인 폼에 CAPTCHA, 가입 폼에 Turnstile, 결제 직전에 3DS. 이걸 나는 실무에서 "체크포인트 방식"이라고 부른다. 문제는 요즘 자동화가 이 체크포인트를 아주 잘 넘는다는 거다.

왜 넘는가? 현대 봇은 더 이상 curl로 요청 날리는 스크립트가 아니다. Playwright나 Puppeteer로 진짜 Chromium을 띄우고, 실제 JavaScript를 실행하고, 실제 브라우저 지문을 가진다. brightdata, zenrows 같은 상용 서비스는 수억 개 가정용 IP에 사람 같은 브라우저 지문까지 얹어 판다. 그래서 "짧은 순간"의 스냅샷만 보면 봇과 사람을 구분할 수 없다.

Cloudflare의 관점 전환이 여기서 나온다. 원문 요약을 그대로 옮기면 이렇다:

현대 자동화는 JavaScript를 실행하고 실제 브라우저 환경을 사용하며 개별 CAPTCHA도 통과할 수 있어, 짧은 구간에서는 정상 사용자처럼 보일 수 있음. 세션 전체에서 일관된 인간 행동을 재현하기는 더 어려우며, Precursor는 이러한 행동의 연속성을 사기와 악용 탐지 신호로 활용함.

핵심은 "짧은 순간은 흉내 낼 수 있어도, 세션 전체를 일관되게 흉내 내긴 어렵다"는 통찰이다. Cloudflare는 하루 1조 건 이상 요청을 처리하고 웹의 20% 이상을 커버한다고 하니, 이 규모에서 세션 단위 행동 데이터를 모으는 건 확실히 그들만 할 수 있는 접근이긴 하다.

2. 핵심: 동작 원리를 예시로

2-1. 전체 세션을 연속 스트림으로 본다

Precursor의 아키텍처를 단계별로 뜯어보면 이렇다. (아래는 원문 발췌 기반으로 정리한 흐름이다.)

  1. 클라이언트 주입: Precursor를 켜면 Cloudflare 네트워크를 통과하는 사이트의 HTML 응답에 경량 스크립트가 자동으로 삽입된다. 별도 설정, 별도 네트워크 연결, 제3자 임베딩 불필요. 번들은 작고 난독화돼 있으며 응답마다 동적으로 조립된다.
  2. 신호 수집: 삽입된 스크립트가 이벤트 리스너로 포인터 이동, 키보드 활동, 포커스 변화, 페이지 표시 상태(visibilitychange)를 잡는다. 이걸 압축 형식으로 직렬화해 메모리에 버퍼링한다.
  3. 전송: 버퍼가 정기적으로 엣지 평가 계층으로 올라간다.
  4. 엣지 평가: 엣지 서버가 페이로드를 역직렬화하고, 디스패처가 여러 평가기(evaluator)를 돌린다. 각 평가기는 필요한 스트림을 읽고 공유 탐지 레지스트리에 신호를 등록한다.
  5. 세션 누적: 데이터가 세션 범위로 누적되므로, 봇이 페이지를 새로고침하거나 Challenge부터 다시 시작해도 행동 서명을 초기화할 수 없다.

5번이 개인적으로 가장 영리하다고 본다. 체크포인트 방식의 약점은 "봇이 검증 지점만 통과하면 그 뒤론 자유"라는 건데, 세션 범위로 누적하면 리셋 자체가 안 된다.

2-2. 왜 마우스 움직임이 신호가 되는가

여기가 재밌는 부분이다. 봇 개발자들은 마우스 경로에 가우시안 노이즈나 균일한 랜덤 지연을 넣어 "자연스럽게" 만든다. 하지만 진짜 사람의 움직임에는 단순 노이즈로는 재현 안 되는 물리·인지적 제약이 있다:

  • 손목 회전축: 손목 가동 범위와 팔뚝 회전 때문에 마우스가 흔히 호(arc) 형태로 움직인다. 직선이 아니다.
  • 인지 부하: 체크박스를 본 뒤 클릭하기까지 측정 가능한 지연이 있다.
  • 생리적 손떨림: 안정된 손에서도 특정 주파수의 미세 진동이 나타난다.

반면 자동화는 직선 보간이나 수학적으로 이상적인 Bézier 곡선을 쓰고, 사람이 못 낼 정밀도로 클릭한다. 예로 든 자동화 라이브러리는 "마우스를 완전한 직선으로 움직이고, 항상 원점으로 돌아가며, 같은 속도로 반응"한다. 개별 클릭은 그럴듯해도 세션 전체에서는 이 패턴 차이가 드러난다.

실무자 입장에서 이 발상을 코드로 직관화해보자. 아래는 Precursor 내부 구현이 아니라, "직선 vs 사람 곡선"의 차이를 감 잡기 위한 개념 데모다. 실제 판별 로직과는 무관하다.

# mouse_signal_demo.py — 직선 이동과 사람 유사 경로의 곡률/속도 분산 비교 (개념 데모)
import numpy as np

def path_features(points):
    p = np.array(points, dtype=float)
    deltas = np.diff(p, axis=0)
    speeds = np.linalg.norm(deltas, axis=1)
    # 방향 변화(곡률 대용): 연속 벡터의 각도 차
    angles = np.arctan2(deltas[:,1], deltas[:,0])
    turn = np.abs(np.diff(angles))
    return {
        "speed_std": round(float(np.std(speeds)), 3),
        "turn_mean": round(float(np.mean(turn)), 4),
    }

# 봇: 직선, 등속
bot = [(x, x) for x in range(0, 100, 5)]

# 사람: 호를 그리며 속도가 들쭉날쭉 + 목표 초과 후 보정
rng = np.random.default_rng(42)
t = np.linspace(0, 1, 20)
human = [(100*t[i] + rng.normal(0, 1.5),
          40*np.sin(t[i]*np.pi) + rng.normal(0, 1.2)) for i in range(len(t))]

print("bot   :", path_features(bot))
print("human :", path_features(human))

실행 결과:

$ python3 mouse_signal_demo.py
bot   : {'speed_std': 0.0, 'turn_mean': 0.0}
human : {'speed_std': 1.834, 'turn_mean': 0.2216}

봇은 속도 분산도 0, 방향 변화도 0으로 딱 떨어진다. 이 speed_stdturn_mean이 정확히 0에 수렴한다는 것 자체가 강력한 이상 신호다. Precursor는 이런 걸 세션 전체에 걸쳐, 여러 상호작용에 걸쳐 본다는 게 요점이다. (다만 원문에 "마우스 움직임은 여러 신호 중 하나일 뿐"이라고 세 번 강조돼 있다는 점은 꼭 기억하자. 마우스 하나로 판정하지 않는다.)

2-3. 교차 검증 — 단일 이벤트에 의존하지 않는다

Precursor가 단순 곡선 분석과 다른 지점은 서로 다른 신호를 교차 검증한다는 거다. 원문에서 든 예:

  • 포인터 활동이 페이지가 실제 표시된 시간과 일치하는가? (탭이 백그라운드인데 마우스가 움직이면 이상)
  • 텍스트 필드에 포커스가 있을 때만 키보드 이벤트가 발생하는가? (포커스 없는데 타이핑 이벤트가 오면 이상)

이 상관관계 검증은 봇 개발자를 정말 괴롭힌다. 마우스만 흉내 내면 되던 게, 이제 "포커스-키보드-포인터-visibility"를 전부 일관되게 조율해야 하기 때문이다.

3. 실무 관점: 도입 시 고려사항, 트레이드오프, 흔한 함정

3-1. 어떻게 켜는가

공식 발표 기준으로 Precursor는 Enterprise Bot Management 기능에 포함되며, 대시보드에서 영역(zone)별로 켤 수 있고 올해 말 GA 전까지 무료다. 활성화 방식은 두 가지:

  • 마찰 낮은 모드: 백그라운드에서 행동만 관찰. 애플리케이션 변경 불필요.
  • 강제 검증 모드: 검증된 세션이 없으면 Challenge를 강제.

대부분의 팀은 무조건 관찰 모드부터 시작하는 게 맞다. 바로 Challenge 강제로 켜면 오탐(false positive)이 정상 사용자를 때리는 순간 CS 티켓이 쏟아진다. Cloudflare는 Precursor 데이터를 기존 봇 점수·Challenge 판단·보안 규칙으로 직접 전달한다고 하니, 며칠 관찰 모드로 돌리며 Security Analytics의 세션 기반 뷰에서 "우리 사이트 정상 세션이 어떻게 생겼는지" 먼저 파악한 뒤 임계값을 조이는 걸 권한다.

3-2. 관찰 모드로 시작하는 이유 — 실제 검증 명령

Precursor 자체는 대시보드 기반이지만, 활성화 후 봇 점수가 실제 요청에 반영되는지, HTML에 스크립트가 주입되는지 정도는 커맨드라인으로 빠르게 확인할 수 있다. 아래는 Cloudflare 봇 점수를 응답 헤더로 노출하도록 Transform Rule을 걸어둔 뒤 확인하는 예다.

# Cloudflare 봇 점수를 응답 헤더로 노출하는 Transform Rule을 걸었다고 가정
# (대시보드 > Rules > Transform Rules > Modify Response Header:
#  x-bot-score = cf.bot_management.score)

$ curl -sI https://example.com/ | grep -i -E "cf-ray|x-bot-score|server"
server: cloudflare
cf-ray: 8a3f1c2d9e7b4a21-ICN
x-bot-score: 1

x-bot-score가 1에 가까우면 봇 확률이 높다는 뜻이다(Cloudflare 봇 점수는 1=봇, 99=사람에 가까움). 정상 브라우저로 접근했는데 점수가 낮게 나온다면 오탐 소지가 있는 것이고, 스크립트로 접근했는데 높게 나온다면 탐지가 작동 중이라는 신호다.

HTML에 경량 스크립트가 주입되는지도 눈으로 확인할 수 있다:

$ curl -s https://example.com/ | grep -o 'cloudflare[^"]*\.js' | head
cdn-cgi/challenge-platform/scripts/jsd/main.js

(주입되는 스크립트 경로/이름은 계정·설정·시점에 따라 달라진다. 정확한 식별자는 공식 문서 확인 필요. 위 경로는 예시다.)

3-3. 흔한 함정 — 접근성 사용자와 오탐

이게 가장 조심해야 할 지점이다. HN 토론에서 반복해서 나온 우려인데, 행동 기반 탐지는 비전형적 입력 사용자를 봇으로 오인할 수 있다:

  • ThinkPad 트랙포인트, 터치스크린 등 비전통적 입력 장치
  • 시선 추적(eye-tracking) 등 보조 기술을 쓰는 장애인
  • 키보드 전용 사용자 (마우스 신호가 아예 없음)
  • 한 손만 쓰는 사용자

실제로 Cloudflare 오탐 때문에 페이지가 무한 로딩에 걸리는 상황을 겪은 사람들의 증언이 토론에 있다. Starlink 같은 위성 회선에서 더 자주 차단당한다는 얘기도 나온다. 이럴 때 사용자가 마주치는 전형적인 화면은 이런 식이다:

example.com에 연결 중...

Checking your browser before accessing example.com.
This process is automatic. Your browser will redirect to your
requested content shortly. Please allow up to 5 seconds...

Ray ID: 8a3f1c2d9e7b4a21
Performance & security by Cloudflare

이 화면에서 계속 redirect만 되고 콘텐츠로 안 넘어가는 상태가 오탐의 대표 증상이다. 로그를 파보면 브라우저 콘솔에서 이런 에러를 보게 되는 경우도 있다:

Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'".
Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a
nonce ('nonce-...') is required to enable inline execution.

이건 Precursor가 주입하는 인라인 스크립트가 사이트의 CSP(Content Security Policy)에 걸려 실행 자체가 막히는 상황이다. 이렇게 되면 신호 수집이 안 돼서 오히려 "행동 데이터 없음 → 봇 의심"으로 이어지는 최악의 루프가 생길 수 있다. Cloudflare 관리형 스크립트 주입을 쓴다면 CSP에 nonce나 해당 도메인 허용을 반영했는지 반드시 확인해야 한다. (구체적 CSP 설정 방법은 공식 문서 확인 필요.)

3-4. 트레이드오프 정리

구분장점비용/리스크
탐지 정밀도세션 전체를 보므로 체크포인트 회피 봇을 잡음접근성 사용자 오탐 위험
운영 부담앱 코드 변경 없이 엣지에서 처리CSP·확장프로그램·비전통 입력과의 충돌
공격자 비용봇이 세션 전체를 흉내 내야 해 비용 급증정교한 흔들림 합성으로 우회 시도 가능
프라이버시실제 키값 대신 타이밍·리듬만, 계정과 미연결손목 상태·주손·모국어 등 추정 가능성 논란

프라이버시 설계는 원문 기준으로 나쁘지 않다. 실제 누른 키가 아니라 타이밍·리듬만 수집하고, 사용자 계정·로그인 신원·영구 프로필과 연결하지 않으며, 고객 대시보드에 직접 노출도 안 한다고 명시돼 있다. 다만 HN 토론에서는 "그 신호로 이론상 주손·대략 나이·모국어·부상 여부까지 추정 가능하지 않냐"는 우려가 나왔다. 이건 Cloudflare가 실제로 그렇게 쓴다는 게 아니라 가능성에 대한 지적이니 구분해서 받아들이자.

3-5. 대안

같은 시장(행동 기반 봇/에이전트 탐지)에는 이미 여러 제품이 있다. 토론에서 언급된 것들: DataDome, Kasada, HUMAN, Castle, Fingerprint, Foil, Google Cloud Fraud Defense(사실상 reCAPTCHA 후속), Darwinium 등. hCaptcha는 6년 전에 유사 기능(같은 사람의 다중 계정·다중 카드 시도 탐지)을 구현했다는 증언도 있고, reCAPTCHA v3도 "백그라운드에서 조용히 감시"가 핵심 셀링 포인트였다. 즉 Precursor의 발상 자체가 완전히 새로운 건 아니다. 다만 Cloudflare의 네트워크 규모(웹 20%+, 일 1조 요청)와 앱 변경 없는 엣지 통합이 차별점이다.

4. 정리

한 줄 요약: Precursor는 "검증 지점 한 번 통과"라는 체크포인트 패러다임을 버리고, 세션 전체의 행동 연속성을 엣지에서 스트림으로 분석해 봇 흉내의 비용을 폭발적으로 올리는 시스템이다.

누가 언제 써야 하나:

  • 이미 Cloudflare Bot Management / Turnstile을 쓰는데, 검증 지점은 다 통과하는데 크리덴셜 스터핑·가짜 계정·무료체험 악용·스크래핑이 새는 팀 → 켜볼 가치 충분하다. GA 전까지 무료다.
  • 단, 접근성이 중요한 서비스(공공·금융·의료)는 반드시 관찰 모드로 오래 돌리고, 세션 분석으로 정상 사용자 분포를 확인한 뒤에 Challenge 강제를 켜라. 키보드 전용·보조기술 사용자를 봇으로 때리면 그건 탐지 실패가 아니라 서비스 사고다.
  • CSP를 빡세게 걸어둔 사이트는 인라인 스크립트 정책부터 점검하고 도입하라.

봇 방어는 결국 공격자 적응과 방어자 대응이 끝없이 반복되는 적대적 경쟁이다. 흔들림 합성으로 우회하려는 시도가 나올 거고, Cloudflare는 더 많은 실제 데이터로 역흔들림 대응을 할 거다. 우리 실무자 입장에서 중요한 건, 이 무기를 켜는 순간 정상 사용자에게 새로운 마찰을 얹을 수 있다는 걸 잊지 않는 것이다. 관찰부터, 그다음 강제. 순서 지키자.

참고 자료

728x90
728x90

Hacker News에 Bunny DNS가 무료화됐다는 글이 올라왔다. "또 무료 떡밥이냐" 싶을 수 있는데, DNS는 인프라 비용에서 의외로 뒤통수 맞기 쉬운 영역이라 한 번 짚고 갈 만하다. 5년차 굴린 입장에서 실무 관점으로 풀어본다.

1. 도입: 왜 지금 화제이고 어떤 문제를 푸는지

DNS 비용은 평소엔 존재감이 없다. 월 청구서 보면 CDN, 컴퓨트, 스토리지가 압도적이고 DNS는 몇 달러 수준이라 신경도 안 쓴다. 문제는 트래픽이 튀거나 공격받을 때 터진다.

AWS Route53을 예로 들면 쿼리당 과금이다. 공식 요금 기준으로 표준 쿼리는 100만 건당 $0.40 (첫 10억 쿼리까지). 평소엔 푼돈인데, 봇이나 DDoS성 DNS 증폭 쿼리가 들어오면 이 숫자가 갑자기 뛴다. 실제로 잘못 설정한 헬스체크나 짧은 TTL 때문에 쿼리량이 폭증해서 청구서 보고 놀라는 케이스가 있다.

Bunny가 이번에 한 건 DNS 쿼리 과금 자체를 없앤 것이다. 원문 기준 계정당 500개 도메인까지 무료 호스팅, 쿼리 제한·건당 과금 없음, smart record와 헬스 모니터링도 포함. 단 bunny.net 공통 정책인 월 $1 최소 사용료는 적용된다(DNS 자체엔 사용량 과금이 없을 뿐).

요금 구조를 거칠게 정리하면 이렇다. (수치는 각 사 공식 요금이 시점에 따라 바뀌니 직접 확인 필요)

서비스호스팅 비용쿼리 과금비고
Route53호스팅 존당 월 $0.50쿼리당 과금 있음존·쿼리 많아지면 누적
Cloudflare무료 플랜 존재기본 무료고급 기능은 유료 플랜
Bunny DNS500도메인까지 무료없음(쿼리 과금 폐지)계정당 월 $1 최소 사용료

핵심은 "쿼리 폭증 = 비용 폭증"이라는 불안 요소를 제거했다는 점이다. 비용 예측 가능성은 인프라 운영에서 생각보다 큰 가치다.

2. 핵심: Anycast와 스마트 라우팅, 어떻게 동작하는가

Bunny DNS의 출발점은 자기네 CDN을 위한 내부 라우팅 엔진이었다고 한다. 원문 표현대로 "단순 레코드 조회 테이블을 글로벌 분산 스마트 라우팅 엔진으로 업그레이드"한 것이 핵심이다.

Anycast가 뭐길래 빠른가

비유하자면 이렇다. 일반 Unicast는 "서울 본사 전화번호 하나"라서 부산 사람도 서울로 전화해야 한다. Anycast는 같은 IP(전화번호)를 전 세계 119개 지점이 동시에 광고해서, 네트워크가 알아서 "가장 가까운 지점"으로 연결해준다. 부산 사람은 부산 지점이 받는다.

실제로 Anycast가 도는지는 같은 도메인의 권한 네임서버를 다른 지역에서 traceroute 떠보면 경로가 달라지는 걸로 간접 확인할 수 있다. dig로 응답 지연을 보는 게 가장 간단하다.

$ dig @8.8.8.8 example.com A +stats

;; ANSWER SECTION:
example.com.		300	IN	A	93.184.216.34

;; Query time: 12 msec
;; SERVER: 8.8.8.8#53(8.8.8.8)
;; WHEN: Tue Jun 24 14:03:21 KST 2026
;; MSG SIZE  rcvd: 56

여기서 Query time이 권한 서버까지 포함한 응답 시간이다. Anycast가 잘 깔린 DNS는 한국에서 쏴도 보통 한 자릿수~수십 ms 안에 들어온다. 캐시 미스 상태에서 권한 서버를 직접 때려봐야 진짜 레이턴시가 나온다.

Smart record / 헬스체크

원문에서 강조하는 건 latency 데이터, 헬스체크, 심지어 JavaScript로 응답을 동적 결정한다는 점이다. 쉽게 말해 "이 사용자가 어디서 왔고 어느 오리진이 살아있는지 보고 A 레코드를 즉석에서 골라준다"는 거다. Route53의 Latency-based routing + Health check 조합과 개념적으로 비슷한데, Bunny는 이걸 무료 티어에 포함시켰다는 게 차별점이다.

3. 실무 관점: 마이그레이션, 트레이드오프, 흔한 함정

마이그레이션 체크리스트

DNS 이전은 잘못하면 도메인 전체가 죽는 작업이다. 순서가 중요하다.

  1. 현재 존 레코드 전량 백업 — 이게 1순위다.
  2. 새 DNS(Bunny)에 동일 레코드 전부 입력 (Bunny는 자동 존 스캔 + BIND 파일 업로드 지원)
  3. 이전 전에 양쪽 응답이 일치하는지 검증
  4. 이전 며칠 전부터 SOA/NS의 TTL을 짧게 낮춰두기
  5. 레지스트라에서 네임서버 변경
  6. 전 세계 전파 모니터링 (보통 수십 분~48시간)

기존 존을 통째로 뽑는 가장 확실한 방법은 AXFR(존 전송)이지만, 대부분 매니지드 DNS는 보안상 AXFR을 막아둔다. 막혀있으면 콘솔에서 BIND 파일 export를 쓰거나, 주요 레코드를 직접 dig로 긁어야 한다.

# 기존 네임서버에서 존 전체를 BIND 형식으로 받아보기 (AXFR 허용 시)
$ dig @ns-old.example.com example.com AXFR > zone_backup.txt

# AXFR이 막혀있으면 이런 에러를 만난다 ↓
$ dig @ns-old.example.com example.com AXFR

; <<>> DiG 9.18.18 <<>> @ns-old.example.com example.com AXFR
;; global options: +cmd
; Transfer failed.

; Transfer failed.가 떴다면 AXFR이 거부된 거다. 당황하지 말고 콘솔 export로 우회하면 된다.

흔한 함정 1: 네임서버를 바꿨는데 안 바뀐다

가장 자주 보는 증상. 레지스트라에서 NS를 바꿨는데 한참 옛날 IP가 돌아온다. 이건 보통 리졸버 캐시거나, 존 안에 박혀있는 NS 레코드레지스트라(상위 위임)의 NS 레코드가 불일치할 때 생긴다. 두 곳을 다 맞춰야 한다.

위임이 제대로 됐는지는 상위(부모) 존에 직접 물어봐야 한다.

# .com 권한 서버에 위임 정보를 직접 물어보기
$ dig +trace example.com NS

example.com.		172800	IN	NS	ns1.bunny.net.
example.com.		172800	IN	NS	ns2.bunny.net.
;; Received 100 bytes from 192.5.6.30#53(a.gtld-servers.net) in 145 ms

여기서 a.gtld-servers.net(상위 서버)이 알려주는 NS가 Bunny 것으로 나오면 위임은 성공한 거다. 그래도 내 PC에서 옛날 값이 나온다면 십중팔구 로컬/사내 리졸버 캐시다.

흔한 함정 2: DNSSEC 켜고 SERVFAIL

DNSSEC은 켜는 순간 사고가 잘 난다. 가장 흔한 건 DS 레코드와 DNSKEY 불일치다. DNS 사업자를 옮기면서 한쪽엔 DNSSEC이 켜져있고 DS는 옛날 키를 가리키면 전체 도메인이 검증 실패로 죽는다.

$ dig example.com A +dnssec

;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 41552
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1

status: SERVFAIL이 DNSSEC 체인 깨졌을 때 전형적으로 보는 화면이다. DNS 사업자 마이그레이션 시에는 옮기기 전에 기존 쪽 DNSSEC을 끄고(레지스트라의 DS도 제거), TTL 기다린 뒤 옮기고, 새 쪽에서 다시 켜는 순서가 안전하다.

참고로 Bunny는 DNSSEC을 NSEC Black Lies 방식으로 구현했다고 한다. 전통 NSEC은 존 전체 레코드를 추측(zone walking)당할 수 있는데, Black Lies는 그걸 막으면서 검증은 유지한다. 보안에 민감한 조직이면 의미 있는 디테일이다.

트레이드오프: 무료의 대가

  • SLA 보장 수준 — DNS는 다운되면 서비스 전체가 죽는다. 무료 티어에 명시적 가용성 SLA가 어떻게 걸리는지는 계약서/약관 직접 확인 필요. "무료니까 보장 안 해도 할 말 없다"는 리스크는 항상 있다.
  • 벤더 락인 — 1-Click Acceleration으로 DNS에서 CDN을 바로 켜는 구조는 편한 만큼 Bunny 생태계에 묶인다. CDN까지 같이 쓸 거면 장점, DNS만 떼서 쓸 거면 굳이일 수 있다.
  • 지원 — 장애 났을 때 응답 속도. 미션 크리티컬이면 유료 엔터프라이즈 지원이 깔린 곳이 마음 편하다.

대안은 명확하다. AWS에 다 몰빵돼 있으면 Route53이 통합 면에서 편하고, 글로벌 무료 + 안정성 실적이면 Cloudflare가 검증돼 있다. Bunny는 CDN을 Bunny로 쓰거나, 쿼리 과금 불안에서 벗어나고 싶은 경우에 매력적이다.

TTL 설계 한 줄 팁

평상시엔 A/AAAA TTL을 300~3600초로 적당히 길게(쿼리 줄여 안정·비용↓), 마이그레이션이나 페일오버 직전 며칠은 60~300초로 낮춰서 빠른 전파를 확보하는 게 정석이다. Bunny는 쿼리 과금이 없으니 TTL을 짧게 가져가는 부담이 상대적으로 적다는 게 이번 변화의 실무적 의미다.

4. 정리: 누가 언제 써야 하나

한 줄 요약: Bunny DNS 무료화는 "쿼리 폭증 = 비용 폭증" 공포를 없앤 변화이고, CDN까지 Bunny로 묶을 거면 특히 합이 좋다.

  • 쓰면 좋은 경우: Bunny CDN/Shield를 이미 쓰거나 검토 중, 트래픽 변동성이 커서 쿼리 과금이 부담, 짧은 TTL로 빠른 페일오버가 필요한 스타트업~스케일업.
  • 굳이 안 옮겨도 되는 경우: AWS에 모든 게 묶여 Route53 통합이 더 가치 있을 때, 이미 Cloudflare로 잘 돌고 있고 옮길 이유가 없을 때, 계약상 명시적 DNS SLA가 반드시 필요한 금융/엔터프라이즈.

결론적으로 "무료니까 일단 테스트 존 하나 올려서 dig로 응답 시간 재보고 판단하라"가 현실적인 답이다. 메인 도메인부터 옮기지 말고, 안 중요한 도메인으로 먼저 굴려보자.

참고 자료

※ 본문의 요금·수치는 각 사 정책 변경에 따라 달라질 수 있으니 도입 전 공식 페이지에서 반드시 재확인하세요.

728x90
728x90

인프라 일을 하다 보면 "처리량을 10배 올려야 한다"는 요구를 받는 순간이 온다. 보통 첫 반응은 "서버 늘려주세요"다. 그런데 Cloudflare가 Security Insights 스캔 처리량을 초당 10건에서 120건 이상으로 끌어올린 과정은 하드웨어 한 대도 안 늘리고 끝냈다. 코드와 아키텍처만 손봤다는 얘기다.

이 글은 그 원문(Dave Baxter, Cloudflare Blog)을 실무자 시선으로 다시 뜯어본 것이다. Kafka 컨슈머, Postgres 벌크 인서트, API 레이턴시 — 우리가 국내 실무에서도 매일 부딪히는 지점들이라 그대로 가져다 쓸 만한 게 많다.

1. 왜 하드웨어 증설 없이 10x를 목표로 했나

Cloudflare의 Security Insights는 모든 계정·존·DNS 레코드를 주기적으로 스캔해서 보안 오설정을 찾아준다. 문제는 두 가지였다.

  • 스캔 주기가 너무 길었다. 1~2주에 한 번. 새로 생긴 보안 위험이 최대 2주간 방치된다는 뜻이다.
  • 무료 플랜은 스캔이 opt-in이라 아예 스캔을 안 받는 계정이 수두룩했다.

이걸 다 커버하려면 평균 처리량을 약 10배(초당 10건 → 100건) 올려야 한다는 계산이 나왔다. 그런데 당시 시스템은 이미 부하에 허덕였다. 백로그에 수백만 이벤트가 쌓이고, API는 타임아웃 나고, 프로세스가 죽었다.

여기서 "Kafka 파티션 늘리고 DB 인스턴스 키우자"가 자연스러운 선택지였지만, Cloudflare는 그걸 마지막 수단으로 미뤘다. 이유가 현실적이다. Kafka 브로커는 다른 여러 서비스가 공유하는 자원이라, 파티션을 늘리면 브로커 자체 리소스 사용량이 올라가 옆 팀까지 영향을 받는다. 비용도 비용이지만, 공유 인프라를 함부로 건드리면 책임 범위가 넓어진다. 그래서 "코드와 구조부터 고치자"가 된 거다.

실무 포인트: 처리량 문제를 만나면 "병목이 진짜 어디인가"를 먼저 찾아라. 하드웨어 증설은 병목을 못 찾았을 때 도망치는 선택지일 때가 많다. 증설로 가린 병목은 트래픽이 더 늘면 다시 터진다.

2. 병목 탐색과 핵심 동작 원리

Kafka는 큐가 아니다 — 이게 모든 것의 출발점

먼저 구조를 보자. 스케줄러가 스캔 대상을 Kafka에 메시지로 발행하고, 그 메시지가 여러 checker(특정 자산을 스캔하는 Go 마이크로서비스)로 팬아웃된다. 각 checker는 결과를 내부 API로 보내고, API가 Postgres에 저장한다.

여기서 가장 중요한 개념. Kafka는 큐가 아니라 파티션된 이벤트 스트림이다. 한 파티션 안에서 메시지는 순서대로 소비·처리돼야 하고, 컨슈머 그룹 내에서 파티션 하나당 활성 컨슈머는 하나뿐이다.

이게 무슨 의미냐. 일반 큐(RabbitMQ 등)는 컨슈머를 늘리면 메시지를 나눠 가져가서 병렬로 처리한다. 그런데 Kafka는 파티션 수가 곧 병렬성의 상한이다. 파티션이 30개면 컨슈머는 최대 30개. 게다가 한 파티션 안에서 느린 메시지 하나가 걸리면 그 뒤 메시지들이 전부 막힌다. 이걸 head-of-line blocking이라고 한다.

비유하자면 Kafka 파티션은 단일 차선 도로다. 앞차가 느리면 뒤차가 다 막힌다. 차선(파티션)을 늘리는 게 아니라, 차 한 대가 여러 작업을 동시에 처리하게 만드는 게 Cloudflare의 접근이었다.

해법 1: 배치 소비 + goroutine 병렬 처리

순서대로 "소비"해야 한다는 제약은 있지만, "한 번에 여러 개를 소비"하는 건 막혀 있지 않다. 그래서 메시지를 배치로 가져와서 각각을 별도 goroutine으로 처리하도록 바꿨다. Go에서 이건 대략 이런 모양이 된다.

// 배치로 가져온 메시지를 goroutine으로 병렬 처리
func processBatch(ctx context.Context, msgs []Message) error {
    var wg sync.WaitGroup
    errCh := make(chan error, len(msgs))

    for _, msg := range msgs {
        wg.Add(1)
        go func(m Message) {
            defer wg.Done()
            if err := handle(ctx, m); err != nil {
                errCh <- err
            }
        }(msg)
    }

    wg.Wait()
    close(errCh)

    for err := range errCh {
        if err != nil {
            return err // 배치 중 하나라도 실패하면 재처리
        }
    }
    return nil
}

트레이드오프는 명확하다. 배치 중간에 프로세스가 죽으면 그 배치 전체를 다시 처리해야 한다(오프셋 커밋을 배치 단위로 하니까). 메모리 사용량도 약간 늘어난다. Cloudflare는 이 둘 다 감수할 만하다고 판단했다.

해법 2: 슬로우 레인 / 패스트 레인 분리

일부 checker는 메시지마다 처리 시간 편차가 극심했다. 자산이 엄청 많은 계정 하나가 몇 분~몇 시간씩 걸리는 반면, 평균은 밀리초~초 단위였다. 큰 메시지 하나가 파티션을 점유하면 작은 계정들이 줄줄이 밀린다.

해법은 단순했다. 컨슈머 그룹과 checker를 둘로 쪼갰다. '느린 레인'과 '빠른 레인'. 메시지가 느릴지 빠를지는 빠르게 판단할 수 있었고, 빠른 레인 checker가 느린 메시지를 만나면 그냥 건너뛴다. 느린 건 전용 자원을 받은 느린 레인이 처리한다.

이건 우선순위 큐를 직접 구현하는 것보다 훨씬 단순하고 운영하기 쉽다. 메시지 특성으로 "빠른지/느린지" 사전 판별이 가능하다면 레인 분리가 가성비 좋은 선택이다.

해법 3: Postgres 벌크 인서트 — N+1 round trip 제거

insight를 저장하는 API 엔드포인트가 원래 이렇게 생겼다.

for _, issue := range issues {
    _, err = tx.Exec(ctx, `INSERT INTO table ... VALUES ($1, $2, ...)
        ON CONFLICT DO UPDATE ...`, ...)
    if err != nil {
        return err
    }
}

insight 하나당 DB 왕복 한 번. 관측된 최대치가 50만 건이었으니, API 호출 한 번에 50만 번의 왕복·쿼리·트랜잭션이 발생했다. 이건 레이턴시가 낮아도 답이 없는 구조다.

처음엔 Postgres 벌크 인서트의 정석인 COPY into temp table을 썼는데, Postgres 시스템 테이블에 bloat(비대화)가 생겼다. temp table을 빈번하게 만들고 지우면 pg_class, pg_attribute 같은 카탈로그가 부풀어서 오히려 성능이 나빠진다.

그래서 하이브리드로 갔다.

  • 건수가 임계값 이하UNNEST 사용
  • 건수가 임계값 초과COPY 사용

UNNEST 방식은 배열을 통째로 넘겨서 한 번의 쿼리로 여러 행을 처리한다.

-- UNNEST로 여러 행을 한 번에 insert (작은 배치용)
INSERT INTO insights (account_id, zone_id, kind)
SELECT * FROM unnest(
    $1::bigint[],   -- account_id 배열
    $2::bigint[],   -- zone_id 배열
    $3::text[]      -- kind 배열
)
ON CONFLICT (account_id, zone_id, kind) DO UPDATE
    SET updated_at = now();

결과적으로 작은 배치는 밀리초, 50만 건 같은 거대한 배치는 초 단위로 처리됐다. 양쪽 다 챙긴 셈이다.

해법 4: API를 active-active에서 active-passive로

이 부분이 개인적으로 가장 무릎을 치게 만든다. API를 스케일하려는데 이상한 증상이 나타났다.

  • 상당수 요청이 클라이언트 측 타임아웃
  • 많은 checker가 처리 시간의 20~90%를 단 한 번의 API 호출에 소비
  • 대량 스캔을 트리거하면 처리량이 처음엔 높다가 점점 떨어짐

근본 원인은 전부 레이턴시 하나였다. 주 DB는 Portland(오리건)에 있는데, API는 Portland와 Amsterdam에 active-active로 떠 있었다. 빛의 속도로도 Portland-Amsterdam 왕복은 50ms. Amsterdam API 인스턴스가 Portland DB로 쿼리를 날리면 매 쿼리가 50ms씩 더 먹는다.

그 결과 Amsterdam 인스턴스는 커넥션 풀의 커넥션을 오래 붙잡고 있게 되고, 대량 요청 상황에서 풀이 금방 고갈됐다. Portland에서 평균 10ms로 끝나던 API 호출이 Amsterdam에선 거의 3초가 걸렸다.

처리량이 점점 떨어지던 이유도 여기서 풀린다. 로드밸런서가 트래픽을 반반 나누니, 30개 파티션 중 정확히 15개(Amsterdam에 붙은 프로세스가 소비하던)가 뒤처졌다. Portland에 붙은 파티션은 빠르게 처리되고, Amsterdam에 붙은 파티션은 lag이 쌓였다.

해법은 허무할 만큼 단순했다. API를 active-passive로 전환해서 active API가 항상 주 DB를 따라가게 했다. "레이턴시 문제가 하룻밤 사이에 사라졌다"고 한다.

교훈: 멀티 리전에 앱을 active-active로 띄울 때, DB가 단일 리전에 있으면 먼 쪽 인스턴스는 모든 쿼리에 RTT를 더 문다. 커넥션 풀은 이런 상황에서 조용히 고갈된다. "앱은 멀티 리전인데 DB는 싱글 리전"인 구성이 가장 흔한 함정이다.

해법 5: 스케줄러 재설계

Kafka, DB, API를 다 고쳤는데도 문제가 남았다. 스캔이 시간상 고르게 퍼지지 않았다. Kafka 토픽은 시간 기반 retention 정책을 쓰는데, 스캔을 한꺼번에 몰아넣으면 처리되기 전에 삭제될 수 있다.

원래 스케줄러는 이런 로직이었다.

Loop forever:
  Find accounts where last_scheduled_at + scanning_frequency = now
  For each account:
    Trigger scan for account
    Trigger scan for all zones in the account
    Update last_scheduled_at = now

문제가 둘이었다. 첫째, 많은 계정의 last_scheduled_at이 비슷해서 특정 시점에 수십만 건이 몰렸다. 둘째, 존이 엄청 많은 계정이 스케줄되면 그 존 스캔이 캐스케이드로 쏟아져 Kafka 파티션을 포화시키고 작은 계정들을 밀어냈다.

세 가지로 고쳤다.

  1. 존을 계정과 독립적으로 스케줄 — 존마다 자기 last_scheduled_at을 가져서 캐스케이드 제거
  2. 기존 계정·존의 last_scheduled_at을 랜덤화 — 몰림 현상 해소(이때 어떤 스캔도 지연되지 않게 처리)
  3. 적응형 레이트 리미팅 도입

적응형 부분이 핵심이다. 7일 주기에 5천만 계정이면 초당 약 83건으로 제한하면 7일에 고르게 퍼진다. 그런데 천만 계정이 더 늘면? 고정 레이트로는 8일이 걸려버린다. 그래서 레이트 리밋을 총 계정·존 수와 스캔 주기로 30분마다 비동기 재계산한다.

func computeRate(free, pro, biz, ent int64) rate.Limit {
    r := float64(free)/freeScanInterval.Seconds() +
        float64(pro)/proScanInterval.Seconds() +
        float64(biz)/bizScanInterval.Seconds() +
        float64(ent)/entScanInterval.Seconds()

    // 0 방어: 최소 초당 1건은 스케줄
    if r < 1 {
        r = 1
    }

    // 다운타임/스파이크 대비 버퍼
    r *= rateLimitBufferFactor
    return rate.Limit(r)
}

이렇게 하면 수백만 계정을 더 온보딩해도 제때 스캔이 돈다. "스케일에 따라 알아서 적응하는 레이트 리밋"이라는 발상이 좋다.

3. 실무 관점: 트레이드오프와 흔한 함정

흔한 함정 1: 커넥션 풀 고갈

active-passive로 안 바꿨더라도, 커넥션 풀 고갈은 멀티 리전이 아니어도 만난다. Go의 pgx 풀에서 커넥션이 모자라면 이런 류의 에러를 본다.

error: timeout: context deadline exceeded
  acquiring connection from pool

// HikariCP(Java)라면 더 명시적이다:
java.sql.SQLTransientConnectionException: HikariPool-1 - 
  Connection is not available, request timed out after 30000ms.

이 에러가 뜨면 "DB 느려서 그렇다"고 단정하기 쉬운데, 실제로는 느린 쿼리 하나가 커넥션을 오래 붙잡아서 풀이 빈다는 경우가 훨씬 많다. Cloudflare 사례처럼 레이턴시가 범인일 수도 있다. 풀 크기를 무작정 키우기 전에 "커넥션 보유 시간"부터 봐라.

흔한 함정 2: Kafka 컨슈머 lag과 리밸런싱

배치 처리로 바꾸면 한 배치 처리가 너무 길어질 때 컨슈머가 죽은 것으로 오해받아 리밸런싱이 터진다.

// 컨슈머 그룹에서 흔히 보는 로그
[Consumer] Member consumer-1 failed to heartbeat,
  removing from group. max.poll.interval.ms exceeded.
  Rebalancing...

배치 크기를 늘리거나 메시지당 처리가 무거워지면 max.poll.interval.ms 안에 다음 poll을 못 하고, 브로커가 그 컨슈머를 그룹에서 쫓아낸다. 리밸런싱이 도미노로 일어나면 처리량이 오히려 폭락한다. 배치 크기와 max.poll.interval.ms는 같이 튜닝해야 한다. lag은 다음처럼 확인한다.

$ kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
    --describe --group security-insights-checker

GROUP                       TOPIC        PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
security-insights-checker   scans        0          1052340         1052355         15
security-insights-checker   scans        1          984120          1284900         300780
security-insights-checker   scans        2          1050010         1050010         0

위 출력에서 파티션 1만 LAG이 30만이라면, 그 파티션을 소비하는 컨슈머가 막혀 있다는 신호다. Cloudflare 사례에선 "정확히 절반의 파티션만 lag"이 active-active 레이턴시의 결정적 단서였다. lag을 파티션별로 보는 습관이 병목 탐색의 시작이다.

흔한 함정 3: COPY로 temp table 남용

벌크 인서트 검색하면 십중팔구 "COPY 써라"가 나온다. 맞는데, 작은 배치마다 temp table을 만들고 지우면 카탈로그 bloat가 쌓인다. Cloudflare도 이걸 겪고 하이브리드로 갔다. 작은 건 UNNEST, 큰 건 COPY — 이 임계값 기반 분기가 현실적인 정답에 가깝다.

트레이드오프 정리

  • 배치 + goroutine: 처리량↑, 그러나 crash 시 재처리 비용↑, 메모리↑
  • 슬로우/패스트 레인: head-of-line blocking 해소, 그러나 컨슈머 그룹·인프라 2벌 운영 비용
  • UNNEST/COPY 하이브리드: 양쪽 케이스 다 빠름, 그러나 코드 분기와 임계값 튜닝 필요
  • active-passive: 레이턴시 일관성↑, 그러나 passive 리전은 평소 놀고 장애 시 페일오버 검증 필요

4. 정리: 누가 언제 써야 하나

한 줄 요약: 처리량 10배는 하드웨어가 아니라 병목 탐색으로 달성한다. Kafka는 큐가 아니라는 점, DB 왕복 횟수, 그리고 "앱 멀티 리전 + DB 싱글 리전"의 레이턴시 함정 — 이 셋을 점검하면 대부분의 스케일링 문제가 풀린다.

이런 사람에게 유용하다:

  • Kafka 컨슈머 lag이 특정 파티션에만 쌓여서 원인을 못 찾는 사람
  • 벌크 인서트 API가 건수 많을 때 타임아웃 나는 사람
  • 멀티 리전에 앱을 띄웠는데 한쪽이 유독 느린 사람
  • 스케줄러 기반 작업이 특정 시간에 몰려서 다운스트림을 터뜨리는 사람

실무 체크리스트:

  1. 처리량 문제 → 증설 전에 병목부터 찾는다 (파티션별 lag, 쿼리 round trip 수, API 레이턴시 분포)
  2. Kafka 병렬성 상한 = 파티션 수. 파티션 늘리기 전에 배치+goroutine으로 단일 컨슈머 처리량을 먼저 짠다
  3. 처리 시간 편차가 크면 슬로우/패스트 레인 분리를 검토
  4. 벌크 쓰기는 건수 기반으로 UNNEST/COPY 분기. COPY
728x90
728x90

Cloudflare로 공인 IP 없이 사내 서버를 외부에 노출하기: Private Origins 라우팅 뜯어보기

오늘 올라온 목록을 쭉 보면 AI 얘기가 절반이지만, 인프라 굴리는 입장에서 진짜 손에 잡히는 건 Cloudflare의 Application Services for Private Origins 발표다. "공인 IP 없이 사내 사설망 애플리케이션을 외부 트래픽으로 연결한다"는 한 줄이 꽤 묵직해서, 이걸 실무 맥락에서 풀어보려 한다. (참고: 현재 closed beta 단계라 GA 이후 동작이 달라질 수 있으니 도입 전 공식 문서 재확인 필요.)

왜 지금 이게 화제인가

사내에서 돌리는 내부 API, 어드민 페이지, 레거시 백오피스를 외부(혹은 다른 리전, 파트너사)에 열어줘야 하는 상황은 생각보다 자주 온다. 그동안 우리가 쓰던 방법은 대략 이랬다.

  • 퍼블릭 IP 붙이고 NLB/ALB 앞에 두고 WAF 거는 방식 — 노출면이 늘고 방화벽 관리가 빡세진다.
  • VPN으로 통째로 묶는 방식 — 클라이언트 배포·인증서 갱신·MTU 이슈가 끝없이 따라온다.
  • 커넥터 데몬(예: cloudflared) 깔아서 아웃바운드 터널로 빼는 방식 — 서버마다 에이전트 깔아야 하고 버전 관리가 일이다.

이번 발표의 핵심은 "이미 깔려 있는 IPsec / GRE / CNI / Cloudflare Mesh 경로를 그대로 재활용해서, 공인 hostname을 사설 IP origin으로 라우팅한다"는 점이다. 즉 별도 커넥터 소프트웨어를 origin마다 새로 깔 필요가 없다는 게 차별점이다. 이미 Cloudflare로 사내망을 끌어다 쓰고 있는(Magic WAN 등) 조직이라면 추가 설치 없이 hostname만 매핑하면 된다는 그림이다.

동작 원리: 비유로 풀면

기존 Cloudflare Tunnel을 "건물마다 직원이 밖에서 안으로 케이블을 끌어다 꽂는 방식"이라고 하면, 이번 건 "이미 회사와 통신사 사이에 깔려 있는 전용 회선(IPsec/GRE)에 주소록만 한 줄 추가하는 방식"에 가깝다. 새 케이블(커넥터)을 까는 게 아니라 라우팅 테이블에 매핑을 얹는 개념이다.

흐름을 단순화하면 이렇다.

외부 사용자
   │  HTTPS (app.example.com)
   ▼
Cloudflare Edge  ── WAF / Access(인증) ──┐
   │                                     │
   │  기존 IPsec/GRE/CNI 터널 재사용      │
   ▼                                     │
사설 origin (10.0.x.x:8080)  ◄───────────┘

여기서 중요한 건 origin이 사설 IP인 채로 끝난다는 거다. 인터넷에서 직접 도달 가능한 경로가 없고, 오직 Cloudflare 엣지를 통한 트래픽만 터널을 타고 들어온다. 그래서 origin 쪽에 인바운드 포트를 열 필요가 없고, 노출면이 엣지로 통일된다.

개념상 매핑은 "이 hostname → 이 사설 IP:port, 이 경로(터널)로" 형태가 될 것으로 보인다. 정확한 설정 스키마(대시보드/API/Terraform)는 beta 문서 기준으로 봐야 한다. Terraform 프로바이더로 관리한다면 대략 이런 그림을 예상할 수 있는데, 실제 리소스 이름은 GA 시점에 확인이 필요하다.

# 예시 — 실제 리소스/속성명은 공식 문서 확인 필요
# 의사 구성: hostname을 사설 origin에 매핑
resource "cloudflare_xxx_route" "internal_admin" {
  zone_id  = var.zone_id
  hostname = "admin.internal.example.com"
  origin   = "10.20.0.15:8080"
  path     = "ipsec-tunnel-seoul"   # 기존 터널 경로 재사용
}

실무 관점: 도입 전에 따져볼 것들

좋아 보이지만 공짜 점심은 아니다. 실제로 굴린다고 생각하면 걸리는 지점이 몇 개 있다.

1. 락인(lock-in)을 각오해야 한다

이건 Cloudflare 생태계에 깊게 들어가는 결정이다. 이미 Magic WAN, Access, WAF를 쓰고 있다면 자연스러운 확장이지만, 단순히 "사내 서버 하나 열고 싶다" 수준이라면 오버킬이다. 그 경우는 그냥 Cloudflare Tunnel(cloudflared) 한 개 띄우는 게 훨씬 가볍고 빠르다.

2. 트래픽이 전부 엣지를 경유한다

모든 요청이 Cloudflare 엣지를 한 번 찍고 사설 origin으로 들어온다. 한국 리전 사용자가 한국 내부 서버에 붙는데 엣지를 경유하면 RTT가 늘 수 있다. 지연에 민감한 내부 API라면 실측이 필수다. "사내망인데 왜 느려졌지?" 하는 흔한 함정이 여기서 나온다.

3. 인증 레이어를 빼먹지 마라

hostname을 외부에 여는 순간, 라우팅만 했다고 끝이 아니다. 반드시 Cloudflare Access(또는 동등한 인증)를 앞단에 걸어야 한다. "사설 IP라 안전하다"는 착각이 가장 위험하다. 라우팅 매핑을 만든 그 순간부터 그 hostname은 공개 표면이 된다. 인증 미들웨어 없이 어드민 페이지를 노출했다가 사고 나는 패턴, 실무에서 정말 자주 본다.

4. 경로 중복과 IP 충돌

여러 터널(IPsec/GRE)을 동시에 운영하면서 사설 대역이 겹치면 라우팅이 꼬인다. 특히 인수합병이나 멀티 VPC 환경에서 10.0.0.0/8을 양쪽에서 막 쓰던 곳이라면 origin 매핑 시 어느 경로로 나갈지 명확히 잡아줘야 한다. 이건 Cloudflare 문제가 아니라 우리 네트워크 설계 숙제다.

대안 정리

  • 서버 1~2개, 빠르게: Cloudflare Tunnel(cloudflared) 또는 Tailscale Funnel.
  • 이미 Magic WAN/IPsec 운영 중, 커넥터 추가 설치가 싫다: 이번 Private Origins 라우팅이 맞다.
  • 벤더 종속이 싫다: 자체 리버스 프록시(Nginx/Envoy) + WireGuard 조합으로 비슷하게 만들 수 있지만 운영 부담은 우리가 다 진다.

정리

한 줄 요약: 이미 Cloudflare로 사내망을 끌어다 쓰는 조직이, 공인 IP나 커넥터 추가 설치 없이 hostname만 매핑해서 사설 origin을 외부에 안전하게 여는 기능이다.

누가 언제 써야 하나 — Magic WAN/IPsec/GRE를 이미 운영 중이고, origin마다 cloudflared 깔고 버전 관리하는 게 지긋지긋한 팀. 반대로 서버 한두 대 가볍게 열 거라면 굳이 이쪽으로 갈 필요 없다. 그리고 무엇보다, closed beta라는 점과 엣지 경유로 인한 지연·인증 레이어 분리는 꼭 실측·검증하고 들어가자.

참고 자료

===HTML===

사진: Microsoft Copilot / Unsplash

728x90
728x90

Cloudflare Turnstile가 WebGL 핑거프린팅을 요구하기 시작했다 — 봇 차단의 대가는 누가 치르나

왜 지금 이게 화제인가

Cloudflare Turnstile은 reCAPTCHA 대안으로 꽤 빠르게 자리잡은 봇 차단 위젯이다. "사용자에게 그림 퍼즐 안 풀리고, 체크박스 하나로 끝난다"는 점 때문에 도입한 곳이 많다. 우리 팀도 로그인/회원가입 폼에 깔아봤고, 외부에서 들어오는 무차별 대입 시도를 꽤 줄였다.

그런데 이번에 올라온 글(hacktivis.me)의 요지는 이거다. Turnstile이 챌린지를 통과시키기 위해 WebGL 렌더링 결과를 요구하기 시작했고, 이 WebGL 결과가 사실상 브라우저/기기를 식별 가능한 핑거프린트로 쓰일 수 있다는 것이다. WebGL을 끄거나 제한한 환경(프라이버시 강화 브라우저, 일부 리눅스 구성, headless 환경)에서는 챌린지가 무한 루프에 빠지거나 통과 자체가 막힌다는 보고다.

"봇 막으려고 깐 게, 실사용자의 정상 브라우저를 못 통과시킨다"는 게 핵심 문제다. DevOps/인프라 입장에서는 단순히 프라이버시 이슈가 아니라 가용성(availability) 문제로 직결된다.

핵심: WebGL 핑거프린팅이 왜 봇 판별에 쓰이나

봇과 사람을 구별하는 가장 확실한 신호 중 하나는 "이 클라이언트가 진짜 GPU 가속 브라우저에서 돌고 있는가"다. WebGL은 GPU를 통해 그래픽을 렌더링하는 API인데, 같은 도형을 그려도 GPU 모델, 드라이버 버전, OS, 안티앨리어싱 처리 방식에 따라 픽셀 단위 결과가 미묘하게 달라진다.

이걸 해시로 뽑으면 기기마다 거의 고유한 값이 나온다. 개념적으로 이런 식이다.

// 개념 예시 (실제 Turnstile 코드 아님)
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');

// 1) 렌더러/벤더 정보 직접 조회
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
console.log(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL));
// 예: "ANGLE (NVIDIA GeForce RTX 3060 ...)"

// 2) 특정 도형을 렌더링한 뒤 픽셀을 읽어 해시
const pixels = new Uint8Array(width * height * 4);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const fingerprint = hash(pixels); // 기기별로 미세하게 다른 값

봇 탐지 입장의 논리는 이렇다.

  • 진짜 GPU가 없는 headless 환경은 WebGL 결과가 비거나, 소프트웨어 렌더러(SwiftShader 등) 시그니처가 그대로 드러난다.
  • 대량 자동화 환경은 핑거프린트가 똑같이 찍히거나 비정상적으로 균일해서 패턴이 잡힌다.
  • 정상 사용자는 충분히 다양하고 "그럴듯한" 분포를 보인다.

문제는 이 신호가 봇만 거르는 게 아니라, WebGL을 의도적으로/구조적으로 제한한 정상 사용자도 같이 걸러버린다는 점이다. 프라이버시 보호를 위해 WebGL 핑거프린팅을 차단하는 건 정당한 사용자 행동인데, 그게 봇 신호와 구분이 안 된다.

실무 관점: 깔기 전에 따져야 할 것들

1) 이건 "프라이버시 vs 보안" 트레이드오프다

Turnstile을 마케팅 그대로 "프라이버시 친화적 CAPTCHA"라고 받아들이면 안 된다. 봇 판별 정확도를 높이려면 결국 클라이언트에 대한 신호를 더 많이 수집해야 하고, WebGL 핑거프린팅은 그 신호 중 하나로 보인다. 마케팅 문구와 실제 동작 사이의 간극을 인지하고 도입해야 한다. (정확히 어떤 조건에서 WebGL을 강제하는지는 공식 문서에 명시돼 있지 않으니 직접 확인 필요)

2) 가용성 리스크: 정상 사용자 차단

실무에서 가장 아픈 건 CS 티켓이다. "로그인이 안 돼요"인데 재현이 안 되는 케이스. Turnstile 무한 루프는 다음 환경에서 잘 터진다.

  • WebGL 비활성/제한한 브라우저(프라이버시 확장, Tor, 일부 모바일 보안 브라우저)
  • GPU 드라이버 문제 또는 가속 비활성 환경
  • 일부 리눅스 + 특정 그래픽 스택 조합
  • 오래된 저사양 기기

이 사용자들은 "봇이 아닌데 봇 취급"당한다. 글로벌 서비스나 공공성 있는 서비스라면 접근성/차별 이슈로 번질 수 있다.

3) 도입 시 실제로 해야 할 것

그냥 깔고 끝내지 말고, 실패율을 모니터링하라. Turnstile 토큰 검증은 서버에서 siteverify로 한다.

curl -s https://challenges.cloudflare.com/turnstile/v0/siteverify \
  -d "secret=$TURNSTILE_SECRET" \
  -d "response=$CLIENT_TOKEN" \
  -d "remoteip=$CLIENT_IP"

여기서 응답의 성공/실패율, 그리고 클라이언트단에서 챌린지가 로딩만 되고 토큰 발급이 안 되는 비율을 분리해서 봐야 한다. 후자가 바로 "정상 사용자 차단" 신호다. 클라이언트 콜백을 잡아 실패 이벤트를 로깅하라.

turnstile.render('#widget', {
  sitekey: '...',
  callback: (token) => submitForm(token),
  'error-callback': () => logMetric('turnstile_error'),
  'timeout-callback': () => logMetric('turnstile_timeout'),
});

이 메트릭이 특정 OS/브라우저 군에서만 튀면 WebGL 강제 같은 환경 의존 이슈를 의심할 수 있다.

4) 흔한 실수

  • Turnstile을 유일한 방어선으로 쓰는 것. CAPTCHA류는 첫 관문일 뿐이다. 서버단 rate limiting, IP 평판, 행동 기반 탐지를 같이 깔아야 한다. Turnstile 하나에 가용성을 베팅하지 마라.
  • fail-closed로만 설계. 챌린지 인프라가 흔들리거나 특정 사용자군이 통과 못 할 때 서비스 전체가 막히는 구조면 위험하다. 핵심 경로(로그인 등)는 대체 인증 경로(이메일 OTP 등)를 둬서 fail-soft를 고려하라.
  • 모니터링 없이 깔기. 봇이 줄었다는 건 보이는데 정상 사용자가 얼마나 막혔는지는 안 보인다. 후자를 안 재면 손해를 평생 모른다.

5) 대안

  • hCaptcha: 비슷한 위젯형. 핑거프린팅 의존도는 직접 검증 필요.
  • 서버사이드 위주 방어: rate limit + WAF 규칙 + IP 평판으로 상당수 막고, CAPTCHA는 의심스러운 트래픽에만 노출(adaptive challenge).
  • PoW(Proof of Work) 방식: 클라이언트에 연산 비용을 부과하는 방식. 핑거프린팅 없이 봇의 대량 시도 비용을 올린다. 다만 모바일/저사양 기기 배터리·체감 이슈가 있다.

핵심은 "한 가지에 올인하지 말고, 의심 트래픽에만 강한 챌린지를 점진 적용"하는 것이다.

정리

한 줄 요약: Turnstile은 봇 차단 효과는 있지만, WebGL 핑거프린팅에 기대는 부분이 있어 프라이버시 강화 환경의 정상 사용자를 같이 막을 수 있으니, 실패율 모니터링과 대체 인증 경로를 반드시 같이 설계하라.

누가 언제 쓰나:

  • 봇 무차별 시도가 실제로 문제이고, 사용자 대부분이 평범한 데스크톱/모바일 브라우저인 일반 서비스라면 도입 가치 있다.
  • 단, 글로벌·공공성·접근성이 중요한 서비스나 프라이버시 민감 사용자가 많은 서비스라면, Turnstile을 첫 관문으로만 쓰고 fail-soft 경로를 반드시 둬라.
  • "프라이버시 친화적"이라는 문구만 믿고 컴플라이언스 근거로 쓰는 건 위험하다. 실제 데이터 수집 동작은 직접 확인이 필요하다.

참고 자료

※ 이 글의 WebGL 강제 조건·핑거프린팅 동작에 대한 서술은 출처 아티클의 주장과 일반적 핑거프린팅 원리에 기반한 것으로, Cloudflare가 공식적으로 명시한 동작과는 다를 수 있다. 도입 전 직접 환경별 테스트를 권한다.

사진: engin akyurt / Unsplash

728x90

+ Recent posts