- Published on
Notion·Slack·Linear API를 LLM으로 묶는 워크플로 자동화 핸즈온 — 2026년판 사내툴 글루 코드 가이드
- Authors

- Name
- Youngju Kim
- @fjvbn20031
프롤로그 — 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/sdk4.x, 웹훅 시그니처, 멤버십과 프로젝트 모델. - LLM 글루 — Claude/GPT를 중간에 끼워 넣어 분류·요약·드래프트를 시킨다. 단순 매핑이면 LLM은 사치지만, "PR 7개를 변경로그 한 줄로 압축"은 LLM이 더 잘한다.
이 글은 지난주 Zapier·n8n 비교 글과 짝이지만, 그건 스냅숏 비교였고 이건 직접 짜는 핸즈온이다.
흐름:
- 사전 준비 — 토큰·시크릿·트리거 모델 결정
- Notion API 2026 — Data Sources API의 충격
- Slack API — Bolt 4 + Socket Mode 핸즈온
- Linear GraphQL — SDK와 웹훅
- LLM 글루 패턴 — 언제 끼워 넣고 언제 빼는가
- 풀 스크립트 — Linear 종료 → Slack → Notion
- 시크릿 관리 — 1Password CLI · Doppler · Vercel 환경 변수
- 운영 — 레이트리밋·재시도·관찰
- 정직한 비교 — Zapier/n8n과의 트레이드오프
- 안티패턴
다 읽으면 본인 팀의 두세 개 자잘한 자동화는 직접 짤 마음이 들 것이다.
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 최소 스니펫 — 데이터베이스에 행 추가
import { Client } from '@notionhq/client'
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의 역할은 두 가지다.
- Linear 이슈 종료 알림을 채널에 올린다 —
chat.postMessage. - (옵션) 사용자가 "체인지로그에 안 올림" 버튼을 누르면 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·액션 버튼을 묶은 메시지의 핵심부.
import { WebClient } from '@slack/web-api'
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가 편하다.
import { App } from '@slack/bolt'
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 메서드를 얹어줘서 손으로 쿼리를 쓰지 않아도 된다.
import { LinearClient } from '@linear/sdk'
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 시그니처를 같이 보낸다. 무조건 검증해야 한다 — 그렇지 않으면 누구든 가짜 페이로드로 자동화를 트리거할 수 있다.
import { createHmac, timingSafeEqual } from 'node:crypto'
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으로 바뀐 순간"을 어떻게 잡나
웹훅 페이로드는 액션과 데이터만 주지, "이전 상태"는 안 준다. 두 가지 방법:
- 로컬 상태 캐시 — 이슈별 마지막으로 본 상태를 KV에 저장하고 비교한다. 가장 정확.
- 워크플로 상태 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이 진짜로 더 잘하는 일
세 가지로 압축된다.
- 요약 — PR 7개 + 커밋 메시지 30개를 변경로그 한 줄로.
- 분류 — "이 이슈는 bug/feature/chore/security 중 무엇인가" 같은 짧은 분기.
- 드래프트 — Slack 메시지의 제목과 본문을 일관된 톤으로 작성.
그 외는 거의 다 정규식이나 단순 매핑이 낫다. LLM을 끼우면 비용·지연·비결정성을 떠안는다.
5.2 요약 호출 — Claude Sonnet 4.5 예시
요약 호출의 뼈대.
import Anthropic from '@anthropic-ai/sdk'
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
import { serve } from 'bun'
import { LinearClient } from '@linear/sdk'
import { WebClient } from '@slack/web-api'
import { Client as NotionClient } from '@notionhq/client'
import { createHmac, timingSafeEqual } from 'node:crypto'
import Anthropic from '@anthropic-ai/sdk'
import { z } from 'zod'
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 권장 의사결정
- 자동화가 5개 이하 → 직접 짠다. 더 빨리 배운다.
- 5~30개 → n8n 셀프호스팅. 통합 카탈로그를 활용.
- 30개 이상, 비개발자 손이 필요 → Zapier 또는 Make.
- 컴플라이언스 빡세고 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)
- Notion API — Working with databases (data sources)
- Notion API — Rate limits
- Slack Bolt for JavaScript
- Slack — Socket Mode
- Slack — chat.postMessage
- Slack — Rate limits
- Linear — GraphQL API docs
- Linear — Webhooks
- Linear — TypeScript SDK (
@linear/sdk) - Anthropic — Messages API
- Anthropic — Pricing
- 1Password CLI
- Doppler — Secrets management
- Vercel — Environment variables
- Fly.io — Deploying Bun apps
- Zod — TypeScript-first schema validation