본문으로 건너뛰기
강홍재/ James
← Work
EMBA2026 · Solo Builder· Started(First Commit date)

SPARK 주간 읽을거리

매주 손으로 쓰던 기사 큐레이션을 검증 게이트가 달린 발행 파이프라인으로 옮긴 프로젝트 - 규칙이 현실과 어긋났을 때 규칙을 지우는 대신 경고로 강등해 계속 보이게 뒀다.

  • Node.js
  • SQLite
  • Cloudflare Pages
  • Local LLM
  • Content Pipeline
  • QA

Setup

Problem

창업동아리 SPARK 단톡방에 매주 기사를 골라 올렸다. 힘든 건 노동량이 아니라 시스템 안에 진실이 세 개로 갈라져 있다는 점이었다. 큐레이션 규칙은 산문으로 쓴 마크다운에, 이번 주 콘텐츠는 매주 새로 쓰는 .txt에, 실제 출력물은 카톡에 붙여넣은 평문에 있었다. 셋이 각자 관리되니 서로 어긋나도 아무도 모른다. 증상은 구체적이었다. 규칙 문서에 "설명은 명사형으로 끝낸다"고 적어둬도 그걸 지켰는지는 발행 직전에 사람 눈으로 훑어야 했다. 링크가 죽었는지도 마찬가지다. 지난 호에 무엇을 넣었는지 찾으려면 카톡을 스크롤해야 하고, 웹 아카이브를 따로 만들면 카톡 텍스트와 웹 페이지가 서로 갈라진다. 그래서 목표를 "자동화"가 아니라 "진실을 각각 한 곳에 고정하기"로 잡았다. 규칙은 규칙 파일 하나, 데이터는 DB 하나, 출력물은 전부 거기서 나온 파생물. 제약은 인프라 비용 0원이었다.

Context

설계 문서에서 서빙 모델 두 가지를 비교했다. A는 매 요청마다 집에 있는 Mac mini가 응답하는 방식, B는 발행 시점에 정적 스냅샷을 만들어 Cloudflare Pages에 올리는 방식이다. B를 골랐다. 주간 콘텐츠는 한 번 정해지면 거의 안 바뀌는 정적 데이터라 매 요청마다 개인 기기를 때릴 이유가 없고, 엣지에 정적 파일로 올려두면 Mac mini가 꺼져 있어도 아카이브가 살아 있으며, 무엇보다 쓰기 경로가 공개 인터넷에 아예 노출되지 않는다. 비용 0원이라는 제약이 나머지 선택을 거의 다 결정했다. 유료 LLM API 대신 Mac mini에서 도는 로컬 Ollama, Postgres 대신 파일 하나짜리 SQLite, 그것도 네이티브 모듈 대신 Node 내장 node:sqlite. 런타임 의존성은 XML 파서 하나로 끝났다. 관리 UI는 프레임워크 없이 단일 HTML로 만들고, 공개 인터넷에 노출하지 않고 사설 네트워크로만 연다. 자동화 수준도 의도적으로 절반에서 끊었다. 수집만 자동이고 선별과 발행, 배포는 사람이 버튼을 누른다. 이건 미완성이 아니라 설계 의도라고 README에 적어뒀다.

Users

창업동아리 SPARK 구성원, 즉 카카오톡 단톡방 독자들이다. 큐레이션을 직접 하는 운영자는 나 한 명이고, 라이브 사이트 푸터에 SPARK 회장 크레딧이 공개돼 있다. 다만 정직하게 적자면 이 독자들이 실제로 얼마나 읽는지는 모른다. 단톡방 반응, 독자 수, 링크 클릭률은 측정 전이다. 사이트에 조회수 카운터를 붙여두긴 했지만 봇과 내 방문을 구분하지 않는 단순 증가 카운터라 트래픽 지표로 읽을 수 없다.

Hypothesis

큐레이션 규칙을 사람이 읽는 문서가 아니라 코드가 읽는 파일 하나로 옮기고 발행 직전에 자동으로 검사하면, 매주 눈으로 하던 확인이 사라지고 카톡 텍스트와 웹 아카이브가 갈라지지 않는다.

