Slack으로 서비스 배포하기
배포봇을 만들기까지의 과정
'배포해주세요' 아마 회사에서 가장 많이 하는 말중 하나다.
현재 진행중인 서비스의 배포 담당은 백엔드 개발자 한 분이 주로 담당하고 계신다.
FE 기준으로 작업 후 배포 흐름을 간단하게 설명하자면
- FE 수정/기능개발 후 main 브랜치로 merge
- '백엔드 개발자님 '배포해주세요' -> 네!
- 백엔드 개발자님이 서비스를 배포하는 PC에 원격으로 접속
- 프론트 배포 명령어 입력
- ...배포완료
이렇게 귀찮은 작업을 거의 한 명에게 의존해서 하고 있으니 좋은 흐름은 아닌 것 같았다.
그래서 누구나 배포를 할 수 있으면 좋겠다고 생각했고 (staging환경 기준으로)
슬랙에 있는 봇에 명령어를 등록해서 배포를 추상적으로 가능하게 만들고 싶었다.
아마 대부분의 서비스 회사들은 이렇게 활용하지 않을까 싶다.
원격에 self-hosted runner 설치하기
우리는 원격PC에서 직접 도커를 띄워서 웹서버를 배포하고있다.
배포용 repo에서 프론트/백 등의 dockerfile이 있고, 각 레포지토리에서 클론 후 도커로 띄우는 방식이다.
그래서 결국 원격PC에 접속을 해서 배포를 진행해야하는데, 외부에서 진행을 하려면 접근이 가능하도록 무언가를 뚫어줘야한다. 그래서 찾은 것이 self-hosted runner다
self-hosted runner(자체 호스팅 실행기)
외부 클라우드 환경이 아닌, 우리가 구축한 서버나 PC를 직접 GitHub Actions의 실행 환경으로 사용하는 방식입니다. 외부에서 서버의 방화벽을 뚫고 들어오는 대신, 내부의 러너가 먼저 GitHub에 접속해 명령을 가져오는 '아웃바운드' 방식을 사용하여 강력한 보안을 유지합니다. 또한 코드를 전송하는 지연 시간 없이, 서버에 구축된 고유 환경(WSL, Docker 등)을 그대로 활용하여 즉각적인 배포를 수행할 수 있다는 것이 가장 큰 장점입니다.
우리 서비스는 github를 사용하는데 github에서 제공하는 self-hosted runner를 원격pc에 등록하면 github-actions가 보내는 트리거를 인식하고, 터미널에 접근하여 직접 명령어를 입력할 수 있다.
세팅법은 github에서 setting > actions > self-hosted에서 제공해주는 CLI 커맨드로 간단하게 세팅이 가능하다.
github-actions를 이용해야하니 yml파일도 추가해준다.
on:
workflow_dispatch:
jobs:
deploy:
runs-on: self-hosted
steps:
- name: WSL 환경에서 배포 스크립트 단일 실행
shell: cmd
run: |
:: 배포 명령어
echo "프론트엔드 chamel 환경 배포를 시작합니다..."
workflow_dispatch를 추가하면 워크플로우에서 직접 클릭을 통해 수동으로 실행할 수 있다.

여기까지 해서 원격PC에 접속하지 않고 배포하는 것까지 완료했다!
cloudflare worker 등록하기
이제 github-actions의 워크플로우를 외부의 API요청으로 실행가능하게 만들어야한다.
github는 사용자의 토큰만있으면 api.github.com/repos/{유저이름}/{저장소}.. 이런식으로 깃허브의 기능에 접근가능하다.
AWS Lambda를 사용하는 방법도 있지만, AWS Lambda는 설정하는게 꽤나 귀찮았던 기억이 있어서 가볍고 빠른 cloudflare worker를 활용하기로 했다.
cloudflare worker
api.github.com을 안전하게 호출하려면 결국 외부의 신호를 수신하고, 토큰(Token)을 매핑하여 API를 트리거할 '실행 주체'가 필요합니다. Cloudflare Workers는 복잡한 API Gateway 로직이나 컨테이너 배포 과정 없이, 네트워크의 가장 앞단(Edge)에서 즉각적으로 요청을 가로채고(Intercept) 변환(Mutate)합니다. 이는 단순히 서버 비용을 아끼는 것을 넘어, 네트워크 지연 시간을 최소화하고 인프라 관리의 복잡도를 제로(0)로 만드는 강력한 이벤트 기반(Event-driven) 아키텍처의 구현을 의미합니다.
로그인 후, 워커를 생성할 수 있다. 생성 후에는 도메인 주소가 만들어지며 도메인주소 요청을 하면 등록된 코드가 실행된다.
나는 아래코드로 등록했다.
export default {
async fetch(request, env, ctx) {
// 1. 슬랙(POST) 요청이 아니면 거절
if (request.method !== "POST") {
return new Response("이 주소는 슬랙 봇 전용입니다.", { status: 405 });
}
// 2. 깃허브 API 설정
const githubOrg = ""; // 깃허브 조직 또는 유저명
const githubRepo = ""; // 레포지토리 이름
const workflowFile = ""; // 실행할 액션 파일명
const githubToken = env.GITHUB_TOKEN; // (3단계에서 설정할 비밀변수)
const githubApiUrl = `https://api.github.com/repos/${githubOrg}/${githubRepo}/actions/workflows/${workflowFile}/dispatches`;
try {
// 3. 깃허브로 배포 실행(POST) 요청 보내기
const ghResponse = await fetch(githubApiUrl, {
method: "POST",
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "Cloudflare-Worker" // 깃허브 API는 User-Agent가 필수입니다.
},
body: JSON.stringify({
ref: "" // 배포 기준으로 삼을 브랜치
})
});
if (!ghResponse.ok) {
const errorMsg = await ghResponse.text();
return new Response(`배포 요청 실패: ${errorMsg}`, { status: 500 });
}
// 4. 슬랙 채팅창에 띄워줄 성공 메시지
return new Response("🚀 배포요청이 완료되었습니다.", {
status: 200,
headers: { "Content-Type": "text/plain" }
});
} catch (error) {
return new Response(`에러 발생: ${error.message}`, { status: 500 });
}
},
};
이제 봇이 특정 명령어를 받으면 요청을 보내도록 하면 된다.
봇 설정
기존에 사용하던 봇이 있으니까 커맨드만 추가해주면 된다.

