디스코드 웹훅으로 스크립트 실행 결과 받아보기

자동화 스크립트를 스케줄러에 걸어두고 나면 이상한 습관이 하나 생깁니다. 잘 돌았는지 확인하려고 하루에 몇 번씩 로그 폴더를 열어보는 겁니다. 파일을 찾고, 맨 아래로 내리고, 시간을 대조하고, 아무 일 없었음을 확인하고 닫습니다. 한 번에 2분씩 하루 세 번이면 한 달에 세 시간입니다. 그런데 정작 진짜 문제가 터진 날에는 며칠 뒤에야 알아차립니다. 디스코드 웹훅 알림을 파이썬 코드 다섯 줄로 붙여두면 이 확인 작업이 통째로 사라집니다. 스크립트가 끝나는 순간 휴대폰으로 결과가 먼저 날아오기 때문입니다.

이번 글에서는 웹훅 주소를 만드는 것부터 시작해, 성공·실패를 색으로 구분하는 카드 형태 알림, 기존 스크립트를 고치지 않고 감싸는 래퍼, 로그 파일 첨부까지 순서대로 붙여봅니다. 디스코드 계정만 있으면 되고 서버 구축이나 유료 결제는 필요 없습니다.

왜 하필 디스코드 웹훅인가

알림을 받는 방법은 여럿입니다. 이 블로그에서도 이메일 자동 발송과 카카오톡·텔레그램 알림을 각각 다뤘습니다. 디스코드 웹훅이 개인 스크립트에 특히 잘 맞는 이유는 등록 절차가 없다는 점입니다. 봇을 만들 필요도, 앱 심사를 받을 필요도, 채널 ID를 따로 조회할 필요도 없습니다. 주소 하나를 복사해서 POST를 던지면 끝입니다.

방식준비물설정 난이도이럴 때 좋습니다
디스코드 웹훅웹훅 URL 하나5분혼자 쓰는 스크립트, 실행 로그를 쌓아두고 싶을 때
슬랙 웹훅워크스페이스 + 앱 생성15분업무용 슬랙을 이미 쓰는 팀
텔레그램 봇봇 토큰 + chat_id 조회20분휴대폰 푸시가 가장 확실해야 할 때
이메일(SMTP)앱 비밀번호 + SMTP 설정20분기록을 메일함에 남겨야 할 때

덤으로 디스코드는 채널이 곧 로그 저장소가 됩니다. 알림을 채널 하나에 계속 쌓아두면 “지난주 화요일에도 같은 에러가 났었나”를 검색창으로 확인할 수 있습니다. 파일로 남는 로그와 달리 휴대폰에서도 열립니다.

1단계: 웹훅 주소 만들기 (5분)

먼저 알림을 받을 서버와 채널이 필요합니다. 없다면 디스코드에서 내 서버 만들기 → 직접 만들기 → 나와 친구들을 위한 서버로 개인용 서버를 하나 만들고, 그 안에 #알림 같은 채널을 하나 팝니다. 혼자 쓰는 서버라 아무도 들어오지 않습니다.

  1. 알림을 받을 채널 이름 위에 마우스를 올리고 톱니바퀴(채널 편집)를 누릅니다.
  2. 왼쪽 메뉴에서 연동을 선택합니다.
  3. 웹후크 → 새 웹후크를 누릅니다. 이름과 아이콘은 나중에 바꿔도 됩니다.
  4. 웹후크 URL 복사를 누릅니다. https://discord.com/api/webhooks/숫자/긴문자열 형태입니다.

이 URL은 비밀번호와 같습니다. 주소를 아는 사람은 누구나 그 채널에 글을 쓸 수 있습니다. 깃허브에 올라가는 코드 안에 직접 적지 마세요. 공개 저장소에 올라간 웹훅 주소는 봇이 수집해서 광고를 뿌리는 용도로 쓰입니다. 환경변수나 .env 파일로 분리하는 방법은 이 블로그의 “환경변수(.env) 파일로 API 키 안전하게 관리하는 법” 글에 정리해 뒀습니다.

2단계: 가장 짧은 알림 코드

