프롤로그 — Zapier 청구서가 350달러를 찍은 날
작은 사내툴이 하나 있다. **Linear에서 이슈가 Done으로 바뀌면**, GitHub PR과 커밋 메시지를 모아서 **Slack 채널에 요약 알림**을 보내고, **Notion 체인지로그 데이터베이스에 한 줄을 추가**한다. 처음엔 Zapier 5단계 Zap이었다. 한 달 청구서가 350달러를 찍었을 때 결심했다 — 이건 200줄짜리 스크립트로 충분하다.
이 글은 그 200줄을 처음부터 끝까지 짜본 핸즈온이다. 같이 묶을 친구들:
- **Notion API** — 2025년 8월에 공개된 **Data Sources API**로 데이터 모델이 바뀌었다. 데이터베이스가 더 이상 "단일 테이블"이 아니다.
- **Slack API** — Bolt 4 + Socket Mode + `chat.postMessage` + 인커밍 웹훅. 어느 쪽이 언제 옳은지.
- **Linear GraphQL API** — `@linear/sdk` 4.x, 웹훅 시그니처, 멤버십과 프로젝트 모델.
- **LLM 글루** — Claude/GPT를 중간에 끼워 넣어 **분류·요약·드래프트**를 시킨다. 단순 매핑이면 LLM은 사치지만, "PR 7개를 변경로그 한 줄로 압축"은 LLM이 더 잘한다.
이 글은 [지난주 Zapier·n8n 비교 글](/blog/culture/2026-05-14-workflow-automation-zapier-n8n-make-rpa-uipath-power-automate-comparison-deep-dive-2026)과 짝이지만, 그건 **스냅숏 비교**였고 이건 **직접 짜는 핸즈온**이다.
흐름:
1. 사전 준비 — 토큰·시크릿·트리거 모델 결정
2. Notion API 2026 — Data Sources API의 충격
3. Slack API — Bolt 4 + Socket Mode 핸즈온
4. Linear GraphQL — SDK와 웹훅
5. LLM 글루 패턴 — 언제 끼워 넣고 언제 빼는가
6. 풀 스크립트 — Linear 종료 → Slack → Notion
7. 시크릿 관리 — 1Password CLI · Doppler · Vercel 환경 변수
8. 운영 — 레이트리밋·재시도·관찰
9. 정직한 비교 — Zapier/n8n과의 트레이드오프
10. 안티패턴
다 읽으면 본인 팀의 두세 개 자잘한 자동화는 직접 짤 마음이 들 것이다.
1장 · 사전 준비 — 토큰·시크릿·트리거 모델
1.1 토큰 종류 한 줄 정리
| 플랫폼 | 토큰 이름 | 권한 모델 | 발급 위치 |
|--------|-----------|-----------|-----------|
| Notion | Internal Integration Secret | Capability 토글 + 페이지/데이터베이스 명시 공유 | `notion.so/profile/integrations` |
| Slack | Bot User OAuth Token (xoxb-) + App Token (xapp-) | Scopes 단위 (`chat:write`, `channels:history`, ...) | `api.slack.com/apps` |
| Linear | Personal API Key 또는 OAuth | 워크스페이스 전체 또는 사용자 권한 | `linear.app/settings/account/security` |
| Anthropic | API Key | 워크스페이스 + 사용량 한도 | `console.anthropic.com` |
**중요한 차이:** Notion 통합은 **공유받지 않은 페이지에는 접근할 수 없다**. 통합을 만들고 끝나는 게 아니라, 대상 페이지/데이터베이스에서 "통합 추가" 메뉴를 눌러 명시적으로 공유해야 한다. 처음 시작할 때 가장 많이 막히는 지점이다.
1.2 트리거 모델 — 웹훅 vs 폴링 vs 이벤트 구독
세 플랫폼 모두 셋 다 지원하지만 권장 모델이 다르다.
- **Notion**: 2024년부터 웹훅을 정식 지원한다. `database.content_updated`, `page.created`, `comment.created` 같은 이벤트 타입을 등록할 수 있다. 그래도 여전히 폴링이 흔하다 — Notion의 변경 알림은 부분적이라, "이 페이지의 모든 변경"을 따라가려면 결국 페이지 자체를 다시 가져와 비교해야 한다.
- **Slack**: **Events API** + 웹훅, 그리고 방화벽 안에서 돌릴 때를 위한 **Socket Mode**(WebSocket)가 있다. 로컬 개발과 작은 사내툴에는 Socket Mode가 한참 편하다.
- **Linear**: 웹훅이 1급 시민이다. 이슈/프로젝트/코멘트/사이클 등 거의 모든 변경에 웹훅을 걸 수 있다. GraphQL 쿼리로 폴링하는 경우는 거의 없다.
이 핸즈온의 흐름은 **Linear 웹훅 → Slack 알림 → Notion 행 추가**이므로, "Linear 웹훅을 받아주는 작은 서버"가 시작점이다.
1.3 어디서 돌릴 것인가
세 가지 흔한 선택:
- **Vercel Functions / Cloudflare Workers** — 서버리스. 콜드 스타트가 100~300ms, 무료 티어 후함, 시크릿이 환경 변수로 잘 들어간다. 단점: 백그라운드 처리가 어렵다. 30초 이내에 응답해야 한다.
- **Bun/Node 단일 프로세스 + systemd 또는 Fly.io** — 항상 켜져 있고, Socket Mode를 쓰려면 사실상 이쪽이다. 비용은 월 5~10달러.
- **Lambda + EventBridge** — 트래픽이 많고 SLO가 빡빡하면. 셋업 비용이 든다.
핸즈온에서는 **Bun + Fly.io**를 가정한다. 200줄 사내툴을 굴리는 가장 단순한 모델이다.
2장 · Notion API 2026 — Data Sources API의 충격
2.1 데이터 모델이 바뀌었다
2025년 8월, Notion은 **"Multi-source databases"** 기능을 출시하면서 API에도 큰 변화를 줬다. 한 데이터베이스 안에 여러 개의 데이터 소스를 둘 수 있게 됐고, API도 이걸 반영해야 했다.
- 이전(2024년 9월 버전): 데이터베이스 = 단일 테이블. `databases.query`로 페이지(=행)를 가져왔다.
- 현재(2025-09-03 버전 이후): **데이터베이스가 여러 data source를 포함**한다. 페이지는 더 이상 데이터베이스의 직속 자식이 아니라 **data source의 자식**이다.
새 엔드포인트:
- `GET /v1/data_sources/:id` — 데이터 소스 메타데이터.
- `POST /v1/data_sources/:id/query` — 페이지 쿼리. 이전의 `POST /v1/databases/:id/query` 대체.
- `POST /v1/data_sources` — 데이터 소스 생성.
기존 코드를 그대로 두고 싶다면 **`Notion-Version` 헤더를 옛 버전(`2022-06-28`)으로 고정**하면 당분간 동작한다. 하지만 새 기능(다중 데이터 소스)은 못 쓴다.
2.2 최소 스니펫 — 데이터베이스에 행 추가
const notion = new Client({
auth: process.env.NOTION_TOKEN!,
notionVersion: '2025-09-03',
})
await notion.pages.create({
parent: { data_source_id: process.env.NOTION_CHANGELOG_DATA_SOURCE_ID! },
properties: {
Title: { title: [{ type: 'text', text: { content: title } }] },
Date: { date: { start: dateIso } },
Summary: { rich_text: [{ type: 'text', text: { content: summary } }] },
'Linear Issue': { url: linearIssueUrl },
PRs: { rich_text: [{ type: 'text', text: { content: prUrls.join('\n') } }] },
},
})
핵심 포인트:
- `parent`가 `data_source_id`이지 `database_id`가 아니다. 옛 코드는 `database_id`를 썼고 지금도 호환 모드에서는 동작한다.
- `properties` 키는 **데이터베이스의 컬럼 이름 그대로**를 써야 한다. 대소문자와 공백까지.
- `Title` 컬럼은 매번 같은 이름이 아니다. 데이터베이스마다 다른 이름의 title 컬럼이 있을 수 있어서, 실제로는 먼저 `GET /v1/data_sources/:id`로 스키마를 가져와 title 컬럼 이름을 찾는 게 안전하다.
2.3 데이터 소스 ID 찾기
Notion UI는 데이터베이스 ID만 보여주지 데이터 소스 ID는 안 보여준다. 셸 한 줄.
curl -X GET "https://api.notion.com/v1/databases/$DATABASE_ID" \
-H "Authorization: Bearer $NOTION_TOKEN" \
-H "Notion-Version: 2025-09-03"
응답의 `data_sources` 배열 첫 번째 원소의 `id`가 데이터 소스 ID다. 단일 소스 데이터베이스라면 하나만 들어 있다.
2.4 레이트리밋
Notion은 **초당 3건의 평균 요청**을 보장한다. 버스트는 단기간 허용되지만 지속되면 `rate_limited` 에러(`status: 429`)가 떨어진다. 대응:
- `Retry-After` 헤더를 읽어 그만큼 잠든다.
- 동시 호출은 `p-limit` 등으로 3 이하로 캡한다.
- 한 워크플로에서 페이지 100개를 만드는 식이면 차라리 큐에 넣고 처리한다.
3장 · Slack API — Bolt 4 + Socket Mode 핸즈온
3.1 어떤 SDK를 쓸까
Node/Bun에서 사실상 표준은 `@slack/bolt`다. 2025년에 v4가 나왔고, Web API · Events API · Socket Mode · 인터랙티브 컴포넌트(`buttons`, `modals`)를 한 객체로 묶는다.
이번 워크플로에서 Slack의 역할은 두 가지다.
1. Linear 이슈 종료 알림을 채널에 올린다 — `chat.postMessage`.
2. (옵션) 사용자가 "체인지로그에 안 올림" 버튼을 누르면 Notion 행 작성을 취소한다 — Block Kit 액션 + Events API.
3.2 가장 단순한 시작 — Incoming Webhook
전송만 한다면 굳이 SDK를 만들 필요도 없다.
// slack-webhook.ts
const WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL!
export async function postToSlack(text: string) {
const r = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text }),
})
if (!r.ok) {
throw new Error(`Slack webhook failed: ${r.status} ${await r.text()}`)
}
}
웹훅은 **앱 설치 시 채널 하나에 묶인다**. 채널을 동적으로 바꿀 거면 봇 토큰 + `chat.postMessage`로 가야 한다.
3.3 Bot Token + chat.postMessage
Block Kit으로 헤더·섹션·context·액션 버튼을 묶은 메시지의 핵심부.
const slack = new WebClient(process.env.SLACK_BOT_TOKEN!)
await slack.chat.postMessage({
channel,
text: `${issueTitle} closed`, // 알림 미리보기에 쓰임
blocks: [
{ type: 'header', text: { type: 'plain_text', text: issueTitle } },
{ type: 'section', text: { type: 'mrkdwn', text: summary } },
{
type: 'actions',
elements: [{
type: 'button',
text: { type: 'plain_text', text: 'Skip changelog' },
style: 'danger',
action_id: 'skip_changelog',
value: issueUrl,
}],
},
],
})
`text`는 알림 미리보기에 쓰이므로 비워두지 않는다. 풀 버전은 6장에서.
3.4 Socket Mode로 인터랙션 받기
`Skip changelog` 버튼을 받으려면 인커밍 이벤트 처리가 필요하다. 사내툴은 회사 방화벽 뒤에 있는 경우가 많아 **Socket Mode**가 편하다.
const app = new App({
token: process.env.SLACK_BOT_TOKEN!,
appToken: process.env.SLACK_APP_TOKEN!, // xapp-...
socketMode: true,
})
app.action('skip_changelog', async ({ ack, body }) => {
await ack()
const issueUrl = (body as any).actions[0].value
await markSkip(issueUrl) // KV에 마킹, Notion 쓰기 직전 확인
})
await app.start()
Socket Mode가 좋은 이유는 셋: 인바운드 포트를 안 연다, ngrok/cloudflared 터널 불요, 로컬과 운영 코드가 같다. 단점은 한 가지 — **수평 확장이 까다롭다**. 인스턴스 2개면 동일 이벤트가 양쪽에 도착한다. 사내툴 규모에서는 문제 안 된다.
3.5 Slack 레이트리밋
- `chat.postMessage`는 채널 단위 Tier 4 — **분당 100건**, 사실상 신경 안 써도 된다.
- `users.list` 같은 글로벌 Tier는 분당 50건이라 자주 부른다면 캐시한다.
- 429 응답에 `Retry-After` 헤더가 같이 온다. 그대로 sleep 후 재시도.
4장 · Linear GraphQL — SDK와 웹훅
4.1 SDK가 정답이다
Linear는 공식 TypeScript SDK `@linear/sdk`를 잘 유지한다. 4.x가 안정 버전. GraphQL 스키마 위에 typed 메서드를 얹어줘서 손으로 쿼리를 쓰지 않아도 된다.
const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY! })
const issue = await linear.issue(issueId)
const [comments, attachments] = await Promise.all([
issue.comments(),
issue.attachments(),
])
// issue.identifier === 'ENG-1234', issue.url, issue.title, ...
// PR은 attachments로 들어온다
const prUrls = attachments.nodes
.filter((a) => a.url.startsWith('https://github.com/') && a.url.includes('/pull/'))
.map((a) => a.url)
4.2 웹훅 — 시그니처 검증을 잊지 마라
Linear 웹훅은 `Linear-Signature` 헤더로 HMAC-SHA256 시그니처를 같이 보낸다. **무조건 검증**해야 한다 — 그렇지 않으면 누구든 가짜 페이로드로 자동화를 트리거할 수 있다.
function verifySig(raw: string, sig: string, secret: string) {
const expected = createHmac('sha256', secret).update(raw).digest('hex')
return expected.length === sig.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
}
Bun 서버에서 받는 패턴은 6장 풀 스크립트의 `serve({...})` 그대로. 핵심 원칙은 **200을 빨리** 돌려보내는 것 — Linear는 응답이 5초 넘으면 재시도하고, 며칠 안에 실패가 누적되면 웹훅을 비활성화한다. 무거운 처리는 `queueMicrotask`로 비동기 분리.
4.3 "Done으로 바뀐 순간"을 어떻게 잡나
웹훅 페이로드는 액션과 데이터만 주지, "이전 상태"는 안 준다. 두 가지 방법:
1. **로컬 상태 캐시** — 이슈별 마지막으로 본 상태를 KV에 저장하고 비교한다. 가장 정확.
2. **워크플로 상태 ID로 분기** — 페이로드의 `data.state.id`가 "Done" 상태의 ID와 같은지만 본다. 단점: "Done에서 다시 In Progress로" 같은 회귀를 못 잡는다.
핸즈온에서는 2를 쓴다. Done의 state ID는 워크스페이스마다 고정이라 한 번 알아두면 끝.
const DONE_STATE_IDS = new Set([process.env.LINEAR_DONE_STATE_ID!])
function isClosed(event: any) {
return event.type === 'Issue' && DONE_STATE_IDS.has(event.data?.state?.id)
}
4.4 Linear 레이트리밋
- GraphQL API는 **시간당 1500 복잡도 포인트**라는 모델이다. 단순 쿼리는 1점, 페이지네이션으로 100개씩 가져오면 10점.
- 응답 헤더 `X-RateLimit-Requests-Remaining`을 본다.
- SDK는 429를 받으면 자동으로 한 번 재시도해준다. 그래도 멱등성은 본인 책임.
5장 · LLM 글루 패턴 — 언제 끼우고 언제 빼는가
5.1 LLM이 진짜로 더 잘하는 일
세 가지로 압축된다.
1. **요약** — PR 7개 + 커밋 메시지 30개를 변경로그 한 줄로.
2. **분류** — "이 이슈는 bug/feature/chore/security 중 무엇인가" 같은 짧은 분기.
3. **드래프트** — Slack 메시지의 제목과 본문을 일관된 톤으로 작성.
그 외는 거의 다 정규식이나 단순 매핑이 낫다. LLM을 끼우면 비용·지연·비결정성을 떠안는다.
5.2 요약 호출 — Claude Sonnet 4.5 예시
요약 호출의 뼈대.
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! })
const resp = await anthropic.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 400,
messages: [{ role: 'user', content: prompt }],
})
const text = resp.content
.filter((b) => b.type === 'text')
.map((b) => (b as any).text)
.join('')
const json = text.match(/\{[\s\S]*\}/)?.[0]
비용 감각: Sonnet 4.5는 입력 100만 토큰당 3달러, 출력 100만 토큰당 15달러 수준. 호출 한 번에 0.002~0.01달러. 하루 100건이라도 한 달에 30달러를 안 넘긴다. 풀 프롬프트와 폴백은 6장에서.
5.3 모델 선택 — 무엇을 쓸지
- **분류 같은 짧은 분기**: Haiku 같은 작은 모델. 또는 GPT-4o-mini. 빠르고 싸다.
- **요약·드래프트**: Sonnet 또는 GPT-4o. 품질이 눈에 띄게 좋다.
- **추론(decision)**: 적용 가능한 작업이라면 reasoning 모드. 다만 워크플로 자동화에서 추론이 필요한 경우는 드물다.
5.4 LLM을 끼우지 말아야 할 곳
- "이슈가 닫혔으면 알림 보낸다" 같은 분명한 분기.
- 상태 변환 (`open` → "Open" 같은 대문자화).
- 라우팅(이 채널 vs 저 채널) — 단순 매핑 테이블이면 충분.
매핑이 정해진 곳에서 LLM을 쓰면 **간헐적 실수**가 들어와 디버깅이 골치 아파진다.
5.5 결과 검증 — JSON 출력은 신뢰하지 말 것
LLM은 가끔 JSON 앞뒤에 잡설을 붙이거나 필수 필드를 빼먹는다. **항상** Zod 같은 스키마로 `safeParse`하고, 실패 시엔 안전한 폴백(예: 카테고리는 `chore`, 요약은 issue 제목)으로 떨어진다. 6장 풀 스크립트에 그 패턴이 들어 있다.
6장 · 풀 스크립트 — Linear 종료 → Slack → Notion
지금까지의 조각들을 묶는다. Bun 단일 프로세스, 약 200줄.
// server.ts
const env = z
.object({
LINEAR_API_KEY: z.string(),
LINEAR_WEBHOOK_SECRET: z.string(),
LINEAR_DONE_STATE_ID: z.string(),
SLACK_BOT_TOKEN: z.string(),
SLACK_CHANNEL: z.string(),
NOTION_TOKEN: z.string(),
NOTION_CHANGELOG_DATA_SOURCE_ID: z.string(),
ANTHROPIC_API_KEY: z.string(),
})
.parse(process.env)
const linear = new LinearClient({ apiKey: env.LINEAR_API_KEY })
const slack = new WebClient(env.SLACK_BOT_TOKEN)
const notion = new NotionClient({ auth: env.NOTION_TOKEN, notionVersion: '2025-09-03' })
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })
function verifySig(raw: string, sig: string) {
const expected = createHmac('sha256', env.LINEAR_WEBHOOK_SECRET).update(raw).digest('hex')
return expected.length === sig.length && timingSafeEqual(Buffer.from(expected), Buffer.from(sig))
}
async function summarize(args: {
issueTitle: string
description: string
prTitles: string[]
}) {
const prompt = `Summarize this Linear issue closure as one sentence for a changelog. Also classify.
Title: ${args.issueTitle}
Description:
${args.description.slice(0, 2000)}
PRs:
${args.prTitles.map((t) => '- ' + t).join('\n')}
Output JSON only: {"summary":"...","category":"bug|feature|chore|security"}`
const resp = await anthropic.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 300,
messages: [{ role: 'user', content: prompt }],
})
const text = resp.content
.filter((b) => b.type === 'text')
.map((b) => (b as any).text)
.join('')
const m = text.match(/\{[\s\S]*\}/)
const schema = z.object({
summary: z.string().min(3).max(280),
category: z.enum(['bug', 'feature', 'chore', 'security']),
})
const parsed = m ? schema.safeParse(JSON.parse(m[0])) : null
if (!parsed || !parsed.success) {
return { summary: args.issueTitle, category: 'chore' as const }
}
return parsed.data
}
async function handleIssueClosed(event: any) {
const issueId = event.data.id
const issue = await linear.issue(issueId)
const attachments = await issue.attachments()
const prUrls = attachments.nodes
.filter((a) => a.url.includes('github.com') && a.url.includes('/pull/'))
.map((a) => a.url)
const prTitles = attachments.nodes
.filter((a) => prUrls.includes(a.url))
.map((a) => a.title || a.url)
const { summary, category } = await summarize({
issueTitle: issue.title,
description: issue.description ?? '',
prTitles,
})
await slack.chat.postMessage({
channel: env.SLACK_CHANNEL,
text: `${issue.identifier} closed: ${issue.title}`,
blocks: [
{
type: 'header',
text: { type: 'plain_text', text: `${issue.identifier} closed` },
},
{ type: 'section', text: { type: 'mrkdwn', text: `*${issue.title}*\n${summary}` } },
{
type: 'context',
elements: [
{ type: 'mrkdwn', text: `Category: ${category}` },
{ type: 'mrkdwn', text: `<${issue.url}|Linear>` },
...prUrls.map((u) => ({ type: 'mrkdwn' as const, text: `<${u}|PR>` })),
],
},
],
})
await notion.pages.create({
parent: { data_source_id: env.NOTION_CHANGELOG_DATA_SOURCE_ID },
properties: {
Title: { title: [{ type: 'text', text: { content: issue.title } }] },
Date: { date: { start: new Date().toISOString().slice(0, 10) } },
Summary: { rich_text: [{ type: 'text', text: { content: summary } }] },
Category: { select: { name: category } },
'Linear Issue': { url: issue.url },
PRs: { rich_text: [{ type: 'text', text: { content: prUrls.join('\n') } }] },
},
})
}
const DONE_STATE_IDS = new Set([env.LINEAR_DONE_STATE_ID])
serve({
port: Number(process.env.PORT ?? 3000),
async fetch(req) {
if (req.method !== 'POST') return new Response('only POST', { status: 405 })
const raw = await req.text()
const sig = req.headers.get('linear-signature') ?? ''
if (!verifySig(raw, sig)) return new Response('bad sig', { status: 401 })
const event = JSON.parse(raw)
const closed =
event.type === 'Issue' &&
event.action === 'update' &&
DONE_STATE_IDS.has(event.data?.state?.id)
if (closed) {
queueMicrotask(() => handleIssueClosed(event).catch((e) => console.error('handler failed', e)))
}
return new Response('ok')
},
})
이 한 파일이 전체 워크플로다. 의존성:
bun add @anthropic-ai/sdk @linear/sdk @notionhq/client @slack/web-api zod
Fly.io에 올리는 모양:
fly launch --no-deploy
fly secrets set LINEAR_API_KEY=... LINEAR_WEBHOOK_SECRET=... # ...
fly deploy
7장 · 시크릿 관리 — 1Password · Doppler · Vercel
7.1 절대 하지 말 것
- `.env`를 깃에 커밋. 사고는 30%가 여기서 난다.
- Slack 채널에 토큰을 붙여 넣기. 보존 기간 90일 이상이면 거의 영구.
- 프로덕션 토큰을 로컬 개발에 사용. 실수 한 번이 큰 사고.
7.2 1Password CLI — 1인 개발자에게 가성비
`.env.tpl`에 `LINEAR_API_KEY=op://dev-secrets/linear-prod/password` 같은 **참조만** 적고 `op run --env-file=.env.tpl -- bun run server.ts`로 실행하면 평문 시크릿이 디스크에 절대 떨어지지 않는다.
7.3 Doppler — 팀이 같이 쓸 때
Doppler는 호스팅 시크릿 매니저. 워크스페이스 단위로 시크릿을 두고 `doppler run -- bun run server.ts`로 주입한다. CI/CD에서 같은 인터페이스를 쓸 수 있는 게 큰 장점. 5명 이하 팀이면 무료 티어로 충분.
7.4 Vercel/Cloudflare 환경 변수
서버리스 배포면 콘솔에 넣는 게 가장 단순. 단점은 환경별로 직접 동기화해야 한다는 점. CI에서 자동 주입하려면 `vercel env pull`을 한 번 박아둔다.
7.5 시크릿 회수 (rotation)
3개월에 한 번. 4개 토큰을 한 번에 돌리는 절차는 거의 동일하다 — 신규 발급 → 환경 변수 교체 → 구 토큰 비활성. 단 Notion은 회전 즉시 옛 토큰이 무효라 무중단을 원하면 두 통합을 잠시 병존시킨다. 자동화하지 않으면 인간이 한다 — 인간은 매번 잊는다.
8장 · 운영 — 레이트리밋·재시도·관찰
8.1 재시도 정책
세 API 모두 일시적 5xx와 429를 낸다. 표준 정책 세 줄.
- 5xx: 지수 백오프, 최대 3회.
- 429: `Retry-After` 헤더를 존중. 없으면 1초 → 2초 → 4초.
- **4xx(429 제외): 재시도 금지.** 그건 본인 코드 버그다.
작은 헬퍼는 `for` 루프 + `try/catch`에서 status를 보고 분기하는 형태면 충분하다. 한 번 짜서 모든 API 호출을 감싸면 끝.
8.2 멱등성
웹훅은 재시도된다. 같은 이슈 종료 이벤트가 두 번 오면 Notion에 행이 두 개 생긴다. 방어 두 줄.
- 이벤트마다 `event.id`를 KV에 기록하고, 본 적 있으면 무시.
- Notion 행 생성 전에 "이미 같은 이슈 URL의 행이 있는지" 쿼리.
기본 정책: **항상 중복을 가정하라**.
8.3 관찰과 알림
로그는 `console.log(JSON.stringify({...}))` 한 줄로 구조화한다. Fly.io의 `fly logs`에서 grep 가능. 한 시간 투자해 OpenTelemetry를 끼우면 더 좋다 — Honeycomb 무료 티어로 충분.
워크플로가 깨졌는데 아무도 모른 채 일주일이 가는 게 가장 흔한 사고. 핸들러 최상위에서 try/catch로 잡고, 실패 시 `#alerts-dev` 같은 운영 채널에 한 줄 보낸다. `:rotating_light: workflow failed: ${msg}` 정도면 충분.
9장 · 정직한 비교 — Zapier/n8n과의 트레이드오프
9.1 직접 짤 때가 더 나을 때
- **로직이 단순하지 않다** — 분기 5단계, 조건 매칭, LLM 호출, 외부 KV 등. iPaaS GUI에서 이걸 만들면 디버깅이 지옥이다.
- **태스크 비용이 폭발한다** — Zapier는 태스크당 과금이라 수천 건 워크플로면 월 비용이 가파르게 오른다. 직접 짠 코드는 인프라 비용 5달러로 끝난다.
- **시크릿이 회사 정책상 SaaS에 못 들어간다** — Zapier가 OAuth 토큰을 잡고 있다는 사실이 컴플라이언스 사유로 거절될 수 있다.
- **빨리 배포·롤백이 필요하다** — 코드면 그냥 git revert. GUI는 변경 이력이 약하다.
9.2 Zapier/n8n이 더 나을 때
- **워크플로 수가 많고 종류가 다양하다** — 30개 자동화를 30개 스크립트로 굴리면 운영이 어렵다. n8n 하나로 묶는 게 낫다.
- **비개발자도 손대야 한다** — 마케팅 팀이 트리거 컨디션을 바꾸려면 GUI가 필수.
- **새 SaaS 연결이 잦다** — Zapier의 9,000개+ 통합은 강력하다. 직접 짜려면 SaaS마다 SDK를 익히고 인증을 처리해야 한다.
- **유지보수에 시간을 못 쓴다** — 직접 짠 코드는 본인이 유지해야 한다. 한 명짜리 팀에서는 짐.
9.3 비용 시뮬레이션 — 하루 100건 워크플로
|항목|Zapier|n8n self-hosted|직접 짠 코드|
|----|------|---------------|------------|
|호스팅|0(SaaS)|월 5달러(Fly.io)|월 5달러(Fly.io)|
|태스크 비용|3,000건 × 0.02달러 = 월 60달러|0|0|
|LLM API|미포함|미포함|월 30달러(Claude)|
|개발 시간|2시간|6시간|10시간|
|유지보수/월|0.5시간|1시간|0.5시간|
|총 월 비용|60달러+|5달러+|35달러+|
LLM을 안 끼우면 직접 짠 코드는 월 5달러. 시간만 한 번 쓰면 비용은 거의 0.
9.4 권장 의사결정
1. 자동화가 5개 이하 → 직접 짠다. 더 빨리 배운다.
2. 5~30개 → n8n 셀프호스팅. 통합 카탈로그를 활용.
3. 30개 이상, 비개발자 손이 필요 → Zapier 또는 Make.
4. 컴플라이언스 빡세고 SaaS 토큰 못 줌 → 직접 짠다 + 사내 키 매니저.
에필로그 — 체크리스트와 안티패턴
핸즈온 체크리스트
- 시크릿이 깃에 절대 들어가지 않는가
- Linear 웹훅 시그니처를 검증하는가
- 멱등성 키를 KV에 저장하는가
- LLM 출력은 Zod 스키마로 검증하는가
- 실패 시 운영 채널에 알림이 가는가
- 재시도가 4xx에는 작동하지 않는가 (그건 본인 버그)
- Notion 통합을 대상 데이터베이스에 명시 공유했는가
- Slack 봇이 채널에 초대돼 있는가 (안 되면 `not_in_channel` 에러)
- 환경 변수 6개 모두 운영에 설정됐는가
- 로그는 구조화돼 있는가
안티패턴
- **LLM을 모든 분기에 끼우기** — 비결정성이 누적된다. 단순 매핑이면 LLM은 빼라.
- **`Notion-Version` 헤더 누락** — 명시 안 하면 최신 버전으로 라우팅되고 마이그레이션 시 깨진다. 항상 박아둔다.
- **웹훅에서 즉시 무거운 일** — 5초 SLO 안에 못 끝낼 작업은 큐로 빼라. 안 그러면 웹훅이 끊긴다.
- **재시도 폭주** — 4xx에 재시도하면 본인 코드 버그를 외부 API에 떠넘긴다. 알림을 띄우고 멈춰라.
- **하드코딩된 채널 ID·데이터베이스 ID** — 환경 변수로. 옮길 때 코드 안 고치게.
- **Socket Mode 인스턴스 2개** — 같은 이벤트를 두 번 처리한다. 사내툴 규모에선 단일 인스턴스가 정답.
- **JSON 출력 신뢰** — LLM은 가끔 잡설을 붙인다. 항상 파싱과 스키마 검증.
- **시크릿 회수를 안 함** — 3개월에 한 번. 캘린더에 박아두지 않으면 안 한다.
- **PII 그대로 LLM에** — 고객 이메일이나 개인정보를 그대로 프롬프트에 넣지 마라. 마스킹 또는 사내 모델.
- **에러를 삼키기** — try/catch 빈 블록은 운영의 적. 최소한 구조화된 로그.
다음 글 예고
다음 글에서는 **OpenTelemetry · Tempo · Grafana로 사내 자동화 트레이싱 붙이기 핸즈온**을 다룬다. 이 200줄짜리 스크립트에 5분만 투자해 분산 트레이싱을 끼우면, "왜 어제는 5초 걸렸는데 오늘은 50초 걸리지?"에 답할 수 있게 된다. Anthropic·Slack·Linear·Notion 각각의 호출 지연을 한 화면에서 본다.
참고 / References
- [Notion API — Upgrade Guide (2025-09-03)](https://developers.notion.com/docs/upgrade-guide-2025-09-03)
- [Notion API — Working with databases (data sources)](https://developers.notion.com/docs/working-with-databases)
- [Notion API — Rate limits](https://developers.notion.com/reference/request-limits)
- [Slack Bolt for JavaScript](https://tools.slack.dev/bolt-js/)
- [Slack — Socket Mode](https://api.slack.com/apis/socket-mode)
- [Slack — chat.postMessage](https://api.slack.com/methods/chat.postMessage)
- [Slack — Rate limits](https://api.slack.com/docs/rate-limits)
- [Linear — GraphQL API docs](https://linear.app/developers/graphql)
- [Linear — Webhooks](https://linear.app/developers/webhooks)
- [Linear — TypeScript SDK (`@linear/sdk`)](https://github.com/linear/linear/tree/master/packages/sdk)
- [Anthropic — Messages API](https://docs.anthropic.com/en/api/messages)
- [Anthropic — Pricing](https://www.anthropic.com/pricing)
- [1Password CLI](https://developer.1password.com/docs/cli/)
- [Doppler — Secrets management](https://docs.doppler.com/docs)
- [Vercel — Environment variables](https://vercel.com/docs/projects/environment-variables)
- [Fly.io — Deploying Bun apps](https://fly.io/docs/js/frameworks/bun/)
- [Zod — TypeScript-first schema validation](https://zod.dev/)
현재 단락 (1/384)
작은 사내툴이 하나 있다. **Linear에서 이슈가 Done으로 바뀌면**, GitHub PR과 커밋 메시지를 모아서 **Slack 채널에 요약 알림**을 보내고, **Notio...