Build

What I did
  • 등록된 RSS/Atom 피드를 폴링해 최근 10일치 기사를 후보로 적재 - articles.url에 UNIQUE를 걸고 ON CONFLICT DO NOTHING으로 중복을 DB 레벨에서 차단
  • 수집한 링크마다 실제 HTTP 요청을 보내 살아 있는 것만 검증 기록을 남김 - HEAD를 거부하는 서버는 GET으로 폴백, 리다이렉트 추적, 동시성 6
  • 로컬 Ollama에 JSON 스키마를 강제해 카테고리·명사형 요약·적합도 점수·언어·해외 여부를 생성 - 규칙에 없는 카테고리가 오면 null로 떨궈 사람이 정하게
  • 관리 UI에서 후보를 적합도 순으로 보고 담기·순서 변경·요약 수정·수동 추가·삭제 (관리 API 라우트 19개, 단일 HTML 374줄, 빌드 도구 없음)
  • 발행 직전 rules.json 기준 8종 자동 검증 - 개수, 기간 이탈, 매체 다양성, 설명 유무, em dash 사용, 명사형 종결, 링크 실동작, 해외 태그
  • 같은 DB에서 카톡용 평문 .txt와 웹 HTML을 함께 렌더 - 두 출력이 갈라질 경로 자체를 없앰
  • 발행 버튼 하나로 검증 - 발행 - 정적 사이트 빌드 - Cloudflare Pages 배포까지 실행 (180초 타임아웃 후 강제 종료). 정적 사이트가 못 하는 조회수만 별도 Worker와 KV로 분리
Product decisions
  • 진실을 셋으로 쪼개 각각 한 곳에 고정했다 - 규칙은 rules.json, 데이터는 SQLite, 출력물은 전부 파생물. "카톡용 .txt를 손으로 편집하지 않는다"를 한 문장 규칙으로 못박았다. 손으로 고치는 순간 단일 진실이 다시 깨지기 때문
  • 검증 규칙을 발행 차단용과 경고용으로 나눴다 - 운영해보니 개수 규칙과 매체 다양성 규칙이 현실과 안 맞았고, 규칙을 지우는 대신 warn_only로 강등해 위반이 계속 화면에 뜨게 뒀다. 규칙이 틀린 건지 큐레이션이 틀린 건지 아직 결론이 안 났고, 결론이 날 때까지 사실을 지우지 않기로 한 것
  • 서빙 모델을 문서에서 A/B로 비교하고 스냅샷 발행을 골랐다 - 주간 콘텐츠는 거의 안 바뀌는 정적 데이터라 매 요청마다 개인 기기를 때릴 이유가 없고, 엣지 정적 파일이면 기기가 꺼져도 아카이브가 살아 있으며, 쓰기 경로가 공개되지 않는다
  • 자동화를 절반에서 끊었다 - 수집만 자동, 선별·발행·배포는 사람. 기계는 후보를 모으는 데 강하고 "이번 주에 이 기사를 동아리에 보낼 것인가"는 아직 사람이 낫다고 봤다. README에 설계 의도라고 명시
  • 유료 LLM API를 쓰지 않고 로컬 Ollama를 호출한다 - 비용 0원 목표. 같은 이유로 Postgres 대신 SQLite, 네이티브 모듈 대신 Node 내장 node:sqlite를 써서 네이티브 의존성을 0으로 만들었다. 주간 규모에 Postgres는 과하고 백업은 파일 복사면 끝난다
  • 삭제와 숨김을 분리했다 - hide는 라이브에서만 내리고 데이터는 남겨 언제든 재발행할 수 있게. 이슈를 지우면 담겨 있던 기사는 후보로 되돌아가고, 이슈에 담긴 기사는 삭제 자체가 거부된다
