알erts 와 워크플로우를 위한 Slack 통합 패턴

Slack 는 워크플로우 UI 와 알림 전송 레이어입니다.

Page content

Slack 통합은 하나의 HTTP 호출로 메시지를 게시할 수 있기 때문에 속임수처럼 보일 정도로 간단해 보입니다. 하지만 Slack 을 상호작용적이고 신뢰할 수 있는 시스템으로 만들려고 할 때 흥미로운 부분이 시작됩니다.

Slack Integration Alerts

이 심층 분석에서는 Slack 을 세 가지 다른 통합 표면으로 다룹니다:

  • incoming webhooks 를 통한 단방향 알림을 위한 알림 싱크 (Notification sink).
  • Workflow Builder 와 사용자 정의 워크플로우 단계를 통한 워크플로우 엔진.
  • Block Kit 버튼, 슬래시 명령, 액션 페이로드를 통한 이벤트 인터페이스.

이 페이지는 시스템이 공유 UI 로 넘어가는 방법과 아키텍처로 이벤트를 다시 방출하는 방법에 대해 설명하며, 알림 철학에 대한 내용은 아닙니다. 알림 전략 및 라우팅에 대해서는 가시성 팀을 위한 현대적인 알림 시스템 설계 를 참조하세요.

관련 읽기 자료:

통합 패턴에서의 정석적 프레임 및 배치

Slack 은 단순히 알림이 사라지는 곳이 아닙니다. 잘 활용하면 Slack 은 메시지가 상태가 있는 아티팩트가 되고 사용자 상호작용이 이벤트가 되는 시스템 인터페이스가 됩니다.

이 페이지는 /app-architecture/integration-patterns/slack/ 하위에 정석적으로 배치되었습니다. 핵심 질문은 “알림을 보내야 하는가"가 아니라 “우리의 시스템과 Slack 사이의 계약이 무엇인가"이기 때문입니다.

해결책이 다음 중 하나를 필요로 한다면, 단순한 알림 영역이 아니라 통합 패턴 영역에 속합니다:

  • 인간의 승인이 동작을 게이트하는 의사결정 루프.
  • Slack 이 컨텍스트를 수집하고 단계를 트리거하는 워크플로우.
  • Slack 이 시스템이 구독하는 동작을 방출하는 이벤트 루프.

Slack 플랫폼은 의도적으로 요청 URL 과 상호작용 페이로드를 통해 단방향 메시징과 양방향 상호작용을 모두 지원합니다. Incoming webhooks 는 채널에 Block Kit 빌딩 블록을 포함한 JSON 페이로드를 게시하는 일급 방식입니다 (Sending messages using incoming webhooks). 상호작용성은 설정된 요청 URL 로 HTTP POST 요청으로 앱으로 전달되며, 해당 페이로드는 JSON 페이로드 필드로 폼 인코딩됩니다 (Handling user interaction).

알림 싱크 (Notification sink) 으로서의 Slack

Incoming webhooks 는 알림 및 상태 업데이트에 대해 가장 빠른 가치 제공 경로입니다. 웹훅은 앱 설치에 연결된 고유 URL 이며, 여기에 JSON 메시지를 POST 합니다 (Sending messages using incoming webhooks).

주관적인 의견: 웹훅은 전달 후 잊어버릴 메시지를 원하고 Slack 을 제어 표면으로 사용하지 않을 때 훌륭한 기본 옵션입니다. 또한 웹훅은 온보딩을 최종 앱 아키텍처와 분리하는 훌륭한 방법입니다.

워크플로우 엔진으로서의 Slack

워크플로우 빌더가 존재하는 이유는 실제 일이 채팅에서 일어나기 때문입니다. 워크플로우는 단순하거나 복잡할 수 있으며 앱에 연결할 수 있습니다 (Guide to Workflow Builder).

사용자 정의 워크플로우 단계는 워크플로우 빌더 내에서 시스템을 재사용 가능한 빌딩 블록으로 노출할 수 있게 합니다 (Workflow steps). 이는 채널 내 봇과는 다른 통합 형태입니다. 이는 “외부에서 온 메시지"보다 “Slack 내의 도구"에 가까운 통합으로 이동시킵니다.

주관적인 의견: 조직이 이미 워크플로우와 승인 관점에서 사고한다면, 워크플로우 단계는 맞춤형 봇보다 더 자연스럽게 느껴질 수 있습니다.

이벤트 인터페이스로서의 Slack

