tj.park
← Back

Slack + Jira API로 만드는 데일리 스크럼 봇

·3 min read· views
#python#jira#slack#cron#automation

이 글에서는 Jira REST API로 팀원별 이슈를 모아 Slack 채널에 자동으로 데일리 스크럼 메시지를 게시하는 봇을 만드는 과정을 정리합니다. 초기에는 GitHub Actions로 운영했지만 두 가지 문제로 인해 사내 로컬 서버의 crontab으로 옮긴 이야기까지 함께 다룹니다.

목표

매일 정해진 시각에 Slack 채널로 다음 형식의 메시지가 올라오게 합니다.

🗓️ Daily Scrum [2026-05-11]
추가로 공유할 내용이 있으면 댓글로 작성해주세요
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

팀원 A
• 한일
    • [PROJ-123] 로그인 API 리팩터링
• 할일
    • [PROJ-130] 토큰 갱신 로직 작업

팀원 B
• 한일
    • [PROJ-125] 결제 모듈 버그 수정
• 할일
    • -
...
  • 한일: 전날(월요일이면 금요일부터) 완료 상태로 전환된 이슈
  • 할일: 현재 "진행 중" 상태인 이슈

작동 원리

[로컬 서버 crontab]  ── 매일 10:55 KST
        │
        ▼
[jira-slack-notify.55_10_*_*_1-5.py]
   ├─ .env 환경 변수 로드 (스크립트 내부)
   ├─ Jira REST API → 팀원별 이슈 수집
   │     ├─ 진행 중 이슈 (할일)
   │     └─ 완료된 이슈 + changelog 검증 (한일)
   ├─ Slack mrkdwn 형식으로 메시지 조립
   └─ Slack Web API (chat.postMessage) 전송
            │
            ▼
   [Slack 채널 게시]

※ crontab 등록은 cron_sync.0_*_*_*_*.py 가 매시 정각에 자동 동기화

핵심은 두 가지입니다.

  1. Jira는 "어제 완료된 이슈"를 단번에 알려주지 않는다updated 필드로 후보를 줄인 뒤, 각 이슈의 changelog에서 상태 전이 시각을 직접 확인.
  2. Slack은 <URL|텍스트> 문법으로 링크를 표현한다 → 이슈 키를 클릭하면 바로 Jira로 이동.

1. 사전 준비

Jira API 토큰 발급

  1. Atlassian API tokens 접속
  2. Create API token → 라벨 입력 → 토큰 복사
  3. 인증은 (이메일, API 토큰) Basic Auth로 사용

Slack 봇 앱 생성

  1. Slack API AppsCreate New App → From scratch
  2. OAuth & Permissions → Bot Token Scopes에 다음 추가
    • chat:write (메시지 전송)
    • chat:write.public (초대받지 않은 공개 채널에도 전송하려면)
  3. Install to Workspacexoxb-... 봇 토큰 복사
  4. 메시지를 보낼 채널에 /invite @봇이름 으로 봇 초대

환경변수 정리 (.env)

JIRA_BASE_URL="https://your-domain.atlassian.net"
JIRA_EMAIL="you@company.com"
JIRA_API_TOKEN="..."
SLACK_BOT_TOKEN="xoxb-..."
SLACK_CHANNEL="C0XXXXXXX"   # 채널 ID 권장 (이름은 변경되면 실패)

채널 ID는 Slack에서 채널명 우클릭 → "채널 세부정보 보기" 아래쪽에 표시됩니다.

2. 팀원 정보 정의

Jira는 사용자를 accountId로 식별합니다. 이름이 바뀌어도 ID는 안 바뀌기 때문에 이름이 아닌 ID로 매칭해야 안전합니다.

MEMBERS = {
    "<accountId-1>": "팀원 A",
    "<accountId-2>": "팀원 B",
    "<accountId-3>": "팀원 C",
}
MEMBER_IDS = list(MEMBERS.keys())
MEMBER_IDS_JQL = ", ".join(MEMBER_IDS)

accountId는 Jira에서 해당 사용자 프로필 페이지의 URL 뒤쪽(...?accountId=...)에서 확인할 수 있습니다.

3. 기준일 계산: 월요일 처리

데일리 스크럼의 "한일"은 보통 어제이지만, 월요일에는 금요일·토요일·일요일까지 포함해야 자연스럽습니다.

from datetime import datetime, timedelta, timezone
 
KST = timezone(timedelta(hours=9))
base_date = datetime.now(KST).date()
 
