KainosPeople Archive
  • 인물
  • 인사이트
  • 프로젝트
  • 발견
  • 소개
인물인사이트프로젝트발견소개
AD
Kainos

시대를 초월한 거장들의 삶과 작품을 기록하고 탐구하는 인물 아카이브.

분야
화가 · Painters음악가 · Musicians과학자 · Scientists철학자 · Philosophers작가 · Writers감독 · Directors
콘텐츠
인사이트전체 인물프로젝트발견
Kainos
소개문의
© 2025 Kainos · People Archive. All rights reserved.
이용약관개인정보처리방침
Vibe Coding

Slack 승인 배포 자동화

콘텐츠 자동 생성에서 승인 버튼 하나로 production 배포까지

by Kainos·2026.04.09·12분 읽기

₩0
추가 비용
모든 서비스 무료 플랜
1클릭
배포 방식
Slack 버튼으로 승인
3개
필요 서비스
Slack + Cloudflare + GitHub

전체 흐름

schedule
스케줄 실행
macOS launchd가 매일 정해진 시간에 스크립트 실행
auto_awesome
콘텐츠 생성
Claude Code CLI로 페이지 자동 생성 + 빌드 검증
cloud_upload
develop 푸시
생성된 콘텐츠를 develop 브랜치에 커밋 & 푸시
visibility
프리뷰 배포
Cloudflare Pages가 develop 브랜치를 자동 배포
chat
Slack 알림
승인/반려 버튼이 포함된 메시지를 Slack 채널에 전송
touch_app
검토 & 승인
프리뷰에서 확인 후 Slack에서 승인 → main 자동 머지 → 프로덕션 배포

구성 요소

chat
Slack 앱 (Kainos Web Deploy)
Interactive Message로 승인/반려 버튼을 표시. Interactivity 기능으로 버튼 클릭 이벤트를 Cloudflare Worker로 전달.
cloud
Cloudflare Worker (slack-approval)
Slack 버튼 이벤트를 수신하여 GitHub API로 develop → main 머지를 실행. 반려 시 재생성 마커 파일 생성.
code
GitHub API
Personal Access Token으로 인증. Merges API를 통해 develop 브랜치를 main에 머지.
terminal
자동화 스크립트 (daily-insight.sh)
Claude Code로 콘텐츠 생성 → develop 직접 푸시 → Slack Bot API로 승인 버튼 전송.

준비물

✅ Slack 워크스페이스무료
✅ Cloudflare 계정 (Workers)무료
✅ GitHub 계정 + 리포지토리무료
✅ Cloudflare Pages 프로젝트 (develop 프리뷰)무료
✅ 도메인 (Cloudflare DNS 관리)보유 중

1단계: Slack 앱 생성

Slack API 사이트에서 앱을 생성합니다. 무료이며 워크스페이스당 제한 없이 만들 수 있습니다.

open_in_new
앱 생성
https://api.slack.com/apps 접속 → 'Create New App' → 'From scratch' 선택 → 앱 이름 입력 (예: Kainos Web Deploy) → 워크스페이스 선택 → 'Create App'
key
Signing Secret 확인
Basic Information 페이지 → App Credentials → Signing Secret → 'Show' 클릭 → 값 복사. 이 값은 나중에 Worker에 저장합니다.

2단계: Bot Token 권한 설정

앱이 채널에 메시지를 보낼 수 있도록 권한을 부여합니다.

security
OAuth & Permissions
왼쪽 메뉴에서 'OAuth & Permissions' 클릭 → 'Scopes' 섹션으로 스크롤
add_circle
Scope 추가
'Bot Token Scopes'에서 'Add an OAuth Scope' 클릭 → chat:write 추가
download
워크스페이스에 설치
왼쪽 메뉴 'Install App' → 'Install to Workspace' → '허용' 클릭 → Bot User OAuth Token (xoxb-...) 복사
save
토큰 저장
프로젝트 루트에 .slack-bot-token 파일로 저장. .gitignore에 추가되어 있는지 확인.
Bot Token 로컬 저장
# 프로젝트 루트에서 실행
echo "xoxb-your-token-here" > .slack-bot-token
# .gitignore에 추가 (이미 되어 있다면 생략)
echo ".slack-bot-token" >> .gitignore

3단계: Interactivity 설정

Slack에서 버튼을 클릭했을 때 이벤트를 받을 URL을 설정합니다. 이 URL은 Cloudflare Worker의 엔드포인트입니다.

toggle_on
Interactivity 활성화
왼쪽 메뉴 'Interactivity & Shortcuts' → Interactivity 토글 On
link
Request URL 설정
https://slack-approval.yourdomain.com/interact 입력 (예: https://slack-approval.kainos.kr/interact)
save
저장
'Save Changes' 클릭. Worker 배포 전이라 URL 검증에 실패할 수 있지만 저장은 됩니다.

