
시작하며
안녕하세요. 저는 현재 동남아 최강 라이드헤일링 서비스를 운영하고 있는 타다팀에서 Professional Lunch Menu Picker와 백엔드 개발을 하고 있는 권기범입니다.
이번 글에선 저희 팀 스크럼 마스터들이 겪었던 고질적 귀찮음 유발요소였던 작업 쓰레드 생성 문제를 슬랙봇을 통한 자동화로 해결한 사례를 소개해 보고자 합니다.
몇 년 동안 2주에 한 번씩 남자 3명을 지독하게 괴롭혔던 작업 쓰레드
“이거 꽤 귀찮은데 자동화하면 좋겠네요”
“그거 누가 만들려고 하셨었는데 만드시려다가 퇴사 하셨어요”
“이슈부터 우선 파둘게요” (그렇게 이슈가 생성된 뒤 3달 동안 아무도 눈길을 주지 않았다)
먼저 저희 회사의 업무 구조를 간단하게 설명드리고 싶습니다.
저희 회사는 2주 단위 스프린트로 일을 진행하며 스프린트 매 첫번째 주 월요일에 스프린트 플래닝을 진행하고 있는데요.
해당 스프린트 플래닝에선 비즈니스 소식 공유, 스프린트 분석, 배포 대상 이슈 점검, 팀회고, 스프린트 이슈 어싸인 등이 이루어지며 플래닝의 각 코너를 이어주는 전체 진행은 앱 개발자, 웹 개발자, 서버 개발자 각 1명이 스프린트마다 순서대로 돌아가며 진행하고 있으며 이 역할을 스크럼 마스터라고 합니다.
하지만 스크럼 마스터는 위 일 외에도 플래닝 문서 준비, 작업 쓰레드 생성 등 스프린트 플래닝 전/후로도 책임지는 일이 있습니다. 이중 오늘 중점적으로 소개할 부분은 바로 작업 쓰레드 생성입니다.
작업 쓰레드

작업 쓰레드란 스토리 이슈당 한개씩 생성 되는 슬랙 쓰레드를 의미하는데요. 작업 쓰레드에서는 논의, 개발, 일정 등 해당 스토리에 대한 모든 대화가 이루어져요.
간단하게 말해서 스토리에 대한 모든 대화는 거의 전부 여기서 이루어진다고 이해 하시면 될 것 같아요.
이전의 작업 쓰레드 생성방식
슬랙봇 자동화 이전 작업 쓰레드 생성방식은 다음과 같았어요.
- 플래닝 문서 생성시 문서 안에 아래와 같은 작업 쓰레드 템플릿 문서를 만들어요.
Story title(커멘드 + Shift + C 로 코드블럭 만든 후 제목 붙여넣기)
@APP_DEV @WEB_DEV cc @LEAD @PM @QA
https://mvlchain.atlassian.net/browse/DHL-12345
- 플래닝을 진행하며 이슈 작업자 어싸인이 이루어질때 스토리 이슈마다 위 코드블록을 일일히 복사하여 어싸이니와 이슈 이름, 이슈링크를 수동으로 작성해요.
- 플래닝이 끝나면 스크럼마스터는 작성한 코드블록을 일일히 채널에 복붙해서 생성해요. 이때 쓰레드의 부모 채팅은 스토리 이름으로, 쓰레드의 첫 댓글에는 어싸이니 멘션과 이슈링크를 복붙해요.
- 작업 쓰레드 생성이 완료 되면 쓰레드 링크를 스토리 이슈의 web link에 추가해요.
문제점
위 작업을 글로보면 4단계의 간단한 작업으로 보일 수 있어요.
하지만 스크럼 마스터는 플래닝이 진행 될 때 작업 쓰레드 준비뿐만이 아니라 이슈마다 fix version을 세팅해야하고 본인의 이슈 또한 이슈 어싸인 진행자에게 어싸인 받아야 하기 때문에 플래닝에 집중하지 못하는 문제점이 있었어요.
물론 작업 쓰레드 작업 자체를 수동으로 진행하는 것 또한 근본적 문제이기도 했죠. 이슈가 많았던 시절엔 이슈 어싸인에만 30분이 넘게 걸렸었으니까요.
3달만에 드디어 결실을 맺은 자동화

