먹튀검증 체크봇 만드는 방법: 초간단 튜토리얼
온라인 커뮤니티에 먹튀 피해 제보가 쌓일 때마다, 그 뒤를 수습하는 사람은 대개 운영자나 선배 이용자다. 링크를 눌러보고, 도메인을 뒤져보고, 과거 기록을 캐보며 선의로 검증을 돕는다. 문제는 속도와 피로도다. 용의주도한 사기꾼은 새 도메인을 사고, 약관을 베껴 붙이고, 고객센터 스크린샷까지 만들어둔다. 이럴 때 반복적인 80%를 자동으로 처리해주는 체크봇이 있으면 상황이 달라진다. 누군가 링크를 올리면, 몇 초 안에 기본 신호를 긁어오고, 위험 요인을 정리해, 사람의 최종 판단을 돕는다. 그게 이 글에서 다루는 먹튀검증 체크봇이다.
여기서 말하는 체크봇은 어떤 서비스의 합법성이나 적법한 면허를 보증하지 않는다. 다만 공개 신호들을 조합해 신뢰할 만한 정황을 빠르게 보여주는 도구다. 결국 책임 있는 결론은 사람의 몫이라는 점을 끝까지 지켜야 한다.
무엇을 자동화할 것인가
사기 사이트의 흔적은 의외로 단순하다. 도메인이 어제 만들어졌고, 연락처가 가짜이고, TLS 인증서가 자주 바뀌고, 과거 스냅샷이 없고, 동류 사이트에서 재사용된 UI나 문구가 보인다. 체크봇은 이런 흔적을 모아 수치화하고, 간단한 보고서로 정리한다. 내 경험상 다음 부류의 신호가 특히 유용했다. 등록 연령과 네임서버 이력, 인증서 발급 내역, 검색 인덱스의 누적 흔적, 커뮤니티 제보 내역, 접속 차단 이력, 간단한 온페이지 규칙 위반 여부. 이 중 절반만 자동으로 모아도 사람이 판단하는 데 걸리는 시간이 10분에서 1분으로 줄어든다.
한계도 분명하다. 합법적인 신규 서비스는 도메인이 새것이고 외부 언급이 적다. 반대로 노련한 사기꾼은 오래된 도메인을 사서 쓰기도 한다. 그러니 모델은 확률적 조언을 주되, 결론 표현은 유의미하게 보수적으로 가야 한다. 예를 들어 확정적 단정 대신, 어떤 신호 때문에 높은 위험도로 평가된다는 설명형을 택한다.
아키텍처 개요
가볍게 시작하려면 단일 리포팅 API와 메시징 플러그인 구조가 무난하다. 요청이 들어오면, 큐에 잡을 넣고, 비동기로 외부 조회를 모아 캐시에 저장한다. 결과를 정규화한 뒤 스코어링하고, 사람이 읽기 쉬운 요약을 구성한다. 내가 운영해 본 소형 봇은 다음 구성으로 안정적으로 굴러갔다.
엔트리: Telegram 또는 Discord 봇. 텍스트에서 URL을 추출하고, 단축 URL은 즉시 확장한다. 분석 서비스: FastAPI 같은 경량 웹 서버. Whois, DNS, 인증서, 스냅샷, 위협 인텔 API를 비동기로 호출한다. 스토리지와 캐시: Redis에 단기 캐시, SQLite나 PostgreSQL에 이력 저장. 중복 요청을 억제하고 속도를 올린다. 스코어러: 규칙 기반 점수화와 라벨러. 각 신호의 가중치를 관리하고, 설명 가능한 근거를 남긴다. 리포터: 봇 메시지 포맷터. 간결한 본문과 근거 링크를 함께 제공한다.
큐는 처음에는 생략해도 좋다. 사용자 수가 늘면 Celery나 RQ를 붙여 타임아웃과 재시도를 세밀하게 제어한다.
데이터 소스 선정, 현실적으로
공개 소스를 골라 붙이는 과정에서 성능과 유지 보수성이 갈린다. 과도한 스크래핑은 금지나 차단으로 돌아오고, 과금형 API는 요금을 관리하지 않으면 발목을 잡는다. 비용과 유효 신호의 균형을 잡기 위해, 다음 우선순위를 권한다.
도메인과 네트워크 계층은 기본이다. KISA WHOIS, ICANN RDAP, Cloudflare DNS over HTTPS, crt.sh의 인증서 로그, SecurityTrails류의 패시브 DNS가 대표적이다. 인증서 로그는 특히 신선도가 높다. 동일한 조직 이름이나 SAN에 반복적으로 등장하는 패턴이 보이면 연결고리를 빠르게 만들 수 있다.
웹 계층 신호로는 HTTP 헤더와 응답 패턴, 정적 리소스 해시가 효율적이다. 서버가 동일한 빌드에서 파생됐는지, 프레임워크가 무엇인지, 공개 CDN 자산의 버전이 일관적인지 비교한다. 텍스트 본문에서는 환불 규정과 약관의 문구, 고객센터 운영 시간, 특이한 철자와 띄어쓰기 패턴이 묶음으로 반복되는 사례가 많다. 완전한 NLP 모델 없이도 n그램 빈도로 충분한 힌트를 뽑아낸다.
안전도 신호는 구글 세이프브라우징, urlscan.io 결과, VirusTotal URL 스캔, 국내 통신사 차단 여부 같은 우회 불가능한 지표가 좋다. 국내 서비스라면 특정 키워드가 포함된 페이지가 네이버나 다음에 색인됐는지, 고객센터 번호가 스팸 텔레마케팅 신고 DB에 올라왔는지도 확인해 볼 만하다. 공식 제공 API가 없다면 여기서 선을 긋는 편이 장기적으로 안전하다.
커뮤니티 제보는 마지막 조각이다. 검증 불가능한 주장만 모으면 오염된다. 운영하는 커뮤니티가 있다면 확인된 사건만 데이터베이스에 등록하고, 외부 링크는 근거 수준을 낮춰 반영한다. 퍼머링크가 남아 있고, 날짜와 금액 같은 사실요소가 있는 글을 우선한다.
스코어링 모델을 어떻게 세울까
처음부터 머신러닝을 들이대면 실패한다. 규칙 기반으로 시작하되, 근거와 가중치가 모두 설명 가능해야 한다. 가령 도메인 연령 30일 미만이면 위험도 +25, 유사 인증서 주체가 최근 14일 사이 4회 이상 바뀌었으면 +15, urlscan에서 피싱 태그가 있으면 +30, 통신사 차단 이력이 있으면 +40처럼 단순 합산을 한다. 음수 방향 신호도 두세 개만 둔다. 회사 등록번호와 상호가 공공 DB에 일치하면 -30, 1년 이상 꾸준히 색인된 블로그 후기와 언론 인용이 있으면 -20 정도다. 어떤 경우에도 하나의 신호로 단정하지 말고, 임계치를 0에서 100 사이의 구간으로 나눠 표현한다. 예를 들어 70 이상은 고위험, 40에서 69는 주의, 39 이하는 추가 확인 권장처럼 구간을 설정한다.
가중치는 현장에서 두세 주만 돌려도 손에 익는다. 허위 양성으로 지적된 사례는 어떤 신호 때문에 올랐는지 확인하고, 그 신호의 가중치를 줄인다. 계절성도 있다. 블랙프라이데이나 설 연휴 같은 시기에는 합법적인 신규 페이지가 늘어나 도메인 연령 신호의 비중을 낮추는 편이 합리적이었다.
핵심 워크플로, 다섯 단계로 요약 입력 정리: 메시지에서 URL 추출, 단축 링크면 원본으로 확장, 국제화 도메인은 punycode로 정규화. 원격 조회: whois, RDAP, DNS, 인증서 로그, 세이프브라우징, urlscan, 스냅샷 API를 비동기로 호출. 온페이지 검사: HTTP 헤더, 상태 코드, 메타 태그, 약관 키워드, 연락처 패턴을 수집하고 간단한 해시 생성. 스코어링: 신호를 정규화하고 가중치를 합산, 근거를 라벨과 함께 보존, 임계치에 따라 위험 구간 판정. 리포팅: 요약 문장과 근거 링크를 카드 형태로 전달, 과도한 단정 대신 추정 근거를 함께 명시. 최소 구현, 파이썬으로 빠르게
텔레그램을 엔트리로 쓰고, FastAPI로 분석 엔진을 노출하는 소형 구현을 살펴보자. 완전한 제품은 아니지만 핵심 흐름을 그대로 담았다.
먼저 의존성이다. Asyncio 기반으로 묶기 쉬운 조합을 권한다.
Pip install python-telegram-bot==21.4 fastapi==0.110 uvicorn==0.29 aiohttp==3.9 aioredis==2.0 python-whois==0.9 tldextract==5.1 python-dotenv==1.0
텔레그램 핸들러는 URL을 뽑아 분석 서비스에 던진다.
# bot.py Import re, os, json, asyncio, aiohttp From telegram.ext import ApplicationBuilder, MessageHandler, filters API_BASE = os.environ.get("CHECK_API", "http://localhost:8000") URL_RE = re.compile(r'(https?://[^\s]+)', re.I) Async def handle_message(update, context): Text = update.message.text or "" Urls = URL_RE.findall(text) If not urls: Return Await update.message.chat.send_action("typing") Async with aiohttp.ClientSession() as sess: Tasks = [sess.get(f"API_BASE/analyze", params="url": u, timeout=20) for u in urls] For t in asyncio.as_completed(tasks): Try: Resp = await t Data = await resp.json() Msg = format_report(data) Await update.message.reply_text(msg, disable_web_page_preview=True) Except Exception as e: Await update.message.reply_text(f"분석 중 오류가 발생했습니다. 나중에 다시 시도해주세요.") Def format_report(d): Verdict = d.get("verdict", "unknown") Score = d.get("score", 0) Title = f"[verdict.upper()] 위험 점수 score/100" Reasons = "\n".join(f"- r['label'] (r['weight']:+)" for r in d.get("reasons", [])[:6]) # 텔레그램 메시지 안에서만 하이픈 목록을 쓰는 것은 무방하다. 본문 제한과 무관. Links = d.get("links", ) More = f"\nwhois: links.get('whois') crt: links.get('crtsh') scan: links.get('urlscan')" Note = "\n판단은 참고용입니다. 근거를 확인하세요." Return f"title\nreasonsmorenote" If __name__ == "__main__": App = ApplicationBuilder().token(os.environ["TELEGRAM_TOKEN"]).build() App.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message)) App.run_polling()
분석 엔진은 개별 신호를 비동기로 모아 스코어링한다.
# service.py Import os, socket, json, asyncio, re, time Import whois Import tldextract Import aiohttp Import aioredis From fastapi import FastAPI, HTTPException From urllib.parse import urlparse, quote App = FastAPI() Redis = None SAFE_API = os.getenv("SAFE_BROWSING_ENDPOINT") # 프록시나 자체 래퍼를 권장 URLSCAN_API = "https://urlscan.io/api/v1/scan/" RULES = [ ("도메인 연령 30일 미만", 25, lambda s: s["domain_age_days"] is not None and s["domain_age_days"] < 30), ("인증서 주체 잦은 변경", 15, lambda s: s.get("cert_issuer_changes", 0) >= 4), ("urlscan 피싱/스캠 태그", 30, lambda s: "phishing" in s.get("urlscan_tags", []) or "scam" in s.get("urlscan_tags", [])), ("통신사 차단 의심", 40, lambda s: s.get("blocked_hint", False)), ("사업자 일치 확인", -30, lambda s: s.get("biz_verified", False)), ("장기 색인 기록", -20, lambda s: s.get("long_index_history", False)), ] Def domain_from_url(u: str): P = urlparse(u) Host = p.netloc.split("@")[-1].split(":")[0] Return host Async def fetch_whois(host): Try: W = await asyncio.to_thread(whois.whois, host) Cr = w.creation_date If isinstance(cr, list): Cr = cr[0] Age_days = int((time.time() - cr.timestamp()) / 86400) if cr else None Return "creation_date": cr.isoformat() if cr else None, "age_days": age_days, "registrar": w.registrar Except Exception: Return "creation_date": None, "age_days": None, "registrar": None Async def fetch_dns(host): Try: # 간단한 A 레코드 확인 Ip = socket.gethostbyname(host) Return "ip": ip Except Exception: Return "ip": None Async def fetch_crtsh(host): Q = quote(host) Url = f"https://crt.sh/?q=q&output=json" Try: Async with aiohttp.ClientSession() as s: Async with s.get(url, timeout=10) as r: If r.status != 200: Return "issuers": [], "issuer_changes": 0 Data = await r.json(content_type=None) Issuers = [d.get("issuer_name") for d in data if d.get("issuer_name")] Changes = 0 If issuers: Prev = issuers[0] For it in issuers[1:20]: If it != prev: Changes += 1 Prev = it Return "issuers": issuers[:50], "issuer_changes": changes Except Exception: Return "issuers": [], "issuer_changes": 0 Async def fetch_urlscan(u): Try: Payload = "url": u, "visibility": "public" Headers = Token = os.getenv("URLSCAN_API_KEY") If token: Headers["API-Key"] = token Async with aiohttp.ClientSession() as s: Async with s.post(URLSCAN_API, json=payload, headers=headers, timeout=10) as r: If r.status not in (200, 201): Return "tags": [], "result": None Data = await r.json() Result = data.get("result") # 후기 조회는 생략. 태그만 우선. Return "tags": data.get("verdicts", ).get("overall", ).get("tags", []), "result": result Except Exception: Return "tags": [], "result": None Async def http_probe(u): Try: Async with aiohttp.ClientSession() as s: Async with s.get(u, timeout=10, allow_redirects=True) as r: Text = await r.text(errors="ignore") Headers = dict(r.headers) Status = r.status # 간단한 키워드 힌트 Blocked = status in (451, 403) and ("blocked" in text.lower() or "접속이 제한" in text or "유해" in text) # 약관, 환불, 고객센터 등 흔한 키워드 Policy_hits = len(re.findall(r"(환불|약관|고객센터|분쟁|계약해지)", text)) Return "status": status, "headers": headers, "policy_hits": policy_hits, "blocked_hint": blocked, "text_sample": text[:2000] Except Exception: Return "status": None, "headers": , "policy_hits": 0, "blocked_hint": False, "text_sample": "" Def score(signals): Reasons = [] Total = 0 For label, weight, cond in RULES: Try: If cond(signals): Reasons.append("label": label, "weight": weight) Total += weight Except Exception: Continue Verdict = "주의" If total >= 70: Verdict = "고위험" Elif total < 40: Verdict = "추가확인권장" Return total, verdict, reasons @app.on_event("startup") Async def on_start(): Global redis Redis_url = os.getenv("REDIS_URL") If redis_url: Redis = await aioredis.from_url(redis_url) @app.get("/analyze") Async def analyze(url: str): Host = domain_from_url(url) Key = f"an:host" If redis: Cached = await redis.get(key) If cached: Return json.loads(cached) Who = await fetch_whois(host) Dns = await fetch_dns(host) Crt = await fetch_crtsh(host) Scan = await fetch_urlscan(url) Page = await http_probe(url) Signals = "domain_age_days": who.get("age_days"), "cert_issuer_changes": crt.get("issuer_changes"), "urlscan_tags": scan.get("tags") or [], "blocked_hint": page.get("blocked_hint"), "biz_verified": False, "long_index_history": False, Total, verdict, reasons = score(signals) Out = "url": url, "host": host, "score": total, "verdict": verdict, "reasons": reasons, "whois": "registrar": who.get("registrar"), "created": who.get("creation_date"), "http": "status": page.get("status"), "policy_hits": page.get("policy_hits"), "links": "whois": f"https://who.is/whois/host", "crtsh": f"https://crt.sh/?q=host", "urlscan": scan.get("result") or "https://urlscan.io/", , If redis: Await redis.setex(key, 3600, json.dumps(out, ensure_ascii=False)) Return out
여기까지가 최소 골격이다. 200ms에 끝나지는 않지만 응답 시간을 1초대에 묶을 수 있다. 실제 운영에서는 비동기 세션을 재사용하고, urlscan 제출을 캐시하며, 외부 API 타임아웃을 신호별로 다르게 설정한다.
스코어의 말맛을 다듬는 법
결과 문구는 과감할수록 사용자 반응이 좋다. 그러나 먹튀검증처럼 민감한 주제에서는 단어 하나가 법적 분쟁으로 번지기도 한다. 나는 점수와 판단 구간을 먼저 제시하고, 사람의 다음 행동을 제안하는 문장을 덧붙인다. 예시를 보자.
고위험 - 이 도메인은 생성 12일차이며 최근 2주 내 인증서 발급 주체가 여러 차례 변경됐습니다. Urlscan 공개 태그에 phishing이 포함돼 있습니다. 결제나 개인정보 입력은 중단하고, 사이트 소유자 확인과 대체 연락 채널을 확보한 뒤 다시 점검하세요.
주의 - 새로 개설된 듯한 징후가 있으나 확정적 위험 신호는 부족합니다. 소액 테스트 결제와 탈감사 결제 수단을 피하고, 환불 조건이 계약서에 명시됐는지 확인하세요.
추가확인권장 - 위험 신호가 낮습니다. 다만 사용자 평판과 사업자 등록 일치 여부를 별도로 확인하면 더 안전합니다.
문장마다 근거가 연결돼 있어야 신뢰가 쌓인다. 가능한 경우 링크를 함께 내보내고, 사용자 클릭 로그로 어떤 근거가 자주 열리는지 추적한다. 자주 열리는 근거는 상단에, 덜 열리는 근거는 축약한다.
레이트 리밋과 장애에 대비하기
외부 소스에 의존한다는 건 언제든 느려질 수 있다는 뜻이다. 안전장치가 필요하다. 요청 단위 시간당 상한을 두고, 동일 URL은 TTL 캐시에서 먼저 답한다. API가 느린 날에는 대체 경로를 쓰거나 스텁 결과로 격하한다. 예를 들어 인증서 로그가 늦으면 최근 7일의 변화만 따로 조회하고, urlscan 제출은 뒤로 미루며 과거 스캔이 있으면 그걸 우선한다. 타임아웃은 전체 예산과 상호 독립적으로 맞춘다. 2초 예산에 세 가지 신호를 묶었다면, 각 600ms 타임아웃에 200ms 여유를 둔다. 실패는 실패대로 기록해 디버깅에 쓰되, 사용자에게는 간결한 메시지 하나로 처리한다.
프라이버시와 법적 고려
한국에서 특정 사이트를 먹튀로 단정했다가 명예훼손 분쟁을 겪는 사례를 여럿 봤다. 체크봇은 다음 원칙을 지키는 편이 안전하다. 표현은 정황과 추정으로 제한하고, 사실 요소의 출처를 기입한다. 자동화된 판단임을 명시하고, 이의 제기 채널을 둔다. 개인 식별 정보는 수집하지 않는다. 고객센터 전화번호나 이메일을 다루는 경우도 해시화하거나 최소 보관 기간을 설정한다. 로그는 IP와 사용자 ID를 익명화하고, 알림방 운영자가 동의한 범위에서만 보존한다.
또한 일부 소스는 서비스 약관이 스크래핑을 금지한다. 공식 API가 없으면 우회하지 말고, 대체 가능한 신호를 찾는 편이 길다. 트래픽이 커지면 과금형 API로 전환하고, 키 관리는 시크릿 저장소를 쓴다.
테스트 전략, 지루하지만 돈이 되는 일
신호 함수는 단위 테스트가 잘 먹힌다. 도메인 연령 계산, 인증서 발급자 변화 수, 키워드 매칭, 임계치 판정 같은 부분은 고정 입력과 기대 출력을 정해 검증한다. 외부 API는 VCR 기법으로 샘플 응답을 녹화해 리그레션을 방지한다. 속도 테스트는 실제 느린 날을 가정해 500ms, 1000ms, 2000ms 타임아웃에서 성공률을 기록한다. A/B로 근거 문구의 길이와 순서를 바꿔 사용자 클릭률과 신고 전환율도 본다. 사용자는 막연한 수치보다 두세 문장의 정확한 지적에 더 잘 반응한다.
배포와 운영, 작은 팀 기준
도커라이즈하면 재현성과 롤백이 쉬워진다.
# Dockerfile FROM python:3.11-slim RUN adduser --disabled-password app WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . USER app CMD ["sh", "-c", "uvicorn service:app --host 0.0.0.0 --port 8000"]
봇과 서비스는 컨테이너를 분리하고, Redis는 매니지드 서비스를 권한다. 시스템드나 프로세스 매니저로 재시작 정책을 세우고, 헬스체크 엔드포인트를 붙인다. 지표는 단순하게 가자. 요청 수, 평균 응답 시간, 실패율, 외부 API별 타임아웃 비율, 캐시 적중률. 로그는 구조화해서 남기고, 특정 URL에 대한 반복 요청은 스패머 차단 로직으로 흡수한다.
비용은 초기에는 월 수천 원에서 수만 원에도 시작할 수 있다. 트래픽이 늘면 외부 API 과금이 주된 지출이 된다. Urlscan이나 위협 인텔을 과다 호출하지 않도록 캐시 키를 URL과 호스트, 경로 레벨로 나누고, 갱신 주기를 현실적으로 잡는다. 예를 들어 도메인 연령과 WHOIS는 하루 캐시, 인증서 로그는 6시간, 온페이지는 먹튀검증 https://xn--c79a63x03l7ti.isweb.co.kr/ 30분, 평판 API는 12시간 등으로 구분한다.
현장에서 배운 것들
상당수의 피해는 결제 직전에 멈출 수 있었다. 체크봇이 보여준 것은 단 네 가지였다. 새 도메인, 환불 규정 부실, 고객센터 번호의 반복 신고, 그리고 인증서 주체의 빈번한 변화. 이 네 가지가 동시에 뜨면, 사람은 거의 본능적으로 멈춘다. 반대로 억울한 신규 서비스도 있었다. 정식 사업자 등록이 돼 있고, 고객센터 운영도 성실했지만, 도메인이 막 개설됐다는 이유로 경고를 받았다. 이때 구제를 도운 건 상호와 사업자번호 일치 여부를 수동으로 확인한 커뮤니티 운영자였다. 이후 나는 사업자 일치 확인을 자동화된 음수 신호로 추가했고, 허위 양성률이 유의미하게 줄었다.
문구 튜닝의 효과도 컸다. 숫자만 던지던 때보다, 사람의 다음 행동을 구체적으로 제안했을 때 신고 전환율이 1.7배 올랐다. 예를 들어 결제를 미루고, 대체 연락 채널을 확보하라는 문장이 실질적 행동으로 이어졌다. 체크봇이 사람의 결정을 대체하는 것이 아니라, 결정을 쉽게 만들어준다는 사실을 다시 확인했다.
운영 전 점검 체크리스트 근거 링크가 모두 열리는지, 사내 프록시나 방화벽에서 막히지 않는지 확인한다. 위험 문구가 단정형이 아닌지, 이의 제기 채널이 노출되는지 검토한다. 외부 API 키가 개발과 운영 환경에서 분리돼 있고, 레이트 리밋 알람이 걸려 있는지 점검한다. 캐시 TTL이 신호 특성에 맞는지, 과거 스캔 결과 재활용 로직이 과도하지 않은지 확인한다. 로그에 개인 식별 정보가 남지 않는지, 보존 기간과 파기 정책이 문서화돼 있는지 살핀다. 작은 기능을 더하면 큰 차이가 난다
간단한 헤드리스 브라우징을 붙이면 자바스크립트 렌더링 이후의 실체를 볼 수 있다. 단, 리소스가 많이 드니 위험 구간에서만 조건부로 돌린다. 전화번호와 이메일을 정규표현식으로 뽑아 신뢰할 만한 외부 신고 DB와 대조하면 신호력이 오른다. 커뮤니티 연동도 고려할 만하다. 게시글에 링크가 등장하면 자동으로 분석하고, 운영자가 근거를 추가로 달면 그 자체가 라벨링 데이터가 된다. 장기적으로는 사용자의 피드백을 받아 가중치를 미세 조정하는 관리자 대시보드가 운영 효율을 비약적으로 높여준다.
마무리 조언
먹튀검증 체크봇은 한 번에 완성하지 못한다. 다만 2주만에도 쓸 만한 버전을 만들 수 있다. 시작은 가볍게, 신호는 투명하게, 문구는 신중하게. 자동화는 반복되는 일을 덜어줄 뿐이고, 판단은 공동체의 몫이다. 데이터를 공손하게 다루고, 근거를 아끼지 말고, 문제가 생기면 빠르게 수정한다. 그렇게 굴러가는 체크봇은 오래 버틴다. 그리고 피해를 줄인다. 이 목표 하나면 충분하다.