본문으로 건너뛰기
최서희Frontend Engineer
← 블로그

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. 디스코드 웹훅 만들기

리뷰 요청 스레드의 부모 채널에서 만들어야 합니다. 웹훅은 채널에 속하기 때문에, 다른 채널의 웹훅으로는 그 스레드에 글을 넣을 수 없습니다.

  1. 채널 이름에 마우스를 올리고 톱니(채널 편집) 클릭
  2. 연동 → 웹후크 → 새 웹후크
  3. 이름을 정합니다. 이 이름이 메시지 보낸 사람으로 표시됩니다 (예: PR 리뷰 요청)
  4. 웹후크 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 — 서버 설정에서

  1. 사용자 설정(왼쪽 아래 톱니) → 고급 → 개발자 모드 켜기
  2. 서버 이름 우클릭 → 서버 설정 → 역할
  3. 역할 우클릭 → 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.md

Draft 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 계정으로 댓글을 남길 수 있어요.