QA considerations
  • 게이트가 로그만 남기고 통과하지 않는가 - publish.js는 검증 실패 시 위반 항목을 출력하고 exit 1로 발행을 중단한다. 경고만 찍고 계속 진행하는 검증은 없는 것과 같다고 봤다
  • 링크가 정말 살아 있는가를 사람 눈이 아니라 HTTP로 - HEAD를 거부하는 서버는 GET으로 폴백, 리다이렉트 추적, HEAD 10초 GET 15초 타임아웃. 죽은 링크는 검증 기록이 안 남아 발행 직전 게이트에서 자동으로 걸린다
  • LLM이 만든 카테고리가 조용히 규칙 밖으로 새지 않는가 - Ollama에 JSON 스키마와 temperature 0.2를 걸고, 응답 카테고리가 rules.json의 목록 밖이면 null로 떨궈 사람이 결정하게 했다. 그럴듯한 새 카테고리가 자동으로 늘어나는 게 가장 조용한 실패 모드
  • 외부 피드 하나가 죽으면 그 주 수집이 통째로 멈추는가 - 피드별 try/catch로 격리하고 20초 타임아웃을 걸었다. RSS 2.0과 Atom을 같은 형태로 정규화하면서 title/link가 문자열이 아니라 객체로 오는 케이스, HTML 태그, 숫자·16진·명명 entity를 모두 처리했고, 403을 내는 서버용으로 브라우저 User-Agent를 붙였다
  • 내려간 호수가 엣지 캐시에 살아남지 않는가 - 은퇴 호수에는 묘비 페이지를 만들어 이전 배포가 남긴 Cloudflare 엣지 캐시를 덮어쓰고, 404.html로 미매칭 경로가 진짜 404를 반환하게 했다. 라이브에서 존재하지 않는 호수를 요청해 404가 나오는 것까지 확인
  • 순서를 바꿀 때 UNIQUE 제약에 걸려 깨지지 않는가 - issue_items에 UNIQUE(issue_id, position)가 걸려 있어, 트랜잭션 안에서 임시 음수 position으로 먼저 밀어두고 다시 1부터 N까지 쓰는 2패스로 처리했다
  • 확인된 공백 - 검증기를 검증하는 자동화 테스트가 0건이다. 명사형 종결 판정은 금지 어미 4개를 문자열로 매칭하는 휴리스틱이라 통과했다고 명사형이 보장되지 않고, 관리 API에는 CSRF 방어가 없고 인증은 단일 비밀번호 Basic Auth 하나뿐이라 관리 UI가 사설 네트워크 밖으로 나가는 순간 쓰기 경로가 그대로 열린다. 규칙을 코드로 옮기는 단계까지 왔고, 그 코드를 검증하는 단계는 아직

Outcome

