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 비교 글과 짝이지만, 그건 스냅숏 비교였고 이건 직접 짜는 핸즈온이다.

흐름:

  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 토큰 종류 한 줄 정리

플랫폼토큰 이름권한 모델발급 위치
NotionInternal Integration SecretCapability 토글 + 페이지/데이터베이스 명시 공유notion.so/profile/integrations
SlackBot User OAuth Token (xoxb-) + App Token (xapp-)Scopes 단위 (chat:write, channels:history, ...)api.slack.com/apps
LinearPersonal API Key 또는 OAuth워크스페이스 전체 또는 사용자 권한linear.app/settings/account/security
AnthropicAPI 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') } }] },
  },
})

핵심 포인트:

  • parentdata_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·액션 버튼을 묶은 메시지의 핵심부.

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으로 바뀐 순간"을 어떻게 잡나

웹훅 페이로드는 액션과 데이터만 주지, "이전 상태"는 안 준다. 두 가지 방법:

  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 예시

요약 호출의 뼈대.

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.tplLINEAR_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건 워크플로

항목Zapiern8n self-hosted직접 짠 코드
호스팅0(SaaS)월 5달러(Fly.io)월 5달러(Fly.io)
태스크 비용3,000건 × 0.02달러 = 월 60달러00
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

현재 단락 (1/384)

작은 사내툴이 하나 있다. **Linear에서 이슈가 Done으로 바뀌면**, GitHub PR과 커밋 메시지를 모아서 **Slack 채널에 요약 알림**을 보내고, **Notio...

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