참고: Request URL은 Worker 배포 후에 동작합니다. 순서상 먼저 설정해두고, Worker를 배포하면 자동으로 연결됩니다.

4단계: 채널 설정

알림을 받을 Slack 채널에 앱을 초대하고, 채널 ID를 저장합니다.

채널 설정
# 1. Slack 채널에서 앱 초대
/invite @Kainos Web Deploy
# 2. 채널 ID 확인: 채널 이름 클릭 → 하단에 C로 시작하는 ID
# 예: C0ARWQ54J2V
# 3. 프로젝트 루트에 저장
echo "C0ARWQ54J2V" > .slack-channel-id

5단계: GitHub Personal Access Token 생성

Worker가 GitHub API로 머지를 실행하려면 인증 토큰이 필요합니다. Fine-grained token을 사용하면 특정 리포지토리에만 권한을 부여할 수 있습니다.

open_in_new
토큰 생성 페이지 접속
https://github.com/settings/tokens?type=beta → 'Generate new token' 클릭
edit
기본 설정
Token name: kainos-slack-deploy / Expiration: 원하는 기간 또는 No expiration
folder
Repository access
'Only select repositories' → 대상 리포지토리 선택 (예: kainos-kr-web)
security
Permissions
Repository permissions → Contents: Read and write (머지에 필요). Metadata는 자동으로 Read-only 추가됨. 나머지는 체크하지 않음.
check_circle
생성
'Generate token' 클릭 → github_pat_... 토큰 복사. 이 값은 다시 볼 수 없으니 안전하게 보관.

6단계: Cloudflare Worker 생성

Slack 버튼 클릭 이벤트를 수신하고, GitHub API를 호출하여 머지를 실행하는 Worker입니다.

