얼마 전 여기 올렸던 익명 커뮤니티(노딩, https://news.hada.io/topic?id=32285)를 운영하면서 3주 동안 붙잡고 있던 문제 하나를 정리해 봅니다. 결론이 "UA 목록을 잘 짜라"가 아니라 "계정이 언제 생기는가"로 끝나서, 비슷한 구조로 서비스 만드는 분들께 참고가 될 것 같아서요.
■ 증상
어느 날부터 신규 가입 목록에 이상한 계정이 섞이기 시작했습니다.
- 매일 12:00, 21:00 정각에 1~2명씩
- 로케일이 전부 en (한국 서비스인데)
- 가입 후 행동 0 — 방 방문 0, 글 0, 30초 뒤 소켓 종료
- 어트리뷰션은 전부 web_share (공유 링크를 타고 웹앱에 들어온 세션)
12:00과 21:00은 우리가 스레드(Threads)에 자동 발행 크론을 도는 시각이었습니다. 발행 글에는 방으로 바로 들어가는 공유 링크가 붙습니다. 즉, 링크를 올리면 어떤 크롤러가 미리보기를 만들려고 링크를 가져가고, 그 과정에서 웹앱이 실행되고, 웹앱은 부팅하면서 소켓을 열고, 서버는 소켓의 userSetup을 받아 유저를 만듭니다. 사람이 아니라 크롤러가 회원가입을 하고 있었던 겁니다.
왜 웹앱이 부팅하자마자 계정을 만들었냐면 — 앱을 먼저 만든 설계라 그랬습니다. 네이티브 앱에선 "켜자마자 익명 세션 생성 → 약관 화면 → 동의"가 자연스럽고, 사람은 어차피 1초 뒤에 동의를 누르니까 순서가 문제 될 일이 없었습니다. 그 코드를 웹으로 그대로 export하면서, 웹에서는 "부팅하는 주체가 사람이 아닐 수 있다"는 전제가 깨졌습니다.
■ 1차: 크롤러 UA를 막자 (8/4)
facebookexternalhit, Twitterbot, Slackbot… 미리보기 크롤러 UA 목록을 만들어, 그 UA면 공유 링크에서 웹앱으로 리다이렉트하지 않고 OG 메타 HTML만 주게 했습니다. 크롤러는 그걸로 충분하니까요.
→ 유령이 계속 생겼습니다. 로그를 보니 공유 링크도, 단축 링크도 크롤러 UA로는 호출되지 않았습니다. 못 찾은 경로가 있다는 뜻이었죠.
■ 2차: 생성 지점에서 막자 + "UA가 없으면 봇" (8/7)
경로를 하나씩 쫓는 대신 마지막 관문인 userSetup에서 판정하기로 했습니다. 그리고 "정상 브라우저는 반드시 UA를 보내니까, UA가 없으면 봇으로 본다"는 규칙을 넣었습니다. 웹 OG 크롤러 기준으로는 맞는 말입니다.
→ 이게 사고였습니다. 네이티브 앱의 socket.io 클라이언트는 User-Agent 헤더를 아예 보내지 않습니다. 결과적으로 8/8~8/11 나흘간 네이티브 신규 설치가 전부 차단됐고, 시뮬레이터로 신규 설치를 테스트하다가 ua='undefined' 차단 로그를 보고 알았습니다. 유령 몇 명 잡으려다 진짜 신규 유저를 나흘 동안 막은 겁니다. (이후 "UA가 있을 때만 판정"으로 수정)
여기서 제목의 질문에 대한 답: 빈 UA가 봇이라는 건 "웹 브라우저 컨텍스트"에서만 참입니다. 같은 서버가 웹과 앱 소켓을 같이 받는다면 빈 UA는 봇의 신호가 아니라 그냥 앱입니다. 판정 규칙은 클라이언트 종류마다 따로 서야 합니다.
■ 그래도 12시·21시 유령은 계속 (8/12~8/16)
UA 판정을 고친 뒤에도 정각 유령은 계속 나왔습니다. 로그를 초 단위로 맞춰 보니 발행 15~30초 뒤에 30초짜리 소켓 세션이 생겼다 사라지고, 그 세션의 UA는 평범한 브라우저 UA였습니다. 요즘 링크 미리보기 크롤러 중엔 헤드리스 브라우저로 JS까지 실행하는 게 있고, 그건 UA 목록으로 구분이 안 됩니다. (그래서 en 로케일 — 헤드리스 브라우저의 기본 로케일)
■ 근본 해결: 동의 전엔 소켓을 열지 않는다 (8/15)
경로도 UA도 아니고, 문제는 "사람의 의사 표시 전에 계정이 생긴다"는 순서 자체였습니다. 그래서 순서를 바꿨습니다.
- 첫 방문자(토큰 없음 + 미동의)는 부팅 시 소켓을 열지 않는다. 약관 화면만 렌더된다.
- "동의하고 시작하기" 버튼이 소켓을 열고, 그때 계정이 생긴다. (기존 유저는 지금처럼 부팅 즉시 연결)
- persist 재수화를 기다린 뒤 판정 — 덜 읽힌 스토어가 기존 유저를 첫 방문자로 오판하지 않게.
크롤러가 무슨 UA를 쓰든, JS를 실행하든, "동의하고 시작하기"를 누르지는 않습니다. 배포 다음 슬롯부터 유령 0, 이후 3일째 0입니다.
■ 부수 효과: 가입 수가 줄었다 (정직해졌다)
이걸 배포하고 나서 알게 된 게 하나 더 있습니다. 그동안 약관 화면만 보고 나간 방문자도 전부 "가입"으로 집계되고 있었다는 것. 웹앱을 열기만 하면 계정이 생겼으니까요. 지난번 긱뉴스에서 이틀간 250명이 들어왔을 때 "0개 방 방문" 계정이 많았던 이유의 상당 부분이 이거였습니다. 지금은 가입 = 동의한 사람이라 숫자는 줄고 재방문율 같은 지표는 진짜 값이 됐습니다.
■ 정리 (3주 치 교훈)
- 빈 UA는 봇인가? 웹에선 대체로 그렇고, 앱 소켓에선 아니다. 판정은 채널별로.
- 크롤러는 JS를 실행한다. UA 목록은 미리보기용 크롤러엔 통해도, 헤드리스 렌더러엔 안 통한다. 경로를 쫓지 말고 불변식을 세울 것.
- "계정이 언제 생기는가"는 프라이버시 이슈이기 전에 계측 이슈다. 동의 전 계정은 지표를 오염시킨다. 사람의 제스처 뒤에 두면 유령도, 부풀림도 같이 사라진다.
같은 실수를 하고 계신 분이 있다면 — 웹앱 부팅 시점에 소켓/세션/계정을 만드는 코드가 있는지 한 번 확인해 보시길. 저는 3주 걸렸습니다.
여러분은 웹앱에서 계정/세션을 어느 시점에 만드시나요? 부팅 즉시인지, 첫 행동 시점인지, 동의 시점인지 — 다른 분들 구조가 궁금합니다.
(영어로 쓴 제작 배경 글: https://dev.to/seongjinkang/…)