준비는 끝났습니다. requests만 있으면 됩니다. 설치는 pip install requests 한 줄입니다.

import requests

WEBHOOK_URL = "https://discord.com/api/webhooks/1234567890/abcdefg..."

res = requests.post(
    WEBHOOK_URL,
    json={"content": "백업 스크립트 완료"},
    headers={"User-Agent": "my-script/1.0"},   # 이 줄이 핵심입니다. 이유는 아래에서
    timeout=10,
)

print(res.status_code)   # 204 가 찍히면 성공입니다

성공하면 응답 코드가 204입니다. 200이 아니라 204인 이유는 디스코드가 “잘 받았고 돌려줄 내용은 없다”는 뜻으로 답하기 때문입니다. 200을 기대하고 if res.status_code == 200으로 검사하면 정상 전송인데도 실패로 처리됩니다. 굳이 보낸 메시지 정보를 받아야 한다면 URL 뒤에 ?wait=true를 붙이면 200과 함께 메시지 객체가 돌아옵니다.

User-Agent를 왜 직접 넣나

위 코드에서 가장 의아한 줄이 headers일 겁니다. 권한 설정은 다 맞는데 403 Forbidden이 돌아오는 경우가 있습니다. 웹훅을 다시 만들어도, 채널 권한을 열어줘도 똑같이 403입니다. 원인은 권한이 아니라 User-Agent입니다. requests는 기본적으로 python-requests/2.x라는 UA를 붙여 보내는데, 디스코드 앞단의 방화벽이 이 값을 차단 대상으로 볼 때가 있습니다. 아무 문자열이나 자기 스크립트 이름으로 UA를 지정하면 그대로 통과합니다. 403을 만나면 권한 화면을 뒤지기 전에 이 한 줄부터 확인하는 편이 빠릅니다.

3단계: 성공과 실패를 색으로 구분하기

텍스트 한 줄짜리 알림은 며칠 지나면 눈에 안 들어옵니다. 디스코드의 embed를 쓰면 제목·본문·색상 막대·타임스탬프가 붙은 카드 형태로 보낼 수 있습니다. 성공은 초록, 실패는 빨강으로 칠해두면 채널을 스크롤만 해도 문제가 있던 날이 바로 눈에 띕니다.

import datetime
import socket

import requests

WEBHOOK_URL = "https://discord.com/api/webhooks/1234567890/abcdefg..."
HEADERS = {"User-Agent": "my-script/1.0"}

GREEN = 3066993      # 0x2ECC71
RED = 15158332       # 0xE74C3C


def notify(title, description="", ok=True, fields=None):
    """디스코드 채널에 색깔 있는 카드 한 장을 보냅니다."""
    embed = {
        "title": title[:256],
        "description": description[:4000],
        "color": GREEN if ok else RED,
        "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
        "footer": {"text": socket.gethostname()},   # 어느 PC에서 돌았는지
    }
    if fields:
        embed["fields"] = [
            {"name": str(k), "value": str(v)[:1024], "inline": True}
            for k, v in fields.items()
        ]

    res = requests.post(
        WEBHOOK_URL,
        json={
            "embeds": [embed],
            "allowed_mentions": {"parse": []},   # 실수로 전체 멘션이 울리는 것 방지
        },
        headers=HEADERS,
        timeout=10,
    )
    res.raise_for_status()


notify(
    "일일 백업 완료",
    "변경된 파일만 동기화했습니다.",
    ok=True,
    fields={"대상 파일": 128, "용량": "2.4 GB", "소요": "41.2초"},
)

color가 #2ECC71 같은 문자열이 아니라 10진수 정수라는 점만 주의하면 됩니다. 원하는 색의 16진수 코드에서 #을 떼고 10진수로 바꿔 쓰면 됩니다. fields에 "inline": True를 주면 항목들이 가로로 나란히 붙어서 한 줄에 세 개까지 들어갑니다. 처리 건수, 용량, 소요 시간처럼 짧은 수치를 넣기 좋습니다.

