Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

프롤로그 — 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...

작성 글자: 0원문 글자: 16,509작성 단락: 0/384