Block Kit 은 메시지를 UI 표면으로 변환합니다 (Block Kit). 버튼과 같은 상호작용 구성 요소는 사용자가 클릭할 때 앱으로 전송되는 동작 페이로드 (일반적으로 block_actions 페이로드) 를 생성합니다 (block_actions payload).

버튼에는 명시적인 식별자인 action_id 와 선택적 value 가 있으며, section 또는 actions 블록 내에 호스팅되어야 합니다 (Button element). 버튼이 있는 메시지를 설계할 때, 당신은 이벤트 소스를 설계하고 있습니다.

여기서 요청 검증, 필요한 스코프, 안전한 내부 트리거와 같은 FAQ 주제가 설계의 중심이 됩니다.

확장 가능한 아키텍처 패턴

단방향 알림을 위한 웹훅 흐름

[service] -> [alert formatter] -> [slack incoming webhook] -> [channel]

Incoming webhooks 는 JSON 페이로드를 받아들이고 Block Kit 레이아웃을 지원합니다 (Sending messages using incoming webhooks).

FAQ 에서 가장 빠른 알림 전송 방법을 물을 때, 이것이 그 방법인 경우가 많습니다.

신뢰성과 백프레셔를 위한 큐를 활용한 브로커 흐름

[services] -> [queue topic] -> [slack dispatcher] -> [slack api]
                     |                 |
                     |                 +-> [rate limit handler]
                     +-> [dead letter queue]

Slack 제한은 incoming webhooks 를 포함한 HTTP 기반 API 에 적용되며, 제한을 초과하면 HTTP 429 와 Retry-After 헤더를 반환합니다 (Rate limits).

주관적인 의견: 모든 서비스에서 직접 알림을 게시하면, 첫 번째 사고가 자신의 Slack 통합에 대한 분산 서비스 거부 공격 (DDoS) 으로 변합니다. 큐 뒤에 있는 디스패처가 더 차분한 아키텍처가 되는 경향이 있습니다.

승인을 위한 워크플로우 자동화 패턴

[alert] -> [slack message with button] -> [button click]
   -> [action payload] -> [approval handler] -> [internal API] -> [update message]

Slack 상호작용성은 요청 URL 설정 및 상호작용성 활성화가 필요합니다. Slack 은 페이로드 파라미터에 JSON 을 포함하는 application/x-www-form-urlencoded 형식으로 상호작용 페이로드를 전송하며, 3 초 이내에 HTTP 200 으로 응답해야 합니다 (Handling user interaction).

이는 안전한 내부 동작을 트리거하는 FAQ 항목 뒤에 있는 패턴입니다.

Slack 상호작용 흐름도 다이어그램

Slack interaction diagram

웹훅 대 앱 및 구현 메커니즘

권장 라이브러리

Go:

Python:

웹훅 대 앱 봇 접근 방식

실용적인 비교:

기능 Incoming webhook Slack app with bot token
메시지 게시
Block Kit 레이아웃 게시
버튼 클릭 수신 상호작용성이 있는 앱과 연결된 경우에만
슬래시 명령 아니오
워크플로우 단계 아니오
보안 표면 웹훅 URL 기밀성 OAuth 토큰 및 서명 비밀
최적 적합 단방향 알림 워크플로우, 승인, 상호작용 UI

Slack 은 incoming webhooks 로 Block Kit 레이아웃을 명시적으로 지원합니다 (Sending messages using incoming webhooks). 상호작용성은 앱별로 설정되며 요청 URL 로 전달됩니다 (Handling user interaction).

주관적인 의견: 웹훅은 훌륭한 첫 번째 이정표이지만, Slack 을 제어 표면으로 사용하려는 순간부터 앱을 구축하고 있습니다. 그렇지 않은 척하지 마세요.

스코프 및 권한

Slack 스코프는 앱이 할 수 있는 일을 정의합니다. 중앙 스코프 참조 및 개별 스코프 페이지가 있습니다 (Scopes reference). Web API 를 통해 메시지를 보내려면 chat:write 가 정석적인 스코프입니다 (chat:write scope).

슬래시 명령의 경우 일반적으로 commands 스코프와 설정된 명령 요청 URL 이 필요합니다 (명령은 상호작용성 문서의 일부이며, 각 명령은 자체 요청 URL 을 가짐) (Handling user interaction).

