공개 문서

docs/archive/2026-03/newsletter-pipeline/newsletter-pipeline.design.md

아래는 docs/archive/2026-03/newsletter-pipeline/newsletter-pipeline.design.md 와 동일한 원문입니다. Markdown과 HTML 변환 결과를 각각 복사할 수 있습니다.

공개 문서 원문 (Markdown)

# [Design] newsletter-pipeline — Slack + Buttondown 뉴스레터 파이프라인 설계

**Feature**: newsletter-pipeline
**Date**: 2026-03-01
**Phase**: Design
**참조 Plan**: `docs/01-plan/features/newsletter-pipeline.plan.md`

---

## 1. 프로젝트 초기화

```bash
npx create-next-app@latest slack-automation2 \
  --typescript --tailwind --app --src-dir --import-alias "@/*"
cd slack-automation2
npm install @slack/bolt @slack/webhook crypto
```

---

## 2. 파일 구조 (전체)

```
slack-automation2/
├── src/
│   └── app/
│       ├── page.tsx                         # 웹폼 메인 페이지
│       ├── layout.tsx
│       ├── globals.css
│       ├── components/
│       │   └── SubscribeForm.tsx            # 구독 폼 컴포넌트
│       └── api/
│           ├── subscribe/
│           │   └── route.ts                 # 웹폼 구독 API
│           ├── slack/
│           │   └── subscribe/
│           │       └── route.ts             # Slack 슬래시 커맨드
│           └── newsletter/
│               └── send/
│                   └── route.ts             # 뉴스레터 발송 API
├── lib/
│   ├── buttondown.ts                        # Buttondown API 클라이언트
│   ├── slack.ts                             # Slack 알림 유틸
│   └── storage.ts                           # JSON 기록 저장
├── data/
│   ├── subscribers.json                     # 구독자 이력
│   └── newsletters.json                     # 발송 이력
├── .env.local                               # 환경변수
└── .env.example                             # 환경변수 템플릿
```

---

## 3. 환경변수 설계

### `.env.example`
```env
# Buttondown (https://buttondown.email/settings/api)
BUTTONDOWN_API_KEY=

# Slack Bot Token (api.slack.com/apps → OAuth & Permissions)
SLACK_BOT_TOKEN=xoxb-

# Slack Slash Command 검증용
SLACK_SIGNING_SECRET=

# Slack 알림 Webhook URL (Incoming Webhooks)
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...

# 배포 URL (Vercel)
NEXT_PUBLIC_APP_URL=http://localhost:3000
```

---

## 4. lib/buttondown.ts — Buttondown API 클라이언트

```typescript
// lib/buttondown.ts
const BASE = 'https://api.buttondown.email'

function headers() {
  return {
    'Authorization': `Token ${process.env.BUTTONDOWN_API_KEY}`,
    'Content-Type': 'application/json',
  }
}

// 구독자 등록
export async function addSubscriber(email: string) {
  const res = await fetch(`${BASE}/v1/subscribers`, {
    method: 'POST',
    headers: headers(),
    body: JSON.stringify({ email, type: 'regular' }),
  })
  const data = await res.json()

  if (!res.ok) {
    // 중복 구독 처리
    if (data.code === 'email_already_exists') {
      return { success: false, error: 'already_subscribed' }
    }
    return { success: false, error: data.detail ?? 'unknown_error' }
  }

  return { success: true, subscriber: data }
}

// 구독자 목록 조회
export async function listSubscribers() {
  const res = await fetch(`${BASE}/v1/subscribers`, { headers: headers() })
  const data = await res.json()
  return data.results ?? []
}

// 뉴스레터 발송
export async function sendNewsletter(subject: string, body: string) {
  const res = await fetch(`${BASE}/v1/emails`, {
    method: 'POST',
    headers: headers(),
    body: JSON.stringify({
      subject,
      body,
      status: 'about_to_send',   // 즉시 발송
    }),
  })
  const data = await res.json()
  if (!res.ok) return { success: false, error: data.detail ?? 'send_failed' }
  return { success: true, email: data }
}
```

---

## 5. lib/slack.ts — Slack 알림 유틸

