배경

롱블랙(longblack.co)은 매일 1편씩 프리미엄 기사를 발행하는 유료 콘텐츠 플랫폼이다. 비즈니스, 브랜드, 라이프스타일에 대한 깊이 있는 글들이 올라오는데, Contents Hub 다이제스트에 포함시키고 싶었다. 문제는 페이월 — 유료 구독자만 전문을 볼 수 있다.

이미 LinkedIn, Twitter 크롤러에서 쿠키 인증 패턴을 확립해둔 상태라 롱블랙도 같은 접근이 가능했다. 하지만 실제 구현 과정에서 몇 가지 인사이트를 얻었다.

인사이트 1: Two-Phase 크롤은 페이월 플랫폼의 표준 패턴

롱블랙 크롤러는 2단계로 동작한다:

  1. 목록 페이지 (/note) → 기사 URL 추출 (로그인 불필요)
  2. 개별 기사 페이지 (/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의 전체 파이프라인이 실제로 동작하는 것을 확인한 의미 있는 세션이었다.