# 월요일(weekday() == 0)이면 3일 전(금요일)부터, 아니면 어제부터
if base_date.weekday() == 0:
    done_since = base_date - timedelta(days=3)
else:
    done_since = base_date - timedelta(days=1)

서버 시간이 UTC인 환경에서 그냥 datetime.now()를 쓰면 새벽 시간대에 날짜가 어긋납니다. KST를 명시하는 게 안전합니다.

4. Jira 이슈 조회: JQL과 페이지네이션

JQL로 후보 좁히기

JQL(Jira Query Language)은 SQL의 WHERE 절과 비슷합니다.

project = PROJ
AND statusCategory = "In Progress"
AND assignee in (accountId1, accountId2, ...)
ORDER BY assignee ASC
  • statusCategory는 "할일/진행중/완료" 3가지로 묶인 메타 상태. 워크플로우마다 세부 상태명("진행 중", "지연 중" 등)이 달라도 카테고리는 동일하므로 1차 필터로 적합.
  • assignee in (...) 로 팀원만 추림.

페이지네이션

Jira search/jql 엔드포인트는 한 번에 최대 100건. 다음 페이지는 응답의 nextPageToken으로 가져옵니다.

def search(jql: str) -> list:
    url = f"{JIRA_BASE_URL}/rest/api/3/search/jql"
    all_issues, next_token = [], None
    while True:
        params = {
            "jql": jql,
            "fields": "summary,assignee,key,status",
            "maxResults": 100,
        }
        if next_token:
            params["nextPageToken"] = next_token
 
        r = requests.get(url, headers=HEADERS, auth=AUTH, params=params, timeout=30)
        r.raise_for_status()
 
        data = r.json()
        batch = data.get("issues", [])
        all_issues.extend(batch)
 
        if data.get("isLast", True) or not data.get("nextPageToken") or not batch:
            break
        next_token = data["nextPageToken"]
    return all_issues

포인트

  • fields로 필요한 필드만 받아 응답 크기를 줄임.
  • timeout=30 으로 무한 대기 방지. 타임아웃은 거의 반드시 잡아둘 것.
  • 종료 조건은 isLast=True 든 다음 토큰이 없든 안전하게 OR로 묶음.

5. "한일" 판정: changelog로 상태 전이 확인

여기가 가장 까다로운 부분입니다.

왜 단순히 updated 만으로는 안 되나?

updated이슈의 마지막 변경 시각입니다. 댓글이 달려도, 라벨이 바뀌어도 갱신됩니다. 그래서 "어제 updated 된 완료 이슈"는 어제 완료된 것이 아니라 어제 마지막으로 손댄 것일 뿐입니다.

→ 후보를 updated로 좁힌 뒤, changelog에서 "status 필드가 '완료'로 바뀐 시점"을 직접 검증해야 합니다.

changelog 조회

def get_changelog(issue_id: str) -> list:
    url = f"{JIRA_BASE_URL}/rest/api/3/issue/{issue_id}/changelog"
    all_hist, start = [], 0
    while True:
        r = requests.get(
            url, headers=HEADERS, auth=AUTH,
            params={"startAt": start, "maxResults": 100},
            timeout=30,
        )
        r.raise_for_status()
        data = r.json()
        vals = data.get("values", [])
        all_hist.extend(vals)
        start += len(vals)
        if start >= data.get("total", 0) or not vals:
            break
    return all_hist

상태 전이 시각 검증

DONE_NAMES = {"완료", "지연 완료"}
 
for h in hist:
    # "2026-05-10T18:00:00.000+0900" → ISO 파싱 가능 형식으로 보정
    created = re.sub(
        r"([+-])(\d{2})(\d{2})$",
        r"\1\2:\3",
        h["created"].replace("Z", "+00:00"),
    )
    dt_kst = datetime.fromisoformat(created).astimezone(KST).date()
 
    if not (done_since <= dt_kst <= base_date):
        continue
 
    for item in h.get("items", []):
        if item.get("field") == "status" and item.get("toString") in DONE_NAMES:
            matched = True
            break

포인트

  • Jira changelog의 created 타임존 형식 +0900 은 Python fromisoformat이 못 읽으므로 +09:00으로 보정.
  • 한 changelog 엔트리에는 여러 필드 변경이 한 묶음으로 들어 있을 수 있어서 items를 순회.
  • toString이 우리가 정한 "완료" 계열 상태명에 들어가는지 확인.

6. Slack 메시지 조립: mrkdwn 문법

