TL;DR
Threads 마케팅 에이전트 연재의 마지막 편입니다. 1편은 소재를 확보하는 크롤링, 2편은 그것을 글로 만들어 발행하고 성과를 재는 파이프라인을 다뤘습니다. 이번 편의 주제는 발행한 글에 반응을 붙이는 것 — 서브계정들이 각자 다른 목소리로 댓글과 좋아요를 다는 일입니다.
결론부터 말하면 이 구간도 AI 문제가 아니었습니다. 댓글 문구는 이미 2편에서 본문과 함께 생성해 두었고, 진짜 난제는 “이 계정들을 어떻게 서로 남처럼 보이게 하는가”였습니다.
설계는 크게 세 가지로 정리됩니다.
-
두뇌와 손을 분리한다. 발행과 DB는 VPS가 맡고, 실제 브라우저 조작은 Mac mini가 담당합니다. 둘은 큐로 연결합니다.
-
계정 격리는 프로필 단위로 건다. 호스트 IP는 거의 무관합니다. AdsPower 프로필마다 모바일 프록시와 지문을 따로 물려 계정끼리 엮이지 않게 합니다.
-
“사람처럼”을 세 층에서 만든다. 네트워크(프록시), 행동(클릭·타이핑 리듬), 시간(언제 다는가) 전부입니다.
미리 밝혀두면, 자기 글에 여러 계정으로 반응을 붙이는 일이므로 플랫폼 ToS 관점에서는 회색지대입니다. 그래서 “탐지 회피 팁”이 아니라 시스템을 어떻게 설계했는지에 대한 기록으로 남깁니다. 무엇이 검증됐고 무엇이 아직인지는 맨 뒤에 솔직하게 적었습니다.
1. 왜 이렇게 만들었나
2편에서 게시물을 만들 때 본문만이 아니라 댓글 3개도 함께 생성했습니다. 본문이 어떤 화자·어떤 앵글인지 알아야 그 반대편에서 자연스러운 반론을 달 수 있기 때문입니다. 그렇게 남은 문제는 하나였습니다. 이 댓글을 누가 다는가.
여기에는 피할 수 없는 제약이 셋 있었습니다.
첫째, 본계정이 자기 글에 스스로 답글을 달면 대화가 아니라 독백입니다. 반응은 다른 목소리가 붙어야 의미가 있으므로, 댓글은 서브계정의 몫입니다.
둘째, Threads 공식 API로는 임의의 글에 댓글을 달 수 없습니다. API가 자기 대화 관리 위주라, 임의의 공개글에 답글이나 리포스트를 하는 통로를 열어주지 않습니다. 1편의 결론이 여기서 반복됩니다 — 결국 브라우저를 직접 조종하는 수밖에 없습니다.
셋째, 그리고 이것이 가장 큰데, 여러 계정을 같은 IP·같은 지문으로 운영하면 플랫폼은 이들을 곧 하나의 운영자로 묶습니다. 하나가 걸리면 연쇄로 함께 죽습니다. 그래서 이 프로젝트의 진짜 숙제는 댓글을 다는 기술이 아니라, 계정들을 어떻게 서로 남처럼 보이게 하는가였습니다.
2. 아키텍처 — 두뇌와 손을 나누다
왜 두 대로 나눴나
처음에는 hermes(발행 담당)에 브라우저까지 얹으려 했지만 곧 접었습니다. hermes는 발행과 KPI 수집을 24시간 돌리는 컨테이너입니다. 여기에 브라우저 대여섯 개를 띄우면 메모리가 터집니다 — 컨테이너가 5GB 한도인 데다 VPS는 swap도 0이라 여유가 없습니다. 결정적으로 VPS는 데이터센터 IP여서 계정 격리에는 최악입니다. 참여는 별도 워커로 분리하는 편이 맞았습니다.
그래서 역할을 두 머신에 나눴습니다.
[Hostinger VPS] 발행 두뇌(메인계정) + DB(SQLite 단독 소유) └ engagement_jobs 큐 + jobq.py (얇은 접근 계층) ▲ claim(due 잡 선점) / report(결과 보고) │ Tailscale 사설망 (100.x) — SSH로 jobq 호출 ▼[Mac mini M4] 참여 워커 (launchd/cron 상시) ← 손 └ due 잡 pull → AdsPower open-browser → CDP(ws.puppeteer) → Playwright → 좋아요/댓글 → 결과 report → close-browser └ 프로필별 싱가포르 모바일 프록시 / 계정당 1액션 / 랜덤 딜레이DB는 VPS가 단독으로 소유합니다. SQLite는 파일이라 원격에서 여럿이 동시에 쓰면 안전하지 않습니다. 그래서 Mac mini는 DB에 직접 닿지 않고, VPS의 jobq.py를 SSH로 호출해 잡을 받아오고(claim) 결과를 보고합니다(report). 큐잉·원자성·정합성은 전부 VPS 한 곳에서만 일어납니다.
접근 통로도 얇게 두었습니다. jobq.py는 상시 떠 있는 서비스가 아니라 docker exec로 그때그때 호출하는 CLI입니다. 흥미로운 점은 이것이 1편과 방향만 반대인 동일한 패턴이라는 것입니다. 그때는 손(Mac mini)이 데이터를 길어 올렸고, 이번에는 손이 행동을 내보냅니다.
두 머신은 Tailscale 사설망으로 묶여 있어 공개 포트를 열지 않고도 SSH가 됩니다. 이렇게 나누면 발행의 데이터센터 IP와 참여의 모바일 IP가 물리적으로 갈라집니다. 안정성이 필요한 두뇌는 서버에, 격리가 필요한 손은 모바일 회선 쪽에 두는 셈입니다.
정작 중요한 것은 호스트 IP가 아니다
의외지만 Mac mini 자체의 IP는 격리에 거의 의미가 없습니다. 격리의 단위는 머신이 아니라 AdsPower 프로필이기 때문입니다.
sub_account A ─ AdsPower 프로필 A ─ 모바일 프록시 A(싱가포르) + 지문 Asub_account B ─ AdsPower 프로필 B ─ 모바일 프록시 B(싱가포르) + 지문 B … … …(같은 Mac mini에서 돌지만, 플랫폼이 보는 IP·지문·쿠키는 프로필마다 완전히 다름)AdsPower는 프로필마다 브라우저 지문(UA·canvas·WebGL·타임존…), 전용 프록시, 쿠키 저장소를 따로 부여합니다. 그래서 한 머신에서 프로필 여러 개를 동시에 열어도 플랫폼 입장에서는 서로 다른 기기·다른 회선의 다른 사람입니다.
비밀번호 같은 민감 정보는 우리 코드가 갖고 있지 않습니다. AdsPower가 보관하고, 우리 DB에는 매핑만 남습니다.
sub_accounts(account, adspower_profile_id, cohort, proxy_tag, status, last_action_at, …)load_sub_accounts.py는 AdsPower가 내보낸 JSON(계정명·프로필ID·프록시 태그, 비밀은 없음)만 받아 계정 기준으로 UPSERT 합니다. 비밀번호·2FA·프록시 자격증명은 DB에 저장하지 않습니다. 워커는 “이 프로필을 열어라”라고만 요청하면 됩니다. 로더는 멱등이라 다시 실행해도 이미 꺼둔(paused) 계정을 되살리지 않습니다.
프록시는 약 30분마다 IP가 로테이션됩니다. 이것을 약점이 아니라 자산으로 씁니다. 잡이 실패하면 재시도를 20–45분 뒤로 미뤄, 다음 시도가 로테이션을 넘긴 새 IP로 나가게 했습니다. 죽은 IP에 물린 잡도 자동으로 새 회선을 얻습니다. (재시도는 4장에서 다시 다룹니다.)
이 댓글을 누가 다는가
인수 시점에 정해지지 않은 것이 하나 있었습니다. 댓글 3개는 본문과 함께 생성되어 있지만, 어느 댓글을 어느 계정이 다는지가 DB 어디에도 없었습니다. 저장 형식마저 두 가지가 섞여 있었습니다 — "댓글1: … 댓글2: …" 식 마커형과, 빈 줄로 나눈 세 문단형.
두 단계로 정리했습니다.
첫째, 형식을 통일했습니다. 두 포맷을 모두 읽는 파서로 합본 텍스트를 개별 댓글로 쪼개 comment_items에 저장합니다. 이후 큐는 “댓글 뭉치”가 아니라 댓글 한 개 단위로 움직입니다.
둘째, 배정은 고정하지 않았습니다. “이 댓글은 이 계정”으로 박아두는 대신, 잡을 적재할 때 80개 규모의 active 풀에서 그때그때 고릅니다. 가장 오래 쓰지 않은 계정을 우선하되(last_action_at 기준) 약간의 무작위를 섞고, 대기 중인 잡이 있는 계정은 후보에서 제외합니다. 한 계정이 두 글에 겹쳐 배정되지 않게 하기 위해서입니다.
그 결과 “누가 다는가”는 미리 정한 표가 아니라 런타임 로테이션의 결과가 됩니다. 부수 효과로, 특정 서브계정이 늘 같은 상대에게만 반응하는 상관관계(엮였다는 단서)도 남지 않습니다.
실제 출력은 이렇습니다. 한 글에 세 계정이 서로 다른 논지를 답니다(계정명은 익명화).
- A(신차 옹호): “저 가격에 저 마감이면 낫지, 1.5 터보가 상대 2.0 NA보다 좋잖아”
- B(현실론): “AS와 부품 수급을 어떻게 이겨. 감가도 답 없을걸”
- C(대안): “그 돈이면 인증중고 다른 브랜드를 가지. 인지도가 낮아 되팔 때 골치야”
세 댓글이 서로 다른 계정, 다른 IP에서 나오고 논지도 겹치지 않습니다. 깔끔한 정보 정리가 아니라 “대화”가 만들어지는 것이 목적입니다. 2편에서 잡아둔 persona 축이 여기서 결실을 맺습니다.
참여 방식(좋아요만·댓글만·둘다·안 함)은 글마다 디스코드에서 운영자가 직접 고릅니다(2편의 clarify 재사용). ‘둘다’는 한 계정이 한 세션에서 댓글을 달고 몇 초 뒤 좋아요까지 하는 잡 하나입니다. 자기 IP·자기 세션이라 자연스럽습니다. 계정당 액션은 하나로 제한합니다.
3. 사람처럼 댓글 달기 — 브라우저 자동화
여기서부터는 잡을 받은 “손”이 실제로 브라우저를 여는 이야기입니다.
CDP로 브라우저에 직접 붙기 (그리고 mouse.click의 함정)
먼저 흔한 오해 하나. AdsPower의 CLI나 MCP만으로는 브라우저를 조작할 수 없습니다. open-browser는 프로필을 열어줄 뿐 navigate·click·fill 같은 조작 명령은 제공하지 않습니다. 대신 응답에 CDP 엔드포인트를 그대로 반환합니다.
r = requests.get(f"{API}/api/v1/browser/start", params={"user_id": pid, "headless": 0}, headers={"Authorization": f"Bearer {KEY}"}).json()ws = r["data"]["ws"]["puppeteer"] # AdsPower가 준 CDP 엔드포인트b = p.chromium.connect_over_cdp(ws) # 포트를 추측하지 말고 반환값에 그대로 연결page = b.contexts[0].pages[0]브라우저는 AdsPower의 것을 쓰므로 Playwright가 자체 브라우저를 설치할 필요도 없습니다. 그런데 여기서 가장 비싸게 배운 교훈이 나옵니다.
사람처럼 보이려고 page.mouse.move → click, 즉 좌표를 찍어 누르는 실제 클릭을 쓰려다 한참 헤맸습니다. AdsPower가 얹은 CDP 계층에서 마우스 이벤트가 응답 없이 멈췄습니다(hang). 결국 조작을 두 갈래로 나눴습니다. 이동은 좌표 클릭이 아니라 goto나 focus + Enter로, 버튼(좋아요·답글·게시)은 좌표 대신 JS 클릭(el.click())으로, 스크롤은 mouse.wheel이 아니라 window.scrollBy로.
“사람 같은 좌표 클릭”이라는 이상은 접었습니다. 대신 환경이 실제로 허용하는 방식 위에서 사람다움을 다시 쌓았습니다. 좌표 클릭이 막혀도 사람다움은 타이밍·순서·발견 경로에서 나옵니다(바로 다음 두 절). 1편의 “안티봇을 뚫는 것은 Playwright가 아니라 실제 브라우저다”와 같은 맥락입니다 — 드라이버의 이상이 아니라 환경의 제약에 맞춰 설계한다.
그 글까지 어떻게 가는가 — 직접 URL 대신 검색과 스크롤
댓글을 달 글의 URL은 DB에 모두 있습니다. 가장 쉬운 구현은 그 URL로 바로 goto 하는 것이지만, 의도적으로 그렇게 하지 않았습니다.
사람은 낯선 글에 딥링크를 쳐서 도착하지 않습니다. 검색하다가, 프로필을 눌러보다가, 피드를 내리다가 도달합니다. URL 직행은 그 자체로 봇 신호입니다. 그래서 발견 과정을 이렇게 흉내 냈습니다.
1) 토픽 키워드로 검색 (예: 브랜드 키워드 — 핸들 "@main"을 직접 치지 않음)2) 검색 드롭다운에 뜬 @main 프로필로 진입3) 프로필에서 뭉텅이 스크롤로 목표 글(URL 끝 code)까지 내려가 발견 → 그 글을 열고 → (읽고) → 좋아요/댓글핸들을 정확히 검색하지도, 목표 글로 직행하지도 않습니다. 토픽 키워드로 검색하고, 뜬 프로필로 들어가고, 스크롤로 그 글을 만납니다. 경로 자체가 사람의 브라우징 궤적을 닮습니다. 목표 글은 스크롤 도중 a[href*='<code>'] 셀렉터로 등장하는 순간 잡습니다.
디테일한 리듬 — 행동 레이어
경로가 사람다워도 동작 하나하나가 기계적이면 티가 납니다. 그래서 모든 상호작용에 사람 행동 레이어를 깔았습니다.
-
읽는 시간은 글 길이에 비례합니다. 열자마자 실제 렌더된 글자 수를 재서(
article.innerText.length) 한글 스키밍 속도(10–16자/초)로 체류 시간을 계산합니다(2.5–22초). 짧은 글은 빠르게, 긴 글은 오래 “읽습니다”. -
스크롤은 뭉텅이로, 가끔 되감기. 한 번에 300–720px씩 내리고 잠깐 멈춰 읽은 뒤, 15% 확률로 살짝 위로 올립니다. 등속 스크롤은 사람이 하지 않는 동작입니다.
-
누르기 전에 뜸을 들입니다. 버튼도 hover 후 잠깐 머뭇거린 뒤 누릅니다. 클릭 자체는 JS지만 그 앞뒤 딜레이로 사람의 결정 지연을 표현합니다.
-
타이핑도 생각하듯 합니다. 댓글을 한 번에 붙여넣지 않고 문장·구 단위로 끊어(글자당 55–150ms) 치며, 중간중간 0.3–1.1초씩 멈칫합니다.
-
끝나도 곧장 닫지 않습니다. 액션 뒤 1.5–4초 머무르며 가끔 한 번 더 스크롤한 뒤 나갑니다.
핵심은 그냥 랜덤이 아니라 상황에 종속된 랜덤이라는 점입니다. 체류 시간이 글 길이를 따라가고, 타이핑이 문장 경계에서 끊기고, 스크롤이 목표를 만나면 멈칫합니다. 균일 난수가 아니라 내용에 반응하는 변동이라야 사람처럼 보입니다.
시간도 지문이다 — 언제 다는가
1편에서 TLS 지문, 즉 연결 계층에서 봇이 드러난다는 이야기를 했습니다. 참여 자동화에는 그 시간축 버전이 있습니다. 반응이 규칙적이면 그 자체가 협응(coordination)을 드러내는 지문이 됩니다. 세 계정이 발행 후 정확히 5분·10분·15분에, 매번 정각에 댓글을 달면 내용이 아무리 자연스러워도 패턴에서 걸립니다.
그래서 스케줄러가 규칙성을 일부러 무너뜨립니다.
-
첫 댓글은 발행 뒤
[2분, 창(예: 40분)]사이 랜덤. 이후 계정은앞 잡 시각 + [0, 2시간 40분]랜덤이되 분이 아니라 초 단위로 흩뜨립니다. 고정 간격도, 정각도 없습니다. -
대신 하한을 둡니다. 첫 댓글은 최소 2분이 지나야 나갑니다. 발행 직후 0초 반응은 명백한 봇 신호이기 때문입니다.
-
너무 오래된 글은 건드리지 않습니다(기본 48시간 이내). 앵커를
max(발행시각, 지금)으로 두어 묵은 백로그가 한꺼번에 튀어나가는 것도 막습니다. -
계정도 앞서 말한 로테이션(오래된 것 우선 + 무작위 + 대기 중 제외)이라 시간축뿐 아니라 계정 축의 규칙성도 깨집니다.
굳이 초 단위인 이유는, 분 단위로 끊으면(5분·10분) 그것도 격자 무늬가 되기 때문입니다. 초 단위로 흩어야 “우연히 그때 봤다”에 가까워집니다. 워커가 하나라 실행은 어차피 순차(약 180초에 한 잡)지만, 예약 시각의 분포를 흩뜨리는 것이 목적입니다.
4. 신뢰성·한계, 그리고 배운 것
멱등 큐와 재시도
손(Mac mini)은 언제든 죽을 수 있고(디스크·프록시·로그인 만료), 브라우저 자동화는 본질적으로 잘 깨집니다. 그래서 큐를 다시 실행해도 안전한 멱등 구조로 짰습니다. 2편 발행 워커의 원자적 claim을 그대로 가져왔습니다.
-
잡을 집을 때
UPDATE … SET status='claimed' WHERE id=? AND status='pending'로 선점합니다. 이WHERE가드 덕분에 두 워커가 같은 잡을 동시에 집을 수 없습니다. -
예약이 24시간 넘게 밀린 pending은 실행하지 않고
expired처리합니다. 워커가 오래 죽었다 살아났을 때 밀린 잡을 한꺼번에 쏟아내는 것(봇 버스트)을 막기 위해서입니다. -
재시도는 최대 4회. 실패하면 20–45분 뒤 다시 넣는데, 이 지연이 30분 프록시 로테이션을 넘겨 새 IP로 재시도되게 합니다. IP 하나가 죽어도 잡이 영영 실패하지 않습니다.
-
끝나면 결과를
report로 기록하고(done/failed)last_action_at을 갱신합니다. 댓글 성패는 그 글의 디스코드 스레드로 알리고, 좋아요 성공은 조용히 넘어갑니다. 알림이 지나치게 시끄러우면 오히려 보지 않게 됩니다.
어디까지 됐고, 무엇이 아직인가
2편과 같은 태도로, 검증된 것과 아닌 것을 나눠 적습니다.
-
이것은 여러 계정으로 자기 글에 반응을 붙이는 일이므로 플랫폼 ToS 관점에서는 회색지대가 맞습니다. 그래서 “탐지 회피 비법”이 아니라 시스템 설계 기록으로 남깁니다. 격리·큐·멱등·페이싱은 그 자체로 흔한 분산 시스템 기법이고, 여기서 진짜 중요한 리스크 관리는 계정 격리(하나가 터져도 전체로 번지지 않게)입니다.
-
검증된 것은 파이프라인이 끝까지 돈다는 점입니다. 발행 → 큐 적재 → Mac mini가 CDP로 실제 좋아요·댓글 → 결과 보고까지 이어집니다.
mouse.click우회, 검색·스크롤 발견, 멱등 재시도가 모두 실제로 동작합니다. -
아직인 것은 효과입니다. 이 반응이 실제 도달·전환을 끌어올리는지는 표본이 부족해 아직 알 수 없습니다. 2편과 마찬가지로, 지금 성과를 주장하지 않겠습니다.
배운 것
-
병목은 한 번도 생성이 아니었습니다. 1편(네트워크), 2편(콘텐츠·측정), 3편(행동·시간) 내내 어려운 부분은 “사람처럼 보이기”였고, LLM은 그중 가장 쉬운 조각이었습니다.
-
결국 이상보다 환경에 맞추게 됩니다.
mouse.clickhang 때문에 깔끔한 설계(좌표 클릭)를 버렸지만, 사람다움은 클릭 방식이 아니라 타이밍·순서·경로에서 나온다는 것을 배웠습니다. -
격리의 단위는 머신이 아니라 프로필입니다. 호스트 IP 대신 프로필별 프록시·지문·쿠키에 맡기니 “한 머신에 N계정”이 안전해졌습니다. 비밀은 코드가 아니라 AdsPower가 보관합니다.
-
다음은 참여 결과를 2편의 어트리뷰션(engagement → click → conversion)에 연결해, 어떤 페르소나·타이밍 조합이 실제 클릭으로 이어지는지 학습 루프를 닫는 것입니다.
실제 운영 중인 Threads 마케팅 에이전트(Hermes)의 빌드 로그입니다. 피드백 환영합니다.