언론홍보대행사는 매일 아침 같은 일을 반복합니다. 고객사 이름으로 뉴스를 검색하고, 관련 있는 기사만 골라, 보기 좋게 정리해 뉴스클립 메일로 보냅니다. 회사가 여럿이면 이 과정을 회사 수만큼 반복합니다. 사람이 하기엔 단조롭고, 놓치면 티가 나는 일입니다.

이걸 자동화하는 SaaS를 만들었습니다. 지침만 입력하면 매일 아침 기사를 모아오고, AI가 관련성을 판정해 요약하고, 뉴스클립 메일로 발송합니다. 이 글은 빈 저장소에서 시작해 MVP까지 간 과정을 정리한 기록입니다.

설계부터 정한 것들

만들기 전에 스택과 방향을 먼저 골랐습니다. 결정을 빨리 내리는 게 아니라, 되돌리기 비싼 것부터 정하는 게 목적이었습니다.

항목 선택 이유
프레임워크 Next.js 16 (App Router) 서버 액션 + 라우트 핸들러로 프런트·백을 한 저장소에
ORM Prisma 7 + PostgreSQL 트랜잭션 래퍼 + Service 패턴으로 DB 접근을 한 겹 감싸기
인증 next-auth v4 (JWT, 어댑터 없음) 이메일·구글 로그인, 세션 테이블 없이 단순하게
뉴스 소스 provider 추상화 (Google News RSS 활성) 네이버 API는 인터페이스만 만들어 두고 나중에 켜기
기사 필터링 Claude (haiku-4-5) 지침을 이해해 관련 기사만 골라내고 요약
메일 Resend + React Email 도메인 검증만 하면 발송, 템플릿은 JSX로

핵심 판단은 두 가지였습니다. 첫째, 뉴스 소스를 인터페이스 뒤에 두기로 했습니다. 지금은 Google News RSS 하나만 켜지만, 나중에 네이버 검색 API를 붙일 때 수집 오케스트레이션 코드는 손대지 않게 하고 싶었습니다. 둘째, 기사 필터링에 어떤 모델을 쓸지였습니다.

왜 Haiku였나

수집에서 Claude가 하는 일은 두 가지입니다. 지침 한 문장에서 검색 키워드를 뽑고, 모아온 기사 제목·출처를 보고 지침에 관련 있는지 판정하며 두 문장으로 요약합니다. 둘 다 깊은 추론이 아니라 분류와 짧은 요약입니다.

이런 대량·저비용·구조화 추출 작업은 가장 저렴한 모델이 정답입니다. 1회 수집에 Haiku는 대략 40원, Sonnet은 120원, Opus는 200원 선입니다. 지침이 "삼성전자의 반도체 투자만, 스마트폰은 제외" 같은 미묘한 부정 조건을 자주 요구하면 Sonnet이 값어치를 하지만, 일반적인 회사명·주제 매칭이면 Haiku로 충분합니다.

구조화 출력은 messages.parse에 Zod 스키마를 넘겨 받았습니다. 모델이 스키마에서 벗어나면 SDK가 재시도해 주므로 파싱 코드를 따로 쓸 필요가 없습니다.

const response = await client.messages.parse({
  model: "claude-haiku-4-5",
  max_tokens: 4096,
  system: "각 기사가 지침에 관련 있는지 판정하고, 관련 있으면 2문장 요약을 작성한다.",
  messages: [{ role: "user", content: `수집 지침: "${guideline}"\n\n${listing}` }],
  output_config: { format: zodOutputFormat(assessmentSchema) },
});

수집 파이프라인

수집은 지침에서 키워드를 뽑는 데서 시작합니다. 키워드는 지침이 바뀌지 않는 한 재사용하려고, 지침 내용의 sha256을 캐시 키로 저장해 두었습니다. 지침이 그대로면 Claude를 다시 부르지 않습니다.

provider들이 키워드로 검색해 온 기사는 URL을 정규화한 뒤 sha256으로 해시해 중복을 제거합니다. 추적 파라미터(utm_*, gclid 등)를 떼고 호스트를 소문자로 맞추면, 같은 기사의 다른 링크가 한 해시로 모입니다. 그렇게 걸러진 신규 기사만 Claude에 보내 관련도 60점 이상만 저장합니다.

const settled = await Promise.allSettled(
  providers.map((p) => p.search(keyword, { limit: 20 })),
);
for (const s of settled) {
  if (s.status !== "fulfilled") continue; // provider 하나 실패해도 계속
  for (const raw of s.value) {
    const h = urlHash(raw.url);
    if (seen.has(h)) continue;
    seen.add(h);
    candidates.push({ raw, urlHash: h, guidelineId: g.id });
  }
}

즉시 수집은 수십 초가 걸릴 수 있어 라우트 핸들러로 두고 실행 시간을 늘렸습니다. 실제로 Google News RSS를 붙여 보니 키워드 하나에 100건이 넘게 들어왔고, 제목에서 "제목 - 언론사" 형태의 출처를 분리하는 것까지 잘 동작했습니다.

메일과 예약 발송

메일 템플릿은 React Email로 세 종류(클래식·컴팩트·다이제스트)를 만들고, 발송은 Resend 배치 API로 수신자별 개인화해 보냅니다. 미리보기는 서버에서 HTML로 렌더해 iframesrcDoc으로 띄웠습니다.