Slack은 자체 변형 마크다운인 mrkdwn을 씁니다. 일반 마크다운과 차이가 있는 부분만 짚으면:

표현일반 마크다운Slack mrkdwn
굵게**텍스트***텍스트*
이탤릭*텍스트*_텍스트_
링크[텍스트](URL)<URL|텍스트>
인용> ...> ... (동일)

이슈 한 줄 만들기

key = iss["key"]
url = f"{JIRA_BASE_URL}/browse/{key}"
line = f'<{url}|[{key}]> {iss["fields"]["summary"]}'
# 결과: <https://.../browse/PROJ-123|[PROJ-123]> 로그인 API 리팩터링

전체 메시지 구성

> 접두사가 들여쓰기 + 좌측 회색 바를 만들어 줘서 사람별 블록을 깔끔하게 분리해 줍니다.

DATE = base_date.strftime("%Y-%m-%d")
SEP = "" * 40
 
lines = [
    f"*🗓️ Daily Scrum [{DATE}]*",
    "_추가로 공유할 내용이 있으면 댓글로 작성해주세요_",
    SEP,
]
 
for name in ORDERED_MEMBERS:
    done_items = done_map.get(name, [])
    todo_items = todo_map.get(name, [])
    lines.append(f"\n*{name}*")
    if done_items:
        lines.append(">• *한일*")
        for t in done_items:
            lines.append(f">    • {t}")
    if todo_items:
        lines.append(">• *할일*")
        for t in todo_items:
            lines.append(f">    • {t}")
    if not done_items and not todo_items:
        lines.append(">• -")
 
lines.append(f"\n{SEP}")
message = "\n".join(lines)

7. Slack 전송

chat.postMessage 엔드포인트에 JSON으로 POST.

r = requests.post(
    "https://slack.com/api/chat.postMessage",
    headers={
        "Authorization": f"Bearer {SLACK_BOT_TOKEN}",
        "Content-Type": "application/json",
    },
    json={
        "channel": SLACK_CHANNEL,
        "text": message,
        "mrkdwn": True,
        "unfurl_links": False,  # Jira 링크 미리보기 펼침 방지
        "unfurl_media": False,
    },
    timeout=30,
)
res = r.json()
if not res.get("ok"):
    print(f"Slack 전송 실패: {res.get('error')}")
    raise SystemExit(1)

포인트

  • HTTP 200이 와도 res["ok"]False일 수 있음 → 반드시 JSON 본문 확인.
  • 자주 만나는 에러
    • not_in_channel: 봇을 채널에 초대 안 함
    • channel_not_found: 채널 이름/ID 오타, 비공개 채널 권한 부족
    • invalid_auth: 토큰 만료/오타
  • unfurl_links=False로 끄지 않으면 이슈 링크마다 거대한 카드가 펼쳐져서 메시지가 폭발합니다.

8. 자동 실행: GitHub Actions에서 로컬 cron으로

초기에는 별도 서버 없이 굴릴 수 있고 토큰을 GitHub Secrets로 안전하게 관리할 수 있다는 장점 때문에 GitHub Actions로 띄웠습니다. 그러나 운영 과정에서 두 가지 문제가 드러나 사내 로컬 PC 서버의 crontab으로 옮겼습니다.

문제 1. 사내 IP Allowlist에 막힌 GitHub Runner

Jira API를 호출하려면 사내 망의 IP 허용 정책을 통과해야 했는데, actions/checkout으로 외부 스크립트를 가져오는 단계에서 GitHub Runner의 IP 자체가 차단되어 워크플로우가 실행되지 못했습니다.

우회책으로 Python 코드를 YAML 안에 인라인으로 박아 넣는 방식까지 시도했지만, "GitHub 서버에서 실행된다"는 구조적 의존은 그대로였습니다.

문제 2. GitHub Actions schedule의 지연/누락

GitHub Actions의 schedule 트리거는 GitHub 서버 부하가 몰릴 때 실행이 지연되거나 예약된 job 자체가 누락될 수 있는 구조적 한계가 있습니다. (공식 문서에도 명시)

실측해 보니 매일 약 1시간씩 늦게 실행되는 일이 잦았습니다. 데일리 스크럼은 "정해진 시각에 채널에 떠 있어야" 의미가 있는 알림이라 허용하기 어려운 리스크였습니다.

전환 결과: 로컬 서버 crontab

GitHub 외부 인프라 의존을 끊고, 사내에 상시 켜져 있는 PC에 crontab을 등록하는 방식으로 옮겼습니다.

