
나의 AI PR Reviewer가 계속해서 같은 말을 반복했다 — 버그와 해결 방법
요약
LLM 기반 GitHub Action인 ai-pr-reviewer 개발 중 발생한 중복 리뷰 버그와 그 해결 과정을 다룹니다. 별도의 데이터베이스 없이 GitHub API와 리뷰 본문에 숨겨진 마커를 활용해 상태를 관리하는 Stateless 설계 방식을 제안합니다.
핵심 포인트
- PR 업데이트 시 이전 리뷰 내용을 반복하는 버그 발생 원인 분석
- Stateless 설계를 유지하기 위해 별도 DB 대신 GitHub API 활용
- 리뷰 본문에 마커를 삽입하여 마지막 리뷰 커밋을 식별하는 기법
- GitHub 자체를 신뢰할 수 있는 원천(Source of Truth)으로 활용
저는 ai-pr-reviewer를 만들었습니다. 이는 Pull Request (PR)에 대해 LLM (Large Language Model) 기반의 코드 리뷰를 실행하고, 사람이 리뷰하는 것처럼 인라인 댓글을 게시하는 GitHub Action입니다. 처음에는 잘 작동했습니다. 그러다 제 개인 PR에 실제로 사용하기 시작하면서 짜증 나는 점을 발견했습니다. 열려 있는 PR에 새로운 커밋을 푸시할 때마다, 이미 확인했던 댓글들을 다시 게시하는 것이었습니다. 여기에는 이미 수정된 내용이나, 제가 답글로 이미 틀렸다고 논쟁했던 내용까지 포함되어 있었습니다.
단순히 텍스트가 중복되는 것이 아니었습니다. 매 푸시마다 동일한 내용이 새로운 리뷰인 것처럼 다시 올라왔습니다.

원인
오케스트레이션 (Orchestration) 로직은 예상대로 매우 단순했습니다:
async reviewPullRequest(req: ReviewRequest): Promise<void> {
const { owner, repo, prNumber, headSha } = req;
...
fetchPullRequestDiff는 마지막 실행 이후 변경된 부분만 가져오는 것이 아니라, 항상 PR 전체에 대한 base...head diff (차이점)를 가져옵니다. 매 푸시마다 이 과정이 처음부터 다시 트리거되며, 이전에 했던 말에 대한 기억이 전혀 없습니다. PR에 커밋이 10개 쌓이면, 이미 리뷰했던 이전 9개 커밋 분량의 코드를 아홉 번이나 다시 읽고 다시 판단하게 됩니다.
해결책: 데이터베이스가 아닌 마커(Marker)
명백한 해결책은 "이미 리뷰한 내용을 기억하는 것"입니다. 덜 명백한 부분은 "그것을 어디에 기억할 것인가"입니다. 저는 이를 위해 별도의 데이터베이스를 추가하고 싶지 않았습니다. 이 서비스 전체가 의도적으로 Stateless (상태 비저장)로 설계되었기 때문입니다. 하지만 GitHub는 이미 PR에 게시된 모든 리뷰의 전체 이력을 보관하고 있습니다. 그래서 해결책을 찾았습니다. 모든 리뷰 본문에 숨겨진 마커를 찍고, GitHub 자체 API를 신뢰할 수 있는 원천 (Source of Truth)으로 사용하는 것입니다.
// 우리가 게시하는 모든 리뷰 본문에 숨겨져 있어, 데이터베이스 없이도
// PR에서 우리의 과거 리뷰를 식별하고 (그리고 해당 리뷰가 게시된 커밋을 찾아)
// 사용할 수 있습니다 — GitHub 자체의 리뷰 목록이 신뢰할 수 있는 원천입니다.
...
그런 다음, 리뷰를 시작하기 전에 PR의 리뷰 이력을 되돌아보며 우리의 마지막 리뷰를 찾고, 해당 리뷰가 게시된 커밋을 가져옵니다:
async findLastReviewedCommit(owner: string, repo: string, prNumber: number): Promise<string | null> {
const reviews = await this.octokit.paginate(this.octokit.pulls.listReviews, {
owner, repo, pull_number: prNumber, per_page: 100,
...
이를 확보하면, 오케스트레이터 (orchestrator)는 PR의 베이스 (base)가 아닌, 해당 지점부터 새로운 헤드 (head)까지의 차이점 (diff)을 비교합니다:
private async fetchDiff(owner: string, repo: string, prNumber: number, headSha: string): Promise<string> {
const lastReviewedSha = await this.github.findLastReviewedCommit(owner, repo, prNumber);
...
세 가지 상태, 세 가지 동작: 이 PR을 리뷰한 적이 없음 → 전체 차이점 (full diff). 이미 이 정확한 커밋을 리뷰함 → 아무것도 하지 않음, LLM을 호출조차 하지 않음. 이전 커밋을 리뷰함 → 새로운 부분만 차이점 (diff) 추출.
해결책이 스스로의 버그를 잡아냈습니다
제가 예상하지 못했던 부분은 이겁니다. 이 리포지토리 (repo)는 스스로를 테스트 (dogfood) 합니다 — 즉, 자신의 풀 리퀘스트 (pull request)를 스스로 리뷰합니다. 제가 정확히 이 변경 사항을 위한 PR을 열었을 때, 봇이 스스로를 리뷰하며 우리의 ours[ours.length - 1]가 listReviews의 응답 순서가 항상 오래된 순서대로 유지될 것이라고 신뢰하고 있다는 점을 지적했습니다. 이는 실제로 문서화된 보장 사항이 아닙니다. 위에서 보여준 것처럼 id로 정렬하는 것이 해결책이었으며, 봇이 자신의 PR에서 이를 플래그 (flag)한 후에 추가되었습니다. 리뷰 중복 제거 기능이, 그 기능이 해결하려고 만든 대상으로부터 정확성 리뷰를 받는 것은 시스템이 제대로 작동하고 있다는 좋은 신호처럼 느껴졌습니다.
결과
엔드 투 엔드 (end to end)로 검증되었습니다: 마지막 리뷰 이후 변경 사항이 없는 PR은 이제 완전히 건너뜁니다 — LLM 호출도 없고, 반복되는 댓글도 없습니다. 새로운 커밋이 있는 PR은 오직 차이점 (delta)에 대해서만 리뷰를 받습니다.
전체 차이점 (diff) 및 테스트: PR #9. 직접 사용해보고 싶다면: 현재 GitHub Marketplace에 있으며, 워크플로 (workflow) 파일에 한 줄만 추가하면 됩니다.
저는 NestJS/AI 인프라 (infra) 프로젝트를 담당하고 있는 백엔드 테크 리드 (Tech Lead) Niv입니다. GitHub · LinkedIn
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기