포스트

40대 남성 아침 브리핑 뉴스레터 에이전트

AI 뉴스레터 에이전트와 같은 LangGraph 구조로 주제를 40대 남성의 남성복·스포츠·화장품으로 바꿔, 매일 아침 10건을 골라 Discord로 받게 만든 학습일지입니다.

40대 남성 아침 브리핑 뉴스레터 에이전트

같은 날 먼저 만든 매일 아침 AI 소식 5건 뉴스레터 에이전트와 같은 구조로, 주제만 바꿔 뉴스레터 에이전트를 하나 더 만들었다. 이번 주제는 40대 남성의 남성복·스포츠·화장품 아침 브리핑이다.

나는 기존 ai-newsletter-agent에서 중요한 내용을 PRD.md로 옮겨 뼈대를 만들고, 소스·키워드를 정리한 sources-master.docx를 준비하고, 단계마다 방향을 정했다. PRD를 주제에 맞게 고쳐 쓰고, 소스를 재고, 코드와 테스트를 만들고, 실행해 확인한 것은 Claude Code였다. 결과물은 my-newsletter-agent 저장소에 있다.

왜 40대 남성 패션·스포츠·뷰티 아침 브리핑인가

sources-master.docx에 정리한 독자는 출퇴근과 주말을 함께 챙기는 40대 남성이다. 정장보다 스마트 캐주얼을 입고, 러닝·수영·골프·등산으로 체지방과 관절을 관리하고, 운동 전후 피부와 안티에이징에 관심이 있다. 이 사람에게 필요한 정보는 Runner’s World, Men’s Health, GQ 코리아, 에스콰이어 코리아, Permanent Style 같은 국내외 매체에 흩어져 있다.

실제로 피드를 재 보니 문제가 숫자로 보였다.

  • 24시간에 약 60건이 올라온다.
  • 그중 16-22건은 연예 화보, 프로 경기 결과, 쇼핑 딜, 데이팅 글, 1020 트렌드처럼 이 독자와 상관없는 글이다.
  • 남은 글도 한 매체에 쏠린다. Runner’s World와 Men’s Health가 수집량의 절반 가까이였다.

그래서 매일 아침 이 독자에게 쓸모 있는 10건만 골라 한국어로 요약해 보내는 것을 목표로 잡았다. 주제는 바뀌었지만 LangGraph로 풀 문제는 AI 뉴스레터와 같다. 많이 모으고, 비교해서 고르고, 요약하고, 원문과 대조하고, 매일 아침 보낸다.

노드 구조와 이번 프로젝트의 대응

부노드가 던지는 질문AI 뉴스레터에서 고른 답이번에 바꾸거나 새로 확인한 것
1부 · 뼈대무엇부터 만들까?빈 노드 다섯 개로 끝까지 도는 틀설정을 audience.yaml로 빼고, 틀리면 시작 단계에서 멈추게 했다
2부 · collect기사를 어디서 가져올까?RSS + 원문 본문 추출docx 22개 항목을 관문으로 재서 피드 17개(매체 14곳) 채택
3부 · select많은 기사에서 어떻게 고를까?예선 → 본선 (서로 비교)파트 세 개 × 3건 + 결승 1건. 순위는 LLM, 배분은 코드
4부 · report요약을 어떤 모양으로 받을까?headline · summary · why 세 칸why를 “40대에게 왜 쓸모 있나”로 바꿨다
5부 · verify틀린 요약을 무엇으로 잡을까?따로 부른 LLM이 원문과 대조why 칸은 대조에서 빼고, 떨어진 자리는 예비 후보로 채운다
6부 · publish어디로 보낼까?Discord 웹훅파트별 메시지, 보낸 주소 기록, 사본 파일
7부 · 자동 실행매일 아침 누가 돌릴까?GitHub Actions예비 실행 중복 방지, 실행 기록(metrics)을 저장소에 커밋

전체 흐름은 이렇다.

