Skip to content
Published on

Slack Bot 구축 실전 가이드 — Python Bolt SDK, 이벤트 핸들링, 슬래시 커맨드

Authors
  • Name
    Twitter

들어가며

Slack은 전 세계 수십만 팀이 사용하는 업무 커뮤니케이션 플랫폼입니다. 단순한 메시징을 넘어, Slack Bot을 통해 장애 알림, 배포 자동화, 데이터 조회, 승인 워크플로우 등 다양한 업무를 자동화할 수 있습니다.

Slack 공식 프레임워크인 Bolt for Python은 이벤트 핸들링, 슬래시 커맨드, 모달, Block Kit 등 Slack의 모든 인터랙션을 간결한 데코레이터 패턴으로 구현할 수 있게 해줍니다. 이 글에서는 앱 설정부터 프로덕션 배포, 에러 핸들링, 실패 복구까지 실전에서 필요한 모든 내용을 다룹니다.


1. Slack 앱 아키텍처와 설정

1.1 Slack 앱 아키텍처 개요

Slack Bot의 동작 흐름은 다음과 같습니다.

  1. 사용자가 Slack에서 메시지, 슬래시 커맨드, 버튼 클릭 등의 인터랙션을 수행
  2. Slack 서버가 이벤트를 Bot 서버로 전달 (HTTP 또는 WebSocket)
  3. Bot이 이벤트를 처리하고 Slack Web API를 통해 응답
  4. 사용자에게 메시지, 모달, 리치 블록 등으로 결과 표시

1.2 앱 생성 및 OAuth 설정

# 1단계: Slack API 사이트에서 앱 생성
# https://api.slack.com/apps 접속
# "Create New App" -> "From scratch" 선택
# App Name: "MySlackBot", Workspace 선택

# 2단계: OAuth & Permissions에서 Bot Token Scopes 설정
# 필요한 스코프 목록:
#   chat:write          - 메시지 전송
#   chat:write.public   - 미참여 공개 채널에 메시지 전송
#   commands            - 슬래시 커맨드 등록
#   im:history          - DM 메시지 읽기
#   im:read             - DM 채널 정보 접근
#   channels:history    - 공개 채널 메시지 읽기
#   channels:read       - 채널 목록/정보 조회
#   users:read          - 사용자 프로필 조회
#   reactions:read      - 리액션 정보 읽기
#   reactions:write     - 리액션 추가
#   files:write         - 파일 업로드

# 3단계: Event Subscriptions 활성화
# Subscribe to bot events:
#   message.im          - DM 수신
#   message.channels    - 채널 메시지 수신
#   app_mention         - @봇 멘션 수신
#   app_home_opened     - 앱 홈 탭 열기
#   reaction_added      - 리액션 추가 이벤트
#   reaction_removed    - 리액션 제거 이벤트

# 4단계: Interactivity & Shortcuts 활성화
# 모달, 버튼, 셀렉트 메뉴 등 인터랙티브 기능 사용에 필요

# 5단계: Socket Mode 활성화 (개발/간편 배포용)
# Settings -> Socket Mode -> Enable Socket Mode
# App-Level Token 생성 (connections:write 스코프)

주의사항: Bot Token(xoxb-)과 App Token(xapp-)은 용도가 다릅니다. Bot Token은 Slack Web API 호출에, App Token은 Socket Mode WebSocket 연결에 사용합니다.

1.3 토큰 종류 정리

토큰 유형접두사용도획득 경로
Bot Tokenxoxb-Slack API 호출 (메시지, 채널 등)OAuth & Permissions
App Tokenxapp-Socket Mode WebSocket 연결Basic Information
User Tokenxoxp-사용자 권한 기반 API 호출OAuth & Permissions
Signing Secret-HTTP 요청 서명 검증Basic Information

2. Python Bolt SDK 설치와 기본 구성

2.1 프로젝트 초기화

# Python 3.9+ 권장
python -m venv .venv
source .venv/bin/activate

# 핵심 의존성 설치
pip install slack-bolt==1.27.0 slack-sdk python-dotenv

# 프로젝트 디렉토리 구조
mkdir -p slack-bot/{handlers,services,utils}
touch slack-bot/{app.py,.env,requirements.txt}
touch slack-bot/handlers/{__init__.py,commands.py,events.py,actions.py,modals.py}
touch slack-bot/services/{__init__.py,notification.py}
touch slack-bot/utils/{__init__.py,rate_limiter.py,logger.py}

# 디렉토리 구조 확인
# slack-bot/
# ├── app.py                 # 메인 앱 엔트리포인트
# ├── .env                   # 환경변수 (토큰 등)
# ├── requirements.txt
# ├── Dockerfile
# ├── handlers/
# │   ├── __init__.py
# │   ├── commands.py        # 슬래시 커맨드 핸들러
# │   ├── events.py          # 이벤트 핸들러 (메시지, 멘션)
# │   ├── actions.py         # 버튼/셀렉트 액션 핸들러
# │   └── modals.py          # 모달 뷰 핸들러
# ├── services/
# │   ├── __init__.py
# │   └── notification.py    # 알림 서비스
# └── utils/
#     ├── __init__.py
#     ├── rate_limiter.py    # 레이트 리밋 유틸리티
#     └── logger.py          # 로깅 설정