footer에 socket.gethostname()을 넣어둔 이유가 있습니다. 회사 PC와 집 PC에서 같은 스크립트를 돌리게 되면, 알림만 보고는 어느 쪽에서 온 건지 알 수 없어서 한참 헤매게 됩니다.

4단계: 기존 스크립트를 고치지 않고 감싸기

여기까지 오면 “그럼 스크립트 곳곳에 notify를 넣어야 하나” 싶어집니다. 그럴 필요 없습니다. 기존 함수를 그대로 두고 바깥에서 한 번 감싸면 성공·실패·소요 시간이 자동으로 보고됩니다.

# runner.py - 기존 스크립트를 건드리지 않고 통째로 감싸는 방식
import time
import traceback

from notify import notify   # 위에서 만든 함수


def run_with_report(task_name, func):
    started = time.time()
    try:
        result = func()
    except Exception:
        tail = traceback.format_exc()[-1500:]   # 뒤쪽 1,500자만. 길면 잘려서 안 보입니다
        notify(
            task_name + " 실패",
            "```\n" + tail + "\n```",
            ok=False,
            fields={"소요": "%.1f초" % (time.time() - started)},
        )
        raise          # 알림만 보내고 에러는 그대로 올려보냅니다
    else:
        notify(
            task_name + " 완료",
            "정상 종료했습니다.",
            ok=True,
            fields={"소요": "%.1f초" % (time.time() - started), "결과": result},
        )
        return result


if __name__ == "__main__":
    from my_backup import do_backup

    run_with_report("일일 백업", do_backup)

이 구조의 장점은 실패했을 때 에러 내용이 그대로 휴대폰에 뜬다는 점입니다. traceback.format_exc()로 스택 전체를 잡되 뒤쪽 1,500자만 잘라 보냅니다. 파이썬 트레이스백은 진짜 원인이 항상 맨 아래에 있기 때문에 뒤쪽을 남기는 게 맞습니다. 앞에서 자르면 “무슨 파일 몇 번째 줄”만 잔뜩 오고 정작 에러 메시지가 사라집니다.

raise를 빼먹지 마세요. 알림만 보내고 예외를 삼켜버리면 스크립트가 종료 코드 0으로 끝납니다. 그러면 작업 스케줄러 기록에는 “성공”으로 남아서, 나중에 실행 이력만 볼 때 실패한 날을 찾을 수 없게 됩니다.

5단계: 로그 파일 통째로 첨부하기

에러 요약만으로 부족할 때가 있습니다. 디스코드 웹훅은 파일도 받습니다. 다만 이때는 JSON이 아니라 multipart/form-data로 보내야 해서 코드 모양이 조금 달라집니다.

import json
import os

import requests


def notify_with_log(text, log_path):
    """메시지와 함께 로그 파일을 첨부합니다. 첨부는 JSON이 아니라 multipart 입니다."""
    if os.path.getsize(log_path) > 8 * 1024 * 1024:
        text += "\n(로그가 8MB를 넘어 첨부를 건너뜁니다)"
        return requests.post(WEBHOOK_URL, json={"content": text},
                             headers=HEADERS, timeout=10)

    with open(log_path, "rb") as f:
        return requests.post(
            WEBHOOK_URL,
            headers=HEADERS,
            data={"payload_json": json.dumps({"content": text})},
            files={"files[0]": (os.path.basename(log_path), f)},
            timeout=30,
        )

json=이 아니라 data=와 files=를 쓰는 게 핵심입니다. 메시지 내용은 payload_json이라는 이름의 폼 필드에 JSON 문자열로 넣고, 파일은 files[0]에 담습니다. 부스트하지 않은 일반 서버의 업로드 한도는 계정마다 다르지만 대체로 수 MB 수준이라, 위 코드처럼 크기를 먼저 확인하고 너무 크면 첨부를 건너뛰게 해두는 편이 안전합니다. 한도를 넘기면 전송 자체가 실패해서 알림을 아예 못 받습니다.

6단계: 파워셸 스크립트에서 보내기

윈도우 배치 작업은 파워셸로 짜는 경우가 많습니다. 파이썬을 거칠 필요 없이 Invoke-RestMethod 한 번이면 됩니다.