Metrics
  • 9호 발행 (feed.json, 2026-10-10 실측) - #1~#5는 매주(KST 08-10 / 08-16 / 08-23 / 08-30 / 09-06), 그 뒤 3주를 건너뛰고 #6~#8을 09-26 하루에 몰아 발행, #9는 10-03. 커밋은 며칠에 몰려 있어서 운영의 증거는 git 히스토리가 아니라 발행 기록에 있고, 그 기록에는 밀린 3주도 그대로 남아 있다
  • 라이브 이슈 9호에 수록 기사 110건 (#1 8 / #2 10 / #3 18 / #4 17 / #5 13 / #6 11 / #7 14 / #8 5 / #9 14), 인용 매체 9곳, 카테고리 6종 전부 사용, 해외 태그 기사 2건, 링크 실동작 검증 기록(link_checked_at) 110건 전부
  • rules.json 검증 규칙 8종 전부 validate.js에 구현 - 그중 2종(개수, 매체 다양성)은 warn_only로 강등
  • 자동 수집 RSS 피드 4개, 관리 API 라우트 19개, npm 스크립트 9개
  • 백엔드 JavaScript 약 1,149줄(11개 파일) + 관리 UI 단일 HTML 374줄. 런타임 의존성 1개, 네이티브 의존성 0, 커밋 32개(2026-08-08 ~ 2026-09-06)
  • 자동화 테스트 0건 - 테스트 파일도 프레임워크도 없다
  • 조회수 카운터가 붙어 있지만 지표로 쓰지 않는다 - 봇과 내 방문을 구분하지 않는 단순 증가 카운터라 트래픽으로 읽을 수 없다. 단톡방 반응·독자 수·클릭률은 측정 전
Result / Learning

데모가 아니라 실제로 발행했다. #1부터 #5까지는 2026년 8월 10일부터 9월 6일까지 매주 나왔고, 그 뒤 3주를 건너뛴 다음 #6~#8을 9월 26일 하루에 몰아서 냈고, #9는 10월 3일에 냈다. 아카이브는 https://spark-weekly.pages.dev 에 이슈 9개와 feed.json이 그대로 열려 있다. 커밋은 며칠에 몰려 있어서 git 히스토리만 보면 주말 해커톤처럼 보이지만, 운영의 증거는 커밋 로그가 아니라 발행 기록에 있고, 그 기록에는 밀린 3주도 그대로 남아 있다. 이 프로젝트에서 가장 값진 순간은 코드가 아니라 규칙과 현실이 부딪힌 지점이었다. rules.json은 한 호에 8~9건을 담으라고 돼 있는데, 실제로 큐레이션을 해보면 그 주에 보낼 만한 기사가 그보다 많았다. 발행을 시작한 직후인 2026년 8월 10일 커밋에서 개수 규칙과 매체 다양성 규칙을 warn_only로 강등했다. 규칙을 지우지도, 숫자를 슬쩍 고치지도, 규칙을 무시한 채 발행하지도 않았다. 심각도만 낮춰서 계속 보이게 뒀다. 그 판단이 어디로 가는지는 이후 호수가 보여줬는데, #3과 #4는 규칙이 정한 8~9건 대신 18건과 17건을 담았고 그 사실이 매 발행마다 경고로 뜬다. 반대 방향도 있었다. #8은 5건으로 최소치 8건 아래였고, 같은 경고가 같은 자리에 떴다. 배운 건 게이트의 가치가 "막는 것"만이 아니라 "심각도를 조절할 수 있다는 것"에도 있다는 점이다. 하드 실패만 있는 검증기는 현실과 한 번 부딪히면 통째로 꺼지거나 무시당한다. 만약 개수 규칙을 지웠다면 지금 #3과 #4가 원래 기준에서 얼마나 벗어났는지 아무도 모를 것이다. 규칙과 현실이 다르다는 사실 자체를 데이터로 남겨둔 게 이 파이프라인이 한 일 중 제일 QA다운 일이었다.

Retrospective
  • 검증기를 만들어놓고 검증기의 테스트는 0건이다. 9호를 발행하는 동안 validate.js가 잘못된 걸 통과시켰는지 확인할 방법이 없었고, 규칙을 고칠 때마다 8종을 손으로 다시 돌려본 게 유일한 방어였다. 다음 작업은 기간 이탈·em dash·명사형 종결·링크 미검증 네 케이스의 테스트다. 가장 싼 값에 가장 큰 구멍이 메워지는 자리인데 미뤘다.
  • 단일 진실을 만들자고 시작한 프로젝트인데 사람용 문서가 다시 갈라졌다. 수집 스케줄을 토요일에서 매일로 바꾸고 README를 안 고쳤고, 규칙 파일에는 지금은 아무 효과가 없어 보이는 은퇴 호수 항목이 잔재로 남아 있고, 이슈마다 rules_version을 기록만 할 뿐 그 시점 규칙으로 과거 호수를 재현하는 로직은 없다. 코드가 읽는 진실은 지켰는데 사람이 읽는 문서는 못 지킨 셈이다.
  • 수집 단계에서 RSS description을 이미 파싱해두고도 요약 단계에는 제목과 매체만 넘긴다. 요약의 근거가 제목뿐이라 LLM 요약 품질의 상한을 스스로 낮춰놓은 상태다. 파이프라인 안에서 이미 갖고 있는 정보를 다음 단계로 안 넘긴 건 설계 실수에 가깝다.
Tech stack
  • Node.js >= 22.5
  • node:sqlite (내장 모듈)
  • SQLite
  • node:http (프레임워크 없음)
  • fast-xml-parser
  • Ollama qwen2.5:32b (로컬 LLM)
  • Cloudflare Pages
  • Cloudflare Workers + KV
  • wrangler
  • launchd (macOS)
  • Tailscale
  • Vanilla HTML/JS 관리 UI