커맨드를 등록할 때, Request URL 필드가 보이지 않았는데, 슬랙의 socket mode를 꺼주어야한다고한다. socket mode는 슬랙서버와 내부망서버가 연결되어서 websocket형식으로 주고받을 수 있게한다. 이때는 토큰을 활용하게된다.
테스트
명령어를 입력하면 worker에 작성된 워크플로우 실행 API가 요청되고, 성공/실패 유무에따라 메시지가 도착한다.

팝업 띄우기
조금 더 확장성을 생각해서 팝업을 띄우고 환경과 프론트/백을 선택해서 처리할 수 있도록 기능을 조금 수정했다.
먼저, worker의 코드에서 팝업을 띄우는 코드를 추가했다.
if (params.has("trigger_id")) {
const triggerId = params.get("trigger_id");
console.log("trigger_id:", triggerId);
const modalView = {
type: "modal",
title: { type: "plain_text", text: "🚀 배포 하기" },
submit: { type: "plain_text", text: "배포 실행" },
close: { type: "plain_text", text: "취소" },
blocks: [
{
type: "section",
text: { type: "mrkdwn", text: "원하는 시스템과 환경을 선택해주세요." }
},
{ type: "divider" },
{
type: "input",
block_id: "target_block",
element: {
type: "static_select",
action_id: "target_select",
placeholder: { type: "plain_text", text: "프론트/백 선택" },
options: [
{ text: { type: "plain_text", text: "FE" }, value: "fe" },
{ text: { type: "plain_text", text: "BE" }, value: "be" }
]
},
label: { type: "plain_text", text: "대상 시스템" }
},
{
type: "input",
block_id: "env_block",
element: {
type: "static_select",
action_id: "env_select",
placeholder: { type: "plain_text", text: "배포 환경 선택" },
options: [
{ text: { type: "plain_text", text: "Dev (브랜치: dev)" }, value: "dev" },
{ text: { type: "plain_text", text: "Prod (브랜치: main)" }, value: "prod" }
]
},
label: { type: "plain_text", text: "배포 환경" }
}
]
};
const res = await fetch("https://slack.com/api/views.open", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.SLACK_BOT_TOKEN}`,
"Content-Type": "application/json; charset=utf-8"
},
body: JSON.stringify({ trigger_id: triggerId, view: modalView })
});
const result = await res.json();
console.log("views.open result:", JSON.stringify(result));
if (!result.ok) {
console.error("views.open failed:", result.error);
console.error("views.open messages:", JSON.stringify(result.response_metadata?.messages));
}
return new Response(null, { status: 200 });
}
console.log("Bad Request - no payload or trigger_id found");
return new Response("Bad Request", { status: 400 });
}
슬랙 봇 설정에서 Interactivity에 worker url을 추가한다

이렇게 설정을 하면 하나의 워커에서 팝업을 여는것과 github-acions로의 요청을 할 수 있다.
지금은 아래코드처럼 하나의 워커에서 처리하도록 하였지만, 웬만하면 역할이 명확하게 두 개의 워커로 하는게 좋아보인다.
// 1. payload가 있다면? -> 사용자가 팝업창에서 [확인]을 누르고 돌아온 상황
if (formData.has("payload")) {
// 배포 로직 실행 (여기엔 trigger_id가 필요 없음)
}
// 2. payload는 없는데 trigger_id가 있다면? -> 사용자가 방금 명령어를 친 상황
else if (formData.has("trigger_id")) {
// 팝업창 띄우기 (여기서 trigger_id를 소모함)
}
테스트
아직까진 우리 내부에서 관리중인 서버만 배포가능하도록 세팅해놓았다.
고객사 내부망에 무턱대고 self-hosted 설치하기엔 무식한 행동이기에 조금 더 알아보고 안전한 방법이라면 그대로 확장만 하면 된다.
END OF ARTICLE