2.2 메인 앱 기본 구성

# app.py
import os
import logging
from dotenv import load_dotenv
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

load_dotenv()

# 로깅 설정
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)

# Bolt App 초기화
app = App(
    token=os.environ["SLACK_BOT_TOKEN"],
    signing_secret=os.environ.get("SLACK_SIGNING_SECRET"),
)


# ===== 글로벌 미들웨어 =====
@app.middleware
def log_request(logger, body, next):
    """모든 요청을 로깅하는 미들웨어"""
    logger.info(f"Request type: {body.get('type', 'unknown')}")
    next()


# ===== 이벤트 핸들러 등록 =====
from handlers.events import register_event_handlers
from handlers.commands import register_command_handlers
from handlers.actions import register_action_handlers
from handlers.modals import register_modal_handlers

register_event_handlers(app)
register_command_handlers(app)
register_action_handlers(app)
register_modal_handlers(app)


# ===== 글로벌 에러 핸들러 =====
@app.error
def global_error_handler(error, body, logger):
    logger.exception(f"Unhandled error: {error}")
    logger.debug(f"Request body: {body}")


# ===== 실행 =====
if __name__ == "__main__":
    logger.info("Slack Bot starting in Socket Mode...")
    handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
    handler.start()

3. 이벤트 핸들링 -- 메시지, 멘션, 리액션

Slack Bot의 핵심은 이벤트 처리입니다. Bolt SDK는 데코레이터 패턴으로 이벤트 리스너를 등록합니다.

3.1 이벤트 핸들러 구현

# handlers/events.py
import re
from datetime import datetime


def register_event_handlers(app):
    """이벤트 핸들러를 앱에 등록"""

    # --- 앱 멘션 처리 ---
    @app.event("app_mention")
    def handle_app_mention(event, say, client, logger):
        """@봇 멘션 시 호출"""
        user_id = event["user"]
        text = event.get("text", "")
        channel = event["channel"]

        # 멘션 텍스트에서 봇 ID 제거
        clean_text = re.sub(r"<@\w+>", "", text).strip()

        try:
            user_info = client.users_info(user=user_id)
            display_name = user_info["user"]["real_name"]
        except Exception as e:
            logger.error(f"Failed to fetch user info: {e}")
            display_name = "사용자"

        if "도움" in clean_text or "help" in clean_text.lower():
            say(
                channel=channel,
                blocks=[
                    {
                        "type": "section",
                        "text": {
                            "type": "mrkdwn",
                            "text": (
                                f"안녕하세요 {display_name}님!\n\n"
                                "*사용 가능한 명령어:*\n"
                                "- `/task` - 작업 생성\n"
                                "- `/status` - 서비스 상태 조회\n"
                                "- `/oncall` - 온콜 담당자 확인\n"
                                "- DM으로 자유롭게 질문하세요!"
                            ),
                        },
                    }
                ],
            )
        else:
            say(
                channel=channel,
                text=f"{display_name}님, 말씀하신 내용을 확인하겠습니다.",
                thread_ts=event.get("ts"),  # 스레드로 응답
            )

    # --- DM 메시지 처리 ---
    @app.event("message")
    def handle_message(event, say, logger):
        """채널/DM 메시지 이벤트 처리"""
        # 봇 자신의 메시지, 편집, 삭제 이벤트 무시
        if event.get("bot_id") or event.get("subtype"):
            return

        channel_type = event.get("channel_type", "")
        text = event.get("text", "")
        user_id = event.get("user", "")

        # DM 메시지만 자동 응답
        if channel_type == "im":
            logger.info(f"DM from {user_id}: {text[:50]}...")

            # 키워드 기반 분기 처리
            if "장애" in text or "incident" in text.lower():
                say(
                    text="장애 보고를 등록하시겠습니까?",
                    blocks=[
                        {
                            "type": "section",
                            "text": {
                                "type": "mrkdwn",
                                "text": "장애 보고를 등록하시겠습니까?",
                            },
                            "accessory": {
                                "type": "button",
                                "text": {"type": "plain_text", "text": "장애 보고"},
                                "action_id": "open_incident_modal",
                                "style": "danger",
                            },
                        }
                    ],
                )
            else:
                say(f"메시지를 받았습니다. 자세한 도움이 필요하시면 `/help`를 입력해주세요.")

    # --- 리액션 이벤트 처리 ---
    @app.event("reaction_added")
    def handle_reaction_added(event, client, logger):
        """리액션 추가 이벤트 처리 (예: 이모지로 작업 트리거)"""
        reaction = event["reaction"]
        item = event["item"]
        user_id = event["user"]

        logger.info(f"Reaction :{reaction}: added by {user_id}")

        # :white_check_mark: 리액션으로 작업 완료 처리
        if reaction == "white_check_mark":
            client.chat_postMessage(
                channel=item["channel"],
                thread_ts=item["ts"],
                text=f"<@{user_id}>님이 이 작업을 완료 처리했습니다.",
            )

        # :eyes: 리액션으로 작업 확인 처리
        elif reaction == "eyes":
            client.chat_postMessage(
                channel=item["channel"],
                thread_ts=item["ts"],
                text=f"<@{user_id}>님이 이 내용을 확인 중입니다.",
            )

    # --- 앱 홈 탭 ---
    @app.event("app_home_opened")
    def handle_app_home_opened(client, event, logger):
        """앱 홈 탭 열릴 때 대시보드 표시"""
        user_id = event["user"]
        now = datetime.now().strftime("%Y-%m-%d %H:%M")

        try:
            client.views_publish(
                user_id=user_id,
                view={
                    "type": "home",
                    "blocks": [
                        {
                            "type": "header",
                            "text": {
                                "type": "plain_text",
                                "text": "Bot Dashboard",
                            },
                        },
                        {
                            "type": "section",
                            "text": {
                                "type": "mrkdwn",
                                "text": (
                                    "*사용 가능한 기능:*\n"
                                    "- `/task` : 작업 생성 및 관리\n"
                                    "- `/status` : 서비스 상태 확인\n"
                                    "- `/oncall` : 온콜 담당자 조회\n"
                                    "- DM : 자유 질의응답"
                                ),
                            },
                        },
                        {"type": "divider"},
                        {
                            "type": "context",
                            "elements": [
                                {
                                    "type": "mrkdwn",
                                    "text": f"마지막 업데이트: {now}",
                                }
                            ],
                        },
                    ],
                },
            )
        except Exception as e:
            logger.error(f"Failed to publish home tab: {e}")