FAQ 참고: 버튼 페이로드 전송은 “스코프"가 아니라 앱 설정입니다. 상호작용성이 활성화되고 요청 URL 이 설정되면 앱이 페이로드를 수신하지만, 메시지 업데이트 게시에는 일반적으로 chat:write 가 여전히 필요합니다.

속도 제한 및 재시도

Slack 속도 제한은 HTTP 429 를 반환하고 초 단위로 Retry-After 를 포함하며, 이는 incoming webhooks 를 포함한 HTTP 기반 API 에 적용됩니다 (Rate limits).

실제로는:

  • Retry-After 를 준수
  • 일시적인 5xx 에 대해 지터 (jitter) 와 함께 백오프 적용
  • 볼륨이 증가하면 디스패처에서 Slack 전송을 중앙 집중화

멱등성 및 중복 제거

Slack 은 상호작용 페이로드에 대해 3 초 이내에 확인을 기대하며, 그렇지 않으면 사용자가 오류를 보고 Slack 이 기능에 따라 전송 재시도 동작을 수행할 수 있습니다 (Handling user interaction). Events API 의 경우 Slack 은 명시적으로 x-slack-retry-num과 같은 재시도 메타데이터 헤더를 제공합니다 (Events API).

명시적인 재시도가 없더라도, 사용자가 더블 클릭하고 분산 시스템이 재전송하기 때문에 중복이 발생합니다. 버튼이 내부 동작을 트리거한다면, 클릭을 최소 한 번 이벤트로 취급하고 중복을 제거하세요.

승인을 위한 실용적인 멱등성 전략:

  • 멱등성 키 = team_id + channel_id + message_ts + action_id + user_id
  • 워크플로우 윈도우와 일치하는 TTL 과 함께 Redis 에 키 저장
  • Slack 핸들러뿐만 아니라 내부 동작 API 도 멱등성을 강제

보안 기본 및 요청 검증

Slack 은 앱 서명 비밀을 사용하여 서버로 전송하는 요청에 서명합니다. Slack 은 X-Slack-Signature 와 X-Slack-Request-Timestamp 헤더를 보내며, 재전송 공격을 방지하기 위해 5 분 이상의 구식 요청을 거부할 것을 권장합니다 (Verifying requests from Slack).

실제 코드 리뷰에서 나타나는 두 가지 함정:

  • JSON 파싱 또는 폼 디코딩 전, 원본 요청 본문에 대해 서명을 계산해야 합니다 (Verifying requests from Slack).
  • 상호작용 페이로드를 3 초 이내에 확인해야 하므로, 무거운 작업은 비동기적으로 수행하고 결과를 전달하기 위해 response_url 을 사용해야 합니다 (Handling user interaction).

Python Slack SDK 는 코드 및 문서에 요청 서명 검증 유틸리티를 포함하고 있습니다 (python-slack-sdk signature verifier).

메시지 및 상호작용 설계

알림 메시지 템플릿

Slack 을 시스템 인터페이스처럼 작동하게 하려면, 메시지가 명확한 의사결정을 포함하도록 구조화하세요. 팀 전반에서 잘 작동하는 메시지 템플릿:

  • 제목
  • 심각도
  • 컨텍스트
  • 동작 힌트
  • 링크

최소한의 템플릿:

제목: checkout 오류율 상승 심각도: warn 컨텍스트: service=checkout env=prod region=us-east 동작 힌트: 안전한 재시작을 트리거하려면 승인 클릭

Incoming webhook 페이로드 예제

Incoming webhooks 는 JSON 페이로드를 받아들이고 Block Kit 를 사용하여 풍부한 레이아웃을 포함할 수 있습니다 (Sending messages using incoming webhooks).

{
  "text": "checkout error rate elevated",
  "blocks": [
    {
      "type": "header",
      "text": { "type": "plain_text", "text": "checkout error rate elevated" }
    },
    {
      "type": "section",
      "fields": [
        { "type": "mrkdwn", "text": "*severity*\\nwarn" },
        { "type": "mrkdwn", "text": "*context*\\nservice=checkout env=prod region=us-east" }
      ]
    },
    {
      "type": "section",
      "text": { "type": "mrkdwn", "text": "*action_hint*\\nClick Approve to trigger a safe restart." }
    }
  ]
}

버튼 및 식별자 설계

버튼은 section 또는 actions 블록 내에 있어야 하며 action_id 와 선택적 value 를 포함해야 합니다 (Button element). action_id 는 라우팅 키입니다. value 는 페이로드입니다. 함께 이들은 이벤트 스키마입니다.

