오래 걸리는 작업을 걸어두고 자리를 뜨는 일이 잦아지면, 결국 알림을 붙이게 됩니다. 그리고 알림을 붙이고 나면 꼭 이런 생각이 들어요. “이거 슬랙으로 오면 더 좋을 텐데.”

메일은 조용하고 오래 남습니다. 대신 느립니다. 종일 슬랙을 켜두고 일하는 사람에게는 알림이 한 창 안에 모이는 게 낫고, 폰 진동으로 바로 알고 싶으면 텔레그램이 빠릅니다. 취향 문제만도 아니에요. 팀이 이미 쓰는 채널이 있으면 거기로 보내는 게 맞습니다.

그런데 채널을 바꾸려고 보면 생각보다 걸립니다. 첨부파일 때문입니다. “작업 끝났습니다” 한 줄만 보낼 거라면 어느 채널이든 5분이면 됩니다. 그런데 결과로 나온 보고서나 CSV를 같이 보내려는 순간, 채널마다 방식이 다르고 함정도 다릅니다. 슬랙은 가장 널리 쓰이는 방법이 아예 막혀 있고, 디스코드는 한글 파일명을 지워버립니다.

이 글은 그 부분을 정리합니다. Claude Code로 오래 걸리는 작업을 걸어두는 분들을 위해, 세 채널(슬랙·디스코드·텔레그램)에 파일까지 붙여 보내는 방법과, 결과물을 iCloud Drive·Google Drive에 남기는 방법입니다. 전부 실제로 보내보고 도착한 것을 확인한 내용이라, 되는 것과 안 되는 것을 그대로 적었습니다.

발송부만 갈아끼우는 구조도 - 훅과 LaunchAgent 같은 공통 부분은 그대로 두고, claude-notify가 CLAUDE_NOTIFY_CHANNEL 환경변수로 슬랙·디스코드·텔레그램 중 하나를 골라 보냅니다. 결과 파일은 알림과 별개로 iCloud Drive나 Google Drive에 남기고, 채널마다 웹훅 불가·User-Agent·chat_id 같은 서로 다른 함정이 있습니다
발송부만 갈아끼우는 구조도 - 훅과 LaunchAgent 같은 공통 부분은 그대로 두고, claude-notify가 CLAUDE_NOTIFY_CHANNEL 환경변수로 슬랙·디스코드·텔레그램 중 하나를 골라 보냅니다. 결과 파일은 알림과 별개로 iCloud Drive나 Google Drive에 남기고, 채널마다 웹훅 불가·User-Agent·chat_id 같은 서로 다른 함정이 있습니다

앞선 글 Claude Code 작업 끝나면 결과 파일까지 메일로 받기에서 만든 훅과 예약 실행을 그대로 재사용합니다. 안 읽으셨어도 이 글만으로 됩니다 — 필요한 부분은 여기서 다시 설명합니다. macOS 기준이고, 리눅스는 키체인 부분만 바꾸면 같습니다.

준비 — 이 글부터 읽으신다면

앞선 글을 안 보셨어도 됩니다. 다만 두 가지가 먼저 있어야 해요. 이미 하셨으면 이 절은 건너뛰시면 됩니다.

스크립트를 둘 폴더를 만듭니다.

bash
mkdir -p ~/.claude/bin ~/.claude/hooks

시작 시각을 기록하는 훅이 필요합니다. 이 글에서 만들 알림은 “10분 넘게 걸린 작업만” 알리는데, 그러려면 언제 시작했는지 알아야 합니다. ~/.claude/hooks/notify-start.sh로 저장합니다.

bash
#!/bin/bash
# UserPromptSubmit — 이 턴의 시작 시각을 기록해 둔다. Stop 훅이 경과 시간을 재는 데 쓴다.
set -u
D="${TMPDIR:-/tmp}/claude-notify"; mkdir -p "$D"
SID="$(python3 -c 'import json,sys;print(json.load(sys.stdin).get("session_id","unknown"))' 2>/dev/null || echo unknown)"
date +%s > "$D/$SID.start"
exit 0

실행 권한을 줍니다. 이걸 빼먹으면 훅이 조용히 실패합니다. Claude Code가 이 파일을 직접 실행하기 때문에 실행 비트가 없으면 아무 일도 안 일어나고, 오류도 안 뜹니다.

bash
chmod +x ~/.claude/hooks/notify-start.sh

(hook)은 Claude Code가 특정 시점에 자동으로 실행해주는 명령입니다. UserPromptSubmit은 내가 메시지를 보낼 때, Stop은 Claude가 응답을 마칠 때 발동해요. 표준입력으로 JSON이 들어오는데 거기서 세션 ID를 꺼내 씁니다. 세션마다 파일이 따로 생기니 창을 여러 개 띄워도 섞이지 않습니다.

~/.claude/settings.json에 두 훅을 등록합니다. 파일이 이미 있으면 hooks 항목만 합치세요.

json
{
  "hooks": {
    "UserPromptSubmit": [
      {
        "hooks": [
          { "type": "command", "command": "\"$HOME/.claude/hooks/notify-start.sh\"", "timeout": 5 }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "\"$HOME/.claude/hooks/notify-stop.sh\"", "timeout": 30, "async": true }
        ]
      }
    ]
  }
}

async: true가 있어야 메일이나 메시지를 보내는 동안 Claude가 멈춰 서지 않습니다. notify-stop.sh는 아래에서 만듭니다.

메일 채널을 쓰실 거면 하나 더 필요합니다. 이 글의 어댑터는 mail을 고르면 ~/.claude/bin/claude-mail-attach를 부르는데, 그건 앞선 글에서 만든 것입니다. 안 만드셨다면 이 글의 세 채널(슬랙·디스코드·텔레그램)만 쓰시면 되고, mail은 건드리지 않으시면 됩니다.

알림과 보관은 다른 일입니다

먼저 나눠둘 게 있습니다. 하려는 일이 사실 두 가지예요.

알림은 “끝났다"를 지금 알리는 일입니다. 빠르고 눈에 띄어야 하고, 며칠 뒤에는 사라져도 그만입니다.

보관은 결과물을 나중에 다시 꺼낼 수 있게 두는 일입니다. 느려도 되지만 없어지면 안 됩니다.

슬랙 하나로 둘 다 하려다 보면 어긋납니다. 무료 플랜 슬랙은 90일이 지나면 메시지와 파일이 사라지거든요. 알림으로는 훌륭한데 보관함으로는 못 씁니다. 반대로 Google Drive는 잘 남지만 폴더를 열어보기 전까지 아무 소식이 없습니다.

그래서 이 글은 둘을 나눠서 다룹니다. 채널로 알리고, 드라이브에 남깁니다. 둘 다 붙여두면 폰에서 알림을 받고, 자리에 돌아와서는 드라이브에서 파일을 찾습니다.

만들기가 부담스러우면 Claude에게 맡기세요

아래로 내려가면 스크립트가 여럿 나옵니다. 어댑터 하나, 채널별 발송 함수 셋, 훅 하나. 다 만들려면 시간이 걸리죠. 그런데 이건 파일 만들고 권한 주는 일이라 Claude Code에게 그대로 맡길 수 있습니다.

사람이 꼭 해야 하는 건 하나뿐입니다. 자격증명을 받아오는 일이요. 슬랙 봇 토큰, 디스코드 웹훅 URL, 텔레그램 봇 토큰은 각 서비스에 로그인해서 버튼을 눌러야 나옵니다. 위임할 수가 없어요. 그 방법은 아래 채널별 절에 하나씩 적어뒀습니다.

순서는 이렇게 됩니다.

  1. 쓸 채널을 정하고, 아래 해당 절을 보며 토큰이나 URL을 발급받습니다
  2. security add-generic-password 명령으로 직접 키체인에 넣습니다
  3. 나머지는 아래 프롬프트를 붙여넣어 Claude에게 맡깁니다

터미널에서 claude를 띄우고 통째로 붙여넣으세요.

text
macOS에서 Claude Code 작업 알림을 슬랙·디스코드·텔레그램으로 보내는 환경을 만들어줘.
첨부파일이 같이 가는 게 목적이야. 나는 텔레그램을 쓸 건데, 나중에 채널을 바꿀 수 있게 만들어줘.
(쓸 채널 이름은 각자 바꿔서 넣으세요)

