Skip to Content
DecisionsADR-0006: 정비 뉴스 수집을 이 레포의 스케줄 배치 파이프라인으로 두고, 신규 기사를 N→M 토픽 클러스터로 요약해 admin/news-brief 에 적재한다

ADR-0006: 정비 뉴스 수집을 이 레포의 스케줄 배치 파이프라인으로 두고, 신규 기사를 N→M 토픽 클러스터로 요약해 admin/news-brief 에 적재한다

  • Status: Accepted
  • Date: 2026-09-04
  • 관련: HMH-9100 (이 ADR) · 코어 API POST /admin/news-brief — PR #4765 / HMH-9094 (koreanproptech/howmuchhome) · 데이터 소유 경계는 ADR-0001

Context

정비사업 뉴스(재개발·재건축)를 유저에게 AI 요약 게시물(news_brief) 로 노출하려 한다. 원문 기사는 외부 언론사 사이트에 있고, 이를 주기적으로 수집·요약해 코어 API에 넣어야 한다.

코어 API에 이미 생성 엔드포인트가 있다(PR #4765). 이 계약을 읽으면 설계가 상당 부분 강제된다:

  • news_brief 는 원문이 아니라 요약 결과물이다. 엔티티 docstring이 명시한다 — “원문 기사·크롤링·중복 판정은 외부 요약 모듈 소관이라 이 엔티티는 유저에게 노출할 결과만 담는다.”크롤·중복판정·요약을 담당하는 “외부 요약 모듈”이 이번에 우리가 만드는 것이다.
  • POST /admin/news-brief 본문의 content(NewsBriefContent, content_version=SUMMARY_BULLETS)는 {summary, bullets[], keywords[], references[], images[]} 구조다. references 는 1건 이상·is_primary 정확히 1건이 검증된다. 원문 body 를 그대로 넣을 수 없다 — 요약 단계가 계약상 필수다.
  • 백엔드는 중복 판정을 하지 않는다. POST 는 매번 새 UUID 를 발급하고, 요청에 source URL 유니크 제약이 없다. 같은 기사를 두 번 보내면 브리프가 두 개 생긴다(POST 는 비멱등).
  • /admin/*authorized_admin_id(OAuth2 Bearer, phone+password 로그인 토큰) 인증이다. 자동화 작업은 어드민 자격 토큰이 필요하다.
  • images 는 URL 이 아니라 s3_key(+ OG_THUMBNAIL/LICENSED 는 source_url) 를 요구한다 — 이미지를 넣으려면 S3 업로드 파이프라인이 선행돼야 한다.

수집 대상 사이트의 구조도 제각각이다(실측): m.dosijeongbi.com 은 목록이 JS 렌더라 기사 링크가 클린 숫자 URL 이고 본문에 nav·광고가 섞여 마커 기반 정제가 필요하다. 반면 housingherald.co.kr 은 표준 CMS 라 목록이 서버렌더이고 본문이 itemprop="articleBody" 로 깔끔하다.

검토한 대안

결정축대안채택 안 한 이유
실행 위치Vercel Cron + apps/api 라우트 핸들러크롤 N건 + LLM 요약은 함수 타임아웃(길어야 300s대)에 걸리기 쉬워, 매 틱을 소량 배치로 인위적으로 잘라야 함
별도 워커 레포/서비스운영·배포·관측 대상이 하나 더 늘고, packages/db·fetcher 타입 공유를 잃음
요약 카디널리티기사 1건 = 브리프 1건(1:1)같은 사건을 여러 매체가 쓰면 중복 브리프가 쏟아짐. 엔티티가 의도한 “여러 기사를 묶어 하나로”와 어긋남
주기별 전체를 1건으로(N:1)서로 무관한 기사가 한 브리프에 뭉쳐 품질 저하
중복 판정 위치백엔드에 위임백엔드가 하지 않음(위 Context). 요청에 dedup 키 자체가 없음
하지 않음POST 가 비멱등이라 매 주기 중복 게시
요약 엔진Firecrawl json 추출(스크랩과 동시)프롬프트·톤·한국어·bullets 개수 제어가 약하고, 여러 기사를 한 번에 넣어 묶는 클러스터링에 안 맞음

Decision

크롤 → 중복판정(Neon) → 배치 요약(Claude) → 적재(admin/news-brief) 를 이 레포 안의 스케줄 배치 파이프라인으로 구현한다. 실행은 GitHub Actions 스케줄(하루 2회), 코드·타입은 packages/db·packages/api fetcher 를 재사용한다.

파이프라인(예정 배선):

  1. 목록 수집 — 사이트별 어댑터(listRecent())가 신규 후보 {source, sourceUid, url, title, publishedAt} 를 낸다. 사이트별 파싱 차이(JS렌더/표준CMS)를 어댑터 뒤로 격리한다. 첫 어댑터: dosijeongbi, housingherald.
  2. 중복 판정 + 원자적 선점 — Neon crawledArticle 테이블((source, sourceUid) 유니크)로 이미 처리한 기사를 거른다. “조회 후 마킹”의 2단계는 동시 실행 시 TOCTOU(둘 다 미처리로 읽고 둘 다 POST)를 낳으므로, 후보를 발견하면 INSERT … ON CONFLICT DO NOTHING(또는 UPDATE … WHERE status='PENDING' 조건부 전이)으로 그 자리에서 원자적으로 선점한다. 선점에 성공한 작업만 이후 단계로 진행한다. Claude 호출 전에 걸러 토큰·중복을 막는다. (예정: packages/db/schema/crawledArticle.ts, packages/db/services/crawledArticle.ts)
  3. 상세 크롤 — 어댑터 fetchArticle() 가 Firecrawl 로 본문(markdown)+og 메타를 받아 정제한다.
  4. 배치 요약 — 이번 주기에 선점한 신규 기사들을 Claude 호출에 넣어 토픽별로 묶은 M개 브리프를 만든다(N→M, 교차 매체 묶음 허용). 한 호출의 입력이 무한정 커지지 않도록 한 배치의 최대 기사 수·입력 토큰 예산 상한을 두고, 초과분은 여러 배치로 분할 요약한 뒤 토픽 기준으로 병합한다(각 브리프의 원문 참조·N→M 성질 보존). 요약의 정확한 출력 포맷은 후속 티켓에서 확정하되, 스키마를 “브리프 배열 + 각 브리프의 소속 기사 참조”로 잡아 유연화한다.
  5. 적재 — 브리프마다 NewsBriefContent(references=소속 원문들, primary 1건) 를 조립해, 어드민 토큰으로 POST /admin/news-brief. 2단계에서 선점(POSTING)에 성공한 작업만 POST 한다. 성공 응답을 받은 뒤에만 소속 기사행들을 status=POSTED·newsBriefId 공유로 확정한다.

비멱등 대응: 선점 = POSTING 전이는 위 2단계에서 원자적으로 일어나고, POST 성공 응답을 받은 뒤에만 POSTED+newsBriefId 로 확정한다. POSTING 으로 남은 레코드(POST 성공 후 DB 기록 전 크래시)는 자동 재POST 하지 않고 다음 주기에 사람이 확인한다.

범위: v1 은 이미지 생략(references 만; images 는 빈 배열 허용). 어드민 토큰은 소스에 하드코딩하지 않고 GitHub Actions 시크릿에 두어 런타임 env 로만 주입한다(전용 봇 계정 자격으로 로그인해 토큰을 발급하는 완전 자동화는 후속). 데이터 소유는 ADR-0001 경계와 일치한다 — 크롤/중복 상태는 프론트가 소유하는 Neon 에 둔다.

Consequences

좋아지는 것

  • 백엔드가 위임한 “외부 요약 모듈” 책임(크롤·중복·요약)을 프론트 배포 주기로 독립적으로 굴릴 수 있다
  • 사이트가 늘어도 어댑터 1개 추가로 끝난다 — 러너·DB·요약·적재는 공용
  • N→M 클러스터링이 엔티티 의도(“여러 기사를 묶어 하나로”)와 맞고, 중복 브리프를 구조적으로 줄인다

감수하는 것

  • Vercel 밖 실행 표면이 하나 늘었다. 이 레포 코드가 Vercel 함수가 아니라 GitHub Actions 러너에서도 돈다 — 시크릿·관측을 그쪽에도 둬야 한다
  • 중복 방지가 전적으로 우리 책임이다. Neon 유니크 제약 + 원자적 선점이 정확해야 하며, 여기가 무너지면 곧바로 유저에게 중복 브리프로 보인다. POST 성공~DB 확정 사이 크래시 창은 자동 복구하지 않는다(수동)
  • 어드민 토큰이 GH Actions 시크릿에 상주한다. 소스 하드코딩보다 낫지만, 전용 봇 계정 자격으로 런타임에 토큰을 발급하는 완전 자동화 전까지는 장수(long-lived) 어드민 토큰이 시크릿에 남는다
  • 요약 품질·비용이 Claude 호출 하나에 실린다. 정제 포맷이 확정되기 전까지 출력이 흔들릴 수 있다

이 결정을 다시 열어야 할 때

  • 크롤·요약이 GH Actions 스케줄 상한(러너 시간·하루 2회)으로는 부족해질 때 → 큐/워커 또는 Vercel Cron 소량 배치로 재검토
  • 백엔드가 references[].url 기반 dedup 또는 idempotency-key 를 추가하면 → 우리 쪽 POSTING 수동 복구를 걷어낼 수 있다
  • 이미지(S3 og-thumbnail)·동일 토픽 실시간 클러스터링 등 범위가 커져 배치 러너 하나로 감당이 안 될 때
Last updated on