LabHub

블로그

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

한국어English日本語

프롤로그 — Zapier 청구서가 350달러를 찍은 날

작은 사내툴이 하나 있다. Linear에서 이슈가 Done으로 바뀌면, GitHub PR과 커밋 메시지를 모아서 Slack 채널에 요약 알림을 보내고, Notion 체인지로그 데이터베이스에 한 줄을 추가한다. 처음엔 Zapier 5단계 Zap이었다. 한 달 청구서가 350달러를 찍었을 때 결심했다 — 이건 200줄짜리 스크립트로 충분하다.

이 글은 그 200줄을 처음부터 끝까지 짜본 핸즈온이다. 같이 묶을 친구들:

이 글은 지난주 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 이벤트 구독

세 플랫폼 모두 셋 다 지원하지만 권장 모델이 다르다.

이 핸즈온의 흐름은 Linear 웹훅 → Slack 알림 → Notion 행 추가이므로, "Linear 웹훅을 받아주는 작은 서버"가 시작점이다.

1.3 어디서 돌릴 것인가

세 가지 흔한 선택:

핸즈온에서는 Bun + Fly.io를 가정한다. 200줄 사내툴을 굴리는 가장 단순한 모델이다.


2장 · Notion API 2026 — Data Sources API의 충격

2.1 데이터 모델이 바뀌었다

2025년 8월, Notion은 "Multi-source databases" 기능을 출시하면서 API에도 큰 변화를 줬다. 한 데이터베이스 안에 여러 개의 데이터 소스를 둘 수 있게 됐고, API도 이걸 반영해야 했다.

새 엔드포인트:

기존 코드를 그대로 두고 싶다면 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') } }] },
  },
})

핵심 포인트:

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)가 떨어진다. 대응:


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 레이트리밋


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 레이트리밋


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 모델 선택 — 무엇을 쓸지

5.4 LLM을 끼우지 말아야 할 곳

매핑이 정해진 곳에서 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 절대 하지 말 것

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를 낸다. 표준 정책 세 줄.

작은 헬퍼는 for 루프 + try/catch에서 status를 보고 분기하는 형태면 충분하다. 한 번 짜서 모든 API 호출을 감싸면 끝.

8.2 멱등성

웹훅은 재시도된다. 같은 이슈 종료 이벤트가 두 번 오면 Notion에 행이 두 개 생긴다. 방어 두 줄.

기본 정책: 항상 중복을 가정하라.

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 직접 짤 때가 더 나을 때

9.2 Zapier/n8n이 더 나을 때

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 토큰 못 줌 → 직접 짠다 + 사내 키 매니저.

에필로그 — 체크리스트와 안티패턴

핸즈온 체크리스트

안티패턴

다음 글 예고

다음 글에서는 OpenTelemetry · Tempo · Grafana로 사내 자동화 트레이싱 붙이기 핸즈온을 다룬다. 이 200줄짜리 스크립트에 5분만 투자해 분산 트레이싱을 끼우면, "왜 어제는 5초 걸렸는데 오늘은 50초 걸리지?"에 답할 수 있게 된다. Anthropic·Slack·Linear·Notion 각각의 호출 지연을 한 화면에서 본다.


참고 / References

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다