1
2
START → collect → select ─(Send × 10)→ report → verify → publish → END
        피드 17개   3·3·3 + 결승 1     세 칸 요약   원문 대조   Discord

PRD로 뼈대를 먼저 잡았다

기존 ai-newsletter-agent에서 중요한 내용을 PRD.md로 옮겨 시작했다. 파일 여섯 개(graph.py, run.py, audience.yaml, requirements.txt, store/metrics.jsonl, REPORT.md)마다 할 일을 적은 형태다. Claude Code가 이 구조는 그대로 두고 주제만 40대 남성으로 바꿔 다시 썼다.

PRD는 몇 번 바뀌었다. 처음 안은 Claude Code가 docx의 시스템 프롬프트(주간 뉴스레터, 섹션 4개)를 따라 잡았고, 그 뒤 방향은 내가 정했다.

순서정한 것PRD에 반영된 것
1(처음 안) docx의 주간 4섹션 구성주간 발행, 섹션 4개
2노드 1-7부 구조로, 남성복·스포츠·화장품 각 3건, 매일 아침매일 07:30, 파트 3개 × 3건
3수집은 24시간, 하루 10건. 화장품이 모자라면 다른 파트로 채우고, 1건을 더해 중요한 파트에 준다하루 10건 배분 규칙
4추가 1건은 결승으로 정한다bonus: best

처음 정리한 0호 뉴스레터는 Claude Code가 실제 피드를 모아 원문을 읽고 사람 손으로 고른 기준선이다. 나중에 3부 select를 검증할 때 정답 세트로 썼다.

1부: 설정을 파일로 빼기

노드처럼 빈 노드 다섯 개로 START부터 END까지 도는 틀을 먼저 세웠다. 이번에는 독자·파트·배분 규칙·기준·제외 조건·소스를 전부 audience.yaml로 뺐다. 코드를 고치지 않고 이 파일만 바꿔서 고르는 기준을 바꿀 수 있다.

설정은 pydantic으로 검사한다. 파일이 없거나, yaml 문법이 틀리거나, bonus가 best도 파트 이름도 아니거나, 기본 몫 합이 하루 건수보다 크면 수집을 시작하기 전에 멈춘다.

1
2
3
4
5
6
7
allocation:
  total: 10               # 하루 발행 건수
  base_per_part: 3        # 파트마다 기본 몫
  max_per_part: 5         # 결승으로 받아도 이 이상은 안 감
  max_per_source_in_part: 2
  max_per_source_total: 3
  bonus: best             # best = 남은 자리를 결승으로 / 파트 이름 = 항상 그 파트

2부: 다섯 길과 소스 관문

노드의 다섯 길(공식 API · RSS/Atom · 공개 데이터셋 · 집계 서비스 · 직접 스크래핑) 중 RSS·Atom 피드를 골랐다. 패션·뷰티 매체는 공개 API가 없고, 아침 브리핑은 새로 올라온 글이 재료라 시간순 피드가 맞는다. 스크래핑은 고른 기사의 본문을 뽑을 때만 쓴다.

docx의 22개 항목을 관문 세 개로 쟀다.

관문질문
G1 본문원문에서 본문이 600자 넘게 뽑히나?
G2 생존최근에도 글이 올라오나?
G3 접근403·404·410, robots.txt로 막히지 않나?

재 보면서 피드 주소를 바꾼 곳이 있다.

  • SwimSwam: 전체 피드 10건이 모두 대회·대학 리크루팅 소식이라 훈련 카테고리 피드로 바꿨다.
  • Robb Report: 전체 피드는 자동차·부동산 경매뿐이라 그루밍 피드로 바꿨다.
  • Men’s Health: docx의 호주판 피드가 처음엔 0건이라 미국판 fitness·grooming 피드로 바꿨다.

뺀 곳을 다시 확인했다

처음에 뺀 10곳이 아깝다고 보고, 포함할 방법이 있는지 다시 확인해 달라고 했다.