핵심 포인트:

  • event.get("bot_id")event.get("subtype") 체크는 무한 루프 방지에 필수입니다.
  • thread_ts를 지정하면 스레드에 응답하여 채널이 깔끔하게 유지됩니다.
  • 리액션 이벤트를 활용하면 이모지 기반의 간편한 워크플로우를 만들 수 있습니다.

4. 슬래시 커맨드 구현

슬래시 커맨드는 사용자가 /명령어를 입력하여 봇 기능을 호출하는 방식입니다.

4.1 기본 커맨드와 모달 연동

# handlers/commands.py
import json
from datetime import datetime


def register_command_handlers(app):

    @app.command("/task")
    def handle_task_command(ack, body, client, logger):
        """작업 생성 모달을 여는 슬래시 커맨드"""
        # 반드시 3초 내에 ack() 호출 필수!
        ack()

        try:
            client.views_open(
                trigger_id=body["trigger_id"],
                view={
                    "type": "modal",
                    "callback_id": "task_create_modal",
                    "title": {"type": "plain_text", "text": "작업 생성"},
                    "submit": {"type": "plain_text", "text": "생성"},
                    "close": {"type": "plain_text", "text": "취소"},
                    "blocks": [
                        {
                            "type": "input",
                            "block_id": "title_block",
                            "element": {
                                "type": "plain_text_input",
                                "action_id": "title_input",
                                "placeholder": {
                                    "type": "plain_text",
                                    "text": "작업 제목을 입력하세요",
                                },
                            },
                            "label": {"type": "plain_text", "text": "제목"},
                        },
                        {
                            "type": "input",
                            "block_id": "priority_block",
                            "element": {
                                "type": "static_select",
                                "action_id": "priority_select",
                                "options": [
                                    {
                                        "text": {"type": "plain_text", "text": "P1 - 긴급"},
                                        "value": "P1",
                                    },
                                    {
                                        "text": {"type": "plain_text", "text": "P2 - 높음"},
                                        "value": "P2",
                                    },
                                    {
                                        "text": {"type": "plain_text", "text": "P3 - 보통"},
                                        "value": "P3",
                                    },
                                    {
                                        "text": {"type": "plain_text", "text": "P4 - 낮음"},
                                        "value": "P4",
                                    },
                                ],
                            },
                            "label": {"type": "plain_text", "text": "우선순위"},
                        },
                        {
                            "type": "input",
                            "block_id": "assignee_block",
                            "element": {
                                "type": "users_select",
                                "action_id": "assignee_select",
                                "placeholder": {
                                    "type": "plain_text",
                                    "text": "담당자 선택",
                                },
                            },
                            "label": {"type": "plain_text", "text": "담당자"},
                        },
                        {
                            "type": "input",
                            "block_id": "desc_block",
                            "element": {
                                "type": "plain_text_input",
                                "action_id": "desc_input",
                                "multiline": True,
                                "placeholder": {
                                    "type": "plain_text",
                                    "text": "작업 내용을 상세히 기술하세요",
                                },
                            },
                            "label": {"type": "plain_text", "text": "설명"},
                            "optional": True,
                        },
                    ],
                },
            )
        except Exception as e:
            logger.error(f"Failed to open modal: {e}")

    @app.command("/status")
    def handle_status_command(ack, say, command, logger):
        """서비스 상태를 조회하는 커맨드"""
        ack()

        now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        say(
            blocks=[
                {
                    "type": "header",
                    "text": {"type": "plain_text", "text": "Service Status Dashboard"},
                },
                {
                    "type": "section",
                    "fields": [
                        {"type": "mrkdwn", "text": "*API Server*\nHealthy (v2.4.1)"},
                        {"type": "mrkdwn", "text": "*Web Frontend*\nHealthy (v3.1.0)"},
                        {"type": "mrkdwn", "text": "*Worker*\nDegraded (v1.8.2)"},
                        {"type": "mrkdwn", "text": "*Database*\nHealthy (CPU 38%)"},
                    ],
                },
                {"type": "divider"},
                {
                    "type": "actions",
                    "elements": [
                        {
                            "type": "button",
                            "text": {"type": "plain_text", "text": "상세 보기"},
                            "action_id": "status_detail",
                            "value": "all",
                        },
                        {
                            "type": "button",
                            "text": {"type": "plain_text", "text": "새로고침"},
                            "action_id": "status_refresh",
                        },
                    ],
                },
                {
                    "type": "context",
                    "elements": [
                        {
                            "type": "mrkdwn",
                            "text": f"조회 시각: {now}",
                        }
                    ],
                },
            ],
        )

    @app.command("/oncall")
    def handle_oncall_command(ack, say, command, logger):
        """현재 온콜 담당자를 조회하는 커맨드"""
        ack()

        # 실제로는 PagerDuty, OpsGenie 등 외부 서비스 연동
        say(
            blocks=[
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": (
                            "*현재 온콜 담당자*\n\n"
                            "- Backend: <@U12345678>\n"
                            "- Frontend: <@U23456789>\n"
                            "- Infra: <@U34567890>\n\n"
                            "긴급 연락은 온콜 담당자에게 DM을 보내주세요."
                        ),
                    },
                }
            ],
        )