workers/slack-approval/wrangler.toml
name = "slack-approval"
main = "worker.js"
compatibility_date = "2026-03-27"
preview_urls = false
routes = [
{ pattern = "slack-approval.yourdomain.com", custom_domain = true }
]
[vars]
GITHUB_REPO = "your-github-username/your-repo-name"
# Secrets (wrangler secret put 으로 설정):
# SLACK_SIGNING_SECRET — Slack 앱 > Basic Information > Signing Secret
# GITHUB_TOKEN — GitHub PAT (Contents read/write 권한)
workers/slack-approval/worker.js
/**
* Cloudflare Worker: Slack Approval
*
* Slack 버튼 클릭 이벤트를 수신하여:
* - 승인: GitHub API로 develop → main 머지
* - 반려: .regenerate 마커 파일 생성 → 다음 스케줄에서 재생성
*/
export default {
async fetch(request, env, ctx) {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const url = new URL(request.url);
if (url.pathname === "/interact") {
return handleInteraction(request, env, ctx);
}
return new Response("Not Found", { status: 404 });
},
};
async function handleInteraction(request, env, ctx) {
const body = await request.text();
// Slack 서명 검증
const isValid = await verifySlackRequest(request, body, env.SLACK_SIGNING_SECRET);
if (!isValid) {
return new Response("Invalid signature", { status: 401 });
}
// Slack payload 파싱 (URL-encoded form: payload=JSON)
const params = new URLSearchParams(body);
const payload = JSON.parse(params.get("payload"));
const action = payload.actions[0];
const actionId = action.action_id;
const responseUrl = payload.response_url;
const user = payload.user.username;
let meta = {};
try { meta = JSON.parse(action.value || "{}"); } catch (e) {}
if (actionId === "approve_deploy") {
ctx.waitUntil(handleApprove(env, responseUrl, user, meta));
return jsonResponse({
replace_original: true,
text: "⏳ 승인 처리 중...",
});
}
if (actionId === "reject_deploy") {
ctx.waitUntil(handleReject(env, responseUrl, user, meta));
return jsonResponse({
replace_original: true,
text: "⏳ 반려 처리 중...",
});
}
return jsonResponse({ text: "Unknown action" });
}
async function handleApprove(env, responseUrl, user, meta) {
try {
const res = await fetch(
\`https://api.github.com/repos/\${env.GITHUB_REPO}/merges\`,
{
method: "POST",
headers: {
Authorization: \`Bearer \${env.GITHUB_TOKEN}\`,
"Content-Type": "application/json",
"User-Agent": "slack-approval-worker",
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify({
base: "main",
head: "develop",
commit_message: \`deploy: \${meta.slug || "content"} (approved by @\${user})\`,
}),
}
);
let message;
if (res.status === 201) {
message = "✅ 승인 완료! develop → main 머지 성공. 배포가 진행됩니다.";
} else if (res.status === 204) {
message = "ℹ️ 이미 최신 상태입니다.";
} else if (res.status === 409) {
message = "⚠️ 머지 충돌 발생. 수동으로 해결해주세요.";
} else {
const errBody = await res.text();
message = \`❌ 머지 실패 (\${res.status}): \${errBody}\`;
}
await updateSlackMessage(responseUrl, message);
} catch (e) {
await updateSlackMessage(responseUrl, \`❌ 오류: \${e.message}\`);
}
}
async function handleReject(env, responseUrl, user, meta) {
try {
const type = meta.type || "insight";
const slug = meta.slug || "unknown";
const markerPath = \`scripts/.regenerate-\${type}\`;
const content = btoa(JSON.stringify({
slug, requestedAt: new Date().toISOString(), requestedBy: user
}));
// 기존 마커 파일 SHA 확인
let sha = null;
const getRes = await fetch(
\`https://api.github.com/repos/\${env.GITHUB_REPO}/contents/\${markerPath}?ref=develop\`,
{
headers: {
Authorization: \`Bearer \${env.GITHUB_TOKEN}\`,
"User-Agent": "slack-approval-worker",
Accept: "application/vnd.github.v3+json",
},
}
);
if (getRes.ok) { sha = (await getRes.json()).sha; }
const putBody = { message: \`chore: request regeneration of \${slug}\`, content, branch: "develop" };
if (sha) putBody.sha = sha;
await fetch(
\`https://api.github.com/repos/\${env.GITHUB_REPO}/contents/\${markerPath}\`,
{
method: "PUT",
headers: {
Authorization: \`Bearer \${env.GITHUB_TOKEN}\`,
"Content-Type": "application/json",
"User-Agent": "slack-approval-worker",
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify(putBody),
}
);
await updateSlackMessage(responseUrl,
\`🔄 반려됨. \${slug} 재생성이 다음 스케줄에서 실행됩니다.\`);
} catch (e) {
await updateSlackMessage(responseUrl, \`❌ 반려 처리 오류: \${e.message}\`);
}
}
async function updateSlackMessage(responseUrl, text) {
await fetch(responseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ replace_original: true, text }),
});
}
async function verifySlackRequest(request, body, signingSecret) {
const timestamp = request.headers.get("X-Slack-Request-Timestamp");
if (!timestamp) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) return false;
const sigBasestring = \`v0:\${timestamp}:\${body}\`;
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(signingSecret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(sigBasestring));
const hexSig = "v0=" + [...new Uint8Array(sig)]
.map((b) => b.toString(16).padStart(2, "0")).join("");
return hexSig === request.headers.get("X-Slack-Signature");
}
function jsonResponse(data) {
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
}

7단계: Worker 배포

wrangler CLI로 Worker를 배포하고, 시크릿을 설정합니다.

Worker 배포 & 시크릿 설정
# Worker 디렉토리로 이동
cd workers/slack-approval
# Worker 배포
npx wrangler deploy
# Slack Signing Secret 설정 (Slack 앱 > Basic Information에서 복사한 값)
npx wrangler secret put SLACK_SIGNING_SECRET
# → 프롬프트에 Signing Secret 값 붙여넣기
# GitHub Token 설정 (5단계에서 생성한 PAT)
npx wrangler secret put GITHUB_TOKEN
# → 프롬프트에 github_pat_... 값 붙여넣기

참고: Worker 배포 시 custom_domain 설정에 의해 Cloudflare DNS에 자동으로 도메인이 추가됩니다. 별도의 DNS 설정이 필요 없습니다.

8단계: 자동화 스크립트 수정

기존 스크립트에서 PR 생성 흐름을 제거하고, develop 직접 푸시 + Slack 승인 버튼 방식으로 변경합니다. 핵심 변경 사항 3가지:

remove_circle
PR 생성 제거
feature 브랜치 생성 → PR → 리뷰 흐름 대신, develop에 직접 커밋 & 푸시
add_circle
Slack Bot API로 알림
기존 webhook 대신 chat.postMessage API 사용. 승인/반려 Interactive 버튼 포함
refresh
재생성 마커 처리
스크립트 시작 시 .regenerate 마커 파일 확인 → 반려된 콘텐츠 재생성
Slack 알림 핵심 코드 (승인/반려 버튼)
# Slack Bot Token과 채널 ID를 파일에서 읽기
SLACK_BOT_TOKEN_FILE="$PROJECT_DIR/.slack-bot-token"
SLACK_CHANNEL_FILE="$PROJECT_DIR/.slack-channel-id"
# 프리뷰 URL (Cloudflare Pages develop 브랜치)
PREVIEW_URL="https://develop.your-project.pages.dev/insights/$NEXT_SLUG"
# Slack 승인/반려 버튼 전송
curl -s -X POST "https://slack.com/api/chat.postMessage" \
-H "Authorization: Bearer $(cat $SLACK_BOT_TOKEN_FILE)" \
-H "Content-Type: application/json" \
-d '{
"channel": "'$(cat $SLACK_CHANNEL_FILE)'",
"blocks": [
{"type":"header","text":{"type":"plain_text","text":"새 인사이트가 생성되었습니다"}},
{"type":"section","fields":[
{"type":"mrkdwn","text":"*제목:*\nINSIGHT_TITLE"},
{"type":"mrkdwn","text":"*날짜:*\n2026-04-09"}
]},
{"type":"section","text":{"type":"mrkdwn","text":"*미리보기:*\n<PREVIEW_URL|dev에서 확인>"}},
{"type":"actions","elements":[
{"type":"button","text":{"type":"plain_text","text":"✅ 승인 & 배포"},"style":"primary","action_id":"approve_deploy","value":"{\"slug\":\"SLUG\",\"type\":\"insight\"}"},
{"type":"button","text":{"type":"plain_text","text":"❌ 반려 & 재생성"},"style":"danger","action_id":"reject_deploy","value":"{\"slug\":\"SLUG\",\"type\":\"insight\"}"}
]}
]
}'

9단계: macOS launchd 등록

스크립트를 매일 정해진 시간에 자동 실행합니다.

~/Library/LaunchAgents/kr.kainos.daily-insight.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>kr.kainos.daily-insight</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/path/to/your/project/scripts/daily-insight.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>21</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/path/to/your/project/scripts/daily-insight.log</string>
<key>StandardErrorPath</key>
<string>/path/to/your/project/scripts/daily-insight.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>/Users/yourname</string>
</dict>
</dict>
</plist>
launchd 등록 & 관리
# 등록
launchctl bootstrap gui/$(id -u) \
~/Library/LaunchAgents/kr.kainos.daily-insight.plist
# 즉시 실행 (테스트)
launchctl kickstart -k gui/$(id -u)/kr.kainos.daily-insight
# 상태 확인
launchctl list | grep kainos
# 해제 (중단)
launchctl bootout gui/$(id -u)/kr.kainos.daily-insight

반려 시 재생성 흐름

Slack에서 반려 버튼을 누르면 Worker가 GitHub API로 마커 파일(scripts/.regenerate-insight)을 develop 브랜치에 생성합니다. 다음 스케줄 실행 시 스크립트가 이 파일을 감지하고:

find_in_page
마커 파일 감지
스크립트 시작 시 .regenerate-insight 파일 존재 여부 확인
cleaning_services
이전 생성물 정리
해당 slug의 페이지 디렉토리 삭제, 데이터 파일 복원, 큐 상태를 pending으로 리셋
auto_awesome
재생성 실행
동일한 slug로 Claude Code가 다시 콘텐츠를 생성. 이전과 다른 결과물이 나옴
chat
다시 Slack 알림
재생성된 콘텐츠가 develop에 푸시되고, 다시 승인/반려 버튼이 전송됨

문제 해결

error_outline
Slack 버튼 클릭 시 반응 없음
Interactivity의 Request URL이 Worker 주소와 일치하는지 확인. Worker가 배포되었는지 wrangler로 확인.
lock
GitHub 머지 실패 (401/403)
GitHub Token 권한 확인: Contents Read and write 필요. 토큰 만료 여부 확인.
chat_error
Slack 메시지 전송 실패
Bot Token이 유효한지 확인. 채널에 앱이 초대되었는지 확인 (/invite @앱이름).
merge
머지 충돌 (409)
develop과 main 사이에 충돌이 있음. 로컬에서 수동으로 머지 후 푸시.
timer_off
Slack 서명 검증 실패 (401)
Worker의 SLACK_SIGNING_SECRET 값이 Slack 앱의 Signing Secret과 일치하는지 확인.

보안 고려사항

verified_user
Slack 서명 검증
모든 요청은 HMAC-SHA256으로 서명을 검증합니다. Worker URL을 알아도 Slack을 통하지 않으면 머지 불가.
key
최소 권한 원칙
GitHub Token은 특정 리포지토리의 Contents 권한만 부여. Slack Bot은 chat:write만 부여.
visibility_off
시크릿 관리
.slack-bot-token, .slack-channel-id는 .gitignore에 추가. Worker 시크릿은 wrangler secret으로 관리.

관련 도구

콘텐츠 자동화CloudflareSlackStatusline

참고: 이 워크플로우는 kainos.kr에서 실제로 운영 중입니다. 모든 서비스(Slack, Cloudflare Workers, GitHub)는 무료 플랜으로 충분합니다.

← 이전Statusline 커스터마이징목록다음 →Anthropic 공식 교육 과정 가이드