결과소스근거
복구GQ 코리아docx 링크(두산매거진)엔 피드가 없었지만 gqkorea.co.kr에 전체·카테고리 피드가 있었다
복구Men’s Health AU피트니스 카테고리 피드에 주 8건
보류Gentleman’s Journal사이트맵은 허용이지만 마지막 갱신이 9일 전 (G2)
보류Racquet Magazine본문 484자, 주 1건 (G1·G2)
제외AskMen피드는 있지만 robots.txt가 ChatGPT-User·GPTBot 같은 AI 에이전트를 명시적으로 막는다
제외MR PORTER, Golf Digest, 화해, 올리브영403이거나 해당 경로 수집 금지 (G3)

재점검 중에 Claude Code가 브라우저처럼 보이게 요청 헤더를 바꿔 다시 시도하려다 권한 검사에서 거부됐다. 막힌 곳을 우회하는 방법이었다. 이후로는 my-newsletter-agent/0.1 (RSS reader; personal study)처럼 이름을 밝힌 요청만 쓰고, 막히면 건너뛰게 했다.

collect가 하는 일

  • 지난 24시간 글만 남긴다. 날짜가 없는 글은 버린다.
  • 주소를 비교할 때 ? 뒤를 통째로 떼지 않고 추적용 꼬리표(utm_* 등)만 뗀다. 골프매거진코리아도 기사 번호가 ?idx= 뒤에 있어서, AI 뉴스레터 때 AI타임스에서 겪은 문제가 그대로 생길 수 있었다.
  • 같은 글이 전체 피드와 카테고리 피드에 함께 있으면 한 번만 남기고 카테고리 쪽 파트를 붙인다.
  • 이미 보낸 주소(store/sent.jsonl)는 뺀다.
  • 한 소스가 실패해도 나머지는 계속 모으고, 실패한 소스 이름을 로그에 남긴다.

실제로 돌리니 60건이 모였고 응답 없는 소스는 없었다. 그런데 GQ 코리아 STYLE 피드로 들어온 각질 관리 기사가 남성복 꼬리표를 달고 있었다. 그래서 피드의 파트는 힌트로만 쓰고, 3부에서 LLM이 모든 기사의 파트를 다시 정하게 했다.

3부: 순위는 LLM, 배분은 코드

AI 뉴스레터의 select는 “100건에서 5건”이었다. 이번에는 “60건에서 파트마다 3건 + 결승 1건”이라, 그대로 LLM에게 10건을 고르라고 하면 그날 어느 파트가 몇 건 나올지 장담할 수 없다. 그래서 일을 나눴다.

1
2
3
4
5
약 60건 ─ ① 파트 배정 (LLM, 40건씩 묶음) ─→ 👔 / 🏃 / 🧴 / 제외
            ├─ ② 파트별 본선: 후보를 한 화면에 놓고 전부 순위 (LLM)
            │     └ 후보가 40건을 넘으면 예선: 묶음마다 8건 → 본선
            ├─ ③ 기본 몫: 파트마다 3건 (코드)
            └─ ④ 결승: 파트별 다음 후보끼리 한 번 더 순위 → 남은 자리 (LLM + 코드)

배분(allocate)은 LLM 없이 규칙만으로 돈다. 그래서 테스트로 증명할 수 있다. “화장품 후보가 1건뿐이면 나머지 9건을 다른 파트가 채운다”, “같은 매체는 하루 3건까지”, “bonus: sports로 고정하면 스포츠가 추가 1건을 받는다” 같은 규칙을 테스트 11개로 고정했다.

묶음 크기 40은 노드의 lost in the middle 이유를 그대로 따랐다. 24시간 60건은 파트 배정에서 묶음 두 개로 끝나고, 파트별 후보는 스포츠도 21-31건이라 지금은 예선 없이 본선만 돈다.

실제 데이터로 돌리니 테스트가 못 본 버그가 나왔다