```typescript
// lib/slack.ts
import crypto from 'crypto'

// Incoming Webhook으로 메시지 전송
export async function sendSlackNotification(text: string) {
  const webhookUrl = process.env.SLACK_WEBHOOK_URL
  if (!webhookUrl) return

  await fetch(webhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  })
}

// Slack 슬래시 커맨드 서명 검증
export function verifySlackSignature(
  signingSecret: string,
  body: string,
  timestamp: string,
  signature: string
): boolean {
  const fiveMinutes = 5 * 60 * 1000
  if (Math.abs(Date.now() - Number(timestamp) * 1000) > fiveMinutes) {
    return false
  }
  const baseStr = `v0:${timestamp}:${body}`
  const hmac = crypto
    .createHmac('sha256', signingSecret)
    .update(baseStr)
    .digest('hex')
  return `v0=${hmac}` === signature
}
```

---

## 6. lib/storage.ts — 기록 저장

```typescript
// lib/storage.ts
import fs from 'fs'
import path from 'path'

const DATA_DIR = path.join(process.cwd(), 'data')

function readJSON(file: string) {
  const filePath = path.join(DATA_DIR, file)
  if (!fs.existsSync(filePath)) return []
  return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
}

function writeJSON(file: string, data: unknown) {
  fs.mkdirSync(DATA_DIR, { recursive: true })
  fs.writeFileSync(path.join(DATA_DIR, file), JSON.stringify(data, null, 2))
}

export function saveSubscriber(email: string, source: 'webform' | 'slack') {
  const records = readJSON('subscribers.json')
  records.push({ email, source, subscribedAt: new Date().toISOString() })
  writeJSON('subscribers.json', records)
}

export function saveNewsletter(subject: string, recipientCount: number) {
  const records = readJSON('newsletters.json')
  records.push({ subject, recipientCount, sentAt: new Date().toISOString() })
  writeJSON('newsletters.json', records)
}
```

---

## 7. API Routes 설계

### 7.1 POST /api/subscribe — 웹폼 구독

```typescript
// src/app/api/subscribe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { addSubscriber } from '@/lib/buttondown'
import { sendSlackNotification } from '@/lib/slack'
import { saveSubscriber } from '@/lib/storage'

export async function POST(req: NextRequest) {
  const { email } = await req.json()

  // 유효성 검사
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return NextResponse.json({ error: '유효하지 않은 이메일' }, { status: 400 })
  }

  // Buttondown 등록
  const result = await addSubscriber(email)
  if (!result.success) {
    if (result.error === 'already_subscribed') {
      return NextResponse.json({ error: '이미 구독 중입니다' }, { status: 409 })
    }
    return NextResponse.json({ error: '등록 실패' }, { status: 500 })
  }

  // Slack 알림
  await sendSlackNotification(`새 뉴스레터 구독자 등록\n이메일: ${email}\n경로: 웹폼`)

  // 기록 저장
  saveSubscriber(email, 'webform')

  return NextResponse.json({ success: true, message: '구독 완료!' })
}
```

### 7.2 POST /api/slack/subscribe — 슬래시 커맨드

```typescript
// src/app/api/slack/subscribe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifySlackSignature, sendSlackNotification } from '@/lib/slack'
import { addSubscriber } from '@/lib/buttondown'
import { saveSubscriber } from '@/lib/storage'

export async function POST(req: NextRequest) {
  const rawBody = await req.text()
  const timestamp = req.headers.get('x-slack-request-timestamp') ?? ''
  const signature = req.headers.get('x-slack-signature') ?? ''

  // 서명 검증
  const isValid = verifySlackSignature(
    process.env.SLACK_SIGNING_SECRET!,
    rawBody,
    timestamp,
    signature
  )
  if (!isValid) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // 파라미터 파싱
  const params = new URLSearchParams(rawBody)
  const email = params.get('text')?.trim()
  const userName = params.get('user_name') ?? 'unknown'

  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return NextResponse.json({
      response_type: 'ephemeral',
      text: '사용법: /subscribe user@example.com',
    })
  }

  // 즉시 ack (3초 제한)
  const ackResponse = NextResponse.json({
    response_type: 'ephemeral',
    text: `${email} 등록 처리 중...`,
  })

  // 백그라운드 처리
  ;(async () => {
    const result = await addSubscriber(email)
    if (result.success) {
      await sendSlackNotification(
        `새 뉴스레터 구독자 등록\n이메일: ${email}\n등록자: @${userName}\n경로: Slack /subscribe`
      )
      saveSubscriber(email, 'slack')
    }
  })()

  return ackResponse
}
```