예약 발송이 조금 까다로웠습니다. 무료 배포 환경(Vercel Hobby)은 cron을 하루 한 번 수준으로만 돌릴 수 있습니다. 그래서 스케줄을 매 실행마다 하나씩 확인하는 게 아니라, DB에 저장된 스케줄 중 실행 시각이 지난 것들을 dispatcher가 한 번에 훑는 방식으로 설계했습니다. 나중에 유료 플랜으로 올려 cron을 자주 돌려도 코드는 그대로 두고 cron 표현식만 바꾸면 됩니다.

진행 방식TASKS.md 하나로

작업은 저장소 루트의 TASKS.md에 P1부터 P8까지 체크박스로 분해해 두고, 태스크 하나를 구현하면 검증하고 체크한 뒤 그 태스크만 커밋하는 루프로 진행했습니다. 도메인 기반(P1) → 인증(P2) → 프로젝트 CRUD(P3) → 지침·메일링(P4) → 수집(P5) → 메일(P6) → 스케줄링(P7) → 마케팅·마감(P8) 순서였습니다.

이 방식의 좋은 점은 커밋 히스토리가 곧 진행 로그가 된다는 것입니다. 어디까지 됐는지, 각 단계에서 무엇을 검증했는지가 커밋 메시지에 남습니다.

함정 모음

새 프레임워크와 최신 라이브러리를 쓰면 학습 데이터와 다른 지점에서 시간을 씁니다. 이번에 밟은 것들을 정리합니다.

함정 증상 해결
shadcn이 base-ui 기반 asChild가 타입 에러 asChild 대신 render prop 사용
read-excel-file v9 기본 export가 전체 시트를 반환 readSheet로 rows만 받기
Prisma migrate migrate dev 후에도 클라이언트가 옛 스키마 prisma generate 수동 실행
스케줄 시각 저장 timestamp(no tz) 컬럼을 Prisma가 UTC로 해석 유틸이 UTC Date를 반환하도록
서버 페이지 try/catch JSX를 try 안에서 반환하면 lint 에러 데이터 로딩만 try, JSX는 밖에서 반환

첫 번째가 가장 자주 나왔습니다. 요즘 shadcn 컴포넌트는 Radix가 아니라 base-ui 위에 올라가 있어서, 다른 컴포넌트로 감싸 렌더할 때 asChild가 아니라 render를 씁니다.

// Radix 시절 습관 (base-ui에선 타입 에러)
<Button asChild><Link href="/projects">대시보드</Link></Button>

// base-ui
<Button render={<Link href="/projects" />}>대시보드</Button>

스케줄 함정은 검증 중에 드러났습니다. 테스트로 스케줄을 DB에 직접 넣을 때 SQL의 now()를 그대로 썼는데, 스케줄이 실행 대상으로 잡히지 않았습니다. 원인은 컬럼이 timezone 없는 timestamp이고 Prisma가 이 값을 UTC로 해석한다는 점이었습니다. SQL의 now()는 로컬(KST) 벽시계 값을 넣었으니, Prisma가 보기엔 9시간 미래였습니다. 앱 코드는 항상 UTC Date를 저장하므로 문제가 없었고, 잘못된 건 검증용 테스트 쪽이었습니다. "버그처럼 보이는 신호가 실제로는 다른 원인"인 전형적인 경우였습니다.

검증 중에 잡은 버그 하나

발송 탭의 미리보기가 템플릿을 바꿔도 반영되지 않는다는 지적이 있었습니다. 원인은 미리보기가 버튼을 눌렀을 때만 렌더되도록 되어 있어서, 템플릿을 바꾼 뒤 다시 누르지 않으면 이전 화면이 남는 것이었습니다.

미리보기 버튼을 없애고, 템플릿이나 날짜가 바뀌면 자동으로 다시 렌더되게 고쳤습니다. 겸사겸사 미리보기 안의 기사 링크를 새 창으로 열도록 바꿨는데, 여기서 또 한 가지를 배웠습니다. 미리보기 iframesandbox=""를 주면 스크립트뿐 아니라 링크 이동까지 전부 막힙니다. 스크립트는 계속 막으면서 링크만 새 창으로 열려면 allow-popups를 명시해야 했습니다.

<iframe
  srcDoc={previewHtml}
  sandbox="allow-popups allow-popups-to-escape-sandbox"
/>

헤드리스 브라우저로 로그인해 발송 탭까지 들어가, 템플릿을 클래식에서 다이제스트로 바꾸자 미리보기가 카드형으로 즉시 바뀌는 것을 확인했습니다.

남은 것

MVP는 로그인·프로젝트·지침·메일링 리스트·수집·메일·예약까지 동작하고, 테스트와 프로덕션 빌드를 통과합니다. 실제 뉴스 필터링과 메일 발송, 구글 로그인은 각각 API 키를 넣어야 켜지는데, 이건 운영자가 채우는 값이라 구조만 준비해 두었습니다.

다음 후보로 정리해 둔 것은 대행사가 자기 도메인으로 발송하는 기능입니다. 이건 Resend의 도메인 검증을 테넌트별로 붙여야 해서 규모가 있습니다. 그래서 바로 만들지 않고, 필요한 변경사항을 TODO 문서로만 정리해 두었습니다. 대부분의 니즈는 발신 표시명과 Reply-To만으로 해결되기 때문에, 자기 도메인 발송이 확실한 요건일 때만 착수하는 게 맞다고 봤습니다.

되돌리기 비싼 결정을 먼저 내리고, 나머지는 인터페이스 뒤에 숨겨 두는 것. 이번에 provider 추상화와 도메인 TODO가 그 역할을 했습니다. 지금 안 만드는 것을 잘 정리해 두는 것도 설계의 일부입니다.