같은 60건으로 실제 OpenAI를 여러 번 돌려 보면서 네 가지를 고쳤다. 고칠 때마다 먼저 그 상황을 재현하는 테스트를 만들어 실패를 확인한 뒤 고쳤다.

증상원인고친 것
화장품이 2건만 뽑혔다남성복부터 몰아 채우다 보니 남성복·스포츠가 GQ 상한 3건을 먼저 써서, 화장품의 GQ 기사가 막혔다후보가 적은 파트부터 한 건씩 돌아가며 채운다
하루 8건만 나왔다LLM이 서로 다른 기사에 “남성 그루밍” 같은 뭉뚱그린 사건 라벨을 붙여 같은 사건으로 지워졌다라벨 대신 same_as(같은 사건을 다룬 앞 순위 기사 번호)만 받는다
두 번 돌리면 10건 중 6건만 같았다temperature 0이어도 결과가 흔들렸다seed를 고정했다 → 9/10
9건에서 멈췄다결승 후보를 파트마다 남은 자리 수만큼만 모았는데, 같은 매체라 하나를 뽑으면 나머지가 상한에 막혔다결승 뒤에도 자리가 남으면 파트별 순위를 따라 끝까지 채운다

선별 기준에도 두 줄을 더했다. 처음엔 “28일 만에 식스팩” 같은 제목이 스포츠 1-2위에 올라왔다.

  • 단기간 몸 만들기·과장된 제목보다 오래 이어 갈 수 있는 습관·부상 예방·회복을 높게
  • 제품만 늘어놓은 쇼핑 목록보다 고르는 기준이나 쓰는 법을 알려 주는 글을 높게

그래도 그 기사가 한 번은 결승으로 들어왔다. 고친 뒤 결과는 이렇다.

검사결과
건수매번 10건
파트 정확도0호 10건이 모두 맞는 파트로 배정됐다
쏠림파트 안 같은 매체 2건 이하, 전체 3건 이하
재현두 실행 공통 9/10

4부: Send로 펼쳐 세 칸 요약

select 뒤 조건부 엣지에서 고른 기사 수만큼 Send("report", {"article": a})를 돌려준다. 기사마다 워커가 원문 본문을 trafilatura로 뽑고, OpenAI 구조화 출력으로 세 칸을 받는다.

칸내용
headline25자 내외 한국어 제목
summary두세 문장. 제품명·가격·수치·인명은 원문에 있는 것만
why40대에게 왜 쓸모 있나 한 문장

본문이 600자 미만이거나 못 가져오면 빼고 로그에 남긴다. 세 칸에 한글이 없으면 한국어 지시를 붙여 한 번 다시 쓰고, 그래도 영어면 뺀다. 워커 10개가 drafted에 동시에 쓰니 이 칸에는 operator.add 리듀서가 있다.

실제 10건은 본문이 1,313-6,000자로 모두 600자를 넘었고 영어로 나온 건 없었다. 요약 속 숫자를 원문에서 찾아보니 프라다 향수 “8-9시간 지속”, Cutler 스웨터 “£2800”, 28일 프로그램 “13파운드 감량”은 맞았다. 그런데 각질 기사 요약은 원문의 “면도하지 않는 날에 하라, 아침에 면도하면 밤에 해도 된다”를 “면도하는 날엔 하지 말라”로 단정했다. 5부가 잡아야 할 사례로 남겼다.

5부: 다른 호출로 원문과 대조

요약을 쓴 호출과 따로 부른 LLM이 제목·요약이 원문에 근거하는지 판정한다. 떨어진 자리와 4부에서 빠진 자리는 3부의 예비 후보(spares)를 자리마다 한 번씩만 요약·검수해서 채운다. 검수 호출이 실패하면 통과시키지 않는다.

