![[스크린샷 2026-04-19 오전 9.54.20.png]]
#개발 #기술블로그 #Discord #봇 #Node #자동화 #PR #코드리뷰 #Railway
#### 참고자료
- [Railway Pricing Plans (공식 문서)](https://docs.railway.com/pricing/plans)
- [Railway Free Trial (공식 문서)](https://docs.railway.com/pricing/free-trial)
- [discord.js 공식 가이드](https://discordjs.guide/)
- [GitHub Webhook events — pull_request / pull_request_review](https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request)
요즘 큐시즘에서 밋업하면서 처음으로 3인 백엔드 팀에서 활동하게 되었다!
그런데 예상되는 문제가 있었다...
1. 기간이 길지는 않고, 개발을 스프린트 단위로 해서 PR이 많이 올라올 예정이었다
2. 평소에 하던 2명 팀에서는 그냥 죽음의 리뷰듀오처럼 서로 고문하면서 독촉이 가능했는데, 3인 팀에서는 현실적으로 트래킹이 어려울 수 있는 상황이었다
3. 그래서 12시간 안에 리뷰를 달아주기로 팀 컨벤션을 정했는데, 사실 12시간 계산이 그리 쉽지는 않을 게 분명했다
4. 결과적으로 **"PR 올렸고, 12시간 지났는데 모두 리뷰를 달지 못하는 상황"** 이 발생 가능했다 😅
분명 나는 PR 올리고 슬랙 멘션도 했는데, 다들 자기 작업하느라 바쁘니까 자연스럽게 묻혀버리고... 그러다 다음 날 아침에 "그거 머지해도 되나요" 하면 또 누군가가 "못봤어요잠시만요" 하는 그런 무한루프... 가 막 머릿속에 그려지고 그랬다
그래서 **PR 올라오면 디스코드에 알림 + 일정 시간 지나면 독촉하는 봇**을 만들었다.
이름은 직관적으로 `pr-review-bot`. 디스코드 닉네임은 `PR리뷰해#6861` 이다.
깃헙 퍼블릭으로 올려뒀다.
👉 [github.com/gyesswhat/discord-pr-reminder](https://github.com/gyesswhat/discord-pr-reminder)
---
# 📓 왜 만들었나
사실 깃헙에 알림 통합 서비스가 없는 건 아니다.
근데 내가 원하는 동작은 좀 더 까다로웠다.
`내가 원했던 것`
1. PR 올라오면 **본인 제외 팀원 전원** 멘션 (작성자한테 멘션 가는 건 필요가 없으니...)
2. 일정 시간이 지나도 리뷰가 안 달리면 **독촉**
3. approve 달리면 **자동으로 샤라웃(감사 인사)** 하고 독촉 중단
4. **공짜로 돌리기** (겨레는 실제로 AWS 서버비만 달에 20씩 뜯긴다)
깃헙 → 디스코드 통합은 단순 미러링에 가까워서 1번이랑 2번이 잘 안 됐다.
그래서 그냥 **GitHub Webhook → 직접 만든 Express 서버 → discord.js** 구조로 만들었다.
js는 잘 모르지만, 요즘은 바이브코딩이 쉬워서 클로드 코드의 도움을 받았다!
# 💡 어떤 원리로 동작하나
전체 구조는 생각보다 단순하다.
```
GitHub Railway (Node) Discord
────── ──────────────── ────────
PR opened ─webhook─▶ express /webhook
│ HMAC-SHA256 검증
▼
store.js (Map 저장)
│
▼
notifier ──임베드+멘션──▶ 📬 알림
scheduler (cron 1분)
│ 6h / 11h / 12h 체크
▼
notifier ──메시지──────▶ ⏰→🚨→💀
approved ─webhook─▶ store.markReviewed
│
▼
notifier ──감사 인사────▶ ✅ 완료
```
각 모듈 역할을 정리하면 이렇다.
| 파일 | 역할 |
| -------------- | ------------------------------------------------------ |
| `index.js` | discord.js 클라이언트 로그인 → 채널 핸들 잡고 webhook + scheduler 부팅 |
| `webhook.js` | GitHub Webhook 수신, 시그니처 검증, 이벤트 분기 |
| `scheduler.js` | `node-cron` 1분 간격으로 모든 PR 검사 |
| `store.js` | PR 상태를 `Map` 에 인메모리 저장 |
| `notifier.js` | Discord 임베드/메시지 전송 (멘션 조립 포함) |
| `config.js` | `USER_MAP_JSON` env → `users.json` → `{}` 순으로 폴백 |
## 🔐 GitHub Webhook 시그니처 검증
요건 좀 짚고 가고 싶다.
GitHub Webhook 은 **누구든 URL 만 알면 POST를 쏠 수 있기 때문에**, 시크릿으로 서명을 검증해야 한다. 안 그러면 누가 가짜 PR 알림 보내서 디스코드 채널을 도배할 수 있기 때문이다...
```js
// webhook.js
function verifySignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const hmac = crypto.createHmac('sha256', secret);
const expected = 'sha256=' + hmac.update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
```
여기서 한 가지 포인트는 `crypto.timingSafeEqual` 을 쓴 것!
일반 `"==="` 로 비교하면 **timing attack**이 이론적으로 가능해서, 길이가 같은 두 버퍼를 일정 시간 안에 비교하는 함수를 써줘야 한다고 한다.
> 🕵️ **[Timing Attack](https://wiki.doda.dev/timing-attack) 이란?**
> 두 문자열을 비교할 때 일반적인 === 는 앞에서부터 한 글자씩 비교하다가 다르면 즉시 false를 반환한다. 즉, *"틀린 위치가 뒤쪽일수록 비교에 걸리는 시간이 더 길어진다"* 는 뜻. 공격자가 시그니처를 한 글자씩 바꿔가며 응답 시간(나노초 단위)을 측정하면, **어디까지가 정답인지** 를 역산해낼 수 있다. 그래서 `crypto.timingSafeEqual` 같은 **상수 시간 비교(constant-time comparison)** 함수를 써서, 입력값과 무관하게 항상 동일한 시간을 들여 비교해야 한다.
그리고 `express.raw()` 로 **본문을 raw Buffer 그대로 받는 것**도 중요하다. JSON 파싱된 객체로 비교하면 키 순서나 공백 때문에 시그니처가 절대 안 맞는다고 한다...
## ⏰ 스케줄러 — "왜 1분 간격?"
```js
// scheduler.js
async function checkPR(pr, now) {
if (pr.isReviewed) return;
const elapsed = now - new Date(pr.openedAt).getTime();
if (elapsed >= OVERDUE_12H && !pr.shamed) { ... }
if (elapsed >= REMIND_11H && !pr.reminded11h) { ... }
if (elapsed >= REMIND_6H && !pr.reminded6h) { ... }
}
```
처음엔 그냥 6시간 후, 11시간 후, 12시간 후에 `setTimeout` 으로 예약하면 되겠지~ 했다.
근데 짜다 보니 setTimeout 방식이 은근히 귀찮은 구석이 많았다.
1. **리뷰 완료 처리** — approve 들어왔을 때 그 PR 에 걸린 타이머 3개를 일일이 `clearTimeout` 해줘야 했다. 안 그러면 이미 머지된 PR 한테 "💀 리뷰 마감 지났어요" 가 날아간다...
2. **타이머 핸들 관리** — PR 마다 핸들 3개씩 어딘가에 들고 있어야 하고, 누락되면 좀비 알림이 된다
3. **장시간 setTimeout 은 정확도가 살짝 떨어짐** — OS 슬립/스로틀링 영향 받아서 12시간짜리는 실제로 좀 밀린다고 한다.
그래서 **상태는 Map 에 있고, 스케줄러는 1분마다 모든 PR 의 경과 시간을 직접 계산해서 보내야 할 알림을 결정**하는 구조로 갔다. approve 들어오면 `isReviewed = true` 한 줄로 끝나고, 다음 tick 에서 알아서 skip 한다.
> 💡 나중에 인메모리 Map 을 Redis 같은 외부 저장소로 교체해도, 스케줄러 코드는 그대로 둬도 된다. "매 tick 마다 전체 PR 을 훑어서 경과 시간 본다" 는 로직 자체가 저장소에 무관하기 때문.
## 🎭 본인 제외 멘션
이게 사소한데 의외로 사용자(겨레) 경험에 차이를 준다고 생각했다.
```js
// notifier.js
function buildMentions(excludeGithubUser) {
return Object.entries(USER_MAP)
.filter(([githubUser]) => githubUser !== excludeGithubUser)
.map(([, discordId]) => `<@${discordId}>`)
.join(' ');
}
```
GitHub username → Discord user ID 매핑 (`USER_MAP`) 을 들고 다니면서, **PR 작성자만 빼고** 멘션을 조립한다.
내가 PR 올렸는데 나한테 멘션 오면 싫으니까...
그리고 approve 한 사람한테 감사 인사 보낼 때는 **그 사람만** 멘션한다.
```js
async function notifyReviewed(pr, reviewerGithubUser) {
const discordId = USER_MAP[reviewerGithubUser];
const reviewerLabel = discordId ? `<@${discordId}>` : reviewerGithubUser;
await ensureChannel().send(
`✅ ${reviewerLabel} 님이 리뷰해주셨어요! 수고하셨습니다 🎉\n${pr.url}`,
);
}
```
## 💾 인메모리 저장 — 의도적 단순화
`store.js` 는 그냥 `Map` 이다. DB도 Redis 도 안 쓴다.
```js
const prStore = new Map();
```
이건 **결정사항** 이었다.
- PR 은 어차피 길어야 며칠 단위로 살아 있는 객체
- 재시작되면 진행 중이던 PR 의 리마인더 상태가 초기화되긴 하는데, 그래봤자 *"이미 알림 갔던 PR 에 한 번 더 갈 수 있음"* 정도의 영향
- 그냥 내가 좀 편하려고 만드는 건데 굳이 DB 붙이는 수고와 돈을 들이고 싶지 않았다.
이런 트레이드오프는 정리해서 README 주의사항에도 써두었다.
> PR 상태는 인메모리(`Map`) 저장 — **프로세스 재시작 시 모든 진행 중 PR 상태가 초기화됨** (허용 범위로 간주)
# 🚂 Railway로 배포해서 돈 안 나가는 법
백엔드 하면서 이걸 모르고 있었다는 게 신기한데... 하여튼 이번에 디코 봇은 보통 어떻게 배포하지? 하고 찾아보다가 많이 쓴다는 레일웨이도 처음 알게 되었다.
[Railway](https://railway.com) 는 PaaS 인데, 깃헙 레포 연결하면 그냥 알아서 빌드하고 배포해준다.
> ☁️ **PaaS (Platform as a Service) 란?**
> 클라우드 서비스 모델 중 하나로, *"OS나 런타임, 빌드 환경 같은 인프라는 다 알아서 해줄 테니, 너는 코드만 올려"* 라는 컨셉의 서비스. 비교하자면, IaaS(Infrastructure as a Service, 예: EC2) 는 **빈 컴퓨터**를 빌리는 거라 OS 설치부터 Node 깔고 서비스 데몬 등록까지 다 직접 해야 하고, SaaS(예: Notion) 는 **완성된 제품**을 그냥 쓰는 거다. PaaS 는 그 중간, **코드만 push 하면 빌드/배포/실행을 알아서 해주는 레이어**라고 보면 된다.
사실 요즘 프론트 따라잡기 한다고 Vercel은 몇 번 건드려봐서 아는 걸로 가고 싶었는데; 쓰지는 못했다.
왜냐? Vercel 은 본질적으로 서버리스(Serverless) 플랫폼 이라, 요청 들어오면 함수가 깨어났다가 응답 끝나면 죽는 구조다.
그런데 디스코드 봇은
1. discord.js 클라이언트가 디스코드 Gateway 와 WebSocket 을 24시간 붙잡고 있어야 하고,
2. `node-cron` 도 매분 돌아야 한다.
즉, "항상 켜져 있는 프로세스"가 필요한데 서버리스랑은 모양이 안 맞는다.
반면 Railway 는 컨테이너 기반 PaaS 라서 그냥 우리가 짠 Node 프로세스를 죽지 않고 계속 돌려준다. 같은 PaaS 라도 결이 좀 다른 셈!
사담이 길어졌는데, Railway 이야기로 다시 돌아가서...
Railway의 가격 구조는 이렇다 ([Railway 공식 가격 정책](https://docs.railway.com/pricing/plans) 기준):
| 플랜 | 월 요금 | 크레딧/리소스 |
| --------- | ----- | ----------------------------------------------------------------------------------- |
| **Trial** | $0 | 가입 후 30일간 한 번 $5 크레딧 ([Free Trial 문서](https://docs.railway.com/pricing/free-trial)) |
| **Free** | $0 | 매월 $1 크레딧 (트라이얼 종료 후 자동 전환) |
| **Hobby** | $5/월 | 월 $5 리소스 사용 크레딧 포함 |
| **Pro** | $20/월 | 월 $20 리소스 사용 크레딧 포함 |
`핵심` Hobby 플랜은 월 $5 인데, **그 $5 안에 리소스 사용 크레딧 $5 가 그대로 포함**되어 있다.
즉 디스코드 봇 정도의 가벼운 Node 프로세스 하나는 **$5 안쪽으로 충분히 들어가고**, Hobby 플랜 구독료 $5 만 나간다.
그리고 디스코드 봇은 트래픽이 거의 없어서 (webhook 들어올 때만 잠깐 일함), 사실상 **CPU/메모리 사용량이 minimal** 하다.
> 🤓 좀 더 싸게 쓰고 싶다면... **트라이얼 30일** 동안 $5 크레딧으로 돌리고 → Free 플랜의 월 $1 크레딧으로 버틸 수도 있다. 다만 Free 플랜은 sleep 정책이 있어서 webhook 첫 호출이 느려질 수 있을 것 같다.
## 🛠 셋업은 이렇게 하면 됩니다
레포에도 자세히 써뒀지만 ([README.md](https://github.com/gyesswhat/discord-pr-reminder#셋업-가이드)), 한 줄 요약하면 이렇다.
1. 레포 **fork & clone** → `npm install`
2. [Discord Developer Portal](https://discord.com/developers/applications) 에서 봇 만들고 토큰 받기
3. 봇을 디스코드 서버에 초대 + 알림 받을 채널 ID 복사
4. `openssl rand -hex 32` 로 GitHub Webhook 시크릿 생성
5. `.env` 정리해두기 (당연히 Railway 세팅하고 거기에만 올려야 한다)
```bash
DISCORD_TOKEN=...
DISCORD_CHANNEL_ID=...
GITHUB_SECRET=...
USER_MAP_JSON=...
```
6. **Railway 배포**: New Project → Deploy from GitHub repo → 포크한 레포 선택 → Variables 탭에 위 env 다 등록 → Settings → Networking → Generate Domain
7. 팀 레포 → Settings → Webhooks → `https://<railway-url>/webhook` 등록 (Events: `Pull requests` + `Pull request reviews`)
> 💡 **Tip** Railway Settings → Networking → **Healthcheck Path** 에 `/health` 등록해두면, 배포 시 자동으로 헬스체크 후 트래픽 라우팅하고 죽으면 알아서 재시작해준다.
# ✅ 만들고 나서
우선 디코 앱 만드는 걸 해보고 싶었는데,
정말 간단한 거라도 해보니 장벽이 좀 낮아진 느낌이다!
사이드프로젝트를 해보고 싶다고 생각하면서도 뭔가 기획적인 부분을 생각하다보니 그냥

먼말인지 모르게떠염 상태가 되곤 했는데...
그냥 내가 필요한 걸 그때그때 가볍게라도 만들어보면 어떻게든 배울 점이 있는 것 같다 (지금도 오픈소스 기여 관련 툴을 만들어보고 있다...)
그 외 기술적으로 만들면서 좋았던 점은,
1. **GitHub Webhook 시그니처 검증** 같은 보안 토픽을 써본 것
2. **상태를 어디에 둘 것인가** (DB vs 인메모리) 트레이드오프를 결정해본 것
3. **PaaS, IaaS의 차이를 체감**하고, Railway를 써본 것
요 정도가 있는 것 같다
다음에도 뭔가 필요한 게 있다면 바이브코딩 베이스로 만들어보고,
만들고 나서는 이렇게 회고하면서 코드 뜯어보면 좋을 것 같다.
공부가 안 될 줄 알았는데 은근 되네요
혹시 비슷한 고민 있으셨던 분들 있다면 부담 없이 fork 해서 쓰시고 (근데 솔직히 너무간단한툴이라 올리면서도 좀 부끄 였음), 이슈/PR 도 환영합니다 🙌
그럼 끝!
