Bluesky 자동화가 스스로를 드러내지 못하도록 차단하는 Pre-Post QC 게이트를 구축한 방법
요약
Bluesky 자동화 게시물이 자동화된 기원을 드러내어 사용자 불신을 사는 것을 방지하기 위해 Pre-Post QC 게이트를 구축하는 방법을 설명합니다. 생성 단계와 게시 단계 사이에 필터 스크립트를 도입하여 특정 키워드를 차단하는 워크플로를 구현했습니다.
핵심 포인트
- 자동화 메커니즘 노출 방지를 위한 QC 게이트 도입
- 정규 표현식을 활용한 자동화 관련 어휘 필터링
- 게시 전 검증 단계를 추가한 직렬화된 파이프라인 구축
- 검증 실패 시 게시를 건너뛰는 안정적인 워크플로 설계
자동화된 콘텐츠 파이프라인(content pipeline)을 통해 Bluesky 큐를 운영한 지 3주째 되던 날, "콘텐츠 파이프라인"을 직접적으로 언급하는 게시물이 올라가는 것을 보았습니다. 아주 심각한 수준은 아니었습니다. 그저 지나가는 문구였을 뿐입니다. 하지만 social timeline(소셜 타임라인)에서 읽히는 방식은 dev.to 기사에서 읽히는 방식과 달랐습니다. dev.to에서는 자동화에 대해 솔직하게 밝히는 것이 하나의 특징(feature)이지만, Bluesky에서는 콘텐츠 팜(content farms)을 불신하도록 이미 준비된 인간 독자들에게 자신의 자동화 메커니즘을 자발적으로 언급하는 것은 레드 플래그(red flag, 경고 신호)로 인식됩니다.
앞서 설명한 JSONL 기반 큐는 기계적으로는 잘 작동하고 있었습니다. 항목들이 생성되고, 큐에 머물다가, cron job(크론 잡)을 통해 하나씩 배출되었습니다. 하지만 생성 단계와 게시 단계 사이에 필터가 없었습니다. 프롬프트(prompt)가 생성하는 것은 무엇이든 큐로 들어갔고, 큐의 맨 앞에 있는 것은 무엇이든 게시되었습니다. Bluesky AT Protocol 게시 API는 스팸 탐지 외에는 서버 측 콘텐츠 필터가 없으므로, 그 책임은 전적으로 클라이언트(client)에게 있습니다.
저는 토요일 하루를 할애하여 bluesky-qc.mjs를 구축했습니다. 이것은 이제 게시 워크플로(workflow)의 첫 번째 단계로 실행되는 게이트 스크립트(gate script)입니다. 작동 방식은 다음과 같습니다.
아키텍처: 큐와 게시 사이의 게이트
bluesky-qc.mjs를 도입하기 전, cron job은 다음과 같았습니다:
bluesky-post-queue.mjs → Bluesky API
도입 후:
bluesky-qc.mjs → (PASS) bluesky-post-queue.mjs → Bluesky API
두 스크립트 모두 동일한 content/bluesky-queue.jsonl 파일을 읽습니다. QC 스크립트는 항목들을 순서대로 훑으며 각 항목에 네 가지 게이트를 적용합니다. 그 후, 깨끗한 첫 번째 항목을 게시 스크립트가 사용할 수 있도록 통과시키거나, 통과하지 못한 항목들을 거부 로그(rejection log)로 이동시킵니다. 그러면 게시 스크립트는 큐에서 게시되지 않은 첫 번째 항목을 찾게 되는데, QC가 방금 실행되었다면 그 항목은 깨끗한 상태여야 합니다.
GitHub Actions에서는 이것이 단일 복합 명령(composite command)인 pnpm bluesky:qc-then-post로 실행됩니다. 만약 QC가 큐(queue)에 있는 모든 항목을 거부하여 통과할 수 있는 깨끗한 항목이 없다면, 워크플로(workflow)는 게시를 수행하지 않고 종료 코드 0으로 종료됩니다. 하루를 건너뛰는 것은 괜찮습니다. 하지만 자동화임을 드러내는 듯한 게시물을 올리는 것은 괜찮지 않습니다.
이는 제가 이전에 작성했던 Cloudflare Pages 레이스 컨디션 수정에 담긴 철학과도 연결됩니다. 즉, 모든 단계를 병렬로 실행할 수 있을 것처럼 보이는 파이프라인 내에 명시적인 직렬화(serialization)를 구축하는 것입니다.
게이트 1: 어휘 거부 (vocabulary rejection)
G1은 자동화된 기원을 나타내는 구절 목록에 대해 컴파일된 대소문자 구분 없는 정규 표현식(regex)입니다:
const REVEAL =
/programmatic|content[\s-]pipeline|AI-curated|AI-generated|AI authorship|
my sites|three (independent )?sites|pages generated|page count|records_in|
...
이것은 설계 단계부터 공격적으로 설정되었습니다. "curat"는 자동화된 콘텐츠 맥락에서 흔히 쓰이는 "curated"와 "curation"을 모두 잡아냅니다. "generate"는 생성(generation)을 언급하는 모든 것을 잡아냅니다. 게시물 중 가끔 제가 허용 가능하다고 생각하는 구절 때문에 G1에 걸리는 경우가 있지만, 저는 의도적으로 정규 표현식을 엄격하게 유지합니다. 만약 게시물이 자신의 논지를 전달하기 위해 생성(generation)이나 큐레이션(curation)을 언급해야 한다면, Bluesky는 아마도 그 생각을 전달하기에 적절한 채널이 아닐 것입니다. dev.to가 더 적합하겠죠.
이 패턴은 시간이 지나면서 성장했습니다. 처음에는 약 15개의 용어로 시작했으나, 특정 항목들이 초기 버전을 통과한 후 거부 로그(rejection log)를 검토했을 때 여전히 부적절하다고 느껴져 약 15개를 더 추가했습니다. 최종 정규 표현식은 게이트가 잡아냈어야 할 항목들을 4주 동안 수동으로 감사(auditing)한 결과물입니다.
나중에 추가한 한 가지는 \bcron\b입니다. "I run this as a cron job"과 같은 문장에서 "cron"이라는 단어조차도, 사람들이 수동으로 게시한다는 것이 기본 전제인 소셜 맥락에서는 자동화를 나타내는 신호 구절이 됩니다.
게이트 2: 신선도 (freshness), 두 부분
신선도 저하(staleness)는 두 가지 뚜렷한 형태로 나타납니다.
Stale phrasing (오래된 표현): 게시물이 생성될 당시에는 정확했지만, 실제로 게시되는 시점에는 더 이상 정확하지 않은 시간 상대적 언어(time-relative language)를 사용하는 경우입니다.
const STALE =
/\btoday\b|this week|yesterday|this morning|just\s+(announced|released|landed|launched|dropped)/i;
예를 들어, 프롬프트가 실행될 때는 최신이었던 내용에 대해 게시물이 "방금 출시되었습니다(just dropped)"라고 말할 수 있지만, 실제 게시 시점에는 이미 3일이 지난 상태일 수 있습니다. G2a는 이러한 내용이 타임라인에 도달하기 전에 이를 포착합니다.
Stale timestamp (오래된 타임스탬프): 게시물이 TTL_DAYS = 14일보다 더 이전에 생성된 경우입니다. 만약 특정 게시물이 한참 전에 생성되었고 그 사이에 더 최신 게시물들이 앞질러 갔다면, 해당 게시물은 대기열(queue)의 뒤쪽에 머물게 됩니다. 이 저장소(repo)의 생성 스크립트들이 일관되지 않기 때문에 created_at과 generated_at 필드 이름을 모두 확인합니다:
const ts = entry.created_at || entry.generated_at || entry.createdAt;
if (ts) {
const ageDays = (Date.now() - Date.parse(ts)) / 86400000;
...
14일이라는 TTL(Time To Live)은 저의 대기열 깊이(queue depth)와 게시 속도를 기준으로 설정되었습니다. 하루에 한 번 게시한다고 가정할 때, 대기열에 14개 이상의 게시물이 쌓여 있다면 그 게시물들은 아마도 더 이상 유효하지 않은 맥락에서 생성되었을 가능성이 높습니다. 언급된 도구가 업데이트되었을 수도 있고, 프레임워크(framing)가 시대에 뒤떨어진 느낌을 줄 수도 있기 때문입니다.
두 하위 검사(sub-checks)는 각각 별도의 거절 사유(rejection reasons)를 기록하므로, 로그를 통해 어떤 종류의 신선도 저하(staleness)가 발생했는지 명확히 알 수 있습니다.
게이트 3: 참여도 예측 (engagement prediction) (v1에서는 경고 전용)
게이트 3은 bluesky-engagement-stats.mjs에 의해 매주 생성되는 data/bluesky-engagement-profile.json을 사용합니다. 해당 스크립트는 Bluesky API에서 저의 최근 게시물 300개를 가져와, 각 게시물에 대해 좋아요 + 2×리포스트 + 답글로 점수를 계산하며(리포스트를 더 강력한 신호로 보고 가중치를 높게 부여함), 해시태그별 상세 내역을 구축합니다.
게시 시점에 G3는 대기 중인 게시물에 어떤 해시태그가 포함되어 있는지 확인하고, 기준점(baseline) 대비 예측 점수를 계산합니다:
if (profile?.by_hashtag) {
const tags = [...entry.text.matchAll(/#[A-Za-z][A-Za-z0-9_]+/g)]
.map((m) => m[0].toLowerCase());
...
G3는 v1에서 경고 전용(warn-only)으로 작동합니다. 예측 점수와 기준 점수(baseline score)를 로그로 남기지만, 거부(reject)하지는 않습니다. 그 이유는 신호가 신뢰할 수 있을 만큼 충분한 게시물 이력(post history)이 아직 쌓이지 않았기 때문입니다. 콘텐츠 생성에 사용하는 공유 Claude Haiku 클라이언트는 약 두 달 동안 실행되었고, 게시물 양은 하루에 대략 하나꼴로, 약 60개의 데이터 포인트 정도입니다. 중간 참여도(Median engagement)가 여전히 충분히 낮아서 G3의 예측값에 노이즈가 많습니다.
G3를 강제 실패(hard-fail)로 전환하면(코드에는 임계값 체크가 들어갈 위치에 주석이 달려 있습니다), 기술적으로는 텍스트 검사를 통과했지만 이 계정에서 성과가 나지 않는 해시태그를 타겟팅하는 G1/G2 생존자들을 잡아낼 수 있을 것으로 기대합니다. 코드 내의 주석은 이를 다음과 같이 표시합니다:
// v1: 경고 전용(warn-only). 데이터 축적 후 hard-fail로 승격 예정
참여도 통계 스크립트는 시간 범위(30일, 60일, 90일)별로도 분류되므로, 향후 몇 달 동안 데이터가 축적됨에 따라 프로필의 유용성이 높아질 것입니다.
Gate 4: Codex를 위해 예약됨
네 번째 게이트는 아직 구현되지 않았습니다. 설계 의도는 G1-G3를 통과했지만 여전히 최종 검토가 필요한 모든 항목에 대해 품질 검사를 수행하도록 Codex를 호출하는 --codex 플래그를 추가하는 것입니다. 현재 설정에서 Codex는 기사 파이프라인을 통해 생성 시점에 실행되지만(3계층 Codex 프로토콜이 기사 품질을 처리합니다), Bluesky 게시물의 경우 생성 단계에 Codex 검사가 포함되어 있지 않습니다. G4는 그 간극을 메울 것입니다.
G3가 안정될 때까지 G4 구현을 미루고 있는데, 데이터 기반이 아직 탄탄하지 않은 상태에서 이미 하루에 세 번 실행되는 크론(cron) 작업에 지연 시간(latency)과 API 비용을 추가하는 것은 의미가 없기 때문입니다.
거부된 항목은 어떻게 되는가
실패한 모든 항목은 data/bluesky-qc-rejected.jsonl에 추가됩니다:
appendFileSync(
REJECTED,
JSON.stringify({
...
거부된 로그(rejected log)는 두 가지 목적을 수행합니다. 첫째, 아무것도 유실되지 않도록 하는 것입니다(항목을 편집하여 큐에 다시 추가함으로써 복구할 수 있습니다). 둘째, 상위 단계의 생성 프롬프트(generation prompts)를 조정하기 위한 주요 피드백 메커니즘 역할을 합니다. 만약 G1이 도구 정리(tool roundups)용으로 작성되어야 할 게시물에서 계속해서 "콘텐츠 파이프라인(content pipeline)"이라는 표현을 사용한다면, 해당 게시물을 생성하는 프롬프트에 파이프라인 전문 용어가 유출되고 있는 것입니다. 이는 게이트(gate)의 문제가 아니라 프롬프트의 수정 문제입니다.
저는 일주일에 한 번 정도 거부된 로그를 검토하는데, 이는 G3 프로필을 갱신하기 위해 bluesky-engagement-stats.mjs를 실행하는 세션과 동일한 시간에 이루어집니다. 이 작업들을 모두 합쳐 약 20분 정도 소요됩니다.
배포 후 체크 패턴과의 차이점
이 시스템이 포함된 단일 CI 파이프라인은 이미 모든 Cloudflare Pages 빌드 후에 배포 후 체크(post-deploy checks)를 실행합니다. 해당 체크들은 프로덕션의 정확성(production correctness)에 관한 것입니다. 즉, 올바른 페이지가 렌더링되었는지, JSON-LD 블록이 유효한지, 사이트맵이 최신 상태인지 등을 확인합니다. 반면 Bluesky QC 게이트는 어조(tone)와 문맥(context)에 관한 것입니다. 즉, 텍스트가 소셜 타임라인에 적합하게 자연스러운지를 확인합니다.
설계 원칙은 동일합니다. 가능한 한 조기에 게이트를 설정하고, 실패 시 유익한 정보를 제공하며, 오류를 조용히 삼키지 않는 것입니다. 하지만 실패 모드(failure modes)는 다릅니다. 깨진 JSON-LD 블록은 객관적으로 틀린 것입니다. 하지만 "콘텐츠 파이프라인"을 언급하는 게시물은 틀린 것이 아니라, 대상 독자에게 문맥상 부적절한 것입니다.
제가 다르게 구현한다면
원자적 상태 머신 (Atomic state machine). 현재의 두 개의 스크립트로 구성된 설계에는 조정의 공백(coordination gap)이 있습니다. QC가 항목을 통과시키고 게시 스크립트가 실행될 때, 만약 게시 도중 네트워크 오류가 발생하면 해당 항목은 큐의 맨 앞에 그대로 남아 다음 실행 시 QC가 이를 다시 평가하게 됩니다. 이는 해롭지는 않지만 낭비적입니다. 게시를 시도하기 전에 항목의 상태를 잠그고(lock), JSONL 파일에 qc_passed로 표시하는 단일 스크립트를 사용한다면 재평가로 인한 번거로움(re-evaluation churn)을 제거할 수 있을 것입니다.
정규식(regex) 대신 분류기(Classifier) 사용. REVEAL 정규식은 30개 이상의 용어로 구성되어 있으며 계속 늘어날 것입니다. 장기적으로 올바른 설계는 거부된 로그가 100개 이상 모이면 작은 로지스틱 회귀(logistic regression) 모델이나 몇 번의 예시만으로 학습시키는 Claude 분류기를 사용하는 것입니다. 현재의 거부율로 볼 때 약 6개월 후에 충분한 데이터를 확보할 수 있을 것입니다. 그때까지는 정규식이 지연 시간 제로, 비용 제로, 결정론적이라는 장점이 있습니다. 이는 아직 필요하지 않은 정확도를 위해 포기할 의향이 없는 속성들입니다.
G3를 더 빨리 하드 게이트(hard gate)로 사용. 돌이켜보면 G3의 경고 전용 기간은 너무 보수적으로 느껴집니다. 저는 매우 관대한 임계값(예: 예측 점수가 기준선 중앙값의 0.5 미만)을 설정하여 광범위한 데이터가 필요하지 않더라도 명확하게 신호가 낮은 케이스를 포착할 수 있었을 것입니다. 후속 기록이 90일이 되면 다시 검토할 것입니다.
FAQ
후처리(post time) 대신 생성 시간(generation time)에 필터링하는 이유는 무엇인가요?
저도 그렇게 합니다. 생성 프롬프트에는 자동화 사용을 피하라는 명시적인 지침이 포함됩니다. 하지만 프롬프트는 확률적입니다. 게이트는 결정론적입니다. 두 계층을 순차적으로 실행하는 것이 어느 한쪽에만 의존하는 것보다 비용 효율적입니다.
전체 대기열(queue)이 QC에 실패하면 어떻게 되나요?
스크립트는 로그 메시지와 함께 0으로 종료됩니다. 아무것도 게시되지 않습니다. 대기열은 비워지지 않으므로 다음 실행에서 다시 시도합니다. 만약 대기열이 여러 날 연속 비어 있게 되면, 대기열 재충전 크론(queue refill cron)(bluesky-refill-queue.mjs)이 새로운 항목을 추가합니다.
REVEAL 목록에 무엇을 넣을지 어떻게 결정하나요?
사람에게
세 개의 AI 큐레이션 디렉토리 사이트를 운영하는 6개월간의 지속적인 실험의 일부입니다. 여기에 기술된 주장들은 사실이며, 이 글은 AI의 도움을 받아 작성되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기