Python과 Claude를 활용한 비디오 메타데이터 모더레이션 파이프라인 구축
요약
YouTube API를 통해 수집된 비디오 메타데이터의 스팸 및 부적절한 콘텐츠를 필터링하기 위해 Python과 Claude API를 활용한 모더레이션 파이프라인 구축 사례를 소개합니다. 기존 정규 표현식 방식의 한계를 LLM의 문맥 이해 능력으로 해결하는 과정을 다룹니다.
핵심 포인트
- 정규 표현식 기반 차단 목록의 미탐 및 오탐 문제 해결
- Claude API를 활용한 구조화된 메타데이터 판결 프로세스
- 데이터 수집, 모더레이션, 인덱싱 단계의 디커플링 설계
- LLM을 통한 문맥 기반의 정교한 콘텐츠 필터링 구현
스팸 제목이 당신의 최고의 다큐멘터리보다 상위에 노출될 때
매일 밤, cron job이 YouTube Data API로부터 수천 개의 새로운 비디오 레코드를 제가 운영하는 무료 비디오 탐색 플랫폼인 DailyWatch로 가져옵니다. 각 레코드는 제목, 설명, 채널 이름, 태그, 썸네일 URL이라는 일반적인 형태를 띠고 있습니다. 서류상으로는 깔끔한 JSON입니다. 하지만 실제로는 그중 3~5%가 쓰레기입니다. 모두 대문자로 작성된 클릭 유도형(engagement-bait) 제목, 40개의 해시태그와 3개의 Telegram 링크로 가득 찬 설명, 실제 크리에이터를 사칭하는 재업로드 채널, 그리고 제목은 완벽하게 안전하지만 설명은 전혀 그렇지 않은 간헐적인 클립들이 그것입니다.
SQLite의 FTS5 인덱스는 이러한 것들을 전혀 신경 쓰지 않습니다. 🔥🔥 FREE $$$ CLAIM NOW 🔥🔥를 기꺼이 토큰화(tokenize)하여 합법적인 다큐멘터리 바로 옆에 순위를 매겨버립니다. 이곳에서 탐색(Discovery)은 제품의 핵심이므로, 오염된 인덱스는 미적인 문제가 아니라 생존의 문제입니다. 이 포스트는 데이터 수집(ingestion)과 인덱싱(indexing) 사이에 위치하는 모더레이션(moderation) 단계를 구축한 방법에 관한 것입니다. 즉, 각 레코드에 대해 Claude API에 구조화된 판결을 요청하는 작은 Python 워커(worker)와, 읽기 시점에 해당 판결을 강제하는 PHP 쿼리 게이트(query gate)에 대한 이야기입니다.
키워드 차단 목록(blocklist)만으로는 부족했던 이유
저는 모든 사람이 시작하는 방식인 정규 표현식(regex) 차단 목록으로 시작했습니다. 하지만 이는 시도하는 모든 사람에게 똑같이 두 가지 방향으로 실패합니다.
첫째, 미탐(False negatives)입니다. 스패머들은 당신이 열거하는 속도보다 더 빠르게 난독화(obfuscation) 방식을 변경하기 때문입니다. free는 f r e e가 되고, 그다음엔 fr€e가 되며, 그다음엔 단어 중간에 폭이 없는 결합 문자(zero-width joiner)가 삽입된 형태가 됩니다. 해당 패턴 파일을 유지 관리하는 것은 무급의 두 번째 직업이나 다름없으며, 당신은 항상 한 발짝 뒤처지게 됩니다.
둘째, 오탐(False positives)입니다. 부분 문자열 매칭(substring matching)은 문구의 의미를 전혀 알지 못하기 때문입니다. _The Free Speech Wars_라는 제목의 다큐멘터리는 결제 사기 키워드에 대한 단순 매칭으로 인해 삭제됩니다. _The Naked Chef_라는 이름의 요리 채널은 성인 필터에 걸립니다. 문맥(Context)이 핵심인데, 차단 목록에는 문맥이 전혀 없습니다.
언어 모델(Language Model)은 제가 단 하나의 규칙도 작성하지 않았음에도 불구하고, _The Naked Chef_를 요리 브랜드로 읽어내고 send $500 to this Telegram to claim your prize를 스캠(Scam)으로 인식합니다. 이것이 바로 제가 원했던 거래였습니다. 패턴 간의 군비 경쟁을 유지하는 것을 멈추고, 한 번의 평이한 영어 기술만으로 정책을 설명하기 시작하는 것입니다.
파이프라인의 형태 (Shape of the pipeline)
설계 목표는 두 부분이 서로 직접 통신하지 않는 것이었습니다. 이들은 데이터베이스를 통해 디커플링(Decoupled)되어 있습니다.
- 수집 (Ingest). 페치 크론(Fetch cron)이 원본 YouTube 메타데이터를
moderation_status가NULL인 상태로videos테이블에 기록합니다. - 모더레이션 (Moderate). Python 워커(Worker)가
NULL행을 배치(Batch) 단위로 가져와 각각을 Claude로 보내고, 그 판결(Verdict)을 다시 기록합니다. - 인덱싱 (Index).
moderation_status = 'allow'인 행만 FTS5 가상 테이블로 복사됩니다. - 서빙 (Serve). PHP 프론트엔드는 모든 검색 결과(Search hit)를 판결(Verdict) 컬럼과 조인(Join)합니다. 따라서 이전 빌드 중에 인덱스에 포함되었더라도 차단된 콘텐츠는 보이지 않는 상태로 유지됩니다.
이러한 레이아웃의 중요한 특성은 장애 격리(Failure isolation)입니다. Python은 PHP를 호출하지 않습니다. 만약 모더레이션 워커가 다운되더라도 수집은 계속 실행되며, 행들은 단순히 NULL 상태로 쌓일 뿐입니다. 즉, 아직 인덱싱되지 않을 뿐입니다. 사용자가 접하는 서비스는 아무것도 깨지지 않는데, 왜냐하면 탐색(Discovery)은 오직 검증된(Vetted) 행들만 읽기 때문입니다. 장애가 발생하면 데이터의 최신성(Freshness)을 잃을 수는 있지만, 정확성(Correctness)을 잃지는 않습니다.
다음은 워커의 읽기(Read) 측면 코드입니다. 모더레이션되지 않은 행의 배치를 가져와 데이터 클래스(Dataclass)로 변환(Hydrate)합니다.
import sqlite3
from dataclasses import dataclass
...
WHERE moderation_status IS NULL 절은 조용히 이중 역할을 수행합니다. 작업 대상을 선택하는 동시에, 나중에 중요하게 작용할 워커 전체의 멱등성(Idempotent)을 보장합니다.
모델로부터 구조화된 판결(Structured verdicts) 추출하기
분류를 위해 LLM을 호출하는 가장 단순한(naive) 방법은 모델에게 "JSON으로 응답해줘"라고 요청한 뒤 그 응답을 파싱하는 것입니다. 그렇게 하지 마세요. 결국 마크다운 펜스(markdown fences)를 제거하고, Sure, here's the JSON: 같은 불필요한 서문을 처리하며, 환각(hallucination)으로 생성된 필드에 대응하기 위한 방어적인 코드를 작성하게 됩니다. Claude API에는 더 깔끔한 메커니즘이 있습니다. JSON 스키마(JSON schema)를 가진 도구(tool)를 정의하고 모델이 이를 호출하도록 강제하는 것입니다. 응답은 이미 형태가 잡혀 있고 타입 제약(type-constrained)이 적용된 상태로 돌아오므로 별도의 파싱이 필요 없습니다.
import os
from anthropic import Anthropic
...
여기서 언급할 만한 두 가지 선택지가 있습니다. 첫째, tool_choice={'type': 'tool', 'name': 'record_moderation'}는 호출을 강제합니다. 모델이 산문(prose)으로 응답할 수 없으므로, block.input이 스키마와 일치함을 보장할 수 있습니다. 둘째, 세 가지 상태를 가진 verdict 열거형(enum)입니다. 허용(allow)/차단(block)의 이진 분류는 함정입니다. 흥미로운 사례는 경계선에 있는 것들이며, 이를 자동으로 차단해 버리면 결코 확인할 수 없기 때문입니다. review 버킷은 모호한 기록을 사람의 검토 큐(human queue)로 라우팅하며, 저는 바로 그 큐를 통해 평가 기준(rubric)을 어떻게 미세 조정(tune)할지 실제로 배우게 됩니다.
저는 여기서 의도적으로 Haiku를 사용합니다. 엄격한 기준에 따른 모더레이션은 개방형 추론(open-ended reasoning)이 아닌 분류(classification) 작업이므로, 가장 저렴한 티어(tier)로도 제가 필요한 처리량(throughput)을 충분히 처리할 수 있습니다. 저는 실행 과정 내내 시스템 프롬프트(system prompt)와 도구 스키마를 바이트 단위로 동일하게 유지하며, 이를 통해 프롬프트 캐싱(prompt caching)이 작동하도록 합니다. 첫 번째 요청 이후의 모든 요청은 입력 토큰 비용의 아주 일부만 사용하여 해당 캐시된 접두사(prefix)를 재사용하며, 시스템 블록과 도구 정의가 입력의 대부분을 차지하기 때문에 배치(batch) 작업의 대부분이 캐시 히트(cache hit)로 처리됩니다.
안전하게 배치로 실행하기
이 작업은 새벽 03:00에 실행되며 아무도 기다리고 있지 않으므로, 서비스가 아닌 배치(batch) 작업입니다. 병목 지점은 CPU가 아니라 API 왕복 지연 시간(round-trip latency)이므로, 복잡한 비동기(async) 절차 없이도 적절한 스레드 풀(thread pool)만 있으면 대부분의 처리량을 확보할 수 있습니다. 결과는 단일 executemany 트랜잭션으로 저장됩니다.
import concurrent.futures as cf
import sqlite3
...
멱등성(Idempotency)은 스키마 설계 과정에서 자연스럽게 따라옵니다. 실행 중 충돌(crash)이 발생하면 처리되지 않은 행들은 moderation_status가 여전히 NULL 상태로 남게 되므로, 다음 실행 시 이를 다시 가져오게 됩니다. 개별 API 실패는 포착되어 로그에 기록되고 건너뛰어지며 — 결과적으로 동일한 효과를 냅니다. 해당 행 하나는 내일 다시 시도됩니다. 하룻밤에 수천 건 정도의 레코드라면 데드 레터 큐(dead-letter queue)나 재시도 테이블(retry table)은 필요하지 않습니다. NULL 센티널(sentinel) 자체가 바로 큐 역할을 합니다.
야간 실행 작업은 지연 시간(latency)에 구애받지 않기 때문에, 저는 실제로 이러한 동기식 호출(synchronous calls) 대신 메시지 배치 API(Message Batches API)를 통해 작업의 대부분을 처리합니다. 프롬프트는 동일하며, 결과는 한 시간 이내에 돌아오고, 토큰당 비용은 절반입니다. 위에서 보여준 스레드(threaded) 버전은 낮 시간 동안 데이터 페치(fetch)가 발생했을 때 소규모로 진행하는 일중(intraday) 보충 배치 작업을 위해 실행하는 방식입니다. 두 경로 모두 동일한 persist()를 호출하므로, 판정(verdict)이 어떻게 생성되었든 상관없이 쓰기 경로는 하나로 통일됩니다.
판정 결과를 검색에 연결하기
프런트엔드는 PDO를 통해 SQLite와 통신하는 PHP 8.4이며, 전체 텍스트 검색(full-text search)을 위해 FTS5 가상 테이블을 사용합니다. 모더레이션 게이트(moderation gate)는 단일 조인(join) 조건이며, 그 외의 모든 것은 일반적인 BM25 랭킹 기반 검색입니다:
<?php
declare(strict_types=1);
...
핵심적인 라인은 AND v.moderation_status = 'allow'입니다. 인덱서(indexer)가 allow 행만 videos_fts로 복사하더라도, 쿼리 시점에 실시간 상태 컬럼을 다시 확인하는 것은 의도적인 이중 안전장치(belt-and-suspenders)입니다. 만약 관리자 패널에서 어떤 행을 block으로 변경한다면 — 예를 들어 review 큐를 확인하던 작업자가 무언가를 발견했을 경우 — 재인덱싱(reindex) 없이도 바로 다음 요청부터 검색 결과에서 사라집니다. 판정은 단순히 기록되는 곳뿐만 아니라, 읽히는 곳에서도 강제됩니다.
작은 인덱스 하나가 모더레이션 조회 비용을 저렴하게 유지해 줍니다. 부분 인덱스(partial index)는 실제로 판정이 완료된 행들만 포함하도록 설정되어 있습니다. 시간이 흐름에 따라 테이블의 대부분을 차지하게 되겠지만, 아직 처리되지 않은 새로운 NULL 백로그(backlog)는 건너뜁니다:
CREATE INDEX IF NOT EXISTS idx_videos_moderation
ON videos (moderation_status)
WHERE moderation_status IS NOT NULL;
제 스택의 나머지 부분 덕분에 얻은 기분 좋은 부수 효과가 있습니다. 차단된 행(row)은 페이지로 렌더링되지 않기 때문에, LiteSpeed 페이지 캐시(page cache)에 들어가지도 않고 Cloudflare의 에지(edge)로 푸시되지도 않습니다. 모더레이션(moderation) 결정은 별도의 명시적인 퍼지(purge) 없이도 제가 이미 운영 중인 모든 캐싱 레이어를 통해 전파됩니다. 가장 저렴한 캐시 무효화(cache invalidation)는 아예 생성되지 않은 페이지입니다.
과거의 나에게 해주고 싶은 말
- 패턴을 열거하지 말고 정책을 기술하세요. 정규 표현식(regex) 차단 목록은 첫 번째 커밋부터 발생하는 기술 부채이며, 유지보수는 끝이 없습니다.
tool_choice를 사용하여 구조화된 출력(structured output)을 강제하세요. API가 스키마 검증(schema-validated)된 객체를 제공할 수 있는데, LLM으로부터 산문(prose)을 파싱하려고 하지 마세요.- 데이터베이스를 통해 결합도를 낮추세요(Decouple). 모더레이션 장애가 발생하더라도 인덱싱(indexing)은 멈춰야 하지만, 수집(ingestion)은 절대 멈춰서는 안 됩니다. 이 정도 규모에서는
NULL센티널 컬럼(sentinel column)이 아주 훌륭한 작업 큐(work queue) 역할을 합니다. - 프롬프트를 바이트 단위로 안정적으로 유지하고 캐싱을 활용하세요. 고정된 시스템 프롬프트(system prompt)와 도구 스키마(tool schema)를 사용하면 거의 모든 요청을 캐시 히트(cache hit)로 만들 수 있으며, 지연 시간(latency)에 민감하지 않은 작업은 Batch API를 통해 비용을 절반으로 줄일 수 있습니다.
- 인덱스 시점뿐만 아니라 쿼리 시점에도 판결을 재확인하세요. 정책이 변경되었을 때 이를 적용하기 위해 재인덱싱(reindex)이 필요해서는 안 됩니다.
- 경계선에 있는 사례는 자동으로 차단하지 말고 사람에게 라우팅하세요.
review큐는 여러분의 평가 기준(rubric)에 실제로 무엇이 빠져 있는지 발견하는 곳입니다.
전체 모더레이션 레이어는 약 150줄의 Python 코드와 PHP의 추가적인 WHERE 절 하나로 이루어져 있으며, 이는 제가 조용히 건드리기 두려워했던 정규 표현식 파일을 대체했습니다. 메타데이터 모더레이션은 한 번 해결하면 끝나는 문제가 아닙니다. 스패머들은 적응하며, 평가 기준 또한 적응해야 합니다. 하지만 정책이 수천 줄의 패턴 목록 대신 시스템 프롬프트의 한 단락 속에 살아있다면, 적응한다는 것은 한밤중에 유니코드 호모글리프(Unicode homoglyphs)를 쫓는 것이 아니라 문장 하나를 수정하는 것을 의미합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기