### 7.3 POST /api/newsletter/send — 뉴스레터 발송

```typescript
// src/app/api/newsletter/send/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { sendNewsletter, listSubscribers } from '@/lib/buttondown'
import { sendSlackNotification } from '@/lib/slack'
import { saveNewsletter } from '@/lib/storage'

export async function POST(req: NextRequest) {
  const { subject, body } = await req.json()

  if (!subject || !body) {
    return NextResponse.json({ error: '제목과 내용이 필요합니다' }, { status: 400 })
  }

  // 발송
  const result = await sendNewsletter(subject, body)
  if (!result.success) {
    return NextResponse.json({ error: '발송 실패: ' + result.error }, { status: 500 })
  }

  // 구독자 수 조회
  const subscribers = await listSubscribers()
  const count = subscribers.length

  // Slack 알림
  await sendSlackNotification(
    `뉴스레터 발송 완료\n제목: ${subject}\n수신자: ${count}명`
  )

  // 기록 저장
  saveNewsletter(subject, count)

  return NextResponse.json({ success: true, recipientCount: count })
}
```

---

## 8. Frontend — SubscribeForm.tsx

```typescript
// src/app/components/SubscribeForm.tsx
'use client'
import { useState } from 'react'

export default function SubscribeForm() {
  const [email, setEmail] = useState('')
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
  const [message, setMessage] = useState('')

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setStatus('loading')

    const res = await fetch('/api/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    })
    const data = await res.json()

    if (res.ok) {
      setStatus('success')
      setMessage('구독해 주셔서 감사합니다!')
      setEmail('')
    } else {
      setStatus('error')
      setMessage(data.error ?? '오류가 발생했습니다')
    }
  }

  return (
    <form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-md mx-auto">
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="이메일 주소를 입력하세요"
        required
        className="border rounded px-4 py-2 text-sm"
      />
      <button
        type="submit"
        disabled={status === 'loading'}
        className="bg-black text-white rounded px-4 py-2 text-sm disabled:opacity-50"
      >
        {status === 'loading' ? '처리 중...' : '뉴스레터 구독'}
      </button>
      {message && (
        <p className={status === 'success' ? 'text-green-600' : 'text-red-500'}>
          {message}
        </p>
      )}
    </form>
  )
}
```

---

## 9. Slack App 설정 (수동 작업)

### 9.1 Slash Command 등록

```
api.slack.com/apps → 앱 선택
→ Slash Commands → Create New Command

Command:    /subscribe
Request URL: https://your-domain.vercel.app/api/slack/subscribe
Description: 뉴스레터 구독자 등록
Usage Hint:  [이메일 주소]
```

### 9.2 Incoming Webhook 설정

```
api.slack.com/apps → Incoming Webhooks → Add New Webhook
→ 채널 선택 (예: #04_생각-hq-daily-brief)
→ Webhook URL 복사 → .env에 SLACK_WEBHOOK_URL 설정
```

### 9.3 필요 Scopes

```
commands          ← Slash Command 사용
incoming-webhook  ← 알림 전송
```

---

## 10. 구현 순서 (Do Phase 체크리스트)

### Step 1 — 프로젝트 초기화
- [ ] `npx create-next-app@latest` 실행
- [ ] 의존성 설치 (`@slack/webhook`, `crypto`)
- [ ] `.env.local` 생성 + API Key 입력

### Step 2 — lib 구현
- [ ] `lib/buttondown.ts` — Buttondown API 클라이언트
- [ ] `lib/slack.ts` — 알림 + 서명 검증
- [ ] `lib/storage.ts` — JSON 기록 저장