# notify.ps1 - 파워셸 스크립트에서 한 줄로 보내기
# 웹훅 주소는 코드에 박지 말고 환경변수로 받습니다
$url = $env:DISCORD_WEBHOOK

$payload = @{ content = "백업 스크립트 완료" } | ConvertTo-Json -Compress

# 한글이 ???로 깨지는 걸 막으려면 문자열이 아니라 UTF-8 바이트로 넘겨야 합니다
$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)

Invoke-RestMethod -Uri $url -Method Post -Body $bytes `
    -ContentType "application/json; charset=utf-8" `
    -UserAgent "my-script/1.0"

한글이 ???로 깨진다면 거의 항상 인코딩 문제입니다. Windows PowerShell 5.1은 -Body에 문자열을 넘기면 기본 인코딩으로 바꿔 보내기 때문에, 위처럼 UTF-8 바이트 배열로 변환해서 넘겨야 합니다. 이 계열의 문제는 “한글 깨짐 오류 해결 총정리” 글에서 더 자세히 다뤘습니다.

주의할 점

  • 메시지 길이 제한 — content는 2,000자, embed의 description은 4,096자까지입니다. 넘기면 400 에러가 나면서 알림이 통째로 사라집니다. 로그를 그대로 넣을 생각이라면 반드시 잘라서 보내세요.
  • 너무 자주 보내면 429 — 반복문 안에서 건건이 알림을 보내면 요청 제한에 걸립니다. 원칙은 루프 안에서 보내지 않고 끝나고 한 번 요약해서 보내는 것입니다. 그래도 필요하다면 아래 재시도 코드를 쓰세요.
  • 전체 멘션 사고 — 본문에 @everyone 문자열이 들어가면 실제로 서버 전원에게 알림이 울립니다. 로그를 그대로 퍼 나르다가 터지는 사고입니다. 예제처럼 allowed_mentions를 {"parse": []}로 고정해두면 멘션이 텍스트로만 표시됩니다.
  • 알림이 스크립트를 멈추지 않게 — timeout을 꼭 지정하세요. 값을 주지 않으면 디스코드 응답이 늦어질 때 스크립트가 무한정 기다립니다. 알림 실패 때문에 본 작업까지 멈추는 건 앞뒤가 바뀐 겁니다.
  • 웹훅 주소 유출 — 주소가 새면 채널을 지울 필요 없이 채널 편집 화면에서 해당 웹후크만 삭제하면 즉시 무효화됩니다.
import time

import requests


def post_with_retry(payload, tries=3):
    for _ in range(tries):
        res = requests.post(WEBHOOK_URL, json=payload, headers=HEADERS, timeout=10)

        if res.status_code == 429:                       # 너무 자주 보냈습니다
            wait = float(res.json().get("retry_after", 1))
            time.sleep(wait + 0.5)
            continue

        res.raise_for_status()
        return res

    raise RuntimeError("디스코드 전송 실패: 재시도 초과")

429 응답의 본문에는 retry_after가 들어 있습니다. 몇 초 뒤에 다시 시도하라는 뜻이니, 임의의 숫자로 기다리지 말고 이 값을 그대로 쓰는 게 정확합니다.

마무리

오늘 당장 해볼 가장 작은 것은 이겁니다. 개인 서버에 #알림 채널 하나를 만들고 웹훅 주소를 딴 다음, 지금 돌아가고 있는 스크립트 맨 끝에 2단계의 다섯 줄을 붙여보세요. 5분이면 끝나고, 오늘 밤 그 스크립트가 끝나는 순간 휴대폰이 울립니다. 색깔 카드나 로그 첨부는 그게 익숙해진 뒤에 얹어도 늦지 않습니다.

다음 글에서는 이렇게 만든 스크립트를 작업 스케줄러에 GUI 없이 등록하는 방법을 다룹니다. 화면을 클릭해가며 등록하면 설정이 코드로 남지 않아서 PC를 바꿀 때마다 처음부터 다시 해야 하는데, 등록 자체를 스크립트로 만들어두면 한 줄 실행으로 복원됩니다.

댓글 남기기