자동화 이슈가 나온지 3개월 뒤…
드디어 제 팀 세미나 주제 정하기라는 명목과 작업쓰레드 자동화가 결실을 맺어 작업 쓰레드 생성을 자동화하기로 마음 먹었어요.
스펙
간단하게 스펙을 정의 하자면 다음과 같았어요.
- 사용자가 그 어떤 다른 정보 없이 지라 이슈 링크만 입력해도 작업 쓰레드가 생성되야해요.
- 생성 된 작업 쓰레드는 스토리 이슈 web link에 추가되야해요.
- 스토리 서브 태스크의 이슈 어싸이니가 멘션되야해요.
시작!

bolt-js
https://github.com/slackapi/bolt-js
bolt-js는 슬랙(Slack) 봇을 쉽게 만들 수 있게 해주는 슬랙 공식 프레임워크인데요. 이를 사용하면 slack api 연동을 쉽게 할 수 있고 각종 보일러 플레이트들이 훨씬 줄어들어요.
bolt-js를 쓰지 않는다면 슬랙 web api를 직접 쏴야하고 헤더나 인증 처리를 직접 구현해야해서 왠만해선 편의성을 위해 사용하는 것을 추천 드려요.
이번 자동화 작업에선 Getting Stared 문서에 있는 bolt-js 보일러 플레이트를 사용했어요.
https://docs.slack.dev/tools/bolt-js/building-an-app/
Jira API 호출
작업 쓰레드를 자동으로 생성하기 위해선 지라 이슈로부터 다음과 같은 정보들이 필요했어요.
- 스토리 제목 - 슬랙 쓰레드의 부모 메시지로 사용
- 서브태스크 정보 - 각 서브태스크의 어싸이니를 멘션하기 위함
- 연관된 DSR 이슈 - 디자이너 분들도 멘션하기 위함
HTTP 클라이언트 - ky
API 호출을 위해 ky라는 라이브러리를 사용했어요. ky는 fetch API를 기반으로 한 경량 HTTP 클라이언트인데요. axios보다 가볍고 모던한 문법을 제공해요.
const getJiraApi = () =>
ky.create({
prefixUrl: 'https://mvlchain.atlassian.net/rest/api/2',
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${process.env.JIRA_BASIC_CREDENTIALS}`,
},
})
ky.create()로 베이스 URL과 공통 헤더를 설정해두면 매번 반복해서 작성할 필요가 없어져 편리해요.
이슈 정보 가져오기
이슈 번호로부터 필요한 정보를 가져오는 함수는 다음과 같이 구현했어요.
// 요청 시 fields params를 통해 필요한 정보만 요청
export const getIssueInfo = async (
issueNumber: string
): Promise<{ summary: string; subtasks: any[] } | null> => {
try {
const jiraApi = getJiraApi()
const data: any = await jiraApi
.get(`issue/${issueNumber}`, {
searchParams: { fields: 'summary,subtasks,issuelinks' },
})
.json()
// 서브태스크들의 어싸이니 정보 가져오기
let subtaskDetails = []
if (data.fields?.subtasks) {
subtaskDetails = await Promise.all(
data.fields.subtasks.map(async (subtask: any) => {
const subtaskData: any = await getJiraApi()
.get(`issue/${subtask.key}`, {
searchParams: { fields: 'assignee,summary' },
})
.json()
return {
fields: {
assignee: subtaskData.fields.assignee,
summary: subtaskData.fields.summary,
},
}
})
)
}
// DSR 이슈의 어싸이니도 가져오기
// 디자이너 이슈는 서브태스크가 아닌 'related' 링크로 연결되어 있어요
if (data.fields?.issuelinks) {
const dsrLinks = data.fields.issuelinks.filter((link: any) => {
const issueKey = link.inwardIssue?.key || link.outwardIssue?.key || ''
return issueKey.includes('DSR')
})
const dsrAssignees = await Promise.all(
dsrLinks.map(async (link: any) => {
const issueKey = link.inwardIssue?.key || link.outwardIssue?.key
const issueData: any = await getJiraApi()
.get(`issue/${issueKey}`, {
searchParams: { fields: 'assignee' },
})
.json()
return {
fields: {
assignee: issueData.fields.assignee,
},
}
})
)
subtaskDetails = [...subtaskDetails, ...dsrAssignees]
}
return {
summary: data.fields?.summary || 'No title',
subtasks: subtaskDetails,
}
} catch (error) {
console.error('Error fetching Jira issue:', error)
return null
}
}
이슈 키 추출하기
사용자가 지라 링크를 입력하면 거기서 이슈 키를 추출해야 하는데요. 정규표현식을 사용해 간단하게 구현했어요.
export function extractIssueKey(input: string): string | null {
const match = input.match(/([A-Z]+\-[0-9]+)/)
return match ? match[1] : null
}
이렇게 하면 https://mvlchain.atlassian.net/browse/DHL-21756 같은 URL에서도, DHL-21756 처럼 이슈 키만 입력해도 모두 파싱할 수 있어요.
Slack 슬래시 커맨드 구현
이제 사용자가 지라 이슈 링크만 입력하면 작업 쓰레드를 생성하는 기능을 슬래시 커맨드를 만들어볼게요.
우선 봇 실행에 필요한 아주 기본적인 보일러플레이트는 다음과 같아요.
import { App } from '@slack/bolt'
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
socketMode: true,
appToken: process.env.SLACK_APP_TOKEN,
})
;(async () => {
await app.start(process.env.PORT || 3000)
app.logger.info('⚡️ Bolt app is running!')
})()
/create 커맨드 등록
이제 /create 커맨드를 등록해볼게요.
app.command(
'/create',
async ({ command: { text, channel_id }, ack, respond, client }) => {
// 1. ack 메소드는 응답처리라고 생각하면 되요. 슬랙봇은 3초 이내 응답처리를 하지 않을 시 타임아웃 처리를 하기 때문에 /create 명령어에서는 바로 응답처리를 해주고 있습니다.
await ack()
// 2. 입력 검증
if (!text.trim()) {
await respond({
// response_type: 'ephemeral'을 사용하면 에러 메시지가 명령어를 입력한 사용자에게만 보여요
response_type: 'ephemeral',
text: 'Please provide an issue number or URL.',
})
return
}
// 3. 이슈 키 추출
const issueNumber = extractIssueKey(text.trim())
if (!issueNumber) {
await respond({
response_type: 'ephemeral',
text: 'Invalid issue format.',
})
return
}
// 4. 이슈 정보 가져오기
const issueData = await getIssueInfo(issueNumber)
if (!issueData) {
await respond({
response_type: 'ephemeral',
text: `Could not find issue: ${issueNumber}`,
})
return
}
// ... 작업 쓰레드 생성 로직
}
)
QA 서브태스크 필터링
저희 팀에서는 QA 서브태스크는 작업 쓰레드에 포함시키지 않기로 했어요. 그래서 필터링 로직을 추가했어요.
const { summary, subtasks } = issueData
// QA가 포함된 서브태스크는 제외
const filteredSubtasks = subtasks.filter((subtask: any) => {
const subtaskSummary = subtask.fields?.summary || ''
return !subtaskSummary.toUpperCase().includes('QA')
})
if (filteredSubtasks.length === 0) {
await respond({
response_type: 'ephemeral',
text: `Issue ${issueNumber} has no subtasks (after filtering).`,
})
return
}
Jira 어싸이니를 Slack 멘션으로 변환
이제 가장 고민이 많았던 부분인데요. Jira 이슈의 어싸이니 이름을 Slack 멘션으로 변환해야 해요.
문제점
Slack과 Jira는 각각 독립적인 유저 데이터베이스를 가지고 있어요. 같은 사람이더라도 UUID가 각각 달랐죠.
사실 Slack의 Atlassian 봇에는 Slack과 Jira 유저를 연결하는 기능이 있어요. 하지만 이 연결 정보를 API로 가져올 수 없어서 직접 매칭 로직을 구현해야 했어요.
고려했던 해결 방법들
1. Google Sheets를 통한 수동 매핑
처음엔 Google Sheets에 Jira 유저와 Slack 유저를 매핑한 테이블을 만들까 했어요. 하지만 이 방식은:
- 팀원이 늘어날 때마다 수동으로 업데이트해야 함
- Google Sheets API 연동 필요
- 유지보수 코스트가 너무 높음
결국 이 방법은 제외했어요.
2. 이메일 기반 매칭
Jira 유저의 이메일을 가져와서 Slack 유저 이메일과 매칭하는 방법도 있었어요. 이게 가장 정확한 방법이긴 한데, Jira에서 이메일 정보를 가져오려면 추가 보안 절차를 거쳐야 해서 이번 개발에선 사용하지 않았어요.
3. Display Name 매칭 (채택!)
저희 팀은 다행히 Jira 풀네임의 첫 번째 단어와 Slack display name이 대부분 일치하는 규칙이 있었어요.
따라서 Slack user list API로 모든 멤버를 조회한 뒤, Jira 풀네임에서 첫 번째 단어만 추출해서 Slack display name과 매칭하기로 했어요.
// Jira 어싸이니에서 이름 추출 (첫 단어만)const assigneeNames = filteredSubtasks.map((subtask: any) => {
const fullName = subtask.fields?.assignee?.displayName || 'Unassigned'
return fullName.split(/[\s.]/)[0]// 공백이나 점으로 구분된 첫 단어
})
Slack 멤버 전체 조회
Slack 워크스페이스의 모든 멤버를 조회해요. 페이지네이션을 처리해야 해서 cursor를 사용했어요.
export const getAllSlackMembers = async (client: WebClient) => {
let allMembers: Member[] = []
let cursor: string | undefined
do {
const response = await client.users.list({ cursor })
allMembers = allMembers.concat(response.members || [])
cursor = response.response_metadata?.next_cursor
} while (cursor)
return allMembers
}
주의: Slack 문서에는 limit 파라미터를 전달하지 않으면 전체 값을 리턴한다고 나와 있는데요. 이 말은 첫 페이지에 모든 유저를 내준다는 게 아니라, 여러 페이지로 나눠서 모든 유저를 리턴한다는 뜻이에요. 따라서 위처럼 cursor를 사용한 반복문으로 모든 페이지를 순회해야 전체 유저 리스트를 받을 수 있어요.
이름으로 Slack 유저 찾기
이제 이름으로 Slack 유저를 찾아요. 봇이나 삭제된 유저는 제외하고, display name이 일치하는 유저를 찾아요.
export const findSlackUserByName = (
members: Member[],
name: string
): Member | null => {
if (name === 'Unassigned') return null
return (
members
.filter(({ deleted, is_bot }) => !deleted && !is_bot)
.find(
(user) =>
user.profile?.display_name?.toLowerCase() === name.toLowerCase()
) || null
)
}
멘션 문자열로 변환
마지막으로 Jira 이름들을 Slack 멘션 형식(<@USER_ID>)으로 변환해요.
export const convertToSlackMentions = (
assigneeNames: string[],
slackMembers: Member[]
): string[] => {
return assigneeNames.map((name) => {
if (name === 'Unassigned') return name
const slackUser = findSlackUserByName(slackMembers, name)
return slackUser ? `<@${slackUser.id}>` : name
})
}
매칭되는 유저를 찾으면 멘션으로, 못 찾으면 그냥 이름 그대로 반환해요.
사용 예시
// Slack 멤버 조회
const slackMembers = await getAllSlackMembers(app.client)
// Jira 이름을 Slack 멘션으로 변환
const mentions = convertToSlackMentions(assigneeNames, slackMembers)
const assigneeText = mentions.join(', ')
// 결과: "<@U123456>, <@U789012>, Unassigned"
작업 쓰레드 생성 및 Jira 연동
이제 실제로 슬랙 쓰레드를 생성하고, 그 링크를 Jira에 다시 추가하는 과정이에요.
부모 메시지 생성
먼저 스토리 제목으로 부모 메시지를 생성해요.
const issueTitle = summary
// 부모 메시지 생성
const result = await client.chat.postMessage({
channel: channel_id,
text: `${issueTitle}`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `\`${issueTitle}\``,
},
},
],
})
Rate Limit 주의
Slack API는 rate limit이 있어요. chat.postMessage는 초당 1회로 제한되어 있어서, 연속으로 호출하면 에러가 날 수 있어요.
import wait from 'waait'
// Rate limit 방지를 위한 대기
await wait(2000)
그래서 부모 메시지와 쓰레드 댓글 사이에 2초 대기를 넣었어요.
쓰레드 댓글 생성
이제 첫 번째 댓글로 어싸이니 멘션과 이슈 링크를 추가해요.
const DEFAULT_MENTIONS = '<@ABCD1234> <@QWER0000>' // Lead, PM
if (result.ts) {
const threadReply = await client.chat.postMessage({
channel: channel_id,
thread_ts: result.ts,// 쓰레드로 만들기 위해 부모 메시지의 ts 사용text: `${assigneeText} cc ${DEFAULT_MENTIONS}\nhttps://mvlchain.atlassian.net/browse/${issueNumber}`,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${assigneeText} cc ${DEFAULT_MENTIONS}\nhttps://mvlchain.atlassian.net/browse/${issueNumber}`,
},
},
{
type: 'context',
elements: [
{
type: 'plain_text',
text: 'Created by Thready',
emoji: true,
},
],
},
],
})
}
thread_ts에 부모 메시지의 타임스탬프를 넣으면 댓글로 들어가요. 그리고 context 블록으로 봇이 생성했다는 것을 표시했어요.
팁: 슬랙 봇에서 메세지 스타일을 만질때는 block-kit-builder를 사용해보세요. 메세지를 블록 형태로 만드는 GUI툴인데 이를 객체형태로 복사하여 사용할 수 있어요.
Jira에 쓰레드 링크 추가
마지막으로 생성된 쓰레드 링크를 Jira 이슈의 웹 링크에 추가해요.
if (threadReply.ts) {
try {
// 쓰레드 퍼머링크 가져오기
const permalink = await getSlackPermalink(
client,
channel_id,
threadReply.ts
)
if (permalink) {
// Jira에 링크 추가
const success = await addRemoteLinkToIssue(issueNumber, permalink)
if (success) {
console.log(`Added Slack permalink to Jira issue ${issueNumber}`)
}
}
} catch (error) {
console.error('Failed to add Slack permalink to Jira:', error)
// Graceful degradation - 실패해도 쓰레드 생성은 성공으로 처리
}
}
Slack Permalink 가져오기
Slack의 chat.getPermalink API를 사용해서 쓰레드 링크를 가져와요.
export const getSlackPermalink = async (
client: WebClient,
channelId: string,
messageTs: string
): Promise<string | null> => {
try {
const response = await client.chat.getPermalink({
channel: channelId,
message_ts: messageTs,
})
return response.permalink || null
} catch (error) {
console.error('Error generating Slack permalink:', error)
return null
}
}
Jira Remote Link 추가
Jira API의 remotelink 엔드포인트를 사용해서 웹 링크를 추가해요.
export const addRemoteLinkToIssue = async (
issueKey: string,
url: string,
title: string = 'Work Thread'
): Promise<boolean> => {
try {
const jiraApi = getJiraApi()
await jiraApi.post(`issue/${issueKey}/remotelink`, {
json: {
object: {
url,
title,
icon: {
url16x16: 'https://slack.com/favicon.ico',
},
},
},
})
return true
} catch (error) {
console.error(`Error adding remote link to Jira issue ${issueKey}:`, error)
return false
}
}
이렇게 하면 Jira 이슈 페이지에서 "Work Thread" 링크를 클릭하면 바로 Slack 쓰레드로 이동할 수 있어요!
마무리 및 회고
좋았던점
- 이젠 스크럼마스터들이 30분동안 작업쓰레드를 하나하나씩 만들고 이를 복붙하지않아도 되요.
- 스크럼마스터도 이제 본인의 이슈 어싸인에 조금 더 집중 할 수 있게 되었어요.