주관적인 의견: action_id 값은 안정적인 API 엔드포인트처럼 선택하세요. “button_1"보다 “approve_restart"와 같은 이름이 더 오래갑니다.

상호작용 페이로드 처리, response_url 및 타이밍

Slack 은 JSON 을 포함하는 payload 파라미터가 있는 폼 인코딩 데이터로 요청 URL 로 상호작용 페이로드를 전송합니다. 페이로드에는 block_actions 와 같은 버튼 클릭을 정의하는 type 필드가 포함됩니다 (Handling user interaction, Interaction payloads).

확인 응답에 대해 3 초 이내에 HTTP 200 을 반환해야 합니다 (Handling user interaction). 원본 메시지를 업데이트하거나 채널 또는 스레드에서 응답하기 위해 response_url 을 사용하며, Slack 은 response_url 사용을 30 분 내 최대 5 회로 제한합니다 (Handling user interaction).

이 타이밍 제약은 설계 제약입니다. 이는 “확인"을 “동작 수행"에서 분리하도록 강제합니다.

Slack 에 적합한 상호작용 패턴

  • 승인 및 분기를 위한 Block Kit 버튼.
  • 명시적 사용자 의도 및 파라미터를 위한 슬래시 명령.
  • 워크플로우 빌더 내 반복 가능한 비즈니스 프로세스를 위한 워크플로우 단계 (Workflow steps).
  • 구조화된 입력이 필요할 때 상호작용성 문서에서 설명된 trigger_id 제약과 함께 단축키 및 모달 (Handling user interaction).

Go 와 Python 예제

게시자 노트: 이들은 전용 예제 페이지로 나눌 수 있습니다:

  • /app-architecture/integration-patterns/slack/go-example
  • /app-architecture/integration-patterns/slack/python-example

예제는 시스템을 안정적으로 유지하는 한 가지 우선시합니다:

  • Slack 서명 검증
  • 3 초 이내에 확인
  • 동작 중복 제거
  • 내부 HTTP POST 트리거
  • 선택적으로 response_url 을 사용하여 Slack 업데이트

Go 예제: 알림 전송 및 버튼 승인 처리

전제 조건:

package main

import (
  "bytes"
  "crypto/hmac"
  "crypto/sha256"
  "encoding/hex"
  "encoding/json"
  "io"
  "log"
  "net/http"
  "net/url"
  "os"
  "strconv"
  "strings"
  "sync"
  "time"

  "github.com/slack-go/slack"
)

type BlockActionPayload struct {
  Type  string `json:"type"`
  Team  struct{ ID string `json:"id"` } `json:"team"`
  User  struct{ ID string `json:"id"` } `json:"user"`
  Channel struct {
    ID string `json:"id"`
  } `json:"channel"`
  Message struct {
    Ts string `json:"ts"`
  } `json:"message"`
  ResponseURL string `json:"response_url"`
  Actions []struct {
    ActionID string `json:"action_id"`
    Value    string `json:"value"`
    Type     string `json:"type"`
  } `json:"actions"`
}

type InternalAction struct {
  Action     string `json:"action"`
  TeamID     string `json:"team_id"`
  ChannelID  string `json:"channel_id"`
  MessageTS  string `json:"message_ts"`
  UserID     string `json:"user_id"`
  Value      string `json:"value"`
}

var (
  // In production, store this in Redis with TTL
  seenMu sync.Mutex
  seen   = map[string]time.Time{}
  ttl    = 10 * time.Minute
)