처음엔 why 칸까지 대조했더니 멀쩡한 요약 2건이 “원문에 40대 언급이 없다”는 이유로 떨어졌다. why는 우리가 덧붙이는 해석이라 원문에 없는 게 정상이다. 대조에서 뺐다.

노드처럼 일부러 틀린 요약을 넣어 봤다.

시험1회차2회차
실제 요약 10건10/10 통과10/10 통과
숫자 바꿈 (8-9시간 → 12시간)걸러짐걸러짐
없는 제품 넣음걸러짐걸러짐
원문 오류 옮김 (좋은·나쁜 콜레스테롤 뒤바꿈)걸러짐걸러짐

마지막 시험은 실제 GQ 기사 본문에 LDL·HDL을 거꾸로 쓴 문장이 있어서 넣었다. 다만 앞의 각질 요약 조건 단정은 지시문에 그 예시를 그대로 넣어도 두 번 다 통과했다. 검수도 LLM이라 오판할 수 있다는 한계로 남겼다.

6부: Discord로 보내기

새 Discord 채널을 만들어 웹훅을 받았다. 메시지는 네 개로 나눠 보낸다.

  1. 날짜와 오늘 배분 (👔 3 · 🏃 4 · 🧴 3)
  2. 파트마다 한 메시지. 기사마다 카드 한 장, 제목이 원문 링크

Discord 한도(메시지당 카드 10장, 6,000자)를 넘으면 가장 긴 요약부터 줄인다. 실제 10건은 메시지당 51-1,132자라 한도에 한참 못 미쳤다. 보낸 날에만 issues/날짜.md 사본을 남기고 보낸 주소를 store/sent.jsonl에 쌓는다. dry-run 사본은 git이 무시하는 output/에 쓴다.

실제로 보내기 전에 하나를 막았다. 오늘 날짜로 보내면 사본이 issues/2026-10-29.md에 써지는데, 그 이름이 손으로 만든 0호 파일과 같았다. 0호를 issues/0호-2026-10-29.md로 옮긴 뒤 보냈다. 결과는 10건, 메시지 4개, 보냄이었다.

7부: GitHub Actions로 매일 아침

1
2
3
07:30  실행 → 테스트 57개 → 브리핑 만들고 보내기 → 보낸 기록 커밋
07:50  예비 실행 (오늘 이미 보냈으면 건너뜀)
08:10  예비 실행

run.py가 하는 일은 이렇다.

  • 오늘(KST) 날짜가 store/sent.jsonl에 있으면 예비 실행은 그래프를 돌리지 않고 끝낸다.
  • 보내야 하는데 결과가 보냄이 아니면 exit 1로 끝내 Actions를 빨간불로 만든다. 설정이 틀리면 exit 2.
  • 실행마다 store/metrics.jsonl에 한 줄을 쌓는다. 수집 수, 소스별 건수, 파트별 배분, 빠진 이유, 결과, 토큰, 추정 비용이 들어간다.

Actions는 실행이 끝나면 파일이 사라진다. 다음 날 중복을 막으려면 보낸 기록이 남아야 해서, 워크플로가 store/와 issues/를 저장소에 커밋한다. 같은 그룹으로 묶어 한 번에 하나씩만 돌게 했다. 그래서 뒤 실행은 앞 실행이 커밋한 기록을 받아서 중복을 안다.

빈 값으로 등록된 Secrets

Secrets를 등록하는 명령이 .env를 상대 경로로 찾았다. 다른 폴더에서 실행하면 grep: .env: No such file or directory가 나오는데, gh secret set은 빈 값을 받아서 성공(✓)으로 등록했다. 여러 번 그렇게 빈 값이 들어갔다. Claude Code가 scripts/set_secrets.sh를 만들었다. 어느 폴더에서 실행해도 같은 .env를 읽고, 파일이 없거나 값이 비면 아무것도 등록하지 않고 멈춘다. 이 스크립트로 등록한 뒤 Actions를 dry-run으로 돌려 성공했다. 테스트 57개 통과, 61건 → 10건이었고, 실행 기록에는 파이프라인 58초, 토큰 약 5만 개, 약 $0.026이 남았다.

