공개 문서
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 "@/*"
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 & 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 = '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 }
}
</code></pre>
<hr>
<h2>5. lib/slack.ts — Slack 알림 유틸</h2>
<pre><code class="language-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
}
</code></pre>
<hr>
<h2>6. lib/storage.ts — 기록 저장</h2>
<pre><code class="language-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)
}
</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 '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: '구독 완료!' })
}
</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 '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
}
</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 '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 })
}
</code></pre>
<hr>
<h2>8. Frontend — SubscribeForm.tsx</h2>
<pre><code class="language-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>
)
}
</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>