아쉬웠던점
- Jira API 호출 메소드 타입안정성을 신경쓰지 못했어요.
- 글에서 뭔가 허전한게 느껴지지 않나요…? 작업쓰레드 슬랙 봇 특성상 그리 자주 실행되지 않고 업타임을 보장해야하는 필요성이 적다고 느껴져 배포까지 진행하지 않고 사용할 때마다 로컬에서 키고 있어요.
/create명령어를 연속으로 사용하면 Rate Limit에 걸리는 경우가 있어요. Queue 구조를 통해 이슈를 배열형태로 받아 limit 문제를 해결할 계획을 가지고 있어요.
개인적으로…
이번 작업 쓰레드 슬랙봇을 만들며 고교시절 3D printer로 만들었던 전등 커버가 생각났습니다.
제 고교 급식실의 줄서는 복도에는 전등 스위치가 있었는데 학생들이 벽에 등을 기대면서 이틀에 한번씩은 급식시간마다 급식실에 불이 꺼졌었는데요. 제대로 엉성하게 다루던 캐드와 학교 3d 프린터를 통해 조잡한 전등커버를 만들어서 문제를 해결했었는데요.
이번에 만든 슬랙봇도, 복도 전등커버도 대단하지 않은 기술력을 통해 문제점을 해결했다는 공통점이 있지않나 싶습니다.
읽어주셔서 감사합니다.
'개발' 카테고리의 다른 글
| Vitepress로 마크다운 기반 TIL 저장소를 만들자. (0) | 2024.07.10 |
|---|---|
| 2024년, 중학생이 개발자를 꿈꾼다면 특성화고는 아직 할만한 선택지다. (1) | 2024.06.14 |
| 랜덤 한국어 명언 API 개발 (1) | 2024.04.30 |
| 2. 8살짜리 버스_봇에 오픈소스 기여를 해보자. 해결 방법 및 회고 (1) | 2024.04.19 |
| 1. 8살짜리 버스_봇에 오픈소스 기여를 해보자. 프젝 설명 및 이슈 생성 (1) | 2024.04.19 |