데일리 다이제스트
구독형 콘텐츠 서비스의 글을 매일 파싱해 본문 전문 대신 재서술 요약을 남기는 비공개 읽기 아카이브 - 저작권 제약을 문서가 아니라 스키마와 저장 게이트로 강제했다.
- TypeScript
- Playwright
- Local LLM
- Postgres
- Self-hosted
- QA
Setup
- Problem
단톡방에 흘러가는 글은 며칠이면 묻힌다. 텍스트와 이미지가 따로 떨어져 맥락이 끊기고, 읽을 시간이 없어 넘긴 글은 다시 찾아 올라갈 방법이 없다. 날짜별로 차분히 따라잡을 자리가 필요했다. 기술적으로는 두 겹의 장벽이 있었다. 원문은 시간 제한이 걸린 접근 링크로만 열리고, SPA라 서버리스에서 그냥 fetch하면 본문 대신 JS 셸이 온다. 실제로 렌더링하지 않으면 글자 하나 못 가져온다. 그런데 진짜 어려운 문제는 파싱이 아니었다. 원문이 유료 구독 서비스의 콘텐츠다. "본문을 복제하지 않는다"는 제약을 지키면서 읽을 만한 아카이브를 만들어야 했고, 이 제약은 지키기 어려워서가 아니라 어기기 쉬워서 위험했다. 급할 때 본문을 통째로 넣어두고 나중에 정리하겠다는 유혹이 항상 있다.
- Context
그래서 설계 문서에 "원문 전문을 저장하거나 재호스팅하지 않는다"고 먼저 못박고, 그 문장이 코드에서 어떤 형태로 존재해야 지켜지는지를 정했다. 문서에 적힌 규칙은 잊히고, 급할 때 어겨지고, 나중에 손대는 코드는 아예 모른다. 규칙이 스키마에 있어야 지켜진다고 봤다. 제약은 세 가지였다. 첫째, 서버리스로는 인증이 걸린 SPA를 렌더링할 수 없으니 상시 켜진 머신이 필요했다. 둘째, 원문 본문이 외부 API로 나가면 안 됐다. 셋째, 검색에 잡히면 안 되는 폐쇄형이어야 했다. 결과적으로 Playwright 워커를 집에 있는 Mac mini에 두고, 요약은 로컬 LLM으로만 돌리고, 웹은 robots.txt 전체 disallow + noindex로 색인을 막았다.
- Users
운영자 본인과 비공개 소규모 읽기 그룹. 공개 모집이 없고 링크를 아는 사람만 도달하는 구조다. 실사용자 수, 좋아요·조회 총량 같은 지표는 집계 테이블은 있지만 확인해서 적을 수 있는 건 노트 수뿐이다. 나머지는 측정 전이다.
- Hypothesis
원문 본문 전문을 저장하지 않고 재서술 요약과 인용구 한 개만 남겨도 날짜별로 따라잡는 읽기 아카이브로 충분하고, 그 제약은 문서가 아니라 스키마와 저장 게이트로 박아야 실제로 지켜진다.
Build
- What I did
- 잡 큐 + 헤드리스 렌더링 파이프라인 - 운영자가 원문 URL을 넣으면 API가 잡으로 등록하고, Mac mini 워커가 폴링해 Playwright로 실제 렌더링한 뒤 본문 텍스트와 이미지 출현 순서·캡션을 뽑는다
- 2단계 요약 - 로컬 LLM(Ollama)으로 카드용 짧은 요약(인용구 1개 + 세 줄 요약)과 아카이브용 재서술 단락 4-8개를 따로 만든다. 외부 요약 API를 쓰지 않아 원문이 밖으로 나가지 않는다
- 이해도 퀴즈와 "한 걸음 더" 인사이트를 별도 LLM 호출로 생성하고, 키워드 개념 해설은 생성 시점에 미리 만들어 DB에 캐시
- 주간 트렌드 - 새 노트가 들어오면 워커가 그 주의 다른 노트를 모아 헤드라인·공통점·글별 교훈·회고를 함께 만든다
- 웹 - 최신 1건 Hero + 날짜별 타임라인 홈, 상세, 주간·월간 아코디언 뷰, 요약·캡션 키워드 검색. 정적 export라 서버가 없다
- 저장 게이트와 톤 게이트 - 저장 직전 runSaveGate가 errors/warnings를 나눠 점검하고, normalizeDashes가 em/en dash를 하이픈으로 결정론적으로 치환하며, lintTone이 세 줄 요약 개수가 3이 아니거나 해요체가 의심되면 경고를 낸다
- 운영 도구 - 파싱 진행 단계를 체크리스트로 보여주는 에디터, 진행 중 잡 취소와 노트 삭제가 되는 대시보드, 15분마다 방치된 잡(30분 넘게 점유)을 다시 대기로 되돌리는 cron
- Product decisions
- 저작권 제약을 스키마 수준에서 강제 - 설계 문서에 "원문 전문을 저장하거나 재호스팅하지 않는다"고 적고, notes 테이블에 본문 전문 컬럼 자체를 만들지 않았다. 영구 저장 대상은 재서술 요약, 운영자 메모, 인용구 1개뿐이다. 없는 컬럼에는 실수로도 못 넣는다
- 이미지를 정책으로 관리하려다 기능째로 삭제 - 처음엔 hotlink /
user_upload/ stored 3단계로 나누고 stored(원본 재호스팅)는 저장 게이트에서 경고를 띄우게 했다. 그런데 경고가 계속 뜨는 기능은 결국 위험을 사람 주의력에 맡기는 것이라, 2026-06-22에 이미지를 전면 제거하고 텍스트 전용으로 갔다 - 요약 LLM을 로컬 모델 전용으로 - 외부 LLM API 연동을 지우고 Ollama만 쓴다. 품질을 조금 내주는 대신 원문 본문이 외부 서비스로 나가지 않는다는 성질을 얻었고, 이건 저작권 제약과 같은 방향이다
- 서버리스 대신 상시 켜진 머신 + 잡 큐 폴링 - 인증이 걸린 SPA는 fetch로 JS 셸만 오니 실제 브라우저가 필요했다. 초기엔 무료 플랜에 API를 올렸지만 유휴 스핀다운으로 콜드스타트가 문제가 돼, 2026-07-20에 API까지 Mac mini의 launchd로 옮겼다
- 읽기 로그인 제거(2026-06-23) - 읽는 사람이 매번 패스코드를 넣는 마찰이 컸다. 읽기는 공개로 열고 쓰기(에디터)만 운영자로 막되, 검색 색인 차단과 "링크를 아는 사람만 도달"로 비공개를 유지하기로 했다. 이 결정이 뒤에 적을 결함 하나를 만들었다
- 출력 톤을 프롬프트가 아니라 저장 직전 코드로 강제 - LLM은 확률적이라 "em dash를 쓰지 말라"고 적어도 가끔 쓴다. 그래서 저장 경로에서 결정론적으로 치환하되, 원문 발췌인 인용구는 예외로 두어 손대지 않는다
- QA considerations
- 저장 게이트의 errors / warnings 분리 - 전부 차단으로 만들면 운영자가 게이트를 끄고, 전부 경고로 만들면 아무도 안 읽는다.
source_url누락,source_tag값 이상, "아카이브 블록과 이미지가 모두 비어 저장할 변형물이 없음"은 차단. stored 정책 이미지나file_key없는 업로드는 경고로 통과 - 톤 게이트가 원문 인용구까지 바꿔버리는 경우 - normalizeDashes는 저장 경로에서 em/en dash를 하이픈으로 결정론적으로 치환하는데, 원문 발췌인 인용구까지 고치면 그건 더 이상 인용이 아니다. 그래서 인용구 필드는 치환 대상에서 뺐다. lintTone의 해요체 검사는 문장 끝을 보는 휴리스틱이라 오탐이 나므로 차단이 아니라 경고로만 두고, 오탐 가능성을 코드 주석에 적어뒀다
- LLM이 링크를 지어내거나 깨진 퀴즈를 뱉는 실패 모드 - normalizeQuizInsight가 보기 2개 미만이거나 정답 인덱스가 보기 범위를 벗어난 문항을 버리고, URL처럼 보이는 키워드를 제거한다. 키워드는 "검색해볼 개념"이어야지 존재하지 않는 링크면 안 된다
- 파싱 실패를 타입으로 열거 - ParseErrorCode =
LOGIN_OR_EMPTY|NAV_FAILED|NO_CONTENT. 본문이 200자 미만이면 접근 링크 만료나 로그인 셸로 간주해 저장하지 않고 실패로 보고한다. 반쯤 비어 있는 노트가 조용히 쌓이는 게 최악이다. networkidle이 안 잡히면 domcontentloaded로 1회 재시도 - SSRF 방어 - 워커가 실제 브라우저로 여는 URL이라 내부망 주소나 메타데이터 엔드포인트를 넣으면 그대로 열린다. https 프로토콜 + 원문 도메인(및 서브도메인)만 통과시키고 나머지는 400으로 거절한다
- 스키마와 계약이 갈라지는 자리 두 곳 - 컴파일타임에는 Exact<> 조건부 타입으로 Zod WorkerOutputSchema의 추론 타입과
types.ts의 WorkerOutput이 어긋나면 빌드가 깨지게 했다. CI가 없어도 컴파일러가 대신 잡는 자리다. 런타임에서는 워커와 API를 따로 배포하다 보니 구버전 워커가 새 필드 없이 주간 트렌드를 올려 잡 전체가 400으로 죽는 일을 실제로 겪었고, Zod .default([])로 받아 하위 호환을 열었다 - 발행일 타임존 - 원문이 00:00 KST(전날 15:00Z)에 발행되므로 UTC 날짜를 그대로 쓰면 발행일이 하루 밀린다. 날짜별 아카이브에서 이건 조용히 틀리는 종류의 버그라 KST 변환으로 고치고 코드 주석에 이유를 남겼다
- 저장 게이트의 errors / warnings 분리 - 전부 차단으로 만들면 운영자가 게이트를 끄고, 전부 경고로 만들면 아무도 안 읽는다.
Outcome
- Metrics
- 커밋 71개, 2026-06-22 ~ 2026-09-11 (KST 기준 git log 실측)
- pnpm 워크스페이스 4패키지(shared / api / worker / web), TS·TSX 6,598줄, API HTTP 라우트 28개
- 운영 DB 노트 117건 (일간 100 + 주간 17),
note_date범위 2026-06-15 ~ 2026-10-10 (2026-10-10 공개 읽기 API 실측) - 2026-07-22 이후 코드 수정은 두 묶음뿐이다 - 8월 21~22일(KST) 발행일 날짜 버그와 유령 워커 정리 4건, 9월 11일 연결 오류 오버레이 오탐 수정 1건. 그 사이 파싱 실패 건수는 여전히 확인할 방법이 없다
- 자동화 테스트 0개 - 테스트 파일도, 테스트 러너 의존성도 어느
package.json에도 없다 - 파싱 실패 코드 3종, 본문 최소 인정 길이 200자, Zod 런타임 검증이 걸린 API 경계 2곳
- 실사용 지표(활성 이용자 수, 좋아요·조회 총량, 파싱 성공률, 요약 소요 시간) 전부 측정 전
- Result / Learning
노트 117건이 쌓였고(2026-10-10 실측), 2026-07-22 이후 코드 수정은 두 묶음뿐인 채로 노트가 계속 들어오고 있다. 다만 말할 수 있는 건 거기까지다. 잡이 죽으면 cron이 되돌리고 파싱이 반쯤 실패하면 저장하지 않고 실패로 남기는 경로를 만들어 뒀지만, 그 경로가 실제로 몇 번 돌았는지도, 파싱이 몇 번 실패했는지도 집계를 두지 않아 모른다. "손이 안 갔다"와 "문제가 없었다"는 다른 말이다. 가장 분명하게 배운 건 제약을 어디에 두느냐의 문제다. "본문을 저장하지 않는다"를 문서에 적었을 때와 컬럼을 아예 만들지 않았을 때는 지켜지는 정도가 다르다. 이미지도 같은 이야기였다. 3단계 정책으로 나누고 위험한 경우에 경고를 띄우는 설계는 그럴듯했지만, 경고를 매번 읽고 판단하는 사람이 나 혼자라는 걸 감안하면 그건 방어가 아니라 미룬 결정이었다. 기능을 지우고 나서야 그 판단이 사라졌다. 동시에 이 프로젝트의 가장 큰 구멍도 분명하다. 게이트를 만들었지만 게이트를 검증하는 테스트가 0개다. 저장 게이트와 톤 게이트가 실제로 무엇을 막는지는 지금까지 전부 수동 확인에만 기대고 있다.
- Retrospective
- 자동화 테스트가 0개다. 혼자 쓰는 도구고 게이트 로직이 초반에 계속 바뀌어서 매번 손으로 확인하는 게 빠르다고 판단했는데, 그 판단이 유효한 구간은 이미 지났다.
gate.ts의 runSaveGate와tone.ts의 normalizeDashes / normalizeQuizInsight / lintTone은 입력을 넣으면 출력이 나오는 순수 함수라 테스트를 붙이기 가장 쉬운 자리인데도 없다. 다음 작업은 이 두 파일에 vitest를 붙여 errors/warnings 분리, dash 치환의 인용구 예외, 깨진 퀴즈 문항 제거를 고정하는 것이다. 파싱과 LLM 쪽이 아니라 여기부터인 이유는 여기가 저작권 제약을 실제로 지키는 코드이기 때문이다. - 읽기를 공개로 열면서 방어를 절반만 걸었다. 잡 목록 API는 "접근 링크가 담긴 원문 URL이 노출되므로 공개하지 않는다"고 운영자 전용으로 막아뒀는데, 노트 목록 API에는 같은 방어를 걸지 않아 인증 없이 같은 URL이 나간다. 정책을 한쪽에만 적용한 것이고, 내 코드를 내가 다시 읽다가 찾았다. 같은 맥락에서 레이트 리밋이 전혀 없고 기기 ID가 클라이언트 생성 UUID라 좋아요·조회 집계는 위조 가능하다. 공개 응답에서 원문 URL을 빼는 게 먼저다.
- 기능을 지울 때 문서를 같이 지우지 않았다. 로그인 보호, 2열 이미지 갤러리, 이미지 스토리지, 홈 타이틀 클릭으로 에디터 진입은 전부 이후 커밋에서 제거됐는데 README에는 그대로 남아 있다. 스키마와 게이트로 제약을 강제해 놓고 정작 문서 드리프트는 방치한 셈이다. 지금 README를 읽고 이 프로젝트를 판단하면 네 군데에서 틀린 그림을 갖게 된다.
- 코드를 고친 것과 배포된 것은 다른 말이었다. 7월 21일(KST)에 발행일을 KST 기준으로 뽑도록 파서를 고쳤지만, 그 코드는 8월 21일까지 실제로 돌지 않았다. Mac mini에 launchd 밖에서 6월 23일부터 떠 있던 워커 프로세스가 설치 스크립트의 정리(launchd가 관리하는 것만 내린다)를 피해 살아남아 옛 코드로 잡을 계속 처리했고, 새 워커와 큐를 두고 경쟁하니 발행일이 맞기도 틀리기도 했다. 불규칙하게 재현되는 버그의 원인이 코드가 아니라 프로세스였다. 이후 설치 스크립트가 launchd 밖의 워커를 찾아 PID와 시작 시각을 보여주게 했고, 워커가 시작할 때 실행 중인 커밋을 로그에 남겨 origin/main과 다르면 경고하게 했다. 이미 잘못 저장된
note_date는 재파싱 교정 도구로 되돌렸는데, 기본은 점검만 하고 --apply를 붙일 때만 고치게 나눴고, 교정에 실패한 건을 성공으로 세던 보고 오류도 같이 고쳤다. - 주석이 약속한 동작을 코드가 지키는지는 주석을 읽어서는 알 수 없었다. 연결 오류 오버레이가 폰에서 반복해서 떴고 한 번 뜨면 수동 새로고침 전까지 사라지지 않았는데, 정작 API는 정상이었다(공개 경로 20회 연속 200). 원인은 둘이었다. fetch가 한 번 거부되면 바로 오버레이를 띄웠는데 모바일에서는 탭을 백그라운드로 보내거나 와이파이와 LTE를 오가는 것만으로 그 거부가 난다. 그리고 주석에는 api:ok 이벤트로 오버레이를 내린다고 적혀 있었지만 그 이벤트를 쏘는 코드도 받는 코드도 없어서 복구 경로가 아예 없었다. 9월 11일(KST) 수정에서 /health로 실제 생존을 확인한 뒤에만 띄우고, 떠 있는 동안 5초마다 다시 확인하며 앱 복귀나 온라인 복구 때는 즉시 확인해 살아나면 스스로 내려가게 했다.
- 자동화 테스트가 0개다. 혼자 쓰는 도구고 게이트 로직이 초반에 계속 바뀌어서 매번 손으로 확인하는 게 빠르다고 판단했는데, 그 판단이 유효한 구간은 이미 지났다.
- Tech stack
- TypeScript
- pnpm workspaces (4 packages)
- Next.js 16 (static export)
- React 19
- Tailwind CSS v4
- Hono 4
- Neon Postgres (pg)
- PGlite
- Zod
- Playwright (headless Chromium)
- Ollama local LLM (qwen2.5:32b)
- node-cron
- Cloudflare Pages
- Mac mini + launchd + Tailscale Funnel