ack() 필수 호출: Slack은 슬래시 커맨드를 보낸 후 3초 이내에 acknowledge 응답을 기대합니다. ack()를 호출하지 않으면 사용자에게 "이 슬래시 커맨드를 처리하지 못했습니다"라는 에러가 표시됩니다.


5. 모달과 인터랙티브 컴포넌트

5.1 모달 제출 처리

# handlers/modals.py
from datetime import datetime


def register_modal_handlers(app):

    @app.view("task_create_modal")
    def handle_task_modal_submission(ack, body, client, view, logger):
        """작업 생성 모달 제출 처리"""
        # 입력값 검증
        values = view["state"]["values"]
        title = values["title_block"]["title_input"]["value"]
        errors = {}

        if len(title) < 3:
            errors["title_block"] = "제목은 최소 3자 이상이어야 합니다."

        if errors:
            ack(response_action="errors", errors=errors)
            return

        ack()

        # 입력값 추출
        priority = values["priority_block"]["priority_select"]["selected_option"]["value"]
        assignee = values["assignee_block"]["assignee_select"]["selected_user"]
        description = values["desc_block"]["desc_input"].get("value", "설명 없음")
        creator = body["user"]["id"]

        # 알림 채널에 작업 생성 메시지 전송
        now = datetime.now().strftime("%Y-%m-%d %H:%M")

        client.chat_postMessage(
            channel="#tasks",
            blocks=[
                {
                    "type": "header",
                    "text": {"type": "plain_text", "text": "새 작업이 생성되었습니다"},
                },
                {
                    "type": "section",
                    "fields": [
                        {"type": "mrkdwn", "text": f"*제목:*\n{title}"},
                        {"type": "mrkdwn", "text": f"*우선순위:*\n{priority}"},
                        {"type": "mrkdwn", "text": f"*담당자:*\n<@{assignee}>"},
                        {"type": "mrkdwn", "text": f"*생성자:*\n<@{creator}>"},
                    ],
                },
                {
                    "type": "section",
                    "text": {"type": "mrkdwn", "text": f"*설명:*\n{description}"},
                },
                {"type": "divider"},
                {
                    "type": "actions",
                    "elements": [
                        {
                            "type": "button",
                            "text": {"type": "plain_text", "text": "작업 시작"},
                            "style": "primary",
                            "action_id": "task_start",
                            "value": title,
                        },
                        {
                            "type": "button",
                            "text": {"type": "plain_text", "text": "완료"},
                            "action_id": "task_complete",
                            "value": title,
                        },
                    ],
                },
                {
                    "type": "context",
                    "elements": [
                        {"type": "mrkdwn", "text": f"생성 시각: {now}"}
                    ],
                },
            ],
        )

        # 담당자에게 DM 알림
        client.chat_postMessage(
            channel=assignee,
            text=f"새 작업이 할당되었습니다: *{title}* (우선순위: {priority})\n생성자: <@{creator}>",
        )