### Step 3 — API Routes 구현
- [ ] `POST /api/subscribe` — 웹폼 구독
- [ ] `POST /api/slack/subscribe` — 슬래시 커맨드
- [ ] `POST /api/newsletter/send` — 뉴스레터 발송

### Step 4 — Frontend 구현
- [ ] `SubscribeForm.tsx` — 구독 폼 컴포넌트
- [ ] `app/page.tsx` — 메인 페이지

### Step 5 — Slack 설정 (수동)
- [ ] Incoming Webhook URL 발급 → `.env.local` 입력
- [ ] Slash Command `/subscribe` 등록
- [ ] `SLACK_SIGNING_SECRET` → `.env.local` 입력

### Step 6 — 테스트
- [ ] 웹폼 → Buttondown 등록 확인
- [ ] 웹폼 → Slack 알림 확인
- [ ] Slack `/subscribe` 커맨드 → Buttondown 등록 확인
- [ ] 중복 구독 에러 처리 확인

### Step 7 — 배포
- [ ] Vercel 배포
- [ ] 환경변수 Vercel 대시보드 설정
- [ ] Slack Slash Command Request URL 업데이트

---

## 11. 제약 및 리스크 대응

| 리스크 | 설계 대응 |
|--------|----------|
| Buttondown 중복 등록 | `already_subscribed` 에러 코드 처리 |
| Slack 3초 응답 제한 | 즉시 ack + 백그라운드 처리 패턴 |
| Slack 서명 위조 | HMAC-SHA256 검증 필수 |
| API Key 노출 | 환경변수만, 클라이언트 코드 미노출 |
| Vercel 서버리스 파일 쓰기 | `data/` 폴더는 로컬 전용, 배포 시 Supabase로 교체 |

> **PDCA 다음 단계**: `/pdca do newsletter-pipeline`

공개 문서 변환 코드 (HTML)

