아침 7시 반에 글 초안이 와 있게 만든 구조 — 수집, 후보, 초안, 검토
매일 06:00 뉴스 요약, 06:15 주제 후보 적재, 06:20 초안 한 건, 07:30 텔레그램 알림으로 이어지는 Spring 스케줄러 체인입니다. 후보를 고르는 순서, 안 쓴 초안이 쌓이지 않게 한 장치, 초안을 그대로 올리지 않는 이유를 코드와 함께 적었습니다.
아침 7시 반이면 텔레그램에 오늘 쓸 글 초안이 하나 와 있습니다. 6시에 전날 모은 뉴스 요약이 오고, 6시 15분에 그중 쓸 만한 것을 주제 후보로 쌓고, 6시 20분에 하나를 골라 초안을 씁니다. 7시 반 알림에는 초안 제목과 버튼이 붙어 있어서, 폰에서 바로 쓸지 미룰지 버릴지 고릅니다.
전부 집에 있는 맥미니 서버의 Spring Boot 백엔드 안에서 시간마다 도는 작업입니다. 아래에 단계마다 무엇을 하는지, 후보를 어떤 순서로 고르는지, 안 쓴 초안이 쌓이지 않게 한 장치를 적었습니다.

전체 흐름
| 시각 (KST) | 하는 일 | 결과 |
|---|---|---|
| 06:00 | 정보조사 다이제스트 생성 | 전날 모은 뉴스 요약이 텔레그램으로 옴 |
| 06:15 | 최근 7일 다이제스트에서 주제 후보 생성 | 주제 인박스에 후보(CANDIDATE)로 쌓임 |
| 06:20 | 후보 중 하나로 Threads 초안 작성 | 초안 1건이 작업 큐에 들어감 |
| 07:30 | 오늘 초안 + 대기 초안 알림 | 제목과 버튼이 붙은 텔레그램 메시지 |
| 07:35 | 7일 지난 초안 자동 정리 | 백로그로 내리고 주제는 보류(HOLD) |
시각은 모두 설정값이라 환경 변수로 바꿀 수 있고, 기본값은 위와 같습니다.
content:
topic:
auto-create-cron: ${CONTENT_TOPIC_AUTO_CREATE_CRON:0 15 6 * * *}
draft:
chain-draft-cron: ${CONTENT_DRAFT_CHAIN_DRAFT_CRON:0 20 6 * * *}
chain-notify-cron: ${CONTENT_DRAFT_CHAIN_NOTIFY_CRON:0 30 7 * * *}
chain-daily-limit: ${CONTENT_DRAFT_CHAIN_DAILY_LIMIT:3}
backlog-after-days: ${CONTENT_DRAFT_BACKLOG_AFTER_DAYS:7}
하루에 만드는 초안 수(chain-daily-limit)는 기본 3이지만, 운영 서버에서는 1로 두고 있습니다. 하루에 글 한 편만 내기 때문에 초안이 셋이면 둘은 그대로 쌓이기만 했습니다.
06:15 · 뉴스 요약에서 주제 후보 만들기
6시 다이제스트가 끝나고 15분 뒤, 최근 7일 치 다이제스트를 훑어서 아직 주제가 없는 것만 후보로 만듭니다. 이미 주제가 있는 다이제스트는 다시 만들지 않습니다.
@Scheduled(cron = "\${content.topic.auto-create-cron:0 15 6 * * *}", zone = "Asia/Seoul")
fun autoCreateFromDigests() {
val since = OffsetDateTime.now().minusDays(7)
val created = topicUsecase.autoCreateFromRecentDigests(since)
// 성공·실패를 하트비트로 남겨 서버 상태 화면에서 본다
}
zone = "Asia/Seoul"을 꼭 적습니다. 서버나 컨테이너 시간대가 UTC면 빼먹었을 때 아침 6시가 오후 3시로 밀립니다.
06:20 · 후보를 고르는 순서
주제 인박스에는 뉴스 요약에서 자동으로 생긴 것만 있는 게 아니라, 제가 직접 넣은 아이디어도 있습니다. 그래서 고르는 순서를 정해 두었습니다.
- 정보조사에서 항목을 직접 골라 주제로 올린 것
- 제가 손으로 적은 아이디어·메모
- 뉴스 요약에서 자동으로 생긴 것
같은 묶음 안에서는 최신순입니다. 직접 고른 주제가 하루 한 건 자리를 먼저 차지하게 하려는 순서입니다.
기간 제한이 없다는 점은 알고 써야 합니다. 후보 상태이고 아직 초안이 없으면 몇 달 전 주제도 계속 대상입니다. 안 쓸 주제를 그때그때 보류로 내리지 않으면 오래된 뉴스가 계속 뽑힙니다.
초안은 LLM을 직접 부르지 않고 공용 작업 큐에 넣습니다. 큐를 가져가는 워커가 claude -p로 한 번 호출해서 완성된 글을 만듭니다. 같은 주제가 이미 큐에 있으면 다시 넣지 않고, 재료가 제목뿐인 주제는 초안 없이 보류로 내립니다.
초안이 같은 실수를 반복하지 않게
초안을 만들 때 최근에 제가 반려한 초안의 이유를 프롬프트에 같이 넣습니다. 최근 반려 10건 중 품질 때문에 반려한 것만 골라 5건까지, 이유와 그 초안의 첫 줄을 붙입니다.
- [반려 사유] 반려한 초안의 첫 줄…
반려한 게 없으면 “(없음)“이 들어갑니다.
워커는 이 블로그와 같은 레포의 글쓰기 규칙 파일과 최근 발행 글을 직접 읽고 씁니다. 그래도 초안을 그대로 올리지는 않습니다.
07:30 · 텔레그램에서 바로 고르기
알림에는 오늘 초안 제목과 버튼 세 개가 붙습니다.
- 캘린더로 · 발행 시트에 올림
- 백로그 · 나중에 쓸 것으로 미룸
- 버림 · 안 씀
예전에는 “검토 대기 N건”처럼 개수만 보냈더니 하루 지난 초안은 폰에서 처리할 방법이 없었습니다. 지금은 오늘 초안이 없어도 대기 중인 초안이 있으면 최신 5건까지 버튼을 붙여 보내고, 마지막 줄에 남은 개수와 가장 오래된 초안이 며칠 됐는지 적습니다.
텔레그램 버튼 콜백을 받으려면 버튼이 달린 메시지를 보낸 봇이 콜백도 받아야 해서, 이 알림은 콘텐츠 승인 봇으로 보냅니다. 봇 설정이 빠져 있으면 버튼 없는 글자 알림으로 대신 보냅니다.
안 쓴 초안이 쌓이지 않게
이 체인을 처음 돌렸을 때는 안 쓴 초안이 갈 곳이 없었습니다. 7월 말에 정리하기 전까지 검토 대기가 25건 쌓여 있었습니다. 그래서 두 가지를 넣었습니다.
| 장치 | 동작 |
|---|---|
| 닫으면 주제 보류 | 초안을 버리거나 미루면 그 주제가 보류로 내려가서 다음 날 다시 뽑히지 않음. 되살리면 초안 작성 상태로 돌아감 |
| 7일 자동 정리 | 매일 07:35에 만든 지 7일 지난 초안을 백로그 버튼과 같은 경로로 처리. 알림은 한 줄 요약만 |
자동 정리를 끄려면 backlog-after-days를 0으로 두면 됩니다.
스케줄러를 짤 때 지킨 것
이 체인은 한 번 사고를 낸 뒤 규칙을 몇 개 정했습니다.
- 스케줄러 메서드에는
@Transactional을 걸지 않고, 조회한 엔티티 대신 id만 서비스에 넘겨 서비스 안에서 다시 조회합니다. 트랜잭션 밖에서 읽은 엔티티를 넘기면 의도하지 않은 값이 다시 저장될 수 있습니다 - 주제마다
runCatching으로 감싸서 하나가 실패해도 다음 주제와 다음 단계는 그대로 돕니다 - LLM 호출은 새 경로를 만들지 않고 기존 작업 큐를 그대로 씁니다
targetIds.forEach { topicId ->
runCatching { topicUsecase.threadsDraft(topicId) }
.onFailure { log.warn("초안 실패: topicId={}", topicId, it) }
}
초안을 그대로 올리지 않는 이유
자동 초안에서 가장 자주 고치는 건 두 가지입니다. 해 보지 않은 걸 해 본 것처럼 쓰는 문장과, 확인되지 않은 제품 이름이나 수치입니다. 그래서 초안에서 하고 싶은 말이 맞는지 먼저 보고, 이름과 숫자는 공식 페이지에서 확인하고, 말투를 고친 다음에 올립니다.
자동 초안이 그대로 쓸 만한 날도 있습니다. 7월 마지막 날에 쌓인 초안을 정리하면서 두 건은 확인을 거쳐 발행 후보로 올렸고, 확인이 안 되는 이름이 들어간 한 건은 내렸습니다.
뉴스 요약의 출발점인 자료조사 도구는 유튜브 영상을 요약해 노션에 쌓는 자료조사 도구, 첫 버전 범위와 배포에서 막힌 곳에, 이 체인이 도는 서버는 중고 Mac mini 2018을 25만 원에 사서 회사 서버로 쓰는 구성과 비용에 적었습니다.