1. `~/.claude/bin/claude-notify` 작성 — 얇은 디스패처.
   `CLAUDE_NOTIFY_CHANNEL` 환경변수로 mail|slack|discord|telegram 중 하나를 고르고,
   제목은 인자, 본문은 stdin, 첨부는 나머지 인자로 받아 넘겨줘.
   모르는 채널이 와도 에러 대신 `exit 0`으로 조용히 끝나게 해줘.

2. `~/.claude/bin/claude-notify-send.py` 작성 — 실제 발송. 표준 라이브러리만 써줘.
   비밀값은 전부 `security find-generic-password`로 키체인에서 읽고,
   항목이 없으면 에러 대신 조용히 건너뛰게 해줘. 채널별로 이렇게 해줘.

   - 슬랙: **Incoming Webhook으로는 파일을 못 올리니 봇 토큰을 쓴다.**
     `files.upload`는 은퇴했으니 쓰지 마. `files.getUploadURLExternal`(이건 JSON이 아니라
     폼 인코딩을 받는다) → 받은 URL로 파일 POST → `files.completeUploadExternal` 3단계로.
     키체인 항목은 `claude-notify-slack`의 `bot`(xoxb- 토큰)과 `channel`(C로 시작하는 채널 ID).
   - 디스코드: 웹훅 URL 하나로 multipart 전송(`payload_json` + `files[0]`).
     **User-Agent 헤더를 반드시 넣어줘.** 없으면 Cloudflare가 403 error code 1010으로 막는다.
     디스코드는 한글 파일명을 해시로 바꾸니, non-ASCII 파일명이 있으면 원래 이름을
     메시지 본문에 함께 적어줘. 키체인 항목은 `claude-notify-discord`의 `webhook`.
   - 텔레그램: `sendDocument`로 파일별 전송, 첨부가 없으면 `sendMessage`.
     캡션은 1024자 제한이니 잘라줘. 키체인 항목은 `claude-notify-telegram`의
     `bot`(토큰)과 `chat`(숫자 chat_id).

3. `~/.claude/hooks/notify-start.sh`와 `notify-stop.sh` 작성.
   시작 훅은 `UserPromptSubmit`에서 세션별 시작 시각을 임시 폴더에 기록.
   종료 훅은 `Stop`에서 경과 시간을 재어 임계값(`CLAUDE_NOTIFY_AFTER_SEC`, 기본 600초)을
   넘을 때만 발송. **시간 게이트가 없으면 매 턴 알림이 와서 스팸이 되니 반드시 넣어줘.**
   종료 훅에서 두 가지를 더 해줘.
   - 마지막 응답에서 코드블록·표·마크다운 기호를 걷어내고 400자로 잘라 본문으로 쓴다
   - 시작 시각 파일을 기준으로 이번 턴에 바뀐 결과 파일(md·pdf·csv·xlsx·docx·pptx)을
     찾아 첨부한다. **`find -newermt "@에포크"`는 GNU 전용이라 macOS 기본 find에서 실패한다.
     시작 시각 파일을 그대로 기준 파일로 삼아 `-newer <파일>`을 써줘.**
     `.git`·`node_modules`·`public`·`dist`는 제외하고, 최대 3개까지만.

4. `~/.claude/settings.json`에 두 훅을 등록해줘. 기존 설정이 있으면 덮어쓰지 말고 병합.
   `Stop` 훅에는 `"async": true`를 넣어줘.

5. 만든 스크립트와 훅에 **전부 `chmod +x`** 해줘. 훅은 실행 비트가 없으면 조용히 실패한다.

중요:
- **토큰이나 웹훅 URL을 나에게 묻지 말고, 어떤 파일에도 적지 마.**
  키체인 등록은 내가 직접 할 테니 실행할 명령만 알려줘.
- macOS 기본 bash는 3.2다. `set -u`에서 빈 배열을 전개하면 죽으니 주의해줘.
- 다 만들고 나면 채널별로 손으로 한 번 보내보는 방법을 알려줘.

Claude가 파일을 만들어두면 키체인에 값을 넣고 시험 발송만 해보시면 됩니다.

그래도 아래를 읽어두시길 권합니다. 알림이 안 오기 시작하면 결국 어디가 어떻게 물려 있는지 알아야 고칩니다. 그리고 이 글의 절반은 만들면서 실제로 걸렸던 것들입니다. 슬랙 웹훅으로 파일이 안 올라가는 것, 디스코드가 파일명을 지우는 것, 텔레그램 봇이 먼저 말을 못 거는 것. 프롬프트에 미리 넣어둬서 Claude가 피해 가더라도, 왜 그렇게 됐는지는 알아두시는 게 좋습니다.

발송부만 갈아끼우기 — 어댑터 하나로

채널마다 스크립트를 따로 만들면 금방 지저분해집니다. 훅에 물리는 부분, 시간을 재는 부분, 파일을 모으는 부분은 어느 채널이든 똑같거든요. 다른 건 마지막에 어디로 던지느냐뿐입니다.

그래서 그 마지막 부분만 갈아끼울 수 있게 만듭니다. 이런 걸 어댑터라고 부릅니다 — 공통 부분은 그대로 두고, 상대에 맞는 부분만 바꿔 끼우는 조각이요. 콘센트 변환 어댑터와 같은 발상입니다.

~/.claude/bin/claude-notify로 저장합니다.

bash
#!/bin/bash
# 작업 알림 발송 — 채널을 갈아끼울 수 있는 어댑터.
#   echo "본문" | claude-notify "제목" [첨부파일 ...]
#
# 채널 선택: CLAUDE_NOTIFY_CHANNEL=mail|slack|discord|telegram  (기본 mail)
# 비밀값은 전부 키체인에서 읽는다. 이 파일에는 어떤 토큰도 없다.

set -u
CHANNEL="${CLAUDE_NOTIFY_CHANNEL:-mail}"
SUBJECT="${1:-Claude Code 알림}"; shift || true
BODY="$(cat)"

case "$CHANNEL" in
  mail)
    # 앞선 글에서 만든 메일 발송기를 그대로 쓴다.
    printf '%s' "$BODY" | "$HOME/.claude/bin/claude-mail-attach" "$SUBJECT" "$@"
    ;;
  slack|discord|telegram)
    python3 "$HOME/.claude/bin/claude-notify-send.py" \
      "$CHANNEL" "$SUBJECT" "$BODY" "$@"
    ;;
  *)
    echo "알 수 없는 채널: $CHANNEL" >&2; exit 0 ;;
esac
exit 0

chmod +x ~/.claude/bin/claude-notify로 실행 권한을 줍니다.

두 가지가 의도적입니다.

환경변수로 채널을 고릅니다. CLAUDE_NOTIFY_CHANNEL=slack claude-notify ...처럼 그때그때 바꿀 수 있고, 훅이나 예약 실행에 걸 때는 그쪽에 한 번만 지정하면 됩니다. 훅 스크립트는 손댈 필요가 없어요.

모르는 채널이 와도 exit 0입니다. 알림은 곁다리 기능인데 이게 실패해서 본 작업이 멈추면 곤란합니다. 이 원칙은 아래 파이썬 쪽에도 계속 나옵니다.

훅에 물리기

앞선 글의 종료 훅을 이미 쓰고 계신다면, 마지막 줄에서 claude-mail을 부르던 것을 이렇게 바꾸면 끝입니다.

bash
# 바꾸기 전
  | "$HOME/.claude/bin/claude-mail" "[Claude] 작업 완료 — $(basename "$CWD") (${MIN}분)"

# 바꾼 뒤
  | CLAUDE_NOTIFY_CHANNEL=slack \
    "$HOME/.claude/bin/claude-notify" "[Claude] 작업 완료 — $(basename "$CWD") (${MIN}분)"

종료 훅을 아직 안 만드셨다면 뒤의 「훅 다듬기」 절에 전문이 있으니 그걸 쓰시면 됩니다. 요지는 UserPromptSubmit에서 시작 시각을 적어두고 Stop에서 경과 시간을 재어, 오래 걸린 작업만 알린다는 것입니다. Stop 훅은 턴이 끝날 때마다 발동하기 때문에 이 시간 게이트가 없으면 하루에 수십 통이 날아옵니다.

첨부를 붙이려면 파일 경로를 인자로 넘깁니다.

bash
echo "조사 끝났습니다." | CLAUDE_NOTIFY_CHANNEL=telegram \
  ~/.claude/bin/claude-notify "[Claude] 조사 결과" 결과보고서.md 요약.csv

공통 뼈대

실제 발송은 파이썬이 맡습니다. 세 채널 모두 HTTP로 파일을 올리는데, 이때 쓰는 형식이 multipart/form-data예요. 웹 폼에서 파일을 첨부할 때 브라우저가 만드는 그 형식입니다. 파이썬 표준 라이브러리에는 이걸 만들어주는 함수가 없어서 직접 조립합니다. 추가로 설치할 패키지는 없습니다.

~/.claude/bin/claude-notify-send.py의 앞부분입니다.

python
#!/usr/bin/env python3
"""채널별 발송 어댑터. 첨부파일을 함께 보내는 것이 목적이다.

사용: claude-notify-send.py <channel> <subject> <body> [file ...]
비밀값은 macOS 키체인에서 읽는다. 이 파일에 토큰을 적지 않는다.
"""
import json
import mimetypes
import pathlib
import subprocess
import sys
import urllib.error
import urllib.request
import uuid

# 디스코드는 User-Agent 없는 요청을 Cloudflare에서 막는다(error code 1010).
UA = "claude-notify (https://example.com, 1.0)"


def keychain(service, account):
    """키체인에서 비밀값을 꺼낸다. 없으면 None."""
    try:
        out = subprocess.run(
            ["security", "find-generic-password", "-s", service, "-a", account, "-w"],
            capture_output=True, text=True, check=True)
        return out.stdout.strip()
    except subprocess.CalledProcessError:
        return None


def multipart(fields, files):
    """multipart/form-data 본문을 만든다. (본문, Content-Type) 반환.

    fields: {이름: 문자열}
    files:  [(필드명, 파일명, 바이트)]
    """
    boundary = uuid.uuid4().hex
    out = bytearray()
    for name, value in fields.items():
        out += f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
        out += str(value).encode() + b"\r\n"
    for field, filename, blob in files:
        ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
        out += (f'--{boundary}\r\nContent-Disposition: form-data; '
                f'name="{field}"; filename="{filename}"\r\n'
                f"Content-Type: {ctype}\r\n\r\n").encode()
        out += blob + b"\r\n"
    out += f"--{boundary}--\r\n".encode()
    return bytes(out), f"multipart/form-data; boundary={boundary}"