func main() {
  botToken := os.Getenv("SLACK_BOT_TOKEN")
  signingSecret := os.Getenv("SLACK_SIGNING_SECRET")
  channelID := os.Getenv("SLACK_CHANNEL_ID")
  internalURL := os.Getenv("INTERNAL_API_URL")
  listenAddr := os.Getenv("LISTEN_ADDR") // e.g. :8080

  if botToken == "" || signingSecret == "" || channelID == "" || internalURL == "" || listenAddr == "" {
    log.Fatal("missing env vars SLACK_BOT_TOKEN SLACK_SIGNING_SECRET SLACK_CHANNEL_ID INTERNAL_API_URL LISTEN_ADDR")
  }

  api := slack.New(botToken)

  // Send an alert message with an approval button.
  // Buttons are interactive Block Kit elements with action_id and value.
  // See Slack Block Kit button element docs.
  blocks := slack.Blocks{
    BlockSet: []slack.Block{
      slack.NewHeaderBlock(slack.NewTextBlockObject("plain_text", "checkout error rate elevated", false, false)),
      slack.NewSectionBlock(
        slack.NewTextBlockObject("mrkdwn", "*severity*\\nwarn\\n*context*\\nservice=checkout env=prod", false, false),
        nil,
        nil,
      ),
      slack.NewActionBlock(
        "actions_1",
        slack.NewButtonBlockElement("approve_restart", "restart", slack.NewTextBlockObject("plain_text", "Approve restart", false, false)),
      ),
    },
  }

  _, ts, err := api.PostMessage(channelID, slack.MsgOptionBlocks(blocks.BlockSet...))
  if err != nil {
    log.Fatalf("PostMessage failed: %v", err)
  }
  log.Printf("posted alert message_ts=%s", ts)

  // Interactivity endpoint
  http.HandleFunc("/slack/actions", func(w http.ResponseWriter, r *http.Request) {
    rawBody, err := io.ReadAll(r.Body)
    if err != nil {
      http.Error(w, "read body failed", http.StatusBadRequest)
      return
    }
    r.Body.Close()

    // Verify Slack request signature on the raw body and timestamp.
    // See Slack verifying requests docs.
    if !verifySlackRequest(r.Header, rawBody, signingSecret) {
      http.Error(w, "invalid signature", http.StatusUnauthorized)
      return
    }

    // Slack sends application/x-www-form-urlencoded with payload=JSON
    vals, err := url.ParseQuery(string(rawBody))
    if err != nil {
      http.Error(w, "bad form body", http.StatusBadRequest)
      return
    }
    payloadStr := vals.Get("payload")
    if payloadStr == "" {
      http.Error(w, "missing payload", http.StatusBadRequest)
      return
    }

    var p BlockActionPayload
    if err := json.Unmarshal([]byte(payloadStr), &p); err != nil {
      http.Error(w, "bad payload json", http.StatusBadRequest)
      return
    }

    // Ack within 3 seconds. Do real work async and use response_url for updates.
    // See Slack interactivity docs on acknowledgment timing.
    w.WriteHeader(http.StatusOK)
    _, _ = w.Write([]byte(""))

    go func() {
      if p.Type != "block_actions" || len(p.Actions) == 0 {
        return
      }
      a := p.Actions[0]
      if a.ActionID != "approve_restart" {
        return
      }

      // Dedupe approvals
      key := strings.Join([]string{p.Team.ID, p.Channel.ID, p.Message.Ts, p.User.ID, a.ActionID, a.Value}, "|")
      if !tryOnce(key) {
        return
      }

      req := InternalAction{
        Action:    "approve_restart",
        TeamID:    p.Team.ID,
        ChannelID: p.Channel.ID,
        MessageTS: p.Message.Ts,
        UserID:    p.User.ID,
        Value:     a.Value,
      }

      if err := postJSON(internalURL, req); err != nil {
        log.Printf("internal action failed: %v", err)
        _ = replyViaResponseURL(p.ResponseURL, "action failed, check logs")
        return
      }

      _ = replyViaResponseURL(p.ResponseURL, "approval received, internal action triggered")
    }()
  })

  log.Printf("listening on %s", listenAddr)
  log.Fatal(http.ListenAndServe(listenAddr, nil))
}

func tryOnce(key string) bool {
  now := time.Now()

  seenMu.Lock()
  defer seenMu.Unlock()

  for k, t := range seen {
    if now.Sub(t) > ttl {
      delete(seen, k)
    }
  }
  if _, ok := seen[key]; ok {
    return false
  }
  seen[key] = now
  return true
}

func verifySlackRequest(h http.Header, body []byte, signingSecret string) bool {
  ts := h.Get("X-Slack-Request-Timestamp")
  sig := h.Get("X-Slack-Signature")
  if ts == "" || sig == "" {
    return false
  }

  tsInt, err := strconv.ParseInt(ts, 10, 64)
  if err != nil {
    return false
  }

  // Reject requests older than 5 minutes to reduce replay risk.
  if time.Since(time.Unix(tsInt, 0)) > 5*time.Minute {
    return false
  }

  base := "v0:" + ts + ":" + string(body)
  mac := hmac.New(sha256.New, []byte(signingSecret))
  mac.Write([]byte(base))
  sum := hex.EncodeToString(mac.Sum(nil))
  expected := "v0=" + sum

  return hmac.Equal([]byte(expected), []byte(sig))
}

