배경
롱블랙(longblack.co)은 매일 1편씩 프리미엄 기사를 발행하는 유료 콘텐츠 플랫폼이다. 비즈니스, 브랜드, 라이프스타일에 대한 깊이 있는 글들이 올라오는데, Contents Hub 다이제스트에 포함시키고 싶었다. 문제는 페이월 — 유료 구독자만 전문을 볼 수 있다.
이미 LinkedIn, Twitter 크롤러에서 쿠키 인증 패턴을 확립해둔 상태라 롱블랙도 같은 접근이 가능했다. 하지만 실제 구현 과정에서 몇 가지 인사이트를 얻었다.
인사이트 1: Two-Phase 크롤은 페이월 플랫폼의 표준 패턴
롱블랙 크롤러는 2단계로 동작한다:
- 목록 페이지 (
/note) → 기사 URL 추출 (로그인 불필요) - 개별 기사 페이지 (
/note/{id}) → 전체 본문 추출 (쿠키 인증 필요)
이 패턴이 왜 중요하냐면, 대부분의 유료 콘텐츠 플랫폼이 "목록은 공개, 본문은 잠금" 구조이기 때문이다. 목록 페이지에서 어떤 글이 있는지 파악하고, 실제 읽기에만 인증을 건다. 크롤러도 이 구조를 따르면 효율적이다 — 목록 페이지 한 번 로드로 여러 기사 URL을 한꺼번에 수집.
# Phase 1: 목록 → URL 추출
article_urls = self._extract_article_urls(html) # a[href*="/note/"]
# Phase 2: 개별 기사 크롤 (최근 5개만)
for url in article_urls[:5]:
content = await self._fetch_article(page, url)
최근 5개 제한의 이유: 하루 1편 발행이므로 5개면 충분하고, Playwright 페이지 로드 비용을 최소화한다. 첫 크롤 시에도 기존 cutoff_time 안전장치가 중복을 방지한다.
인사이트 2: 개별 URL 크롤 API는 운영 필수 도구
18개씩 기사를 수동으로 크롤하다 보니, 자연스럽게 API가 필요해졌다:
POST /api/admin/crawl-urls
{
"subscription_id": 25,
"urls": ["https://longblack.co/note/1849", ...]
}
이 API의 핵심 가치는 **백필(backfill)**이다. 새 크롤러를 만들면 과거 콘텐츠를 수집해야 하는데, 스케줄러는 "새 글"만 잡는다. 특정 URL 목록을 직접 크롤하는 API가 있으면:
- 과거 아카이브 일괄 수집
- 크롤러가 놓친 글 보충
- 테스트 & 디버깅
144개 롱블랙 기사를 8배치에 걸쳐 수집하면서 이 API의 가치를 체감했다. 이후 LinkedIn, Twitter에도 동일 패턴을 확장 — urls kwargs를 받으면 타임라인 스크롤 대신 개별 페이지를 직접 방문한다.
인사이트 3: Markdownify로 원문 구조 보존
기존 크롤러들은 get_text()로 플레인 텍스트만 추출했다. 롱블랙 기사처럼 볼드, 인용, 리스트가 중요한 편집 콘텐츠에서는 구조가 사라지면 가독성이 급락한다.
markdownify 라이브러리를 도입해서 HTML → Markdown 변환:
from markdownify import markdownify as md
content = md(str(article_elem), heading_style="ATX", strip=["img", "script"])
이렇게 하면 원문의 볼드, 링크, 리스트, 인용문이 Markdown으로 보존된다. AI 다이제스트가 이 콘텐츠를 요약할 때도 구조 정보가 있으면 더 나은 요약을 생성한다.
인사이트 4: 시맨틱 검색의 함정 — 문서 임베딩 vs 쿼리 임베딩
가장 흥미로운 버그를 발견했다. "AI 시대에서 어떻게 살아가야 하나?" 같은 질문으로 시맨틱 검색을 하면 결과가 0건이었다.
원인: 임베딩 생성 함수(generate_embedding)에 MIN_EMBEDDING_LENGTH=100 체크가 있었다. 이건 문서 임베딩의 품질 게이트 — 100자 미만의 짧은 텍스트는 의미 있는 벡터를 생성하기 어렵다. 그런데 검색 쿼리도 같은 함수를 통과시키고 있었다.
검색 쿼리는 본질적으로 짧다. "AI 시대 살아가는 법"은 12자. 100자 게이트에 걸려서 항상 빈 결과.
# 수정 전 (문서용 함수를 쿼리에도 사용)
query_embedding = await generate_embedding(query, "") # 100자 미만 → None
# 수정 후 (쿼리는 직접 임베딩)
query_embedding = await provider.generate(query) # 길이 체크 없음
교훈: 임베딩에는 두 가지 컨텍스트가 있다. 문서 임베딩은 품질 게이트가 필요하지만, 쿼리 임베딩은 사용자 입력을 있는 그대로 벡터화해야 한다. 같은 함수로 처리하면 한쪽이 희생된다.
수정 후 "AI 시대 살아가는 법"으로 검색하면:
| 유사도 | 제목 |
|---|---|
| 0.7596 | 박웅현 2 : 뭉근하게 쌓는 삶을 살자 |
| 0.7463 | 슈퍼 개인 조명훈 : 마흔셋에 AI 디자이너가 된 인문학자 |
| 0.7449 | 고유지능 : 당신이 여전히 AI보다 똑똑한 이유 |
| 0.7310 | 롱블랙 2026 : '왜 일하는가' 묻는 시대 |
| 0.7301 | 하버드 인생학 특강 |
126개 롱블랙 기사에서 의미적으로 관련 있는 5편이 정확히 검색된다. 이제 RAG 파이프라인의 retrieval 단계가 작동한다.
인사이트 5: 콘텐츠 품질 게이트 — 통계 기반 이상치 탐지
144개 기사를 크롤한 후 본문 크기 분석:
- 평균: 9,265자
- 표준편차: 3,445자
- 하한: avg - 1.5 * std = 4,098자
500~1,100자 범위의 18개 기사가 발견됐다. 이건 크롤링 실패(페이월에 막혔거나 JS 렌더링 불완전)로 인한 불완전한 콘텐츠. 삭제 후 126개로 정리.
이 패턴은 다른 크롤러에도 적용 가능하다:
-- 이상치 탐지: 평균 - 1.5 * 표준편차 미만
SELECT title, LENGTH(content)
FROM contents
WHERE subscription_id = 25
AND LENGTH(content) < (
SELECT AVG(LENGTH(content)) - 1.5 * STDDEV(LENGTH(content))
FROM contents WHERE subscription_id = 25
)
ORDER BY LENGTH(content);
정리: 이번 세션에서 배운 것
| 인사이트 | 핵심 |
|---|---|
| Two-Phase 크롤 | 목록(공개) → 본문(인증), 페이월 플랫폼의 표준 패턴 |
| 개별 URL 크롤 API | 백필, 보충, 디버깅에 필수 — 스케줄러만으로는 부족 |
| Markdownify | 편집 콘텐츠는 구조 보존이 중요, get_text()는 정보 손실 |
| 문서 vs 쿼리 임베딩 | 같은 함수로 처리하면 안 된다 — 품질 게이트는 문서에만 |
| 통계 기반 품질 게이트 | 평균 - 1.5 * std로 이상치 자동 탐지 |
이번 세션으로 Contents Hub에 롱블랙 126편이 추가됐고, 시맨틱 검색으로 "AI 시대에서 어떻게 살아가야 하나?" 같은 질문에 관련 기사를 찾아줄 수 있게 됐다. 크롤 → 임베딩 → 검색 → RAG의 전체 파이프라인이 실제로 동작하는 것을 확인한 의미 있는 세션이었다.