5.2 버튼 액션 처리

# handlers/actions.py


def register_action_handlers(app):

    @app.action("task_start")
    def handle_task_start(ack, body, client, action, logger):
        """작업 시작 버튼 클릭 처리"""
        ack()
        user_id = body["user"]["id"]
        task_title = action["value"]

        # 원본 메시지 업데이트
        client.chat_update(
            channel=body["channel"]["id"],
            ts=body["message"]["ts"],
            blocks=[
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": (
                            f"*{task_title}*\n"
                            f"상태: 진행 중\n"
                            f"담당: <@{user_id}>"
                        ),
                    },
                },
                {
                    "type": "actions",
                    "elements": [
                        {
                            "type": "button",
                            "text": {"type": "plain_text", "text": "완료 처리"},
                            "style": "primary",
                            "action_id": "task_complete",
                            "value": task_title,
                        },
                    ],
                },
            ],
        )

    @app.action("task_complete")
    def handle_task_complete(ack, body, client, action, logger):
        """작업 완료 버튼 클릭 처리"""
        ack()
        user_id = body["user"]["id"]
        task_title = action["value"]

        client.chat_update(
            channel=body["channel"]["id"],
            ts=body["message"]["ts"],
            blocks=[
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": (
                            f"~{task_title}~\n"
                            f"상태: 완료\n"
                            f"완료자: <@{user_id}>"
                        ),
                    },
                },
            ],
        )

    @app.action("open_incident_modal")
    def handle_open_incident(ack, body, client, logger):
        """장애 보고 모달 열기"""
        ack()
        client.views_open(
            trigger_id=body["trigger_id"],
            view={
                "type": "modal",
                "callback_id": "incident_report_modal",
                "title": {"type": "plain_text", "text": "장애 보고"},
                "submit": {"type": "plain_text", "text": "보고"},
                "blocks": [
                    {
                        "type": "input",
                        "block_id": "severity_block",
                        "element": {
                            "type": "static_select",
                            "action_id": "severity_select",
                            "options": [
                                {
                                    "text": {"type": "plain_text", "text": "P1 - Critical"},
                                    "value": "P1",
                                },
                                {
                                    "text": {"type": "plain_text", "text": "P2 - Major"},
                                    "value": "P2",
                                },
                                {
                                    "text": {"type": "plain_text", "text": "P3 - Minor"},
                                    "value": "P3",
                                },
                            ],
                        },
                        "label": {"type": "plain_text", "text": "심각도"},
                    },
                    {
                        "type": "input",
                        "block_id": "incident_desc_block",
                        "element": {
                            "type": "plain_text_input",
                            "action_id": "incident_desc_input",
                            "multiline": True,
                            "placeholder": {
                                "type": "plain_text",
                                "text": "장애 상황을 상세히 기술하세요",
                            },
                        },
                        "label": {"type": "plain_text", "text": "상황 설명"},
                    },
                ],
            },
        )

    @app.action("status_refresh")
    def handle_status_refresh(ack, body, logger):
        """상태 새로고침 버튼"""
        ack()
        logger.info("Status refresh requested")

    @app.action("status_detail")
    def handle_status_detail(ack, body, logger):
        """상태 상세보기 버튼"""
        ack()
        logger.info("Status detail requested")

6. Block Kit을 활용한 리치 메시지

Block Kit은 Slack의 UI 프레임워크로, 구조화된 인터랙티브 메시지를 구성합니다. 주요 블록 타입은 다음과 같습니다.

블록 타입용도사용 위치
section텍스트, 필드, 액세서리(버튼 등)메시지, 모달
actions버튼, 셀렉트 메뉴, 데이트피커 등메시지
input사용자 입력 (텍스트, 셀렉트)모달 전용
header큰 텍스트 제목메시지, 모달
divider구분선메시지, 모달
context작은 텍스트, 이미지메시지, 모달
image이미지 표시메시지, 모달
rich_text서식 있는 텍스트메시지