func postJSON(url string, body any) error {
  b, err := json.Marshal(body)
  if err != nil {
    return err
  }
  req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
  if err != nil {
    return err
  }
  req.Header.Set("Content-Type", "application/json")
  c := &http.Client{Timeout: 5 * time.Second}
  res, err := c.Do(req)
  if err != nil {
    return err
  }
  defer res.Body.Close()
  if res.StatusCode < 200 || res.StatusCode >= 300 {
    return io.ErrUnexpectedEOF
  }
  return nil
}

func replyViaResponseURL(responseURL string, text string) error {
  if responseURL == "" {
    return nil
  }
  // response_url accepts JSON payloads and can post ephemeral by default.
  b, _ := json.Marshal(map[string]string{
    "text": text,
  })
  req, _ := http.NewRequest(http.MethodPost, responseURL, bytes.NewReader(b))
  req.Header.Set("Content-Type", "application/json")
  c := &http.Client{Timeout: 5 * time.Second}
  res, err := c.Do(req)
  if err != nil {
    return err
  }
  defer res.Body.Close()
  return nil
}

Python 예제: 알림 전송 및 버튼 승인 처리

전제 조건:

import os
import json
import time
import threading
import requests
from flask import Flask, request, make_response
from slack_sdk import WebClient
from slack_sdk.signature import SignatureVerifier

SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
SLACK_CHANNEL_ID = os.environ["SLACK_CHANNEL_ID"]
INTERNAL_API_URL = os.environ["INTERNAL_API_URL"]

client = WebClient(token=SLACK_BOT_TOKEN)
verifier = SignatureVerifier(signing_secret=SLACK_SIGNING_SECRET)

app = Flask(__name__)

# In production, store these in Redis
_seen = {}
_TTL_SECONDS = 600

def try_once(key: str) -> bool:
    now = int(time.time())
    expired = [k for k, t in _seen.items() if now - t > _TTL_SECONDS]
    for k in expired:
        _seen.pop(k, None)
    if key in _seen:
        return False
    _seen[key] = now
    return True

def post_internal_action(payload: dict) -> None:
    requests.post(INTERNAL_API_URL, json=payload, timeout=5)

def reply_via_response_url(response_url: str, text: str) -> None:
    if not response_url:
        return
    requests.post(response_url, json={"text": text}, timeout=5)

def send_alert_with_button() -> None:
    blocks = [
        {"type": "header", "text": {"type": "plain_text", "text": "checkout error rate elevated"}},
        {"type": "section", "text": {"type": "mrkdwn", "text": "*severity*\\nwarn\\n*context*\\nservice=checkout env=prod"}},
        {
            "type": "actions",
            "block_id": "actions_1",
            "elements": [
                {
                    "type": "button",
                    "text": {"type": "plain_text", "text": "Approve restart"},
                    "action_id": "approve_restart",
                    "value": "restart"
                }
            ]
        }
    ]
    # chat.postMessage requires chat:write scope.
    client.chat_postMessage(channel=SLACK_CHANNEL_ID, text="checkout error rate elevated", blocks=blocks)

@app.post("/slack/actions")
def slack_actions():
    raw_body = request.get_data()  # Slack recommends verifying raw body before parsing
    if not verifier.is_valid_request(raw_body, request.headers):
        return make_response("invalid signature", 401)

    # Slack sends application/x-www-form-urlencoded with a payload field containing JSON.
    payload_str = request.form.get("payload", "")
    if not payload_str:
        return make_response("missing payload", 400)
    payload = json.loads(payload_str)

    # Ack within 3 seconds. Slack user sees errors if you do not.
    # See Slack interactivity docs on acknowledgment.
    resp = make_response("", 200)

    def work():
        if payload.get("type") != "block_actions":
            return
        actions = payload.get("actions", [])
        if not actions:
            return

        a = actions[0]
        if a.get("action_id") != "approve_restart":
            return

        team_id = payload.get("team", {}).get("id", "")
        channel_id = payload.get("channel", {}).get("id", "")
        user_id = payload.get("user", {}).get("id", "")
        message_ts = payload.get("message", {}).get("ts", "")
        value = a.get("value", "")

        key = "|".join([team_id, channel_id, message_ts, user_id, "approve_restart", value])
        if not try_once(key):
            return

        internal_payload = {
            "action": "approve_restart",
            "team_id": team_id,
            "channel_id": channel_id,
            "message_ts": message_ts,
            "user_id": user_id,
            "value": value,
        }

        try:
            post_internal_action(internal_payload)
            reply_via_response_url(payload.get("response_url", ""), "approval received, internal action triggered")
        except Exception:
            reply_via_response_url(payload.get("response_url", ""), "action failed, check logs")

    threading.Thread(target=work, daemon=True).start()
    return resp