한 발 더: "파일명에 cron 표현식을 인코딩하는" 자동 동기화 패턴

crontab을 직접 편집(crontab -e)하는 건 두 가지가 불편합니다.

  1. 스케줄 정보가 코드 저장소와 분리됨 — 어떤 스크립트가 언제 도는지 보려면 서버에 SSH로 들어가 crontab -l 을 봐야 함.
  2. 새 스크립트를 추가할 때마다 같은 의식 — 파일 떨궈두고, crontab -e 열어서, 한 줄 추가하고, 저장.

이걸 줄이려고 작은 동기화 스크립트를 하나 두고, 파일명에 cron 표현식을 박아두면 자동으로 crontab에 등록되도록 만들었습니다.

파일명 규약

{name}.{schedule_rule}.{ext}
  • 공백은 _, 슬래시(*/5 등)는 ~ 로 치환
  • 예시
    • jira-slack-notify.55_10_*_*_1-5.py55 10 * * 1-5 (평일 오전 10:55)
    • health-check.*~5_*_*_*_*.py*/5 * * * * (5분마다)
    • cron_sync.0_*_*_*_*.py0 * * * * (매시 정각)

디렉토리 구조

~/cron-schedules/
├── cron_sync.0_*_*_*_*.py             # 동기화 스크립트 자기 자신 (매시 정각)
├── jira-slack-notify.55_10_*_*_1-5.py # 데일리 스크럼 (평일 10:55)
├── .env                               # 환경 변수 (Git 제외)
└── logs/
    └── daily_scrum.log                # 실행 로그

cron_sync 가 하는 일

매 정시에 깨어나서:

  1. 디렉토리를 스캔해 *.{cron-expr}.* 패턴인 파일을 모음
  2. 파일명에서 cron 표현식을 디코딩 (_ → space, ~/)
  3. 현재 crontab -l 과 비교
    • 등록 안 된 파일은 추가
    • 디렉토리에서 사라진 파일의 cron 라인은 제거
def decode_rule(rule: str) -> str:
    return rule.replace("_", " ").replace("~", "/")
 
# 스캔 결과: [("55 10 * * 1-5", "/.../jira-slack-notify.55_10_*_*_1-5.py"), ...]
scripts = scan(CRON_SCRIPTS_DIR)
 
# crontab과 비교해 추가/삭제할 항목 산출 후 write_crontab()

전체 구현은 90줄 정도로 짧음. cron 표현식 유효성 검증만 croniter에 위임하면 핵심 로직은 단순합니다.

부트스트랩

한 번만 수동으로 등록하면 그 다음부터는 스스로 굴러갑니다.

# 최초 1회만
crontab -l > /tmp/cur.cron
echo "0 * * * * /home/USER/cron-schedules/cron_sync.0_*_*_*_*.py" >> /tmp/cur.cron
crontab /tmp/cur.cron

이후엔 새 스케줄을 만들고 싶을 때 파일을 떨궈두기만 하면 됩니다. 다음 정시에 cron_sync 가 알아서 등록합니다.

환경 변수와 로그

cron 환경은 셸과 PATH가 거의 비어 있어서, 스크립트 자체에서 챙겨야 합니다. .env 로딩은 외부 의존 없이 한 번에 처리할 수 있습니다.

from pathlib import Path
 
for line in (Path(__file__).parent / ".env").read_text().splitlines():
    line = line.strip()
    if not line or line.startswith("#") or "=" not in line:
        continue
    k, v = line.split("=", 1)
    os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))

로그는 stdout/stderr를 직접 파일로 리다이렉트해도 되지만, 파일명에 명령줄 리다이렉트(>>)는 cron 한 줄에서 다루기 번거로워서 Python 내부에서 logging 핸들러로 파일에 떨굽니다.

운영 시 챙길 점

  • 서버 시간대가 KST인지 확인 (timedatectl 또는 date). UTC면 파일명의 시각도 그에 맞춰 환산해야 함.
  • 로그는 반드시 파일로 떨어뜨릴 것. cron의 stdout은 메일로 가는데 메일 전송이 막혀 있는 환경에서는 그대로 증발함.
  • 서버 재부팅 시 crontab은 자동 유지되지만, PC가 절전/꺼짐 상태가 되면 그날치는 실행되지 않음 → 절전 모드 해제 또는 WoL 정책 확인.
  • API 토큰 만료/회사 이메일 비활성화 등의 사유로 조용히 실패할 수 있으므로, Slack 전송 실패 시 별도 알림(예: 운영용 채널에 에러 메시지)을 한 단계 더 두면 좋음.
  • cron_sync 가 등록한 라인은 명령어 절대경로로 매칭하므로, 파일을 이름만 바꿔도 (= 다른 스케줄) 자동으로 옛 라인은 지워지고 새 라인이 들어옵니다.

