Dev Tools
매번 복붙하던 PR 리뷰 요청, 디스코드로 자동화하기
· 9분 읽기
한 줄 요약 PR을 올리면 디스코드 리뷰 스레드에 요청문을 손으로 쓰던 걸,
명령어 한 줄로
바꿨습니다. 디스코드 웹훅 + GitHub CLI + 셸 스크립트 90줄이면 됩니다.
PR을 올릴 때마다 팀 디스코드 스레드에 리뷰 요청을 남깁니다. 제목 옮겨 적고, 브랜치 이름 쓰고, 뭘 봐달라고 덧붙이고, 링크 붙여넣고. 한 번에 2~3분. 스프린트 중에는 하루에 서너 번 반복됩니다.
더 문제는 형식이 매번 달라지고, 바쁠 땐 아예 올리는 걸 잊어서 PR이 하루 종일 묵힌다는 점이었습니다.
완성된 모습
/review-ping이 한 줄이면 현재 브랜치의 PR이 팀 리뷰 스레드에 이렇게 올라갑니다.
@Front
[ PR 리뷰 요청 ]
feat: 홈 명세서 목록 서버 조회 연동
PR: https://github.com/org/repo/pull/125
브랜치: feat/124-statement-screen-api → develop
리뷰 부탁드립니다 🙏봐줬으면 하는 부분이 있으면 뒤에 붙입니다.
/review-ping 홈 목록부터 봐주세요왜 GitHub Actions가 아니라 로컬 스크립트인가
PR 이벤트로 도는 GitHub Actions를 먼저 떠올렸습니다. 하지만 그러려면 팀 저장소에 워크플로를 추가하고, 웹훅 URL을 저장소 시크릿으로 등록해야 합니다. 팀 전체의 PR에 영향이 가고, 다른 사람이 올린 PR도 같은 형식으로 알림이 나갑니다.
제가 필요했던 건
"내가 올린 PR을, 내가 원할 때, 내 말과 함께"
올리는 것뿐이었습니다. 그래서 저장소는 건드리지 않고 내 컴퓨터에만 두기로 했습니다. 팀에 제안할 일이 생기면 그때 워크플로로 옮기면 됩니다.
준비물
- GitHub CLI (
gh) — 현재 브랜치의 PR 정보를 가져옵니다.gh auth login으로 로그인해두면 됩니다 jq— JSON을 만들고 읽습니다.brew install jq- 디스코드 웹훅 URL — 메시지를 보낼 권한이 담긴 주소입니다
1. 디스코드 웹훅 만들기
리뷰 요청 스레드의 부모 채널에서 만들어야 합니다. 웹훅은 채널에 속하기 때문에, 다른 채널의 웹훅으로는 그 스레드에 글을 넣을 수 없습니다.
- 채널 이름에 마우스를 올리고 톱니(채널 편집) 클릭
- 연동 → 웹후크 → 새 웹후크
- 이름을 정합니다. 이 이름이 메시지 보낸 사람으로 표시됩니다 (예:
PR 리뷰 요청) - 웹후크 URL 복사
복사한 URL은 파일에 저장하고 권한을 600으로 잠급니다. 저장소에는 커밋하지 않습니다.
printf '%s' '웹훅_URL' > ~/.claude/.discord-webhook
chmod 600 ~/.claude/.discord-webhook웹훅 URL은 비밀번호입니다 이 URL을 아는 사람은
누구나 그 채널에 글을 올릴 수 있습니다
. 계정 인증이 필요 없습니다. 저장소에 커밋하지 말고, 노출되면 웹훅을 삭제한 뒤 새로 만들어 파일만 덮어쓰세요.
2. 스레드 ID 얻기
채널이 아니라 스레드에 올리려면 스레드 ID가 필요합니다. 스레드를 열면 주소창(또는 우클릭 → 링크 복사)에 보입니다.
https://discord.com/channels/{서버ID}/{스레드ID}마지막 숫자를 저장합니다.
printf '%s' '스레드ID' > ~/.claude/.discord-thread-id보낼 때는 URL 뒤에 쿼리로 붙입니다.
target="${WEBHOOK}?thread_id=${THREAD_ID}"3. 역할 ID 얻기 (멘션용)
@Front라고
글자로 적으면 멘션이 되지 않습니다
. 회색 글자로 나오고 알림도 울리지 않습니다. 디스코드에서 역할 멘션은 ID로 표현합니다.
<@&1526583660864868503>역할 ID를 얻는 방법은 두 가지입니다.
방법 A — 서버 설정에서
- 사용자 설정(왼쪽 아래 톱니) → 고급 → 개발자 모드 켜기
- 서버 이름 우클릭 → 서버 설정 → 역할
- 역할 우클릭 → ID 복사
방법 B — 이미 올라온 멘션에서 뽑기
권한이 없으면 방법 A의 "ID 복사" 메뉴 자체가 보이지 않습니다. 그럴 때 쓰는 방법입니다.
누군가 그 역할을 멘션한 메시지를 우클릭 → 텍스트 복사 하면 <@&숫자> 원본 형태가 그대로 복사됩니다. 채팅창에 \@Front(백슬래시)를 보내도 같은 결과를 볼 수 있습니다.
printf '%s' '역할ID' > ~/.claude/.discord-front-role웹훅은 멘션을 막는 경우가 있어서, 보낼 때 allowed_mentions도 같이 넣어야 확실합니다.
{
"content": "<@&1526583660864868503> ...",
"allowed_mentions": { "roles": ["1526583660864868503"] }
}4. 스크립트 작성
~/.claude/scripts/discord-review-ping.sh로 저장하고 chmod +x 합니다. 전문은 맨 아래에 두고, 여기서는 핵심 두 조각만 봅니다.
메시지 만들기. > jq로 JSON을 조립하는 게 핵심입니다. PR 제목에 따옴표나 백틱이 들어가면 손으로 이어 붙인 JSON은 깨집니다.
pr=$(gh pr view --json number,title,url,isDraft,headRefName,baseRefName)
mention="@Front"
[ -n "$FRONT_ROLE" ] && mention="<@&${FRONT_ROLE}>"
payload=$(jq --arg mention "$mention" --arg body "$body" --arg role "$FRONT_ROLE" '
{
content: (
"\($mention)\n\n"
+ "**[ PR 리뷰 요청 ]**\n\(.title)\n\n"
+ "PR: \(.url)\n"
+ "브랜치: `\(.headRefName)` → `\(.baseRefName)`"
+ (if .isDraft then "\n상태: Draft (아직 리뷰 요청 전)" else "" end)
+ (if $body == "" then "" else "\n\n\($body)" end)
+ "\n\n리뷰 부탁드립니다 🙏"
),
allowed_mentions: (if $role == "" then { parse: [] } else { roles: [$role] } end)
}' <<<"$pr")② 스레드로 보내기 — 웹훅 URL에 thread_id를 붙이고, wait=true로 보낸 메시지 ID를 받아둡니다.
target="${WEBHOOK}?thread_id=${THREAD_ID}&wait=true"
curl -sS -o /tmp/response.txt -w '%{http_code}' \
-X POST -H "Content-Type: application/json" -d "$payload" "$target"
jq -r '.id' /tmp/response.txt >> "$HOME/.claude/.discord-sent-messages"5. 슬래시 커맨드로 등록
Claude Code를 쓴다면 ~/.claude/commands/review-ping.md에 두면 /review-ping으로 실행됩니다.
---
description: 현재 브랜치의 PR 링크를 디스코드에 리뷰 요청으로 올린다
---
1. `git status --short`로 커밋·푸시되지 않은 변경이 있는지 확인한다. 남아 있으면 알리고 멈춘다.
2. `gh pr view --json number,isDraft`로 PR이 있는지 확인한다. 없으면 알리고 멈춘다.
3. `~/.claude/scripts/discord-review-ping.sh "$ARGUMENTS"`를 실행한다.
4. 결과를 한 줄로 보고한다.Claude Code를 쓰지 않는다면 셸 별칭으로도 충분합니다.
alias review-ping='~/.claude/scripts/discord-review-ping.sh'추가로 커스텀한 것들
만들고 쓰면서 덧붙인 것들입니다.
본문을 인자로 받기
"뭘 봐달라"는 내용은 매번 달라서 인자로 받게 했습니다. 짧으면 한 줄, 길면 파일로 넘깁니다.
/review-ping 홈 목록부터 봐주세요
~/.claude/scripts/discord-review-ping.sh -f notes.mdDraft PR 표시
초안 상태에서 잘못 올리는 걸 막으려고 isDraft면 한 줄을 덧붙이게 했습니다.
상태: Draft (아직 리뷰 요청 전)보낸 메시지 ID 저장 + 일괄 삭제
형식을 다듬는 동안 테스트 메시지가 쌓였는데
지울 방법이 없었습니다
. 웹훅으로 메시지를 지우려면 메시지 ID가 필요한데, 웹훅 토큰으로는 메시지 목록 조회가 안 되기 때문입니다.
그래서 보낼 때 wait=true로 메시지 ID를 받아 파일에 쌓아두고, 지우는 스크립트를 따로 만들었습니다.
#!/bin/bash
# ~/.claude/scripts/discord-review-clear.sh
set -euo pipefail
WEBHOOK=$(tr -d ' \r\n' < "$HOME/.claude/.discord-webhook")
THREAD=$(tr -d ' \r\n' < "$HOME/.claude/.discord-thread-id")
LIST="$HOME/.claude/.discord-sent-messages"
while read -r id; do
[ -z "$id" ] && continue
curl -sS -o /dev/null -w "$id → %{http_code}\n" \
-X DELETE "${WEBHOOK}/messages/${id}?thread_id=${THREAD}"
done < "$LIST"
: > "$LIST"ID를 저장하기 전에 보낸 메시지는 이 방법으로 지울 수 없습니다. 그때는 메시지 우클릭 → 메시지 링크 복사로 얻은 링크의 마지막 숫자가 메시지 ID입니다.
https://discord.com/channels/{서버ID}/{스레드ID}/{메시지ID}이미 보낸 메시지 수정하기
PR에 커밋을 더 쌓았거나 오타를 냈을 때는 지우고 다시 보내는 대신 고치는 편이 낫습니다. 알림이 두 번 울리지 않으니까요. 삭제와 같은 경로에 PATCH를 쓰면 됩니다.
MSG_ID=$(tail -1 ~/.claude/.discord-sent-messages)
curl -sS -X PATCH "${WEBHOOK}/messages/${MSG_ID}?thread_id=${THREAD}" \
-H "Content-Type: application/json" \
-d '{"content": "수정된 내용"}'보낼 때 wait=true로 ID를 받아뒀기 때문에 가능한 일입니다.
임베드 대신 일반 텍스트
처음엔 임베드 카드로 만들었습니다. 색 바가 있고 제목이 링크가 되는 그 형태입니다. 보기엔 좋았는데 팀원들이 손으로 올리던 형식과 달라서
스레드에서 오히려 눈에 안 들어왔습니다
. 결국 일반 텍스트로 되돌렸습니다.
마크다운(**굵게**, 인라인 코드)은 웹훅 메시지에서도 그대로 렌더링되니 형식을 맞추는 데 문제가 없습니다.
커밋 확인 절차를 커맨드에 넣기
슬래시 커맨드를 스크립트 호출 한 줄로 두지 않고, 앞에 확인 절차를 적어뒀습니다. 덕분에 커밋하지 않은 변경이 남아 있는데 "리뷰 부탁드립니다"를 보내는 사고를 한 번 막았습니다.
환경변수로도 받게 하기
설정 파일 대신 환경변수로도 받게 했습니다. CI에서 돌리거나 다른 채널로 보낼 때 편합니다.
DISCORD_THREAD_ID=다른스레드ID ~/.claude/scripts/discord-review-ping.sh설정 파일 정리
전부 홈 디렉터리에 두고 권한은 600으로 잠급니다.
| 파일 | 내용 |
|---|---|
~/.claude/.discord-webhook | 웹훅 URL |
~/.claude/.discord-thread-id | 리뷰 요청 스레드 ID |
~/.claude/.discord-front-role | 멘션할 역할 ID (선택) |
~/.claude/.discord-sent-messages | 보낸 메시지 ID 기록 (삭제용) |
응답 코드 읽는 법
전송이 실패하면 스크립트가 HTTP 코드와 본문을 그대로 보여줍니다. 자주 만나는 건 이 정도입니다.
| 코드 | 의미 | 확인할 것 |
|---|---|---|
204 | 성공 (삭제 요청) | 정상 |
200 | 성공 (wait=true로 보낸 경우) | 응답에 메시지 ID가 들어 있다 |
400 | 페이로드 형식 오류 | JSON 구조, 빈 content |
401 | 웹훅 토큰이 잘못됨 | URL을 다시 복사했는지 |
404 | 웹훅이나 스레드가 없음 | 웹훅 삭제 여부, thread_id 값 |
429 | 속도 제한에 걸림 | 아래 참고 |
속도 제한
디스코드 웹훅은 대략 5초에 5회 정도로 제한됩니다. 손으로 쓸 때는 걸릴 일이 거의 없지만, 반복 호출하거나 CI에 붙이면 만나게 됩니다.
429 응답에는 얼마나 기다려야 하는지가 함께 옵니다.
{ "message": "You are being rate limited.", "retry_after": 1.5, "global": false }retry_after(초)만큼 쉬었다가 다시 보내면 됩니다. 테스트를 반복할 때는 사이에 sleep 2 정도만 넣어도 충분합니다.
막히기 쉬운 것들
| 증상 | 원인 |
|---|---|
| 스크립트가 아무 메시지 없이 종료 | set -e + 조건 검사로 끝나는 함수 → 끝에 return 0 추가 |
| 스레드가 아니라 채널에 올라감 | ?thread_id=가 빠졌거나, 웹훅이 다른 채널에 있음 |
| 멘션이 회색 글자로 나옴 | 역할 ID(<@&숫자>)가 아닌 이름을 넣었거나 allowed_mentions 누락 |
| 보낸 메시지를 못 지움 | wait=true 없이 보내면 메시지 ID를 받을 수 없음 |
슬랙에서 쓰려면
구조는 같고 페이로드 형식만 다릅니다. 슬랙 Incoming Webhook은 content가 아니라 **text**를 쓰고, 멘션은 <!subteam^팀ID>(사용자 그룹) 형식입니다.
curl -X POST -H 'Content-Type: application/json' \
-d '{"text": "*[ PR 리뷰 요청 ]*\n<https://github.com/org/repo/pull/125|#125 제목>"}' \
"$SLACK_WEBHOOK_URL"링크도 [텍스트](url)이 아니라 <url|텍스트> 형식입니다. 스레드에 올리려면 Incoming Webhook 대신 chat.postMessage API와 봇 토큰이 필요하고, thread_ts로 부모 메시지를 지정합니다. 이 부분은 디스코드보다 한 단계 번거롭습니다.
전체 스크립트
#!/bin/bash
# 현재 브랜치의 PR을 디스코드 리뷰 요청 스레드로 보낸다.
# 사용: discord-review-ping.sh ["본문 내용"]
# 본문을 파일로 넘기려면: discord-review-ping.sh -f notes.md
set -euo pipefail
read_config() {
local env_value="$1" file="$2"
if [ -n "$env_value" ]; then
printf '%s' "$env_value"
return 0
fi
if [ -f "$file" ]; then
tr -d ' \r\n' < "$file"
fi
return 0 # 파일이 없어도 성공으로 끝낸다
}
WEBHOOK=$(read_config "${DISCORD_WEBHOOK_URL:-}" "$HOME/.claude/.discord-webhook")
THREAD_ID=$(read_config "${DISCORD_THREAD_ID:-}" "$HOME/.claude/.discord-thread-id")
FRONT_ROLE=$(read_config "${DISCORD_FRONT_ROLE_ID:-}" "$HOME/.claude/.discord-front-role")
if [ -z "$WEBHOOK" ]; then
echo "웹훅 URL이 없습니다." >&2
exit 1
fi
body=""
if [ "${1:-}" = "-f" ] && [ -n "${2:-}" ]; then
body=$(cat "$2")
elif [ -n "${1:-}" ]; then
body="$1"
fi
pr=$(gh pr view --json number,title,url,isDraft,headRefName,baseRefName 2>/dev/null || true)
if [ -z "$pr" ]; then
echo "현재 브랜치에 열린 PR이 없습니다." >&2
exit 1
fi
mention="@Front"
[ -n "$FRONT_ROLE" ] && mention="<@&${FRONT_ROLE}>"
payload=$(jq --arg mention "$mention" --arg body "$body" --arg role "$FRONT_ROLE" '
{
content: (
"\($mention)\n\n"
+ "**[ PR 리뷰 요청 ]**\n\(.title)\n\n"
+ "PR: \(.url)\n"
+ "브랜치: `\(.headRefName)` → `\(.baseRefName)`"
+ (if .isDraft then "\n상태: Draft (아직 리뷰 요청 전)" else "" end)
+ (if $body == "" then "" else "\n\n\($body)" end)
+ "\n\n리뷰 부탁드립니다 🙏"
),
allowed_mentions: (if $role == "" then { parse: [] } else { roles: [$role] } end)
}' <<<"$pr")
target="$WEBHOOK"
if [ -n "$THREAD_ID" ]; then
target="${WEBHOOK}?thread_id=${THREAD_ID}"
fi
# wait=true를 붙이면 보낸 메시지 객체가 응답으로 돌아온다
[[ "$target" == *"?"* ]] && target="${target}&wait=true" || target="${target}?wait=true"
status=$(curl -sS -o /tmp/discord-ping-response.txt -w '%{http_code}' \
-X POST -H "Content-Type: application/json" -d "$payload" "$target")
if [ "$status" -ge 300 ]; then
echo "전송 실패 (HTTP $status): $(cat /tmp/discord-ping-response.txt)" >&2
exit 1
fi
msg_id=$(jq -r '.id // empty' /tmp/discord-ping-response.txt)
if [ -n "$msg_id" ]; then
echo "$msg_id" >> "$HOME/.claude/.discord-sent-messages"
fi
echo "보냈습니다: #$(jq -r .number <<<"$pr") $(jq -r .title <<<"$pr")"댓글
GitHub 계정으로 댓글을 남길 수 있어요.