<h1>[Design] newsletter-pipeline — Slack + Buttondown 뉴스레터 파이프라인 설계</h1>
<p><strong>Feature</strong>: newsletter-pipeline
<strong>Date</strong>: 2026-03-01
<strong>Phase</strong>: Design
<strong>참조 Plan</strong>: <code>docs/01-plan/features/newsletter-pipeline.plan.md</code></p>
<hr>
<h2>1. 프로젝트 초기화</h2>
<pre><code class="language-bash">npx create-next-app@latest slack-automation2 \
  --typescript --tailwind --app --src-dir --import-alias &quot;@/*&quot;
cd slack-automation2
npm install @slack/bolt @slack/webhook crypto
</code></pre>
<hr>
<h2>2. 파일 구조 (전체)</h2>
<pre><code>slack-automation2/
├── src/
│   └── app/
│       ├── page.tsx                         # 웹폼 메인 페이지
│       ├── layout.tsx
│       ├── globals.css
│       ├── components/
│       │   └── SubscribeForm.tsx            # 구독 폼 컴포넌트
│       └── api/
│           ├── subscribe/
│           │   └── route.ts                 # 웹폼 구독 API
│           ├── slack/
│           │   └── subscribe/
│           │       └── route.ts             # Slack 슬래시 커맨드
│           └── newsletter/
│               └── send/
│                   └── route.ts             # 뉴스레터 발송 API
├── lib/
│   ├── buttondown.ts                        # Buttondown API 클라이언트
│   ├── slack.ts                             # Slack 알림 유틸
│   └── storage.ts                           # JSON 기록 저장
├── data/
│   ├── subscribers.json                     # 구독자 이력
│   └── newsletters.json                     # 발송 이력
├── .env.local                               # 환경변수
└── .env.example                             # 환경변수 템플릿
</code></pre>
<hr>
<h2>3. 환경변수 설계</h2>
<h3><code>.env.example</code></h3>
<pre><code class="language-env"># Buttondown (https://buttondown.email/settings/api)
BUTTONDOWN_API_KEY=

# Slack Bot Token (api.slack.com/apps → OAuth &amp; Permissions)
SLACK_BOT_TOKEN=xoxb-

# Slack Slash Command 검증용
SLACK_SIGNING_SECRET=

# Slack 알림 Webhook URL (Incoming Webhooks)
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...

# 배포 URL (Vercel)
NEXT_PUBLIC_APP_URL=http://localhost:3000
</code></pre>
<hr>
<h2>4. lib/buttondown.ts — Buttondown API 클라이언트</h2>
<pre><code class="language-typescript">// lib/buttondown.ts
const BASE = &#39;https://api.buttondown.email&#39;

function headers() {
  return {
    &#39;Authorization&#39;: `Token ${process.env.BUTTONDOWN_API_KEY}`,
    &#39;Content-Type&#39;: &#39;application/json&#39;,
  }
}

// 구독자 등록
export async function addSubscriber(email: string) {
  const res = await fetch(`${BASE}/v1/subscribers`, {
    method: &#39;POST&#39;,
    headers: headers(),
    body: JSON.stringify({ email, type: &#39;regular&#39; }),
  })
  const data = await res.json()

  if (!res.ok) {
    // 중복 구독 처리
    if (data.code === &#39;email_already_exists&#39;) {
      return { success: false, error: &#39;already_subscribed&#39; }
    }
    return { success: false, error: data.detail ?? &#39;unknown_error&#39; }
  }

  return { success: true, subscriber: data }
}

// 구독자 목록 조회
export async function listSubscribers() {
  const res = await fetch(`${BASE}/v1/subscribers`, { headers: headers() })
  const data = await res.json()
  return data.results ?? []
}

// 뉴스레터 발송
export async function sendNewsletter(subject: string, body: string) {
  const res = await fetch(`${BASE}/v1/emails`, {
    method: &#39;POST&#39;,
    headers: headers(),
    body: JSON.stringify({
      subject,
      body,
      status: &#39;about_to_send&#39;,   // 즉시 발송
    }),
  })
  const data = await res.json()
  if (!res.ok) return { success: false, error: data.detail ?? &#39;send_failed&#39; }
  return { success: true, email: data }
}
</code></pre>
<hr>
<h2>5. lib/slack.ts — Slack 알림 유틸</h2>
<pre><code class="language-typescript">// lib/slack.ts
import crypto from &#39;crypto&#39;

// Incoming Webhook으로 메시지 전송
export async function sendSlackNotification(text: string) {
  const webhookUrl = process.env.SLACK_WEBHOOK_URL
  if (!webhookUrl) return

  await fetch(webhookUrl, {
    method: &#39;POST&#39;,
    headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
    body: JSON.stringify({ text }),
  })
}

// Slack 슬래시 커맨드 서명 검증
export function verifySlackSignature(
  signingSecret: string,
  body: string,
  timestamp: string,
  signature: string
): boolean {
  const fiveMinutes = 5 * 60 * 1000
  if (Math.abs(Date.now() - Number(timestamp) * 1000) &gt; fiveMinutes) {
    return false
  }
  const baseStr = `v0:${timestamp}:${body}`
  const hmac = crypto
    .createHmac(&#39;sha256&#39;, signingSecret)
    .update(baseStr)
    .digest(&#39;hex&#39;)
  return `v0=${hmac}` === signature
}
</code></pre>
<hr>
<h2>6. lib/storage.ts — 기록 저장</h2>
<pre><code class="language-typescript">// lib/storage.ts
import fs from &#39;fs&#39;
import path from &#39;path&#39;

const DATA_DIR = path.join(process.cwd(), &#39;data&#39;)

function readJSON(file: string) {
  const filePath = path.join(DATA_DIR, file)
  if (!fs.existsSync(filePath)) return []
  return JSON.parse(fs.readFileSync(filePath, &#39;utf-8&#39;))
}

function writeJSON(file: string, data: unknown) {
  fs.mkdirSync(DATA_DIR, { recursive: true })
  fs.writeFileSync(path.join(DATA_DIR, file), JSON.stringify(data, null, 2))
}

export function saveSubscriber(email: string, source: &#39;webform&#39; | &#39;slack&#39;) {
  const records = readJSON(&#39;subscribers.json&#39;)
  records.push({ email, source, subscribedAt: new Date().toISOString() })
  writeJSON(&#39;subscribers.json&#39;, records)
}

export function saveNewsletter(subject: string, recipientCount: number) {
  const records = readJSON(&#39;newsletters.json&#39;)
  records.push({ subject, recipientCount, sentAt: new Date().toISOString() })
  writeJSON(&#39;newsletters.json&#39;, records)
}
</code></pre>
<hr>
<h2>7. API Routes 설계</h2>
<h3>7.1 POST /api/subscribe — 웹폼 구독</h3>
<pre><code class="language-typescript">// src/app/api/subscribe/route.ts
import { NextRequest, NextResponse } from &#39;next/server&#39;
import { addSubscriber } from &#39;@/lib/buttondown&#39;
import { sendSlackNotification } from &#39;@/lib/slack&#39;
import { saveSubscriber } from &#39;@/lib/storage&#39;

export async function POST(req: NextRequest) {
  const { email } = await req.json()

  // 유효성 검사
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return NextResponse.json({ error: &#39;유효하지 않은 이메일&#39; }, { status: 400 })
  }

  // Buttondown 등록
  const result = await addSubscriber(email)
  if (!result.success) {
    if (result.error === &#39;already_subscribed&#39;) {
      return NextResponse.json({ error: &#39;이미 구독 중입니다&#39; }, { status: 409 })
    }
    return NextResponse.json({ error: &#39;등록 실패&#39; }, { status: 500 })
  }

  // Slack 알림
  await sendSlackNotification(`새 뉴스레터 구독자 등록\n이메일: ${email}\n경로: 웹폼`)

  // 기록 저장
  saveSubscriber(email, &#39;webform&#39;)

  return NextResponse.json({ success: true, message: &#39;구독 완료!&#39; })
}
</code></pre>
<h3>7.2 POST /api/slack/subscribe — 슬래시 커맨드</h3>
<pre><code class="language-typescript">// src/app/api/slack/subscribe/route.ts
import { NextRequest, NextResponse } from &#39;next/server&#39;
import { verifySlackSignature, sendSlackNotification } from &#39;@/lib/slack&#39;
import { addSubscriber } from &#39;@/lib/buttondown&#39;
import { saveSubscriber } from &#39;@/lib/storage&#39;

export async function POST(req: NextRequest) {
  const rawBody = await req.text()
  const timestamp = req.headers.get(&#39;x-slack-request-timestamp&#39;) ?? &#39;&#39;
  const signature = req.headers.get(&#39;x-slack-signature&#39;) ?? &#39;&#39;

  // 서명 검증
  const isValid = verifySlackSignature(
    process.env.SLACK_SIGNING_SECRET!,
    rawBody,
    timestamp,
    signature
  )
  if (!isValid) {
    return NextResponse.json({ error: &#39;Unauthorized&#39; }, { status: 401 })
  }

  // 파라미터 파싱
  const params = new URLSearchParams(rawBody)
  const email = params.get(&#39;text&#39;)?.trim()
  const userName = params.get(&#39;user_name&#39;) ?? &#39;unknown&#39;

  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return NextResponse.json({
      response_type: &#39;ephemeral&#39;,
      text: &#39;사용법: /subscribe user@example.com&#39;,
    })
  }

  // 즉시 ack (3초 제한)
  const ackResponse = NextResponse.json({
    response_type: &#39;ephemeral&#39;,
    text: `${email} 등록 처리 중...`,
  })

  // 백그라운드 처리
  ;(async () =&gt; {
    const result = await addSubscriber(email)
    if (result.success) {
      await sendSlackNotification(
        `새 뉴스레터 구독자 등록\n이메일: ${email}\n등록자: @${userName}\n경로: Slack /subscribe`
      )
      saveSubscriber(email, &#39;slack&#39;)
    }
  })()

  return ackResponse
}
</code></pre>
<h3>7.3 POST /api/newsletter/send — 뉴스레터 발송</h3>
<pre><code class="language-typescript">// src/app/api/newsletter/send/route.ts
import { NextRequest, NextResponse } from &#39;next/server&#39;
import { sendNewsletter, listSubscribers } from &#39;@/lib/buttondown&#39;
import { sendSlackNotification } from &#39;@/lib/slack&#39;
import { saveNewsletter } from &#39;@/lib/storage&#39;

export async function POST(req: NextRequest) {
  const { subject, body } = await req.json()

  if (!subject || !body) {
    return NextResponse.json({ error: &#39;제목과 내용이 필요합니다&#39; }, { status: 400 })
  }

  // 발송
  const result = await sendNewsletter(subject, body)
  if (!result.success) {
    return NextResponse.json({ error: &#39;발송 실패: &#39; + result.error }, { status: 500 })
  }

  // 구독자 수 조회
  const subscribers = await listSubscribers()
  const count = subscribers.length

  // Slack 알림
  await sendSlackNotification(
    `뉴스레터 발송 완료\n제목: ${subject}\n수신자: ${count}명`
  )

  // 기록 저장
  saveNewsletter(subject, count)

  return NextResponse.json({ success: true, recipientCount: count })
}
</code></pre>
<hr>
<h2>8. Frontend — SubscribeForm.tsx</h2>
<pre><code class="language-typescript">// src/app/components/SubscribeForm.tsx
&#39;use client&#39;
import { useState } from &#39;react&#39;

export default function SubscribeForm() {
  const [email, setEmail] = useState(&#39;&#39;)
  const [status, setStatus] = useState&lt;&#39;idle&#39; | &#39;loading&#39; | &#39;success&#39; | &#39;error&#39;&gt;(&#39;idle&#39;)
  const [message, setMessage] = useState(&#39;&#39;)

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setStatus(&#39;loading&#39;)

    const res = await fetch(&#39;/api/subscribe&#39;, {
      method: &#39;POST&#39;,
      headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },
      body: JSON.stringify({ email }),
    })
    const data = await res.json()

    if (res.ok) {
      setStatus(&#39;success&#39;)
      setMessage(&#39;구독해 주셔서 감사합니다!&#39;)
      setEmail(&#39;&#39;)
    } else {
      setStatus(&#39;error&#39;)
      setMessage(data.error ?? &#39;오류가 발생했습니다&#39;)
    }
  }

  return (
    &lt;form onSubmit={handleSubmit} className=&quot;flex flex-col gap-4 max-w-md mx-auto&quot;&gt;
      &lt;input
        type=&quot;email&quot;
        value={email}
        onChange={(e) =&gt; setEmail(e.target.value)}
        placeholder=&quot;이메일 주소를 입력하세요&quot;
        required
        className=&quot;border rounded px-4 py-2 text-sm&quot;
      /&gt;
      &lt;button
        type=&quot;submit&quot;
        disabled={status === &#39;loading&#39;}
        className=&quot;bg-black text-white rounded px-4 py-2 text-sm disabled:opacity-50&quot;
      &gt;
        {status === &#39;loading&#39; ? &#39;처리 중...&#39; : &#39;뉴스레터 구독&#39;}
      &lt;/button&gt;
      {message &amp;&amp; (
        &lt;p className={status === &#39;success&#39; ? &#39;text-green-600&#39; : &#39;text-red-500&#39;}&gt;
          {message}
        &lt;/p&gt;
      )}
    &lt;/form&gt;
  )
}
</code></pre>
<hr>
<h2>9. Slack App 설정 (수동 작업)</h2>
<h3>9.1 Slash Command 등록</h3>
<pre><code>api.slack.com/apps → 앱 선택
→ Slash Commands → Create New Command

Command:    /subscribe
Request URL: https://your-domain.vercel.app/api/slack/subscribe
Description: 뉴스레터 구독자 등록
Usage Hint:  [이메일 주소]
</code></pre>
<h3>9.2 Incoming Webhook 설정</h3>
<pre><code>api.slack.com/apps → Incoming Webhooks → Add New Webhook
→ 채널 선택 (예: #04_생각-hq-daily-brief)
→ Webhook URL 복사 → .env에 SLACK_WEBHOOK_URL 설정
</code></pre>
<h3>9.3 필요 Scopes</h3>
<pre><code>commands          ← Slash Command 사용
incoming-webhook  ← 알림 전송
</code></pre>
<hr>
<h2>10. 구현 순서 (Do Phase 체크리스트)</h2>
<h3>Step 1 — 프로젝트 초기화</h3>
<ul>
<li><input disabled="" type="checkbox"> <code>npx create-next-app@latest</code> 실행</li>
<li><input disabled="" type="checkbox"> 의존성 설치 (<code>@slack/webhook</code>, <code>crypto</code>)</li>
<li><input disabled="" type="checkbox"> <code>.env.local</code> 생성 + API Key 입력</li>
</ul>
<h3>Step 2 — lib 구현</h3>
<ul>
<li><input disabled="" type="checkbox"> <code>lib/buttondown.ts</code> — Buttondown API 클라이언트</li>
<li><input disabled="" type="checkbox"> <code>lib/slack.ts</code> — 알림 + 서명 검증</li>
<li><input disabled="" type="checkbox"> <code>lib/storage.ts</code> — JSON 기록 저장</li>
</ul>
<h3>Step 3 — API Routes 구현</h3>
<ul>
<li><input disabled="" type="checkbox"> <code>POST /api/subscribe</code> — 웹폼 구독</li>
<li><input disabled="" type="checkbox"> <code>POST /api/slack/subscribe</code> — 슬래시 커맨드</li>
<li><input disabled="" type="checkbox"> <code>POST /api/newsletter/send</code> — 뉴스레터 발송</li>
</ul>
<h3>Step 4 — Frontend 구현</h3>
<ul>
<li><input disabled="" type="checkbox"> <code>SubscribeForm.tsx</code> — 구독 폼 컴포넌트</li>
<li><input disabled="" type="checkbox"> <code>app/page.tsx</code> — 메인 페이지</li>
</ul>
<h3>Step 5 — Slack 설정 (수동)</h3>
<ul>
<li><input disabled="" type="checkbox"> Incoming Webhook URL 발급 → <code>.env.local</code> 입력</li>
<li><input disabled="" type="checkbox"> Slash Command <code>/subscribe</code> 등록</li>
<li><input disabled="" type="checkbox"> <code>SLACK_SIGNING_SECRET</code> → <code>.env.local</code> 입력</li>
</ul>
<h3>Step 6 — 테스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 웹폼 → Buttondown 등록 확인</li>
<li><input disabled="" type="checkbox"> 웹폼 → Slack 알림 확인</li>
<li><input disabled="" type="checkbox"> Slack <code>/subscribe</code> 커맨드 → Buttondown 등록 확인</li>
<li><input disabled="" type="checkbox"> 중복 구독 에러 처리 확인</li>
</ul>
<h3>Step 7 — 배포</h3>
<ul>
<li><input disabled="" type="checkbox"> Vercel 배포</li>
<li><input disabled="" type="checkbox"> 환경변수 Vercel 대시보드 설정</li>
<li><input disabled="" type="checkbox"> Slack Slash Command Request URL 업데이트</li>
</ul>
<hr>
<h2>11. 제약 및 리스크 대응</h2>
<table>
<thead>
<tr>
<th>리스크</th>
<th>설계 대응</th>
</tr>
</thead>
<tbody><tr>
<td>Buttondown 중복 등록</td>
<td><code>already_subscribed</code> 에러 코드 처리</td>
</tr>
<tr>
<td>Slack 3초 응답 제한</td>
<td>즉시 ack + 백그라운드 처리 패턴</td>
</tr>
<tr>
<td>Slack 서명 위조</td>
<td>HMAC-SHA256 검증 필수</td>
</tr>
<tr>
<td>API Key 노출</td>
<td>환경변수만, 클라이언트 코드 미노출</td>
</tr>
<tr>
<td>Vercel 서버리스 파일 쓰기</td>
<td><code>data/</code> 폴더는 로컬 전용, 배포 시 Supabase로 교체</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>PDCA 다음 단계</strong>: <code>/pdca do newsletter-pipeline</code></p>
</blockquote>