운영하면서 부딪힌 함정들

"어제 완료된 줄 알았는데 메시지에 안 뜬다"

→ 그 이슈는 어제 완료된 게 아니라 어제 댓글이 달린 것일 수 있음. changelog 기준으로 봤을 때 상태 전이는 그 이전이라 제외된 것. 의도된 동작.

워크플로우 상태명이 한국어/영어 혼용

DONE_NAMES = {"완료", "지연 완료", "Done"} 식으로 실제 사용 중인 상태명을 다 적어두거나, statusCategory 만으로 판단하도록 단순화.

사람이 추가되었는데 메시지에 안 보임

MEMBERS 딕셔너리 업데이트 필요. 이름만 적힌 환경에서는 종종 빠뜨리게 되니, 팀 변경 시 체크리스트에 넣어두면 좋음.

메시지가 너무 길어서 잘림

→ Slack text 길이 한계는 약 40KB. 이슈가 수백 개씩 잡힐 일은 거의 없지만, 만약 그렇다면 사람별 메시지를 스레드 댓글로 쪼개는 식으로 대응.

앞으로 더 해보고 싶은 것

봇이 안정적으로 돌고 있긴 하지만, "Jira에 적힌 텍스트를 그대로 옮겨 붙이는" 수준에 머물러 있습니다. 다음 단계로 가져볼만한 방향을 정리해 둡니다.

1. LLM으로 "그래서 오늘 뭐 하는데?" 한 줄 요약 붙이기

이슈 요약은 "토큰 갱신 로직 작업" 같은 짧은 제목이라, 다른 팀원이 보면 맥락을 잘 모를 때가 많습니다. 이슈 본문 + 최근 댓글을 LLM에 넣어 사람이 듣기 좋은 한 줄("리프레시 토큰 갱신 흐름을 SSE 기반으로 바꾸는 중") 을 자동 첨부하면, Slack 메시지의 정보 밀도가 훨씬 올라갑니다.

2. 블로커/지연 이슈 자동 감지

due date 가 지났거나, 같은 "진행 중" 상태에 오래 머문 이슈를 따로 표시해 두면 스탠드업에서 자연스럽게 화제가 됩니다.

  • 단순히 며칠 지연됐는지 표기하는 것부터 시작
  • 더 나아가 LLM이 changelog와 댓글을 읽고 "외부 의존(디자인 컨펌 대기 등)"인지 "기술적 막힘"인지 분류

3. 양방향 봇: Slack에서 명령으로 조회

/scrum @아무개 처럼 슬래시 커맨드로 임의 시점/임의 사람을 조회할 수 있게 하면, 데일리 스크럼 외에도 1:1이나 인수인계 자리에서 유용합니다.

현재의 제약: LLM은 아직 도입 보류

LLM 활용은 가장 매력적인 방향이지만, 현재 운영 환경이 사내 PC 한 대(서버라기엔 사양이 약함) 라서 로컬 모델을 띄우기엔 무리가 있습니다. 외부 API(OpenAI/Anthropic 등)를 쓰는 길도 있지만, 이슈 본문이 외부로 나가는 것에 대한 보안 검토가 먼저라 일단 보류 중입니다.

→ 사내 GPU 서버가 확보되거나, 사내 망 안에서 돌릴 수 있는 모델 인프라가 마련되는 시점에 다시 본격적으로 붙여 볼 계획입니다.

참고

마치며

데일리 스크럼 자동화의 가치는 "쓰는 시간을 줄여준다"보다 "매일 같은 형식으로 기록이 남는다" 에 가깝습니다.

  • 누가 어떤 이슈를 며칠째 잡고 있는지 자연스럽게 드러나고
  • 회고 때 지난 N주 메시지만 훑어도 흐름이 보이며
  • 새로 합류한 멤버가 팀의 작업 결을 빠르게 익히는 자료가 됩니다.

그리고 운영 측면에서 얻은 가장 큰 교훈은 "매일 정확한 시각에 떠야 하는 알림은 외부 무료 스케줄러에 맡기지 말자" 였습니다. 화려한 인프라보다 내 손에 잡히는 cron 한 줄이 더 정확할 때가 많습니다.

accent