테스트가 실제로 바깥을 부르고 있었다

테스트는 처음부터 LLM과 피드 요청을 가짜로 바꿔 끼우게 짰다. 그런데 5부를 넣자 4부 통합 테스트가 실제 OpenAI 검수를 부르고 있었다. 6부에서는 테스트가 진짜 output/ 폴더에 파일을 쓰고 있었다. tests/conftest.py에서 모든 테스트의 OpenAI·피드·본문 요청·Discord 발송을 기본으로 막고, 파일 경로도 임시 폴더로 돌렸다. 테스트 시간도 2.8초에서 0.1초로 줄었다.

파일테스트 수
test_skeleton.py (설정·그래프 흐름)9
test_collect.py6
test_select.py (배분 규칙 11 + 노드 1)12
test_report.py7
test_verify.py7
test_publish.py9
test_run.py (중복 방지·exit·metrics)7

제출 전에 다시 확인한 것

나는 과제 제출 구조와 저장소를 비교해 보고, 디렉터리가 틀리게 올라가 있다고 짚었다. 확인해 보니 필수 파일 여섯 개 중 두 개가 없었다.

  • store/metrics.jsonl: 실제 발송 때만 기록하게 만들었는데, 유일한 실제 발송이 기록 기능을 넣기 전이었다. PRD는 결과를 보냄 / dry-run / 실패 셋 다 기록하라고 정했으니 dry-run도 기록하게 고쳤다. 첫 발송 줄은 로그와 sent.jsonl로 확인되는 값만 넣고, 모르는 값(소스별 건수, 토큰)은 비워서 “재구성”이라고 표시했다.
  • REPORT.md: 분야·독자 정의, 소스 채택표, 선별 로직, 파이프라인 구조도(Mermaid), 실행 기록, 회고 여섯 항목으로 썼다. Discord 발행 화면은 내가 캡처해 저장했고, Claude Code가 REPORT에 넣었다.

교재 HTML과 docx는 저장소에 올리지 않고 로컬에만 두기로 했다. README도 저장소 첫 화면답게 다시 구성해 달라고 해서, 받는 화면 미리보기와 10건 고르는 흐름, 숫자 요약을 넣었다.

아직 남은 것

  • 10월 30일 07:30 첫 자동 발송 확인, 3일 연속 도착 확인
  • 화장품 후보가 24시간 1-4건뿐이다. 국내 그루밍 소스를 더 찾아야 한다.
  • 검수가 조건을 단정으로 바꾼 요약을 못 잡는다.
  • GQ 코리아가 피드 4개로 하루 15건이 들어와 남성복 후보 대부분을 차지한다. 일주일 기록으로 매체 상한을 다시 본다.

정리

  • 기존 ai-newsletter-agent의 PRD를 뼈대로 쓰니, 주제가 바뀌어도 노드 다섯 개와 State 구조는 그대로 가져올 수 있었다. 바뀐 것은 소스, 기준, 배분 규칙이었다.
  • 소스는 22개 항목을 피드 응답·24시간 건수·본문 길이·robots.txt로 재서 정했다. 뺀 곳도 다시 확인해 두 곳을 살렸고, AI 에이전트를 막은 곳은 넣지 않았다.
  • “파트마다 3건 + 결승 1건”은 순위만 LLM에게 맡기고 배분은 코드로 빼서, 규칙을 테스트로 고정했다.
  • 실제 데이터로 반복해서 돌려 보니 테스트가 못 본 버그가 네 개 나왔다. 하나씩 재현하는 테스트를 먼저 만들고 고쳤다.
  • 실패가 성공처럼 보이는 곳을 따로 막았다: 발송 실패 시 exit 1, 검수 호출 실패 시 불합격, 빈 Secret 등록 방지, 테스트의 실제 호출 차단.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.