if __name__ == "__main__":
    send_alert_with_button()
    app.run(host="0.0.0.0", port=int(os.getenv("PORT", "8080")))

운영 노트: 라우팅, UX, 보안, 링크 및 SEO

Slack 대 페이지 도구 대 Discord 사용 시기

이 페이지는 메커니즘에 관한 것입니다. 라우팅은 전략입니다. 그래도 경계는 쉽게 설명할 수 있습니다.

채널 가장 적합 실패 모드
PagerDuty 또는 유사한 도구 즉각적인 대응이 필요한 긴급 사용자 영향 사람들이 Slack 을 넘어 잠든다
Slack 조정, 승인, 워크플로우 실행 노이즈 및 채널 피로
Discord Discord 에서 생활하는 팀, 가벼운 제어 루프 엔터프라이즈 워크플로우 구조 부족

대화와 워크플로우를 인터페이스로 만들고 싶을 때 Slack 을 사용하세요. 알림이 선택 사항이 아닐 때 페이지 도구를 사용하세요. Slack 상호작용 설계를 서비스 경계 및 영속성 선택과 균형을 맞추고 있다면, 이 앱 아키텍처 개요 가 그 결정을 더 큰 시스템에 배치하는 데 도움이 됩니다. 더 깊은 라우팅 모델에 대해서는 가시성 팀을 위한 현대적인 알림 시스템 설계 를 참조하세요. Discord 통합 대안에 대해서는 알림 및 제어 루프를 위한 Discord 통합 패턴 을 참조하세요.

접근성 및 UX 노트

  • 고빈도 알림은 별도의 채널로 보내고, 인간 간 논의는 스레드에서 진행하세요.
  • 사고별 컨텍스트 및 업데이트를 위해 스레드를 사용하세요. response_url 은 thread_ts 가 제공될 때 채널 및 스레드 내에서 게시할 수 있습니다 (Handling user interaction).
  • 채널 스팸을 피하기 위해 사용자 동작을 확인할 때 일회성 (ephemeral) 응답을 사용하되, 일회성 전송은 보장되지 않고 세션에 의존한다는 점을 기억하세요 (chat.postEphemeral).
  • 레이아웃을 빠르게 프로토타이핑하기 위해 Block Kit Builder 를 사용하세요 (Block Kit).
  • 이미지를 추가할 경우 지원되는 곳에서 의미 있는 대체 텍스트를 포함하고 최상위 text 필드에 평문 대체 텍스트를 유지하세요.

보안 체크리스트

  • 서명 비밀 헤더 및 원본 본문을 사용하여 모든 수신 Slack 요청을 검증하세요 (Verifying requests from Slack).
  • 재전송 위험을 줄이기 위해 5 분 이상의 구식 타임스탬프를 가진 요청을 거부하세요 (Verifying requests from Slack).
  • 토큰 및 웹훅 URL 은 비밀 관리자에 보관하고 절대 git 에 저장하지 마세요.
  • 최소 권한 OAuth 스코프를 사용하며 역할이 변경될 때 비밀을轮换하세요 (Scopes reference).
  • 내부 동작 API 를 별도로 인증 및 권한 부여하고, Slack 을 인증 경계로 취급하지 마세요.
  • 승인을 멱등적이고 중복 제거되도록 만드세요.
  • 팀, 채널, 메시지 타임스탬프, 사용자 및 동작을 포함하여 감사에 적합한 방식으로 승인을 로그하세요.

결론

Slack 은 메시지 싱크가 아닌 시스템 경계로 취급할 때 가장 잘 작동합니다. Incoming webhooks 는 빠른 알림 전달을 처리합니다. 앱과 상호작용성은 Slack 을 워크플로우 엔진 및 이벤트 인터페이스로 만듭니다. 어려운 부분은 서명 검증, 타이밍 제약, 중복 제거, 그리고 Slack 이 알림 라우팅 모델에 어디서 적합한지 선택하는 것입니다.

다음 링크: