# Claude Code 알림을 슬랙·디스코드·텔레그램으로 보내기

> Claude Code 작업 알림을 슬랙·디스코드·텔레그램으로 받고, 결과 파일은 rclone으로 iCloud·구글 드라이브에 남기는 방법. 슬랙 웹훅으로 파일이 안 올라가는 이유와 3단계 업로드, 채널별 첨부 한도와 한글 파일명 실측까지 정리했습니다.

**Published:** 2026-08-18 | **Updated:** 2026-08-18

---


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

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

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

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

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

> 앞선 글 [Claude Code 작업 끝나면 결과 파일까지 메일로 받기]({{< relref "/posts/claude-code-mail-notification" >}})에서 만든 훅과 예약 실행을 그대로 재사용합니다. 안 읽으셨어도 이 글만으로 됩니다 — 필요한 부분은 여기서 다시 설명합니다. 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](https://api.slack.com/apps)에서 **Create New App**을 누릅니다. 여기서 대부분의 가이드는 "From scratch를 고르세요"라고 하는데, **지금은 그 버튼이 없습니다.** `Blank app`으로 이름이 바뀌었어요.

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

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

```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 & Permissions** → **Install 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_json`의 `attachments[].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_CHANNEL`만 `slack`·`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 Drive | Google Drive (rclone) |
|---|---|---|
| 설치 | 없음 (맥 기본) | `brew install rclone` + 구글 로그인 |
| 명령 | `cp` | `rclone 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 호환 `find`가 `PATH` 앞쪽에 들어와 있는 경우가 많거든요. 그런데 훅은 `#!/bin/bash`로 깨끗한 환경에서 도니 원래의 BSD `find`가 잡힙니다. `bash -n`도 이건 못 잡습니다. 문법은 멀쩡하고 실행해야만 드러나니까요.

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

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

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

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

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

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

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

그래서 원래 이름을 본문에 적어주는 코드는 편의 기능이 아니라 **이름을 되돌려주는 일**입니다. 자동으로 붙는 [대체 텍스트가 쓸모없을 때 사람이 다시 써주는 것]({{< relref "/posts/2025-web-accessibility-survey" >}})과 같은 성격이에요. 국내 실태조사에서 대체 텍스트 준수율이 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 <파일>`이 안전하다.**
- 같은 알림을 여러 채널에 뿌리지 않는다. 채널은 하나, 저장소는 별도.
- 해시로 바뀐 파일명은 **이름 없는 버튼과 같다.** 사람이 이름을 되돌려줘야 한다.

{{< faq >}}

## 참고 자료

- [슬랙 파일 업로드 문서](https://docs.slack.dev/messaging/working-with-files)
- [`files.upload` 은퇴 공지](https://docs.slack.dev/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay/)
- [슬랙 무료 플랜 제한](https://slack.com/help/articles/115002422943-Usage-limits-for-free-workspaces)
- [디스코드 웹훅 문서](https://docs.discord.com/developers/resources/webhook)
- [텔레그램 Bot API](https://core.telegram.org/bots/api)
- [rclone Google Drive 설정](https://rclone.org/drive/)
- [WCAG 2.2 성공 기준 2.2.4 중단 이해하기](https://www.w3.org/WAI/WCAG22/Understanding/interruptions.html)