def post(url, data, headers=None, timeout=30):
    headers = {"User-Agent": UA, **(headers or {})}
    req = urllib.request.Request(url, data=data, headers=headers, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return r.status, r.read()
    except urllib.error.HTTPError as e:
        return e.code, e.read()


def post_json(url, payload, token, timeout=30):
    body = json.dumps(payload).encode()
    headers = {"Content-Type": "application/json; charset=utf-8",
               "Authorization": f"Bearer {token}"}
    status, raw = post(url, body, headers, timeout)
    try:
        return json.loads(raw)
    except ValueError:
        return {"ok": False, "error": f"http {status}: {raw[:200]!r}"}


def fail(where, resp):
    print(f"{where} 실패: {resp}", file=sys.stderr)
    return 0  # 알림 실패가 본 작업을 막으면 안 된다

그리고 파일 맨 끝에 들어갈 마무리입니다.

최종적으로 claude-notify-send.py 한 파일에 이 순서로 들어갑니다. 채널별 함수는 아래 절에서 하나씩 나오니, 다 읽고 나서 이어 붙이시면 됩니다.

text
① 위의 공통 뼈대 (import · UA · keychain · multipart · post · post_json · fail)
② send_slack      — 슬랙 절
③ send_discord    — 디스코드 절
④ send_telegram   — 텔레그램 절
⑤ 아래의 마무리 (SENDERS · main)
python
SENDERS = {"slack": send_slack, "discord": send_discord, "telegram": send_telegram}


def main():
    channel, subject, body, *raw_paths = sys.argv[1:]
    paths = []
    for f in raw_paths:
        p = pathlib.Path(f)
        if p.is_file():
            paths.append(p)
        else:
            print(f"첨부 건너뜀(파일 없음): {f}", file=sys.stderr)
    sys.exit(SENDERS[channel](subject, body, paths))


if __name__ == "__main__":
    main()

없는 파일은 경고만 남기고 지나갑니다. 파일 하나가 없다고 알림 전체가 안 가면 알림으로서 실격이거든요.

슬랙으로 보내기 — 웹훅으로는 파일을 못 올립니다

슬랙 알림을 검색하면 Incoming Webhook이 제일 먼저 나옵니다. URL 하나 받아서 거기로 JSON을 던지면 메시지가 올라가는 방식이라 간단하죠. 그런데 파일은 안 됩니다.

웹훅은 텍스트와 Block Kit(슬랙의 메시지 서식)만 받습니다. 게다가 웹훅을 만들 때 고른 채널 하나로 고정되고 다른 채널로 바꿔 보낼 수도 없습니다. 첨부가 필요하면 웹훅이 아니라 봇 앱을 만들어야 합니다. 여기서 한 번 되돌아가야 해서, 슬랙이 셋 중 설정이 가장 깁니다.

앱 만들기 — 검색 결과와 화면이 다를 수 있습니다

api.slack.com/apps에서 Create New App을 누릅니다. 여기서 대부분의 가이드는 “From scratch를 고르세요"라고 하는데, 지금은 그 버튼이 없습니다. Blank app으로 이름이 바뀌었어요.

이름이 또 바뀔 수 있으니 다른 길로 갑니다. 같은 화면의 From a manifest를 쓰면 앱 설정을 JSON으로 한 번에 넣을 수 있습니다.

From a manifest → 워크스페이스 선택 → JSON 탭에 아래를 붙여넣기 → NextCreate

json
{
  "display_information": { "name": "claude-notify" },
  "features": {
    "bot_user": { "display_name": "claude-notify", "always_online": false }
  },
  "oauth_config": {
    "scopes": { "bot": ["chat:write", "files:write"] }
  },
  "settings": {
    "org_deploy_enabled": false,
    "socket_mode_enabled": false,
    "token_rotation_enabled": false
  }
}

files:write가 파일 업로드 권한, chat:write가 메시지 게시 권한입니다. manifest로 넣으면 Bot Token Scopes 대신 User Token Scopes에 권한을 넣는 실수를 원천 차단할 수 있습니다. 화면에서 직접 고를 때 자주 헷갈리는 지점이거든요.

만들어졌으면 왼쪽 메뉴 OAuth & PermissionsInstall to Workspace → 허용. xoxb-로 시작하는 Bot User OAuth Token이 나옵니다.

비공개 채널을 만들고 봇을 초대합니다

슬랙 앱에서 채널을 만듭니다. 왼쪽 사이드바 채널+채널 생성 → 이름 입력 → 비공개 선택 → 사람은 추가하지 않음.

그리고 그 채널에서 봇을 초대합니다.

text
/invite @claude-notify

이걸 빼먹으면 업로드가 거부됩니다. 봇은 초대받은 채널에만 올릴 수 있어요.

마지막으로 채널 ID가 필요합니다. 채널 이름을 클릭하면 세부정보 창이 열리는데, 맨 아래로 스크롤하면 C로 시작하는 채널 ID와 복사 버튼이 있습니다. 이름이 아니라 이 ID를 씁니다.

두 값을 키체인에 넣습니다. 앞의 것은 토큰이라 비밀값이고, 뒤의 것은 그냥 방 번호입니다.

bash
security add-generic-password -s claude-notify-slack -a bot -w
bash
security add-generic-password -s claude-notify-slack -a channel -w

두 명령 모두 실행하면 값을 물어보는데 입력해도 화면에 보이지 않습니다. 정상입니다.

발송을 처음 할 때 macOS가 키체인 접근을 허용할지 묻는 창을 띄웁니다. 여기서 반드시 “항상 허용”을 누르세요. “허용"만 누르면 매번 창이 뜨고, 훅이나 예약 실행처럼 사람이 없는 상황에서는 창이 뜬 채로 멈춰버립니다. 나중에 “왜 손으로는 되는데 자동으로는 안 되지?“로 헤매게 되는 자리입니다.

3단계 업로드 — files.upload는 은퇴했습니다

여기가 슬랙에서 가장 많이 막히는 부분입니다.

검색해서 나오는 슬랙 파일 업로드 예제는 거의 다 files.upload를 씁니다. curl -F file=@보고서.md ... 한 줄이면 끝나는 방식이라 널리 쓰였는데, 이 메서드는 은퇴했습니다. 2024년 5월 16일부터 신규 앱은 아예 접근할 수 없고, 그 전에 만든 앱도 2025년 11월 12일에 끊겼습니다.

지금은 세 단계를 거칩니다.

  1. files.getUploadURLExternal — 파일을 올릴 주소를 받아옵니다
  2. 받은 주소로 파일 자체를 올립니다
  3. files.completeUploadExternal — 올린 파일을 채널에 게시합니다

번거로워 보이지만 이유가 있습니다. 예전 방식은 파일 전송이 끝날 때까지 슬랙 API 응답을 붙잡고 있었는데, 큰 파일에서 자주 실패했거든요. 지금은 전송과 게시가 분리돼 있습니다.

python
def send_slack(subject, body, paths):
    """슬랙. 웹훅으로는 파일을 못 올려서 봇 토큰과 3단계 업로드를 쓴다."""
    token = keychain("claude-notify-slack", "bot")
    channel = keychain("claude-notify-slack", "channel")  # C로 시작하는 채널 ID
    if not token or not channel:
        print("키체인에 claude-notify-slack 항목 없음 — 건너뜀", file=sys.stderr)
        return 0

    text = f"*{subject}*\n{body}"
    if not paths:
        r = post_json("https://slack.com/api/chat.postMessage",
                      {"channel": channel, "text": text}, token)
        return 0 if r.get("ok") else fail("slack", r)

    uploaded = []
    for p in paths:
        blob = p.read_bytes()
        # 1단계: 업로드할 자리를 받아온다.
        # 이 메서드는 JSON이 아니라 폼 인코딩을 받는다 — JSON으로 보내면 실패한다.
        form, ctype = multipart({"filename": p.name, "length": len(blob)}, [])
        raw = post("https://slack.com/api/files.getUploadURLExternal", form,
                   {"Content-Type": ctype, "Authorization": f"Bearer {token}"})[1]
        r = json.loads(raw)
        if not r.get("ok"):
            return fail("slack getUploadURLExternal", r)
        # 2단계: 받은 주소로 파일 자체를 올린다
        form, ctype = multipart({}, [("file", p.name, blob)])
        status, _ = post(r["upload_url"], form, {"Content-Type": ctype})
        if status != 200:
            return fail("slack upload", {"error": f"http {status}"})
        uploaded.append({"id": r["file_id"], "title": p.name})

    # 3단계: 올린 파일들을 채널에 게시한다
    r = post_json("https://slack.com/api/files.completeUploadExternal",
                  {"files": uploaded, "channel_id": channel, "initial_comment": text},
                  token)
    return 0 if r.get("ok") else fail("slack completeUpload", r)

주석으로 적어둔 함정을 한 번 더 짚겠습니다. 1단계는 JSON을 받지 않습니다. 나머지 슬랙 API가 전부 JSON이라 습관대로 보내기 쉬운데, 이 메서드만 폼 인코딩입니다.

이제 보내집니다.

bash
echo "조사 끝났습니다." | CLAUDE_NOTIFY_CHANNEL=slack \
  ~/.claude/bin/claude-notify "[Claude] 조사 결과" 결과보고서.md 요약.csv

한글 파일명은 그대로 살아남습니다. 결과보고서.md결과 보고서.md도 이름 그대로 올라갔습니다.

권한을 최소로 준 대가가 하나 있습니다. 봇이 채널에 잘 들어갔는지 확인하는 conversations.info를 호출하면 missing_scope가 뜹니다. groups:read가 없어서인데, 정작 업로드는 잘 됩니다. 진단용으로 권한을 늘리고 싶어지지만 참는 편이 낫습니다. 슬랙 화면에서 눈으로 보면 되니까요.

디스코드로 보내기 — 웹훅 하나면 되는데 파일명이 바뀝니다

디스코드는 셋 중 가장 쉽습니다. 앱을 만들 필요도, 권한을 고를 필요도 없습니다.

서버가 없으면 하나 만드세요. 왼쪽 사이드바 맨 아래 +직접 만들기나와 친구들을 위한 서버. 무료고 1분입니다. 혼자 쓰는 알림함이라 아무도 초대하지 않아도 됩니다.

그다음 알림 받을 채널 이름 위에서 채널 편집(톱니 아이콘) → 연동웹훅새 웹훅웹훅 URL 복사.

bash
security add-generic-password -s claude-notify-discord -a webhook -w
python
def send_discord(subject, body, paths):
    """디스코드. 웹훅 하나로 파일까지 간다."""
    url = keychain("claude-notify-discord", "webhook")
    if not url:
        print("키체인에 claude-notify-discord 항목 없음 — 건너뜀", file=sys.stderr)
        return 0

    # 디스코드는 non-ASCII 파일명을 해시로 갈아버린다.
    # 원래 이름을 본문에 적어 어느 파일인지 알아볼 수 있게 한다.
    renamed = [p.name for p in paths if not p.name.isascii()]
    text = f"**{subject}**\n{body}"
    if renamed:
        text += "\n\n첨부 원래 이름: " + ", ".join(renamed)
    payload = {"content": text[:2000]}

    files = [(f"files[{i}]", p.name, p.read_bytes()) for i, p in enumerate(paths)]
    data, ctype = multipart({"payload_json": json.dumps(payload)}, files)
    status, raw = post(url, data, {"Content-Type": ctype})
    if status not in (200, 204):
        return fail("discord", {"error": f"http {status}: {raw[:200]!r}"})
    return 0

여기에 함정이 둘 들어 있습니다.

User-Agent가 없으면 막힙니다. 파이썬 urllib은 기본 User-Agent가 Python-urllib/3.13인데, 디스코드 앞단의 Cloudflare가 이걸 거부합니다. 403과 함께 error code: 1010이 돌아와요. curl 예제를 보고 파이썬으로 옮기면 여기서 막힙니다 — curl은 자기 User-Agent를 알아서 붙이거든요. 위 코드의 공통 post 함수에 UA를 넣어둔 이유입니다.

한글 파일명이 사라집니다. 이건 고칠 수가 없었습니다. 네 가지를 시도해봤는데 전부 같은 결과였어요.

데이터 표
시도한 방법도착한 파일명
그냥 보내기3dea60277ccfd7f8.md
payload_jsonattachments[].filename 지정0f26e5259170a9c3.md
filename*=UTF-8''... (RFC 2231 방식)41d8de65f1b204c0.md
파일명을 퍼센트 인코딩EAB2B0EAB3BCEBB3B4EAB3A0EC849C.md

마지막 줄이 이유를 알려줍니다. %EA%B2%B0...을 보냈더니 %만 벗겨진 EAB2B0...이 남았어요. 디스코드는 허용된 문자만 남기고 파일명을 소독한 뒤, 원본과 달라지면 해시로 대체합니다. 확장자와 파일 형식은 유지되니 열리기는 열립니다. 이름만 잃습니다.

그래서 코드에서는 원래 이름을 메시지 본문에 적어줍니다. 파일이 여러 개일 때 어느 게 뭔지 알아볼 수 있게요. 파일명 자체가 꼭 필요하다면 zip으로 묶으면 됩니다. 압축 파일 이름만 영문으로 두면 안에 든 한글 이름은 그대로 남습니다. 실제로 올린 zip을 다시 내려받아 열어 확인했습니다. 대신 폰에서 한 번 더 풀어야 하죠.

텔레그램으로 보내기 — 봇은 먼저 말을 걸 수 없습니다

텔레그램은 첨부 한도가 50 MB로 셋 중 가장 넉넉합니다. 설정도 어렵지 않은데, 마지막 한 단계에서 사람들이 막힙니다.

텔레그램 앱에서 @BotFather를 찾아 /newbot을 보냅니다. 이름을 정하면 토큰이 나옵니다.

bash
security add-generic-password -s claude-notify-telegram -a bot -w

그다음이 문제입니다. 메시지를 보내려면 어느 대화방으로 보낼지 알려줘야 하는데, 그 방 번호를 chat_id라고 합니다. 그런데 이 번호를 알아내려면 봇이 받은 메시지를 조회해야 하고, 받은 메시지가 있으려면 누군가 봇에게 말을 걸었어야 합니다.

텔레그램 봇은 사용자에게 먼저 말을 걸 수 없습니다. 스팸을 막으려고 그렇게 되어 있어요. 그래서 순서가 이렇습니다.

  1. 텔레그램에서 방금 만든 봇을 찾아 /start를 보냅니다
  2. 그다음에 chat_id를 조회합니다
bash
curl -s "https://api.telegram.org/bot$(security find-generic-password -s claude-notify-telegram -a bot -w)/getUpdates" \
  | python3 -c 'import json,sys; print([u["message"]["chat"]["id"] for u in json.load(sys.stdin)["result"] if "message" in u])'

토큰을 셸에 직접 치지 않으려고 키체인에서 바로 꺼내 씁니다. 숫자가 나오면 그게 chat_id입니다. 안 나오면 /start를 아직 안 보내신 거예요.

bash
security add-generic-password -s claude-notify-telegram -a chat -w

이 항목에는 토큰이 아니라 숫자를 넣습니다. 앞의 bot 항목과 이름이 비슷해서 헷갈리기 쉬운데, bot은 비밀값이고 chat은 그냥 방 번호입니다. 둘 다 같은 명령으로 넣다 보니 실수하기 좋은 자리예요.

python
def send_telegram(subject, body, paths):
    """텔레그램. 첨부 한도가 50 MB로 가장 넉넉하다."""
    token = keychain("claude-notify-telegram", "bot")
    chat = keychain("claude-notify-telegram", "chat")
    if not token or not chat:
        print("키체인에 claude-notify-telegram 항목 없음 — 건너뜀", file=sys.stderr)
        return 0

    base = f"https://api.telegram.org/bot{token}"
    caption = f"{subject}\n\n{body}"
    if not paths:
        data, ctype = multipart({"chat_id": chat, "text": caption}, [])
        status, raw = post(f"{base}/sendMessage", data, {"Content-Type": ctype})
        return 0 if status == 200 else fail("telegram", {"error": raw[:200]})

    # sendDocument는 한 번에 파일 하나. 캡션은 첫 파일에만 붙인다.
    for i, p in enumerate(paths):
        fields = {"chat_id": chat}
        if i == 0:
            fields["caption"] = caption[:1024]
        data, ctype = multipart(fields, [("document", p.name, p.read_bytes())])
        status, raw = post(f"{base}/sendDocument", data, {"Content-Type": ctype})
        if status != 200:
            return fail("telegram sendDocument", {"error": raw[:200]})
    return 0

한글 파일명은 공백이 들어간 것까지 그대로 도착합니다. 결과 보고서.md결과 보고서.md로 왔어요.

여기까지 됐는지 확인하기

세 채널 중 하나라도 설정을 마치셨으면, 훅에 물리기 전에 손으로 한 번 보내보세요. 여기서 안 되면 훅에 걸어도 안 됩니다.

bash
printf '# 시험\n\n첨부가 오는지 봅니다.\n' > /tmp/시험.md
echo "연결 확인용입니다." | CLAUDE_NOTIFY_CHANNEL=telegram \
  ~/.claude/bin/claude-notify "[Claude] 연결 확인" /tmp/시험.md

CLAUDE_NOTIFY_CHANNELslack·discord로 바꿔가며 각각 확인하시면 됩니다.

메시지가 안 오는데 오류도 없다면 키체인 항목 이름을 확인하세요. 이 글의 스크립트는 항목이 없으면 오류 대신 조용히 넘어갑니다. 알림이 실패해서 본 작업이 멈추는 게 더 나쁘다고 봤거든요. 대신 이럴 때 원인을 찾기 어려우니, 터미널에 찍히는 키체인에 ... 항목 없음 — 건너뜀 메시지를 눈여겨보세요.

bash
security find-generic-password -s claude-notify-telegram -a bot -w

이게 값을 뱉으면 등록은 된 겁니다. 화면에 비밀값이 그대로 찍히니 다른 사람이 볼 수 있는 자리에서는 실행하지 마세요.

세 채널 중 어디로 — 실측 비교

같은 파일 세 개를 세 채널에 보내고 도착한 것을 확인한 결과입니다.

데이터 표
구분슬랙디스코드텔레그램
설정 시간20분5분10분
앱·봇 만들기필요불필요필요
첨부 한도워크스페이스 5 GB 공용(무료)20 MB(자주 바뀜)50 MB
한글 파일명보존해시로 대체보존
보관90일(무료 플랜)계속계속
걸린 함정웹훅 불가 · files.upload 은퇴User-Agent 없으면 403봇이 먼저 말 못 검

빨리 되게 하려면 디스코드입니다. 웹훅 URL 하나로 끝나고, 파일명이 바뀌는 것만 감수하면 됩니다.

결과물을 자주 열어볼 거면 텔레그램입니다. 한도가 넉넉하고 폰 알림이 제일 빠릅니다.

팀이 이미 슬랙을 쓰면 슬랙이죠. 설정이 길지만 한 번뿐입니다. 다만 무료 플랜이라면 90일 뒤에 사라진다는 걸 염두에 두셔야 합니다.

제가 고른 조합 — 메일을 빼지 않고 텔레그램을 더했습니다

셋을 다 붙여보고 나서 실제로 남긴 구성입니다. 참고가 될까 싶어 적어둡니다.

데이터 표
언제어디로
작업이 끝났을 때텔레그램폰 알림이 제일 빠르고 첨부도 넉넉하다
매일 요약메일나중에 읽는 것. 검색해서 다시 꺼내야 한다
25 MB 넘는 결과물Google Drive에 두고 경로만 알림Gmail이 거부하는 구간

메일을 뺄 수는 없었습니다. 넷 중 유일하게 반년 뒤에도 검색해서 꺼낼 수 있거든요. 대신 메일의 약점인 실시간성을 텔레그램이 채웁니다.

디스코드는 제 용도에 안 맞았습니다. 결과물이 사업계획서_초안.docx 같은 한글 문서인데 해시로 바뀌어 도착하니까요. 본문에 이름을 적는 우회를 넣긴 했지만 그건 대응이지 해결이 아닙니다.

슬랙은 미뤘습니다. 개인 워크스페이스라 평소에 안 켜두는 데다, 무료 플랜은 90일 뒤 사라져서 보관 역할도 못 합니다. 설정은 가장 오래 걸렸는데 얻는 게 제일 적었어요. 팀이 슬랙으로 옮겨오면 그때 환경변수 한 줄만 바꾸면 됩니다. 어댑터로 만들어둔 값어치가 여기서 나옵니다.

한 가지 짚어둘 것은 같은 알림을 여러 채널에 동시에 보내지 않았다는 점입니다. 하루 다섯 번 끝나면 알림이 열 번 오거든요. 시간 게이트를 넣어놓고 채널을 늘려 도로 시끄러워지면 앞뒤가 안 맞습니다. 저장소는 다릅니다 — 알림은 한 번만 가고 파일은 드라이브에 따로 남으니 중복이 아닙니다.

훅 다듬기 — 요약만 보내고 결과 파일은 자동으로 붙이기

채널을 정하고 나니 두 가지가 아쉬웠습니다. 본문이 너무 길고, 첨부를 매번 손으로 지정해야 한다는 것이요.

본문은 짧아야 합니다. 텔레그램 캡션은 1,024자 제한이라 긴 응답은 잘립니다. 어차피 폰에서 표나 코드블록은 못 읽으니, 문장만 남기고 앞부분만 보내는 게 낫습니다.

첨부는 자동으로 골라야 합니다. 여기서 앞선 글에서 만든 것이 뜻밖에 쓸모가 있었어요. UserPromptSubmit 훅이 턴 시작 시각을 파일에 기록해두는데, 시간을 재려고 만든 그 파일이 “언제 이후에 바뀐 파일인가"의 기준이 그대로 됩니다. 그 시각 이후에 생기거나 바뀐 문서 파일이 곧 이번 작업의 결과물이거든요.

~/.claude/hooks/notify-stop.sh 전문입니다.

bash
#!/bin/bash
# Stop — 이 턴이 임계값보다 오래 걸렸을 때만 알린다.
# 매 턴 보내면 스팸이 되므로 시간 게이트가 핵심이다. 기본 10분.
# 본문은 요약만, 이번 턴에 생긴 결과 파일은 첨부한다.
set -u
THRESHOLD="${CLAUDE_NOTIFY_AFTER_SEC:-600}"
CHANNEL="${CLAUDE_NOTIFY_CHANNEL:-telegram}"
SUMMARY_CHARS="${CLAUDE_NOTIFY_SUMMARY_CHARS:-400}"
MAX_FILES="${CLAUDE_NOTIFY_MAX_FILES:-3}"
MAX_MB="${CLAUDE_NOTIFY_MAX_MB:-45}"
D="${TMPDIR:-/tmp}/claude-notify"

IN="$(cat)"
read -r SID CWD TRANSCRIPT <<EOF2
$(printf '%s' "$IN" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(d.get("session_id","unknown"), d.get("cwd","?"), d.get("transcript_path",""))
' 2>/dev/null || echo "unknown ? ")
EOF2

F="$D/$SID.start"
[ -f "$F" ] || exit 0
START="$(cat "$F")"   # 이 파일의 mtime이 곧 턴 시작 시각이다
NOW="$(date +%s)"
ELAPSED=$((NOW - START))
[ "$ELAPSED" -ge "$THRESHOLD" ] || exit 0
MIN=$((ELAPSED / 60))

# 마지막 응답에서 요약만 뽑는다. 텔레그램 캡션은 1024자 제한이라 길면 잘린다.
SUMMARY="$(python3 - "$TRANSCRIPT" "$SUMMARY_CHARS" <<'PY' 2>/dev/null
import json, re, sys
try:
    path, limit = sys.argv[1], int(sys.argv[2])
except (IndexError, ValueError):
    sys.exit()
txt = ""
for line in open(path, errors="ignore"):
    try: d = json.loads(line)
    except Exception: continue
    if d.get("type") != "assistant": continue
    c = d.get("message", {}).get("content")
    if isinstance(c, list):
        t = "".join(b.get("text", "") for b in c
                    if isinstance(b, dict) and b.get("type") == "text")
        if t.strip(): txt = t
# 표·코드블록은 폰에서 어차피 안 읽힌다. 문장만 남긴다.
txt = re.sub(r"\x60{3}.*?\x60{3}", "", txt, flags=re.S)  # 코드블록
txt = re.sub(r"^\s*\|.*$", "", txt, flags=re.M)
txt = re.sub(r"[*#\x60>]", "", txt)  # 마크다운 기호
txt = re.sub(r"\n{2,}", "\n", txt).strip()
print(txt[:limit] + ("…" if len(txt) > limit else ""))
PY
)"

# 이번 턴에 생기거나 바뀐 결과 파일을 찾는다. 시작 시각을 기록해둔 덕에 가능하다.
FILES=()
while IFS= read -r f; do
  [ -n "$f" ] || continue
  SZ=$(( $(stat -f%z "$f" 2>/dev/null || echo 0) / 1048576 ))
  [ "$SZ" -le "$MAX_MB" ] && FILES+=("$f")
done < <(find "$CWD" -maxdepth 4 -type f -newer "$F" \
           \( -name '*.md' -o -name '*.pdf' -o -name '*.csv' -o -name '*.xlsx' \
              -o -name '*.docx' -o -name '*.pptx' \) \
           -not -path '*/.git/*' -not -path '*/node_modules/*' \
           -not -path '*/public/*' -not -path '*/dist/*' -not -path '*/build/*' \
           -not -path '*/.claude/*' 2>/dev/null | head -n "$MAX_FILES")

rm -f "$F"

# $( )는 끝의 줄바꿈을 먹으므로 한 번에 조립한다
COUNT=${#FILES[@]}
BODY="$(printf '작업 폴더: %s\n소요 시간: %d분\n결과 파일: %d개\n\n%s' \
  "$(basename "$CWD")" "$MIN" "$COUNT" "${SUMMARY:-(요약 없음)}")"

printf '%s' "$BODY" | CLAUDE_NOTIFY_CHANNEL="$CHANNEL" \
  "$HOME/.claude/bin/claude-notify" \
  "[Claude] 작업 완료 — $(basename "$CWD") (${MIN}분)" ${FILES[@]+"${FILES[@]}"}
exit 0

저장했으면 이쪽도 실행 권한을 줍니다.

bash
chmod +x ~/.claude/hooks/notify-stop.sh

몇 군데만 짚겠습니다.

요약은 마크다운을 걷어내고 자릅니다. 코드블록과 표는 통째로 지우고, *·# 같은 기호도 뺍니다. 폰 알림에서 별표가 그대로 보이면 지저분하거든요. 길이는 CLAUDE_NOTIFY_SUMMARY_CHARS로 조절합니다.

-newer "$F"가 핵심이자 함정입니다. 처음에는 -newermt "@$START"로 짰습니다. 시작 시각을 이미 숫자로 갖고 있으니 자연스러운 선택이었죠. 그런데 훅에서만 조용히 실패했습니다. 이유는 아래 절에서 따로 다룹니다.

빌드 산출물은 제외합니다. public·dist·build를 빼지 않으면 정적 사이트 생성기가 만든 파일 수백 개가 후보에 들어옵니다. 실제로 처음 돌렸을 때 Hugo가 만든 public/search/index.md가 첫 번째 첨부로 잡혔습니다.

빈 배열 전개에 주의합니다. 첨부가 하나도 없을 때 "${FILES[@]}"를 그냥 쓰면 macOS 기본 bash에서 unbound variable로 죽습니다. ${FILES[@]+"${FILES[@]}"}가 그걸 막는 관용구예요.

결과 파일을 남기기 — iCloud와 Google Drive

여기서 두 번째 이야기로 넘어갑니다. 채널로 보낸 파일은 알림에 딸려 온 사본입니다. 90일 뒤에 사라지거나(무료 슬랙), 대화가 길어지면 위로 밀려 올라가죠. 결과물을 오래 두려면 저장소가 따로 있는 게 낫습니다.

iCloud Drive — 복사했다고 올라간 게 아닙니다

맥이라면 추가 설치 없이 됩니다. 폴더에 복사만 하면 폰의 파일 앱에서 열려요.

bash
mkdir -p ~/Library/Mobile\ Documents/com~apple~CloudDocs/claude-out
cp 결과보고서.md ~/Library/Mobile\ Documents/com~apple~CloudDocs/claude-out/

그런데 여기에 함정이 있습니다. cp는 로컬 복사만 끝내고 즉시 반환합니다. 실제 업로드는 그 뒤에 백그라운드로 일어나요. 그래서 cp 직후에 “끝났습니다” 알림을 보내면, 폰에서 열었을 때 파일이 아직 없을 수 있습니다.

brctl이라는 명령으로 확인할 수 있습니다. iCloud 동기화 상태를 보는 macOS 기본 도구예요. 30 MB 파일을 복사하면서 지켜보면 이렇게 나옵니다.

text
cp 반환됨 (20MB)
 o /big2.md ↑ 20.0 MB (20000000) 24.1%
 o /big2.md ↑ 20.0 MB (20000000) 95.0%
 o /big2.md ☁

가 업로드 중, 가 완료입니다. brctl은 애플이 문서로 공개한 도구가 아니라 표시 방식이 바뀔 수 있습니다. 제 환경에서 관찰한 값이라고 보시면 됩니다. cp는 첫 줄에서 이미 끝났는데 업로드는 그 아래에서 계속되고 있죠. 30 MB로 재보니 복사와 업로드 완료 사이가 약 5초였습니다.

기다려야 한다면 이렇게 합니다. ~/.claude/bin/icloud-wait.sh로 저장하고 chmod +x까지 해두세요.

bash
#!/bin/bash
# iCloud Drive 업로드가 끝날 때까지 기다린다.
#   icloud-wait.sh <iCloud안의파일경로> [최대초]
set -u
F="$1"; MAX="${2:-120}"
DIR="$(dirname "$F")"; NAME="$(basename "$F")"
for ((i=0; i<MAX; i+=2)); do
  # ☁ = 업로드 완료, ↑ = 업로드 중
  if brctl monitor -t 2 "$DIR" 2>/dev/null | grep -F "$NAME" | tail -1 | grep -q '☁'; then
    exit 0
  fi
done
echo "업로드 대기 시간 초과: $NAME" >&2
exit 1

brctl monitor -t 2가 2초간 상태를 뱉고 끝나니, 그걸 반복하면서 완료 표시를 기다리는 방식입니다. 파일명이 한글이어도 그대로 잡힙니다.

Google Drive(구글 드라이브) — rclone은 끝나야 끝납니다

구글 드라이브는 두 갈래입니다. Drive 데스크톱 앱을 설치하면 ~/Library/CloudStorage/ 아래에 폴더가 생겨서 iCloud처럼 cp로 됩니다. 대신 iCloud와 같은 문제를 그대로 안습니다 — 비동기 업로드인데 brctl 같은 확인 수단이 없어요.

그래서 rclone을 권합니다. 클라우드 저장소에 파일을 올리고 내리는 명령줄 도구인데, 구글 드라이브 말고도 수십 가지를 지원합니다. 가장 큰 장점은 rclone copy가 업로드를 끝내고 나서야 반환한다는 점이에요. 명령이 끝난 시점이 곧 도착한 시점이라 기다리는 코드가 필요 없습니다.

bash
brew install rclone
bash
rclone config

여기서부터는 터미널에서 질문이 하나씩 뜨는 대화형 마법사입니다. 화면 아래에 n/s/q> 같은 선택지가 나오면 그 글자를 타이핑하고 엔터를 칩니다.

데이터 표
화면에 뜨는 질문뭘 하는 건지입력
n/s/q>새 연결 만들기n
name>이 연결의 별명gdrive
Storage>목록에서 Google Drive 찾기drive
client_id>내 앱 등록번호. 개인용은 불필요엔터
client_secret>위와 같음엔터
scope>드라이브의 어디까지 건드릴지1
service_account_file>서버용 계정. 개인은 해당 없음엔터
Edit advanced config?세부 설정n
Continue using the shared client_id anyway?공용 등록번호를 계속 쓸지y
Use web browser to automatically authenticate?브라우저로 구글 로그인y
Configure this as a Shared Drive?회사 공용 드라이브인지n
y/e/d>설정 저장y
e/n/d/r/c/s/q>마법사 종료q

굵게 표시한 줄을 조심하셔야 합니다. 화면에 n) No (default)라고 적혀 있어서 기본값이 안전해 보이는데, 여기서 n을 고르면 진행이 막힙니다. “그럼 네 등록번호를 내놔"로 넘어가는데 없으니까 같은 질문이 무한 반복돼요. 저도 여기서 한 번 걸렸습니다.

경고 자체는 사실입니다. rclone이 제공하는 공용 등록번호는 2026년 중에 끊깁니다. 다만 그건 나중에 자기 것을 만들어 교체하면 되는 일이고, 지금 당장은 y로 넘어가는 게 맞습니다.

설정이 끝나면 이렇게 씁니다.

bash
rclone mkdir gdrive:claude-out
rclone copyto 결과보고서.md gdrive:claude-out/결과보고서.md

copy가 아니라 copyto인 데 이유가 있습니다. rclone copy는 소스와 대상 두 개만 받습니다. 파일을 여러 개 나열하면 Command copy needs 2 arguments maximum으로 거부해요. 파일별로 copyto를 돌리거나 --files-from을 쓰면 됩니다.

한글 파일명은 그대로 올라갑니다. 그리고 앞에서 말한 동기 반환도 확인했습니다 — 30 MB 파일에 real 54.88초가 걸렸고, 명령이 끝난 시점에 이미 드라이브 목록에 잡혔습니다. iCloud처럼 “복사는 끝났는데 업로드는 아직"인 구간이 없어요.

데이터 표
구분iCloud DriveGoogle Drive (rclone)
설치없음 (맥 기본)brew install rclone + 구글 로그인
명령cprclone copyto
반환 시점로컬 복사 직후업로드 완료 후
완료 확인brctl monitor로 대기 필요불필요
한글 파일명보존보존

회사 슬랙에 붙여도 되나

회사 워크스페이스가 이미 있으면 거기 붙이고 싶어집니다. 기술적인 답과 현실적인 답이 좀 다릅니다.

기술적으로는 나만 볼 수 있습니다. 비공개 채널을 혼자 만들면 초대받지 않은 사람은 채널 이름조차 못 봅니다. 게다가 무료·프로 플랜은 비공개 채널 내보내기 기능 자체가 없습니다 — 워크스페이스 소유자가 내보낼 수 있는 건 공개 채널뿐이고, 비공개 채널과 DM은 Business+ 이상에서만 가능합니다.

그런데 걸리는 게 셋 있습니다.

앱 설치는 관리자 화면에 기록으로 남습니다. 채널 내용은 안 보여도 “누가 어떤 앱을 설치했다"는 보입니다. 그리고 무료 플랜은 서드파티·커스텀 앱을 10개까지만 설치할 수 있어서, 이미 차 있으면 남의 앱을 지워야 하는 상황이 됩니다. 회사 워크스페이스에서 그럴 순 없죠.

90일이 지나면 사라집니다. 무료 플랜은 최근 90일치 메시지와 파일만 남습니다. 파일 저장 한도도 워크스페이스 전체 5 GB 공용이라, 내가 올린 결과물이 남의 몫을 갉아먹습니다.

마지막으로 회사 자산에 개인 자동화를 얹는 문제가 있습니다. 결과물에 업무 내용이 첨부된다면 더 그렇고요. 이건 각자 조직 정책을 보셔야 합니다.

정리하면, 개인 워크스페이스를 새로 만드는 편이 간단합니다. 무료로 1분이면 되고, 앱 승인 절차도 없고, 회사 정책과 무관해집니다.

따라 하다 막히면 대개 당신 잘못이 아닙니다

이 글을 준비하면서 가이드와 실제 화면이 다른 경우를 네 번 만났습니다. 적어두는 게 도움이 될 것 같아요.

  • files.upload로 파일을 올리는 슬랙 예제가 아직도 검색 상위에 있습니다. 은퇴한 메서드입니다.
  • 슬랙 앱 만들기의 From scratch 버튼이 Blank app으로 바뀌었습니다.
  • rclone에 “공용 등록번호가 곧 끊긴다"는 새 질문이 생겼고, 기본값을 따르면 진행이 막힙니다.
  • 디스코드는 curl로는 되는데 파이썬으로 옮기면 막힙니다(User-Agent).
  • find -newermt "@에포크"GNU find 문법입니다. macOS 기본 find는 BSD 계열이라 못 읽습니다.

이런 건 검색해도 잘 안 나옵니다. 문서는 최신인데 블로그 글은 옛날 것이 상위에 있고, 화면은 또 그 중간 어딘가에 있거든요. 그러니 막히셔도 자책하지 마세요. 한 시간 헤매다 알고 보니 버튼 이름이 바뀐 것뿐인 일이 생각보다 흔합니다.

손으로는 되는데 훅에서만 안 되던 것

-newermt@1787032262 같은 에포크 시각을 주는 건 GNU find 문법입니다. 그런데 macOS에 기본으로 깔린 /usr/bin/find는 BSD 계열이라 이 형식을 모릅니다.

text
find: Can't parse date/time: @1787032262

고약한 건 터미널에서 손으로 돌리면 잘 됐다는 점입니다. 개발 도구를 이것저것 설치하다 보면 GNU 호환 findPATH 앞쪽에 들어와 있는 경우가 많거든요. 그런데 훅은 #!/bin/bash로 깨끗한 환경에서 도니 원래의 BSD find가 잡힙니다. bash -n도 이건 못 잡습니다. 문법은 멀쩡하고 실행해야만 드러나니까요.

해결은 시각 대신 파일을 기준으로 삼는 것입니다. -newer <파일>은 양쪽 다 지원하는 오래된 문법이에요. 마침 시작 시각을 적어둔 파일이 있고, 그 파일의 수정 시각이 곧 턴 시작 시각입니다. 그래서 find를 마친 뒤에 지웁니다.

bash
START="$(cat "$F")"   # 이 파일의 mtime이 곧 턴 시작 시각이다
# ... find "$CWD" -newer "$F" ... 를 마친 뒤에
rm -f "$F"

앞선 글의 “cron은 키체인을 못 읽어 조용히 실패한다"와 같은 부류입니다. 손으로는 되는데 자동으로는 안 되는 것들이요. 자동 실행에 넣기 전에 한 번은 env -i bash 스크립트 같은 깨끗한 환경에서 돌려보시는 게 좋습니다.

접근성 한 꼭지 — 파일명도 이름입니다

디스코드가 결과보고서.md6362520ec7093d67.md로 바꿔버리는 것, 이걸 단순한 불편으로만 보기는 아깝습니다.

웹 접근성에는 접근 가능한 이름(accessible name)이라는 개념이 있습니다. 화면에 보이는 모양과 별개로, 그 요소가 무엇인지 알려주는 이름이요. 버튼에 아이콘만 있고 이름이 없으면 스크린 리더는 “버튼"이라고만 읽습니다. 무슨 버튼인지는 알 수 없죠.

해시로 바뀐 파일명이 정확히 그 상태입니다. 눈으로 보는 사람은 미리보기 썸네일이나 앞뒤 문맥으로 대충 짐작할 수 있지만, 스크린 리더로 첨부 목록을 훑는 사람에게는 아무 정보가 없습니다. 열여섯 자리 16진수를 한 글자씩 읽어주는 걸 상상해보세요. 파일이 세 개면 세 번 반복됩니다.

그래서 원래 이름을 본문에 적어주는 코드는 편의 기능이 아니라 이름을 되돌려주는 일입니다. 자동으로 붙는 대체 텍스트가 쓸모없을 때 사람이 다시 써주는 것과 같은 성격이에요. 국내 실태조사에서 대체 텍스트 준수율이 17.1%에 그친 것도 결국 같은 이야기입니다. 기계가 채워 넣은 이름은 대개 이름 노릇을 못 합니다.

알림에는 접근성 논점이 하나 더 있습니다. WCAG 2.2의 성공 기준 2.2.4 중단(Interruptions)은 이렇게 말합니다.

긴급 상황과 관련된 중단을 제외하고, 중단은 사용자가 연기하거나 억제할 수 있어야 한다.

레벨 AAA라 웹사이트에서도 지키기 까다로운 기준인데, 의도는 명확합니다. 예고 없이 끼어드는 알림은 인지 장애나 주의력 문제가 있는 사용자가 하던 일로 돌아가는 것을 어렵게 만듭니다. 스크린 리더 사용자에게도 마찬가지예요. 읽던 흐름이 끊기면 어디까지 읽었는지 다시 찾아야 합니다.

우리가 만든 알림은 웹사이트가 아니라 개인 도구지만, 원리는 그대로 적용됩니다. 앞선 글에서 시간 게이트를 넣은 것 — 10분 넘게 걸린 작업만 알리는 장치 — 이 실은 이 기준을 지키는 일이었습니다. 짧은 작업까지 전부 알리면 하루에 수십 번 흐름이 끊기거든요. 그리고 채널을 환경변수로 고를 수 있게 만든 것도 같은 맥락입니다. 조용히 받고 싶은 날은 메일로, 즉시 알아야 하는 날은 텔레그램으로. 중단의 강도를 사용자가 정하는 겁니다.

한 장 요약

  • 알림과 보관은 다른 일이다. 채널로 알리고, 드라이브에 남긴다.
  • 토큰 발급만 사람이 하고, 스크립트는 Claude에게 맡겨도 된다. 프롬프트는 본문에.
  • 발송부만 어댑터로 빼면 훅과 예약 실행은 그대로 재사용된다.
  • 슬랙은 웹훅으로 파일을 못 올린다. 봇 앱 + files:write + 3단계 업로드. files.upload는 은퇴했다.
  • 디스코드는 웹훅 하나로 끝나지만 한글 파일명이 해시로 바뀐다. 원래 이름을 본문에 적거나 zip으로 묶는다.
  • 텔레그램은 /start를 먼저 보내야 chat_id가 생긴다. 봇은 먼저 말을 걸 수 없다.
  • iCloud는 cp 직후가 업로드 완료가 아니다. rclone은 명령이 끝나면 끝난 것이다.
  • 무료 슬랙은 90일 뒤 사라진다. 회사 워크스페이스라면 개인 워크스페이스를 따로 만드는 편이 낫다.
  • 훅은 시작 시각 파일을 기준 파일로 쓰면 이번 턴의 결과물을 자동으로 찾아 붙일 수 있다.
  • find -newermt "@에포크"는 GNU 전용. macOS 기본 find는 못 읽는다. -newer <파일>이 안전하다.
  • 같은 알림을 여러 채널에 뿌리지 않는다. 채널은 하나, 저장소는 별도.
  • 해시로 바뀐 파일명은 이름 없는 버튼과 같다. 사람이 이름을 되돌려줘야 한다.

질문으로 다시 보기

스크립트가 여러 개라 만들기 부담스러운데 Claude에게 시킬 수 있나요?
자격증명을 받아오는 일만 직접 하시면 나머지는 맡길 수 있습니다. 슬랙 봇 토큰, 디스코드 웹훅 URL, 텔레그램 봇 토큰은 각 서비스에 로그인해서 발급받아 키체인에 직접 넣으세요. 어댑터와 채널별 발송기, 훅을 만드는 일은 본문에 그대로 붙여넣어 쓸 수 있는 프롬프트를 넣어두었습니다. 프롬프트에는 토큰을 묻지 말라는 지시와 함께, 이 글에서 발견한 함정들도 미리 담아뒀습니다.
슬랙 Incoming Webhook으로 파일을 올릴 수 없나요?
없습니다. 웹훅은 텍스트와 Block Kit만 받고, 채널도 웹훅을 만들 때 고른 하나로 고정됩니다. 파일을 올리려면 봇 앱을 만들어 files:write 스코프를 받아야 합니다. 슬랙 알림을 웹훅으로 시작했다가 첨부에서 막히는 경우가 많습니다.
slack.com/api/files.upload 예제가 동작하지 않습니다.
그 메서드는 은퇴했습니다. 2024년 5월 16일부터 신규 앱은 쓸 수 없고, 기존 앱도 2025년 11월 12일에 끊겼습니다. 지금은 files.getUploadURLExternal로 업로드 주소를 받고, 그 주소로 파일을 올린 뒤, files.completeUploadExternal로 채널에 게시하는 3단계를 거칩니다.
디스코드에 파일을 올렸는데 파일명이 알 수 없는 문자로 바뀝니다.
디스코드가 한글처럼 ASCII가 아닌 파일명을 hex 해시로 대체하기 때문입니다. RFC 2231 형식이나 payload_json의 attachments 필드로도 막을 수 없습니다. 원래 이름을 메시지 본문에 함께 적거나, zip으로 묶어 보내면 압축 파일 안의 이름은 그대로 남습니다.
텔레그램 봇의 chat_id를 어떻게 알아내나요?
먼저 그 봇에게 아무 메시지나 보내야 합니다. 봇은 사용자에게 먼저 말을 걸 수 없어서, 대화를 시작하기 전에는 getUpdates가 비어 있습니다. /start를 보낸 뒤 getUpdates를 호출하면 결과에 chat 항목이 나오고 거기에 id가 들어 있습니다.
iCloud Drive 폴더에 복사했는데 폰에서 파일이 안 보입니다.
cp는 로컬 복사만 끝내고 즉시 반환하며, 실제 업로드는 그 뒤에 백그라운드로 일어납니다. 30MB 파일로 재보니 복사 후 약 5초가 더 걸렸습니다. brctl monitor로 해당 폴더를 보면 업로드 진행률과 완료 표시를 확인할 수 있습니다.
회사 슬랙 워크스페이스에 개인 알림 봇을 붙여도 되나요?
기술적으로는 비공개 채널을 혼자 만들면 다른 사람에게 보이지 않습니다. 무료·프로 플랜은 비공개 채널 내보내기 자체가 없습니다. 다만 앱 설치는 관리자 화면에 기록으로 남고, 무료 플랜은 앱을 10개까지만 설치할 수 있으며 메시지와 파일이 90일 뒤 사라집니다. 개인 워크스페이스를 따로 만드는 편이 간단합니다.

참고 자료