공개 문서

docs/troubleshooting.md

아래는 docs/troubleshooting.md 와 동일한 원문입니다. Markdown과 HTML 변환 결과를 각각 복사할 수 있습니다.

공개 문서 원문 (Markdown)

# Troubleshooting Log — slack-automation2

**프로젝트**: Slack + Buttondown 뉴스레터 자동화 파이프라인
**작성일**: 2026-03-02
**기술 스택**: Next.js 16 (App Router) + Vercel + Slack API + Buttondown API

---

## 목차

1. [Vercel 환경변수 줄바꿈 버그](#1-vercel-환경변수-줄바꿈-버그)
2. [Slack Slash Command 서명 검증 실패 (401)](#2-slack-slash-command-서명-검증-실패-401)
3. [Slack Incoming Webhook URL 만료](#3-slack-incoming-webhook-url-만료)
4. [Slack "문제가 발생했습니다" 오류 (3초 응답 제한 초과)](#4-slack-문제가-발생했습니다-오류-3초-응답-제한-초과)
5. [백그라운드 태스크 중간 종료 (Webhook 알림 미발송)](#5-백그라운드-태스크-중간-종료-webhook-알림-미발송)
6. [Next.js after() Vercel 불안정](#6-nextjs-after-vercel-불안정)
7. [Vercel 읽기전용 파일시스템 크래시](#7-vercel-읽기전용-파일시스템-크래시)
8. [Buttondown 방화벽 차단](#8-buttondown-방화벽-차단)
9. [입력 이메일 가독성 문제 (UI)](#9-입력-이메일-가독성-문제-ui)

---

## 1. Vercel 환경변수 줄바꿈 버그

### 증상
- Slack Slash Command 서명 검증이 로컬에서는 통과하지만 Vercel 프로덕션에서 실패 (401)
- Slack Incoming Webhook 알림이 오지 않음 (`no_service` 반환)

### 원인
Vercel CLI로 환경변수를 설정할 때 `<<<` (here-string)을 사용하면 값 끝에 **개행문자(`\n`)가 자동 포함**됨.

```bash
# 잘못된 방식 — \n이 포함됨
vercel env add SLACK_SIGNING_SECRET production <<< "abc123"
# 실제 저장값: "abc123\n"

# 올바른 방식 — \n 없음
printf 'abc123' | vercel env add SLACK_SIGNING_SECRET production
```

HMAC-SHA256 서명 계산 시 키가 `"abc123\n"`이 되어 서명 불일치 발생.
Webhook URL이 `"https://hooks.slack.com/...\n"`이 되어 요청 자체가 실패.

### 해결

영향받은 환경변수 3개를 모두 재설정:

```bash
# 1. 기존 삭제
vercel env rm SLACK_SIGNING_SECRET production --yes --scope {팀명}
vercel env rm SLACK_WEBHOOK_URL production --yes --scope {팀명}
vercel env rm BUTTONDOWN_API_KEY production --yes --scope {팀명}

# 2. printf로 재등록 (줄바꿈 없음)
printf '{값}' | vercel env add SLACK_SIGNING_SECRET production --scope {팀명}
printf '{값}' | vercel env add SLACK_WEBHOOK_URL production --scope {팀명}
printf '{값}' | vercel env add BUTTONDOWN_API_KEY production --scope {팀명}

# 3. 재배포
vercel --prod --yes --scope {팀명}
```

### 교훈
> Vercel CLI에서 환경변수 설정 시 **항상 `printf`를 사용**한다.
> `echo`, `<<<`은 줄바꿈을 추가하므로 API 키/시크릿/URL에 절대 사용하지 않는다.

---

## 2. Slack Slash Command 서명 검증 실패 (401)

### 증상
```
HTTP Error: 401
Response: {"error":"Unauthorized"}
```
Python 테스트 스크립트로 서명을 올바르게 생성해도 401 반환.

### 원인
[#1 Vercel 환경변수 줄바꿈 버그](#1-vercel-환경변수-줄바꿈-버그)와 동일.
`SLACK_SIGNING_SECRET`에 `\n`이 포함되어 서버에서 계산한 HMAC 값이 요청과 불일치.

### 검증 방법
로컬(`.env.local`)에서는 정상 동작하고, Vercel 프로덕션에서만 실패할 때 환경변수 줄바꿈을 의심.

```bash
# 로컬 테스트 (정상)
python3 test_slash.py --url http://localhost:3000/api/slack/subscribe
# → Status: 200

# 프로덕션 테스트 (실패)
python3 test_slash.py --url https://slack-automation2.vercel.app/api/slack/subscribe
# → HTTP Error: 401
```

### 해결
`printf`로 `SLACK_SIGNING_SECRET` 재설정 후 재배포. (#1 해결책과 동일)

---

## 3. Slack Incoming Webhook URL 만료

### 증상
```bash
curl -X POST {WEBHOOK_URL} -d '{"text":"test"}'
# → 404 no_service
```
Slack 채널에 아무 알림도 오지 않음.

### 원인
Slack Incoming Webhook URL이 만료되거나 앱 재설치 등으로 비활성화됨.
기존 URL: `https://hooks.slack.com/services/T.../B0AHCET8SFR/...` → `no_service`

### 해결

1. [api.slack.com/apps](https://api.slack.com/apps) → 앱 선택
2. **Incoming Webhooks** → **Activate Incoming Webhooks** ON 확인
3. **Add New Webhook to Workspace** → 채널 선택 → 허용
4. 새 URL 복사 후 환경변수 재설정:

```bash
# 기존 삭제 후 새 URL로 재등록
vercel env rm SLACK_WEBHOOK_URL production --yes --scope {팀명}
printf '{새_WEBHOOK_URL}' | vercel env add SLACK_WEBHOOK_URL production --scope {팀명}
vercel --prod --yes --scope {팀명}
```

5. 테스트:
```bash
curl -X POST {새_WEBHOOK_URL} -d '{"text":"테스트"}'
# → ok (200)
```

### 교훈
> Webhook URL은 **언제든지 만료될 수 있다**.
> `no_service` 오류가 나면 코드/환경변수를 먼저 점검하지 말고, URL 자체를 `curl`로 직접 테스트한다.

---

## 4. Slack "문제가 발생했습니다" 오류 (3초 응답 제한 초과)

### 증상
Slack에서 `/subscribe` 또는 `/newsletter` 입력 시:
> "문제가 발생했습니다. 다시 시도해주세요."

### 원인
Slack Slash Command는 **3초 이내 응답**을 요구함.
모든 처리를 동기로 실행하면 Buttondown API (~2-3초) + Webhook (~0.5초) = **3.24초**로 초과.

```bash
# 실제 측정
python3 timing_test.py
# → Time: 3.24s  ← Slack 3초 제한 초과
```

### 해결 과정

**시도 1 (실패)**: 즉시 ack + IIFE 백그라운드 처리
```typescript
// 즉시 응답
const ackResponse = NextResponse.json({ text: '처리 중...' })

// 백그라운드 실행 (fire-and-forget)
;(async () => {
  await addSubscriber(email)      // 실행됨
  await sendSlackNotification()   // Vercel이 함수 종료 후 실행 안됨
})()

return ackResponse  // ← 응답 후 함수 종료, 백그라운드 중단
```
→ Buttondown은 완료되지만 Slack 알림은 오지 않음.

**시도 2 (실패)**: Next.js `after()`
```typescript
import { after } from 'next/server'

after(async () => {
  // Vercel에서 불안정하게 동작
})
```
→ 로컬에서는 작동하지만 Vercel에서 불안정 (후술 #6 참조).

**시도 3 (성공)**: `@vercel/functions` `waitUntil`
```typescript
import { waitUntil } from '@vercel/functions'

waitUntil(
  (async () => {
    await addSubscriber(email)
    await sendSlackNotification()
  })()
)

return NextResponse.json({ text: '⏳ 처리 중...' })  // 즉시 응답
```
→ 응답 시간 **0.91초**, Webhook 알림 정상 수신.

### 교훈
> Slack Slash Command는 **즉시 ack → 백그라운드 처리** 패턴이 필수.
> Vercel에서 백그라운드 실행은 반드시 `@vercel/functions`의 `waitUntil`을 사용한다.

---

## 5. 백그라운드 태스크 중간 종료 (Webhook 알림 미발송)

### 증상
- Buttondown에 구독자가 정상 등록됨 (확인됨)
- Slack 채널에 Webhook 알림이 오지 않음

### 원인
Vercel 서버리스 함수는 **응답을 반환하면 즉시 종료**됨.
fire-and-forget 패턴 (`IIFE`)으로 실행된 백그라운드 태스크는:
- Buttondown API 호출은 응답 전에 완료 (운 좋게 살아남음)
- Slack Webhook 호출은 응답 후 → **함수 종료로 실행 안됨**

```
응답 반환 (t=0.9s)
    ↓
Vercel 함수 종료
    ↓
Buttondown 완료 (t=1.2s) ← 종료 전에 완료됨 (우연)
Slack Webhook (t=1.7s)   ← 종료 후 → 실행 안됨
```

### 해결
`waitUntil`로 교체하여 Vercel이 태스크 완료를 보장하도록 함.
→ #4 해결책과 동일.

---

## 6. Next.js `after()` Vercel 불안정

### 증상
`after()`를 사용해도 Slack Webhook 알림이 오지 않음.
Buttondown은 정상 등록됨 (after() 내에서 실행되는 첫 번째 작업).

### 원인
Next.js `after()`는 내부적으로 Vercel의 `waitUntil`을 사용하지만,
특정 환경/버전 조합에서 완전히 신뢰할 수 없음.
또한 `after()` 콜백 내에서 `saveSubscriber()`가 **파일시스템 크래시**를 일으켜 (#7 참조)
이후 `sendSlackNotification()` 호출이 중단됨.

```typescript
// 문제 코드 — saveSubscriber가 크래시하면 Webhook 호출 안됨
after(async () => {
  const result = await addSubscriber(email)  // 성공
  if (result.success) {
    saveSubscriber(email, 'slack')            // 💥 크래시 (읽기전용 파일시스템)
    await sendSlackNotification(...)          // 실행 안됨
  }
})
```

### 해결

**1단계**: `sendSlackNotification`을 `saveSubscriber` 앞으로 이동
**2단계**: `after()` → `waitUntil`로 교체

```typescript
import { waitUntil } from '@vercel/functions'

waitUntil(
  (async () => {
    const result = await addSubscriber(email)
    if (result.success) {
      await sendSlackNotification(...)          // 먼저 실행
      try { saveSubscriber(email, 'slack') } catch (_) {}  // 실패해도 무시
    }
  })()
)
```

### 교훈
> `after()`보다 `@vercel/functions`의 `waitUntil`이 Vercel에서 더 안정적.
> 백그라운드 콜백 내에서 중요한 작업은 **앞에 배치**하고, 부가 작업은 try-catch로 보호.

---

## 7. Vercel 읽기전용 파일시스템 크래시

### 증상
Slack Webhook 알림이 오지 않음.
Buttondown 등록은 정상이나, 이후 로직 실행 안됨.

### 원인
`lib/storage.ts`가 `process.cwd()/data/` 경로에 JSON 파일을 쓰려고 시도.
Vercel 서버리스 환경은 **`/tmp` 외 모든 경로가 읽기전용**이어서 `EROFS` 오류 발생.

```typescript
// storage.ts — Vercel에서 크래시
const DATA_DIR = path.join(process.cwd(), 'data')  // /var/task/data (읽기전용)

function writeJSON(file: string, data: unknown) {
  fs.mkdirSync(DATA_DIR, { recursive: true })      // 💥 EROFS: read-only file system
  fs.writeFileSync(...)
}
```

이 크래시가 `after()` / IIFE 콜백 전체를 중단시킴.

### 해결

**방법 1**: `sendSlackNotification` 호출을 `saveSubscriber` 앞으로 이동
```typescript
await sendSlackNotification(...)           // 먼저 실행 (크래시 전)
try { saveSubscriber(email, 'slack') } catch (_) {}  // 크래시해도 무시
```

**방법 2 (근본 해결)**: 파일 저장 대신 DB 사용 (Supabase 등)
→ MVP 단계에서는 방법 1로 임시 해결, Phase 2에서 DB 전환 예정.

### 교훈
> Vercel에서 파일 쓰기는 **불가능** (읽기전용).
> MVP라도 JSON 파일 저장은 Vercel 배포 시 동작하지 않는다.
> 배포 환경에서는 Supabase, PlanetScale, Upstash 등 외부 DB를 사용한다.

---

## 8. Buttondown 방화벽 차단

### 증상
```json
{
  "error": "This subscriber was blocked by your firewall."
}
```
또는 구독자 목록에서 `firewall_reasons` 필드에 차단 사유 존재.

### 원인
Buttondown은 스팸/봇으로 의심되는 이메일을 자동 차단함.
차단 기준:

| 오류 코드 | 설명 |
|-----------|------|
| `email_address_gmail_word_numbers` | Gmail 단어+숫자 패턴 (봇 의심) |
| `email_address_is_pwned` | 데이터 유출 이력 있는 이메일 |

테스트 이메일 `timing-test@example.com`, `siwoni1214@gmail.com` 등이 차단됨.

### 해결
- 테스트 시 실제 사용 중인 이메일 주소 사용
- Buttondown 대시보드 → Subscribers에서 `firewall_reasons` 확인
- 필요 시 Buttondown 설정에서 방화벽 규칙 완화 가능

### 교훈
> Buttondown API 테스트는 **실제 이메일**로 진행.
> `success: false`이고 오류 메시지가 firewall 관련이면 코드 문제가 아닌 이메일 자체 문제.

---

## 9. 입력 이메일 가독성 문제 (UI)

### 증상
웹폼에서 이메일을 입력해도 **입력된 텍스트가 잘 보이지 않음** (흰 배경에 연한 텍스트).

### 원인
`SubscribeForm.tsx`의 `<input>` 요소에 텍스트 색상이 지정되지 않음.
브라우저/운영체제 기본 색상이 배경과 비슷하게 렌더링되는 경우 발생.

```tsx
// 수정 전
className="border rounded px-4 py-2 text-sm"

// 수정 후
className="border rounded px-4 py-2 text-sm text-gray-900 placeholder:text-gray-400"
```

### 해결
`text-gray-900` 추가로 입력 텍스트를 진한 색으로 명시적 지정.
`placeholder:text-gray-400`으로 플레이스홀더와 구분.

### 교훈
> 폼 `<input>` 요소에는 **항상 `text-{color}` 클래스를 명시**한다.
> 브라우저 기본 스타일에 의존하면 환경에 따라 가독성이 다를 수 있다.

---

## 요약 — 핵심 패턴

| # | 문제 유형 | 진단 방법 | 해결 패턴 |
|---|----------|----------|----------|
| 1,2 | Vercel 환경변수 줄바꿈 | 로컬 OK, 프로덕션 실패 | `printf` 사용 |
| 3 | Webhook URL 만료 | `curl` 직접 테스트 → `no_service` | 새 URL 재발급 |
| 4 | Slack 3초 제한 | 응답시간 측정 → 3초+ | 즉시 ack + 백그라운드 |
| 5,6 | Vercel 백그라운드 종료 | Buttondown OK, Webhook 실패 | `waitUntil` 사용 |
| 7 | Vercel 읽기전용 FS | 콜백 중간 중단 | try-catch + 순서 변경 |
| 8 | Buttondown 방화벽 | API 오류 메시지 확인 | 실제 이메일 사용 |
| 9 | UI 텍스트 가독성 | 시각 확인 | `text-gray-900` 명시 |

---

## Vercel 배포 체크리스트

이 프로젝트를 새 환경에 배포할 때 반드시 확인:

- [ ] 환경변수 설정 시 `printf` 사용 (`echo` / `<<<` 금지)
- [ ] `SLACK_WEBHOOK_URL` curl 테스트 → `ok` 확인
- [ ] `SLACK_SIGNING_SECRET` 정확히 복사 (공백/줄바꿈 없이)
- [ ] Slack Slash Command Request URL 업데이트
- [ ] 파일 저장 로직은 try-catch로 보호
- [ ] 백그라운드 작업은 `@vercel/functions` `waitUntil` 사용

공개 문서 변환 코드 (HTML)

<h1>Troubleshooting Log — slack-automation2</h1>
<p><strong>프로젝트</strong>: Slack + Buttondown 뉴스레터 자동화 파이프라인
<strong>작성일</strong>: 2026-03-02
<strong>기술 스택</strong>: Next.js 16 (App Router) + Vercel + Slack API + Buttondown API</p>
<hr>
<h2>목차</h2>
<ol>
<li><a href="#1-vercel-%ED%99%98%EA%B2%BD%EB%B3%80%EC%88%98-%EC%A4%84%EB%B0%94%EA%BF%88-%EB%B2%84%EA%B7%B8">Vercel 환경변수 줄바꿈 버그</a></li>
<li><a href="#2-slack-slash-command-%EC%84%9C%EB%AA%85-%EA%B2%80%EC%A6%9D-%EC%8B%A4%ED%8C%A8-401">Slack Slash Command 서명 검증 실패 (401)</a></li>
<li><a href="#3-slack-incoming-webhook-url-%EB%A7%8C%EB%A3%8C">Slack Incoming Webhook URL 만료</a></li>
<li><a href="#4-slack-%EB%AC%B8%EC%A0%9C%EA%B0%80-%EB%B0%9C%EC%83%9D%ED%96%88%EC%8A%B5%EB%8B%88%EB%8B%A4-%EC%98%A4%EB%A5%98-3%EC%B4%88-%EC%9D%91%EB%8B%B5-%EC%A0%9C%ED%95%9C-%EC%B4%88%EA%B3%BC">Slack &quot;문제가 발생했습니다&quot; 오류 (3초 응답 제한 초과)</a></li>
<li><a href="#5-%EB%B0%B1%EA%B7%B8%EB%9D%BC%EC%9A%B4%EB%93%9C-%ED%83%9C%EC%8A%A4%ED%81%AC-%EC%A4%91%EA%B0%84-%EC%A2%85%EB%A3%8C-webhook-%EC%95%8C%EB%A6%BC-%EB%AF%B8%EB%B0%9C%EC%86%A1">백그라운드 태스크 중간 종료 (Webhook 알림 미발송)</a></li>
<li><a href="#6-nextjs-after-vercel-%EB%B6%88%EC%95%88%EC%A0%95">Next.js after() Vercel 불안정</a></li>
<li><a href="#7-vercel-%EC%9D%BD%EA%B8%B0%EC%A0%84%EC%9A%A9-%ED%8C%8C%EC%9D%BC%EC%8B%9C%EC%8A%A4%ED%85%9C-%ED%81%AC%EB%9E%98%EC%8B%9C">Vercel 읽기전용 파일시스템 크래시</a></li>
<li><a href="#8-buttondown-%EB%B0%A9%ED%99%94%EB%B2%BD-%EC%B0%A8%EB%8B%A8">Buttondown 방화벽 차단</a></li>
<li><a href="#9-%EC%9E%85%EB%A0%A5-%EC%9D%B4%EB%A9%94%EC%9D%BC-%EA%B0%80%EB%8F%85%EC%84%B1-%EB%AC%B8%EC%A0%9C-ui">입력 이메일 가독성 문제 (UI)</a></li>
</ol>
<hr>
<h2>1. Vercel 환경변수 줄바꿈 버그</h2>
<h3>증상</h3>
<ul>
<li>Slack Slash Command 서명 검증이 로컬에서는 통과하지만 Vercel 프로덕션에서 실패 (401)</li>
<li>Slack Incoming Webhook 알림이 오지 않음 (<code>no_service</code> 반환)</li>
</ul>
<h3>원인</h3>
<p>Vercel CLI로 환경변수를 설정할 때 <code>&lt;&lt;&lt;</code> (here-string)을 사용하면 값 끝에 <strong>개행문자(<code>\n</code>)가 자동 포함</strong>됨.</p>
<pre><code class="language-bash"># 잘못된 방식 — \n이 포함됨
vercel env add SLACK_SIGNING_SECRET production &lt;&lt;&lt; &quot;abc123&quot;
# 실제 저장값: &quot;abc123\n&quot;

# 올바른 방식 — \n 없음
printf &#39;abc123&#39; | vercel env add SLACK_SIGNING_SECRET production
</code></pre>
<p>HMAC-SHA256 서명 계산 시 키가 <code>&quot;abc123\n&quot;</code>이 되어 서명 불일치 발생.
Webhook URL이 <code>&quot;https://hooks.slack.com/...\n&quot;</code>이 되어 요청 자체가 실패.</p>
<h3>해결</h3>
<p>영향받은 환경변수 3개를 모두 재설정:</p>
<pre><code class="language-bash"># 1. 기존 삭제
vercel env rm SLACK_SIGNING_SECRET production --yes --scope {팀명}
vercel env rm SLACK_WEBHOOK_URL production --yes --scope {팀명}
vercel env rm BUTTONDOWN_API_KEY production --yes --scope {팀명}

# 2. printf로 재등록 (줄바꿈 없음)
printf &#39;{값}&#39; | vercel env add SLACK_SIGNING_SECRET production --scope {팀명}
printf &#39;{값}&#39; | vercel env add SLACK_WEBHOOK_URL production --scope {팀명}
printf &#39;{값}&#39; | vercel env add BUTTONDOWN_API_KEY production --scope {팀명}

# 3. 재배포
vercel --prod --yes --scope {팀명}
</code></pre>
<h3>교훈</h3>
<blockquote>
<p>Vercel CLI에서 환경변수 설정 시 <strong>항상 <code>printf</code>를 사용</strong>한다.
<code>echo</code>, <code>&lt;&lt;&lt;</code>은 줄바꿈을 추가하므로 API 키/시크릿/URL에 절대 사용하지 않는다.</p>
</blockquote>
<hr>
<h2>2. Slack Slash Command 서명 검증 실패 (401)</h2>
<h3>증상</h3>
<pre><code>HTTP Error: 401
Response: {&quot;error&quot;:&quot;Unauthorized&quot;}
</code></pre>
<p>Python 테스트 스크립트로 서명을 올바르게 생성해도 401 반환.</p>
<h3>원인</h3>
<p><a href="#1-vercel-%ED%99%98%EA%B2%BD%EB%B3%80%EC%88%98-%EC%A4%84%EB%B0%94%EA%BF%88-%EB%B2%84%EA%B7%B8">#1 Vercel 환경변수 줄바꿈 버그</a>와 동일.
<code>SLACK_SIGNING_SECRET</code>에 <code>\n</code>이 포함되어 서버에서 계산한 HMAC 값이 요청과 불일치.</p>
<h3>검증 방법</h3>
<p>로컬(<code>.env.local</code>)에서는 정상 동작하고, Vercel 프로덕션에서만 실패할 때 환경변수 줄바꿈을 의심.</p>
<pre><code class="language-bash"># 로컬 테스트 (정상)
python3 test_slash.py --url http://localhost:3000/api/slack/subscribe
# → Status: 200

# 프로덕션 테스트 (실패)
python3 test_slash.py --url https://slack-automation2.vercel.app/api/slack/subscribe
# → HTTP Error: 401
</code></pre>
<h3>해결</h3>
<p><code>printf</code>로 <code>SLACK_SIGNING_SECRET</code> 재설정 후 재배포. (#1 해결책과 동일)</p>
<hr>
<h2>3. Slack Incoming Webhook URL 만료</h2>
<h3>증상</h3>
<pre><code class="language-bash">curl -X POST {WEBHOOK_URL} -d &#39;{&quot;text&quot;:&quot;test&quot;}&#39;
# → 404 no_service
</code></pre>
<p>Slack 채널에 아무 알림도 오지 않음.</p>
<h3>원인</h3>
<p>Slack Incoming Webhook URL이 만료되거나 앱 재설치 등으로 비활성화됨.
기존 URL: <code>https://hooks.slack.com/services/T.../B0AHCET8SFR/...</code> → <code>no_service</code></p>
<h3>해결</h3>
<ol>
<li><a href="https://api.slack.com/apps">api.slack.com/apps</a> → 앱 선택</li>
<li><strong>Incoming Webhooks</strong> → <strong>Activate Incoming Webhooks</strong> ON 확인</li>
<li><strong>Add New Webhook to Workspace</strong> → 채널 선택 → 허용</li>
<li>새 URL 복사 후 환경변수 재설정:</li>
</ol>
<pre><code class="language-bash"># 기존 삭제 후 새 URL로 재등록
vercel env rm SLACK_WEBHOOK_URL production --yes --scope {팀명}
printf &#39;{새_WEBHOOK_URL}&#39; | vercel env add SLACK_WEBHOOK_URL production --scope {팀명}
vercel --prod --yes --scope {팀명}
</code></pre>
<ol start="5">
<li>테스트:</li>
</ol>
<pre><code class="language-bash">curl -X POST {새_WEBHOOK_URL} -d &#39;{&quot;text&quot;:&quot;테스트&quot;}&#39;
# → ok (200)
</code></pre>
<h3>교훈</h3>
<blockquote>
<p>Webhook URL은 <strong>언제든지 만료될 수 있다</strong>.
<code>no_service</code> 오류가 나면 코드/환경변수를 먼저 점검하지 말고, URL 자체를 <code>curl</code>로 직접 테스트한다.</p>
</blockquote>
<hr>
<h2>4. Slack &quot;문제가 발생했습니다&quot; 오류 (3초 응답 제한 초과)</h2>
<h3>증상</h3>
<p>Slack에서 <code>/subscribe</code> 또는 <code>/newsletter</code> 입력 시:</p>
<blockquote>
<p>&quot;문제가 발생했습니다. 다시 시도해주세요.&quot;</p>
</blockquote>
<h3>원인</h3>
<p>Slack Slash Command는 <strong>3초 이내 응답</strong>을 요구함.
모든 처리를 동기로 실행하면 Buttondown API (~2-3초) + Webhook (~0.5초) = <strong>3.24초</strong>로 초과.</p>
<pre><code class="language-bash"># 실제 측정
python3 timing_test.py
# → Time: 3.24s  ← Slack 3초 제한 초과
</code></pre>
<h3>해결 과정</h3>
<p><strong>시도 1 (실패)</strong>: 즉시 ack + IIFE 백그라운드 처리</p>
<pre><code class="language-typescript">// 즉시 응답
const ackResponse = NextResponse.json({ text: &#39;처리 중...&#39; })

// 백그라운드 실행 (fire-and-forget)
;(async () =&gt; {
  await addSubscriber(email)      // 실행됨
  await sendSlackNotification()   // Vercel이 함수 종료 후 실행 안됨
})()

return ackResponse  // ← 응답 후 함수 종료, 백그라운드 중단
</code></pre>
<p>→ Buttondown은 완료되지만 Slack 알림은 오지 않음.</p>
<p><strong>시도 2 (실패)</strong>: Next.js <code>after()</code></p>
<pre><code class="language-typescript">import { after } from &#39;next/server&#39;

after(async () =&gt; {
  // Vercel에서 불안정하게 동작
})
</code></pre>
<p>→ 로컬에서는 작동하지만 Vercel에서 불안정 (후술 #6 참조).</p>
<p><strong>시도 3 (성공)</strong>: <code>@vercel/functions</code> <code>waitUntil</code></p>
<pre><code class="language-typescript">import { waitUntil } from &#39;@vercel/functions&#39;

waitUntil(
  (async () =&gt; {
    await addSubscriber(email)
    await sendSlackNotification()
  })()
)

return NextResponse.json({ text: &#39;⏳ 처리 중...&#39; })  // 즉시 응답
</code></pre>
<p>→ 응답 시간 <strong>0.91초</strong>, Webhook 알림 정상 수신.</p>
<h3>교훈</h3>
<blockquote>
<p>Slack Slash Command는 <strong>즉시 ack → 백그라운드 처리</strong> 패턴이 필수.
Vercel에서 백그라운드 실행은 반드시 <code>@vercel/functions</code>의 <code>waitUntil</code>을 사용한다.</p>
</blockquote>
<hr>
<h2>5. 백그라운드 태스크 중간 종료 (Webhook 알림 미발송)</h2>
<h3>증상</h3>
<ul>
<li>Buttondown에 구독자가 정상 등록됨 (확인됨)</li>
<li>Slack 채널에 Webhook 알림이 오지 않음</li>
</ul>
<h3>원인</h3>
<p>Vercel 서버리스 함수는 <strong>응답을 반환하면 즉시 종료</strong>됨.
fire-and-forget 패턴 (<code>IIFE</code>)으로 실행된 백그라운드 태스크는:</p>
<ul>
<li>Buttondown API 호출은 응답 전에 완료 (운 좋게 살아남음)</li>
<li>Slack Webhook 호출은 응답 후 → <strong>함수 종료로 실행 안됨</strong></li>
</ul>
<pre><code>응답 반환 (t=0.9s)
    ↓
Vercel 함수 종료
    ↓
Buttondown 완료 (t=1.2s) ← 종료 전에 완료됨 (우연)
Slack Webhook (t=1.7s)   ← 종료 후 → 실행 안됨
</code></pre>
<h3>해결</h3>
<p><code>waitUntil</code>로 교체하여 Vercel이 태스크 완료를 보장하도록 함.
→ #4 해결책과 동일.</p>
<hr>
<h2>6. Next.js <code>after()</code> Vercel 불안정</h2>
<h3>증상</h3>
<p><code>after()</code>를 사용해도 Slack Webhook 알림이 오지 않음.
Buttondown은 정상 등록됨 (after() 내에서 실행되는 첫 번째 작업).</p>
<h3>원인</h3>
<p>Next.js <code>after()</code>는 내부적으로 Vercel의 <code>waitUntil</code>을 사용하지만,
특정 환경/버전 조합에서 완전히 신뢰할 수 없음.
또한 <code>after()</code> 콜백 내에서 <code>saveSubscriber()</code>가 <strong>파일시스템 크래시</strong>를 일으켜 (#7 참조)
이후 <code>sendSlackNotification()</code> 호출이 중단됨.</p>
<pre><code class="language-typescript">// 문제 코드 — saveSubscriber가 크래시하면 Webhook 호출 안됨
after(async () =&gt; {
  const result = await addSubscriber(email)  // 성공
  if (result.success) {
    saveSubscriber(email, &#39;slack&#39;)            // 💥 크래시 (읽기전용 파일시스템)
    await sendSlackNotification(...)          // 실행 안됨
  }
})
</code></pre>
<h3>해결</h3>
<p><strong>1단계</strong>: <code>sendSlackNotification</code>을 <code>saveSubscriber</code> 앞으로 이동
<strong>2단계</strong>: <code>after()</code> → <code>waitUntil</code>로 교체</p>
<pre><code class="language-typescript">import { waitUntil } from &#39;@vercel/functions&#39;

waitUntil(
  (async () =&gt; {
    const result = await addSubscriber(email)
    if (result.success) {
      await sendSlackNotification(...)          // 먼저 실행
      try { saveSubscriber(email, &#39;slack&#39;) } catch (_) {}  // 실패해도 무시
    }
  })()
)
</code></pre>
<h3>교훈</h3>
<blockquote>
<p><code>after()</code>보다 <code>@vercel/functions</code>의 <code>waitUntil</code>이 Vercel에서 더 안정적.
백그라운드 콜백 내에서 중요한 작업은 <strong>앞에 배치</strong>하고, 부가 작업은 try-catch로 보호.</p>
</blockquote>
<hr>
<h2>7. Vercel 읽기전용 파일시스템 크래시</h2>
<h3>증상</h3>
<p>Slack Webhook 알림이 오지 않음.
Buttondown 등록은 정상이나, 이후 로직 실행 안됨.</p>
<h3>원인</h3>
<p><code>lib/storage.ts</code>가 <code>process.cwd()/data/</code> 경로에 JSON 파일을 쓰려고 시도.
Vercel 서버리스 환경은 <strong><code>/tmp</code> 외 모든 경로가 읽기전용</strong>이어서 <code>EROFS</code> 오류 발생.</p>
<pre><code class="language-typescript">// storage.ts — Vercel에서 크래시
const DATA_DIR = path.join(process.cwd(), &#39;data&#39;)  // /var/task/data (읽기전용)

function writeJSON(file: string, data: unknown) {
  fs.mkdirSync(DATA_DIR, { recursive: true })      // 💥 EROFS: read-only file system
  fs.writeFileSync(...)
}
</code></pre>
<p>이 크래시가 <code>after()</code> / IIFE 콜백 전체를 중단시킴.</p>
<h3>해결</h3>
<p><strong>방법 1</strong>: <code>sendSlackNotification</code> 호출을 <code>saveSubscriber</code> 앞으로 이동</p>
<pre><code class="language-typescript">await sendSlackNotification(...)           // 먼저 실행 (크래시 전)
try { saveSubscriber(email, &#39;slack&#39;) } catch (_) {}  // 크래시해도 무시
</code></pre>
<p><strong>방법 2 (근본 해결)</strong>: 파일 저장 대신 DB 사용 (Supabase 등)
→ MVP 단계에서는 방법 1로 임시 해결, Phase 2에서 DB 전환 예정.</p>
<h3>교훈</h3>
<blockquote>
<p>Vercel에서 파일 쓰기는 <strong>불가능</strong> (읽기전용).
MVP라도 JSON 파일 저장은 Vercel 배포 시 동작하지 않는다.
배포 환경에서는 Supabase, PlanetScale, Upstash 등 외부 DB를 사용한다.</p>
</blockquote>
<hr>
<h2>8. Buttondown 방화벽 차단</h2>
<h3>증상</h3>
<pre><code class="language-json">{
  &quot;error&quot;: &quot;This subscriber was blocked by your firewall.&quot;
}
</code></pre>
<p>또는 구독자 목록에서 <code>firewall_reasons</code> 필드에 차단 사유 존재.</p>
<h3>원인</h3>
<p>Buttondown은 스팸/봇으로 의심되는 이메일을 자동 차단함.
차단 기준:</p>
<table>
<thead>
<tr>
<th>오류 코드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>email_address_gmail_word_numbers</code></td>
<td>Gmail 단어+숫자 패턴 (봇 의심)</td>
</tr>
<tr>
<td><code>email_address_is_pwned</code></td>
<td>데이터 유출 이력 있는 이메일</td>
</tr>
</tbody></table>
<p>테스트 이메일 <code>timing-test@example.com</code>, <code>siwoni1214@gmail.com</code> 등이 차단됨.</p>
<h3>해결</h3>
<ul>
<li>테스트 시 실제 사용 중인 이메일 주소 사용</li>
<li>Buttondown 대시보드 → Subscribers에서 <code>firewall_reasons</code> 확인</li>
<li>필요 시 Buttondown 설정에서 방화벽 규칙 완화 가능</li>
</ul>
<h3>교훈</h3>
<blockquote>
<p>Buttondown API 테스트는 <strong>실제 이메일</strong>로 진행.
<code>success: false</code>이고 오류 메시지가 firewall 관련이면 코드 문제가 아닌 이메일 자체 문제.</p>
</blockquote>
<hr>
<h2>9. 입력 이메일 가독성 문제 (UI)</h2>
<h3>증상</h3>
<p>웹폼에서 이메일을 입력해도 <strong>입력된 텍스트가 잘 보이지 않음</strong> (흰 배경에 연한 텍스트).</p>
<h3>원인</h3>
<p><code>SubscribeForm.tsx</code>의 <code>&lt;input&gt;</code> 요소에 텍스트 색상이 지정되지 않음.
브라우저/운영체제 기본 색상이 배경과 비슷하게 렌더링되는 경우 발생.</p>
<pre><code class="language-tsx">// 수정 전
className=&quot;border rounded px-4 py-2 text-sm&quot;

// 수정 후
className=&quot;border rounded px-4 py-2 text-sm text-gray-900 placeholder:text-gray-400&quot;
</code></pre>
<h3>해결</h3>
<p><code>text-gray-900</code> 추가로 입력 텍스트를 진한 색으로 명시적 지정.
<code>placeholder:text-gray-400</code>으로 플레이스홀더와 구분.</p>
<h3>교훈</h3>
<blockquote>
<p>폼 <code>&lt;input&gt;</code> 요소에는 <strong>항상 <code>text-{color}</code> 클래스를 명시</strong>한다.
브라우저 기본 스타일에 의존하면 환경에 따라 가독성이 다를 수 있다.</p>
</blockquote>
<hr>
<h2>요약 — 핵심 패턴</h2>
<table>
<thead>
<tr>
<th>#</th>
<th>문제 유형</th>
<th>진단 방법</th>
<th>해결 패턴</th>
</tr>
</thead>
<tbody><tr>
<td>1,2</td>
<td>Vercel 환경변수 줄바꿈</td>
<td>로컬 OK, 프로덕션 실패</td>
<td><code>printf</code> 사용</td>
</tr>
<tr>
<td>3</td>
<td>Webhook URL 만료</td>
<td><code>curl</code> 직접 테스트 → <code>no_service</code></td>
<td>새 URL 재발급</td>
</tr>
<tr>
<td>4</td>
<td>Slack 3초 제한</td>
<td>응답시간 측정 → 3초+</td>
<td>즉시 ack + 백그라운드</td>
</tr>
<tr>
<td>5,6</td>
<td>Vercel 백그라운드 종료</td>
<td>Buttondown OK, Webhook 실패</td>
<td><code>waitUntil</code> 사용</td>
</tr>
<tr>
<td>7</td>
<td>Vercel 읽기전용 FS</td>
<td>콜백 중간 중단</td>
<td>try-catch + 순서 변경</td>
</tr>
<tr>
<td>8</td>
<td>Buttondown 방화벽</td>
<td>API 오류 메시지 확인</td>
<td>실제 이메일 사용</td>
</tr>
<tr>
<td>9</td>
<td>UI 텍스트 가독성</td>
<td>시각 확인</td>
<td><code>text-gray-900</code> 명시</td>
</tr>
</tbody></table>
<hr>
<h2>Vercel 배포 체크리스트</h2>
<p>이 프로젝트를 새 환경에 배포할 때 반드시 확인:</p>
<ul>
<li><input disabled="" type="checkbox"> 환경변수 설정 시 <code>printf</code> 사용 (<code>echo</code> / <code>&lt;&lt;&lt;</code> 금지)</li>
<li><input disabled="" type="checkbox"> <code>SLACK_WEBHOOK_URL</code> curl 테스트 → <code>ok</code> 확인</li>
<li><input disabled="" type="checkbox"> <code>SLACK_SIGNING_SECRET</code> 정확히 복사 (공백/줄바꿈 없이)</li>
<li><input disabled="" type="checkbox"> Slack Slash Command Request URL 업데이트</li>
<li><input disabled="" type="checkbox"> 파일 저장 로직은 try-catch로 보호</li>
<li><input disabled="" type="checkbox"> 백그라운드 작업은 <code>@vercel/functions</code> <code>waitUntil</code> 사용</li>
</ul>