Block Kit Builder(https://app.slack.com/block-kit-builder)를 사용하면 시각적으로 블록을 구성하고 JSON을 미리볼 수 있습니다.


7. Socket Mode vs HTTP Mode 비교

항목Socket ModeHTTP Mode
연결 방식WebSocket (양방향)HTTP POST (단방향)
공개 URL 필요불필요필요 (HTTPS 엔드포인트)
방화벽방화벽 뒤에서도 동작외부 접근 가능해야 함
토큰App Token (xapp-) + Bot TokenBot Token + Signing Secret
로컬 개발매우 편리 (ngrok 불필요)ngrok 등 터널링 도구 필요
확장성단일 인스턴스 권장다중 인스턴스 로드밸런싱 가능
안정성장시간 연결 시 재연결 필요Stateless, 안정적
권장 환경내부 도구, 소규모 팀대규모 서비스, Marketplace 앱
Bolt 설정SocketModeHandlerApp() + WSGI/ASGI

Socket Mode 실행 코드

# Socket Mode (개발, 내부 도구)
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler

app = App(token=os.environ["SLACK_BOT_TOKEN"])
handler = SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
handler.start()

HTTP Mode 실행 코드 (Flask)

# HTTP Mode (프로덕션, 외부 서비스)
from slack_bolt import App
from slack_bolt.adapter.flask import SlackRequestHandler
from flask import Flask, request

app = App(
    token=os.environ["SLACK_BOT_TOKEN"],
    signing_secret=os.environ["SLACK_SIGNING_SECRET"],
)
flask_app = Flask(__name__)
handler = SlackRequestHandler(app)

@flask_app.route("/slack/events", methods=["POST"])
def slack_events():
    return handler.handle(request)

@flask_app.route("/slack/commands", methods=["POST"])
def slack_commands():
    return handler.handle(request)

@flask_app.route("/slack/interactions", methods=["POST"])
def slack_interactions():
    return handler.handle(request)

if __name__ == "__main__":
    flask_app.run(port=3000)

선택 기준: 개발 단계나 내부 전용 봇이라면 Socket Mode가 편리합니다. 다수 워크스페이스에 배포하거나 Marketplace에 등록할 예정이라면 HTTP Mode를 사용하세요.


8. 봇 프레임워크 비교 -- Slack Bolt vs discord.py vs Telegram Bot

항목Slack Bolt (Python)discord.pypython-telegram-bot
플랫폼SlackDiscordTelegram
설치pip install slack-boltpip install discord.pypip install python-telegram-bot
인증 방식OAuth 2.0 + Bot TokenBot TokenBot Token (BotFather)
이벤트 수신Socket Mode / HTTPGateway (WebSocket)Polling / Webhook
UI 프레임워크Block Kit (리치 블록)Embed, Button, SelectInlineKeyboard, ReplyKeyboard
모달 지원지원 (views_open)Modal (discord.ui)미지원 (대화형 흐름으로 대체)
슬래시 커맨드네이티브 지원Application CommandBotCommand
파일 업로드files.upload APIFile 객체send_document
레이트 리밋Tier별 (1~100+ req/min)50 req/sec (글로벌)30 msg/sec (그룹), 1/sec (개인)
비동기 지원AsyncApp 제공기본 비동기asyncio 기본
문서 품질우수 (공식 가이드 충실)우수 (커뮤니티 활발)우수 (예제 풍부)
엔터프라이즈Slack Enterprise Grid제한적제한적

9. 배포 전략 -- Docker와 Kubernetes

9.1 Dockerfile

FROM python:3.12-slim

WORKDIR /app

# 의존성 먼저 복사 (레이어 캐싱 활용)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# 애플리케이션 코드 복사
COPY . .

# 비루트 사용자로 실행
RUN adduser --disabled-password --gecos "" botuser
USER botuser

# 헬스체크 (Socket Mode에서는 별도 HTTP 서버 필요)
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
    CMD python -c "print('healthy')" || exit 1

CMD ["python", "app.py"]

9.2 Docker Compose

# docker-compose.yml
services:
  slack-bot:
    build: .
    env_file: .env
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: '0.5'
    logging:
      driver: json-file
      options:
        max-size: '10m'
        max-file: '3'

9.3 Kubernetes Deployment

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: slack-bot
  labels:
    app: slack-bot
spec:
  replicas: 1 # Socket Mode는 단일 인스턴스 권장
  selector:
    matchLabels:
      app: slack-bot
  template:
    metadata:
      labels:
        app: slack-bot
    spec:
      containers:
        - name: slack-bot
          image: myregistry/slack-bot:latest
          resources:
            requests:
              memory: '128Mi'
              cpu: '100m'
            limits:
              memory: '256Mi'
              cpu: '500m'
          envFrom:
            - secretRef:
                name: slack-bot-secrets
          livenessProbe:
            exec:
              command:
                - python
                - -c
                - "print('alive')"
            initialDelaySeconds: 10
            periodSeconds: 30
          readinessProbe:
            exec:
              command:
                - python
                - -c
                - "print('ready')"
            initialDelaySeconds: 5
            periodSeconds: 10
      restartPolicy: Always
---
apiVersion: v1
kind: Secret
metadata:
  name: slack-bot-secrets
type: Opaque
stringData:
  SLACK_BOT_TOKEN: 'xoxb-your-bot-token'
  SLACK_APP_TOKEN: 'xapp-your-app-token'
  SLACK_SIGNING_SECRET: 'your-signing-secret'

Socket Mode 배포 시 주의: Socket Mode는 하나의 WebSocket 연결만 유지하므로 replicas: 1로 설정합니다. 고가용성이 필요하면 HTTP Mode로 전환하고 로드밸런서를 구성하세요.


10. 에러 핸들링과 레이트 리밋 관리

10.1 레이트 리밋 처리

Slack API는 메서드별로 레이트 리밋 Tier가 다릅니다.

Tier허용량대표 메서드
Tier 11 req/minadmin.*
Tier 220 req/minconversations.create
Tier 350 req/minchat.postMessage
Tier 4100+ req/minusers.info
Special1 req/sec (burst)chat.postMessage (워크스페이스당)
# utils/rate_limiter.py
import time
import logging
from slack_sdk.errors import SlackApiError
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler

logger = logging.getLogger(__name__)


def configure_rate_limit_handler(client):
    """WebClient에 레이트 리밋 핸들러 추가"""
    rate_limit_handler = RateLimitErrorRetryHandler(max_retry_count=3)
    client.retry_handlers.append(rate_limit_handler)
    return client


def safe_api_call(func, *args, max_retries=3, **kwargs):
    """레이트 리밋을 고려한 안전한 API 호출 래퍼"""
    for attempt in range(max_retries):
        try:
            return func(*args, **kwargs)
        except SlackApiError as e:
            if e.response.status_code == 429:
                retry_after = int(e.response.headers.get("Retry-After", 5))
                logger.warning(
                    f"Rate limited. Retrying after {retry_after}s "
                    f"(attempt {attempt + 1}/{max_retries})"
                )
                time.sleep(retry_after)
            else:
                logger.error(f"Slack API error: {e.response['error']}")
                raise
    raise Exception(f"Max retries ({max_retries}) exceeded for API call")


# 사용 예시
# result = safe_api_call(
#     client.chat_postMessage,
#     channel="#general",
#     text="Hello!"
# )

10.2 글로벌 에러 핸들링 패턴

# 앱 수준 에러 핸들러
@app.error
def global_error_handler(error, body, logger):
    """앱 전체의 미처리 에러를 캐치"""
    logger.exception(f"Unhandled error: {error}")

    # 에러 유형별 분기 처리
    if isinstance(error, SlackApiError):
        error_code = error.response.get("error", "unknown_error")
        logger.error(f"Slack API Error: {error_code}")

        if error_code == "channel_not_found":
            logger.warning("Channel not found - check channel ID or bot permissions")
        elif error_code == "not_in_channel":
            logger.warning("Bot is not in the channel - invite the bot first")
        elif error_code == "token_revoked":
            logger.critical("Bot token has been revoked!")
    else:
        logger.error(f"Non-Slack error: {type(error).__name__}: {error}")

11. 실패 사례와 복구 절차

11.1 흔한 실패 사례

사례 1: ack() 미호출로 인한 타임아웃

  • 증상: 슬래시 커맨드 실행 시 "operation_timeout" 에러 표시
  • 원인: 핸들러 시작 시 ack()를 호출하지 않거나, 시간이 오래 걸리는 작업을 ack() 전에 수행
  • 해결: 반드시 핸들러 첫 줄에서 ack() 호출. 오래 걸리는 작업은 ack() 후 별도로 처리

사례 2: Socket Mode 연결 끊김

  • 증상: 봇이 갑자기 응답을 멈춤
  • 원인: 네트워크 불안정, 서버 재시작, Slack 측 WebSocket 세션 만료
  • 해결: SocketModeHandler는 자동 재연결을 지원하지만, 프로세스 감시자(systemd, supervisor) 또는 Kubernetes의 restartPolicy로 이중 보호

사례 3: 봇 메시지 무한 루프

  • 증상: 봇이 자기 메시지에 반응하여 무한 메시지 전송
  • 원인: message 이벤트 핸들러에서 bot_id 체크 누락
  • 해결: if event.get("bot_id"): return 가드 코드 추가

사례 4: 모달 입력값 검증 실패

  • 증상: 모달 제출 시 아무 반응 없음
  • 원인: ack() 호출 시 response_action="errors" 없이 에러 반환
  • 해결: 검증 실패 시 ack(response_action="errors", errors={...}) 패턴 사용

사례 5: 토큰 만료 또는 스코프 부족

  • 증상: missing_scope 또는 invalid_auth 에러
  • 원인: 필요한 OAuth 스코프를 추가하지 않았거나 토큰 재발급 미수행
  • 해결: OAuth & Permissions에서 스코프 추가 후 앱 재설치

11.2 복구 절차 체크리스트

장애 발생 시 다음 순서로 복구를 진행합니다.

  1. 로그 확인: docker logs slack-bot 또는 kubectl logs 명령으로 에러 메시지 확인
  2. 토큰 유효성 검증: curl -H "Authorization: Bearer xoxb-..." https://slack.com/api/auth.test 호출
  3. 이벤트 구독 상태 확인: Slack 앱 설정 페이지에서 Event Subscriptions 상태 점검
  4. 네트워크 연결 확인: Socket Mode의 경우 WebSocket 연결 상태, HTTP Mode의 경우 엔드포인트 접근 가능 여부
  5. 프로세스 재시작: docker restart slack-bot 또는 kubectl rollout restart deployment/slack-bot
  6. 스코프 재확인: 에러 메시지에 missing_scope가 있다면 해당 스코프를 추가하고 앱 재설치

12. 운영 시 참고사항 (Operational Notes)

로깅 권장사항

  • 모든 이벤트 핸들러에 로깅을 추가하여 디버깅 편의성을 확보하세요.
  • 민감 정보(토큰, 사용자 메시지 전문)는 로그에 남기지 마세요.
  • 구조화된 로깅(JSON format)을 사용하면 로그 분석 도구와 연동이 쉽습니다.

보안 주의사항

  • .env 파일은 절대 Git에 커밋하지 마세요. .gitignore에 반드시 추가합니다.
  • HTTP Mode 사용 시 signing_secret으로 요청 서명을 반드시 검증하세요 (Bolt SDK가 자동 처리).
  • Bot Token 권한은 최소 권한 원칙(Principle of Least Privilege)을 따르세요.

성능 최적화

  • 오래 걸리는 작업(외부 API 호출, DB 조회 등)은 ack() 후 별도 스레드에서 처리하세요.
  • AsyncApp을 사용하면 비동기 이벤트 처리로 처리량을 높일 수 있습니다.
  • Block Kit 메시지의 블록 수는 최대 50개까지 허용되므로, 긴 콘텐츠는 페이징 처리하세요.

13. 트러블슈팅 (Troubleshooting)

증상원인해결책
"dispatch_failed" 에러이벤트 핸들러가 등록되지 않음@app.event("message") 등 핸들러 등록 확인
슬래시 커맨드 무응답ack() 미호출 또는 3초 초과핸들러 첫 줄에 ack() 추가
"not_authed" 에러토큰이 비어 있거나 잘못됨.env 파일과 환경변수 확인
모달이 열리지 않음trigger_id 만료 (3초)모달 열기를 ack() 직후에 실행
리액션 이벤트 미수신Event Subscription 미설정reaction_added 이벤트 구독 추가
"missing_scope" 에러필요한 OAuth 스코프 누락OAuth & Permissions에서 스코프 추가 후 재설치
Socket Mode 연결 실패App Token 미생성 또는 잘못됨Basic Information에서 App Token 재생성
봇이 채널에 메시지 못 보냄봇이 해당 채널에 미참여채널에 봇을 초대하거나 chat:write.public 스코프 추가
HTTP 429 응답레이트 리밋 초과Retry-After 헤더 값만큼 대기 후 재시도
이벤트 중복 수신Slack의 재전송 메커니즘이벤트 ID 기반 중복 제거(idempotency) 로직 추가

14. 프로덕션 배포 체크리스트

배포 전 다음 항목을 모두 확인하세요.

  • Bot Token(xoxb-)과 App Token(xapp-)이 올바르게 설정되었는지 확인
  • 필요한 OAuth 스코프가 모두 추가되었는지 확인
  • Event Subscriptions에서 필요한 이벤트가 모두 구독되었는지 확인
  • Interactivity가 활성화되었는지 확인
  • 슬래시 커맨드가 Slack 앱 설정에 등록되었는지 확인
  • .env 파일이 .gitignore에 추가되었는지 확인
  • 모든 핸들러에서 ack()가 호출되는지 확인
  • 봇 자신의 메시지에 대한 무한루프 방지 로직이 있는지 확인
  • 레이트 리밋 핸들링이 구현되었는지 확인
  • 에러 핸들러(@app.error)가 등록되었는지 확인
  • 로깅이 적절히 설정되었는지 확인
  • Docker 이미지가 비루트 사용자로 실행되는지 확인
  • Kubernetes Secret으로 토큰이 관리되는지 확인
  • 프로세스 재시작 정책(restart policy)이 설정되었는지 확인
  • Socket Mode 사용 시 replicas가 1인지 확인

마무리

Slack Bot은 팀의 업무 효율을 획기적으로 높이는 도구입니다. Python Bolt SDK를 사용하면 이벤트 핸들링, 슬래시 커맨드, 모달, Block Kit 등 Slack의 모든 인터랙션을 간결한 코드로 구현할 수 있습니다.

핵심 정리:

  • ack()는 모든 인터랙션 핸들러의 시작점입니다. 3초 타임아웃을 항상 기억하세요.
  • Socket Mode는 개발과 내부 도구에 최적이고, HTTP Mode는 프로덕션 규모 서비스에 적합합니다.
  • Block Kit으로 리치 메시지를 구성하고, 모달로 사용자 입력을 구조적으로 받으세요.
  • 레이트 리밋 핸들링과 에러 복구 절차를 반드시 구현하세요.
  • Docker와 Kubernetes로 안정적인 프로덕션 배포 환경을 구축하세요.

참고 자료