본문으로 건너뛰기

AI로 코드 리뷰 자동화하기 — GitHub Actions + LLM 연동 실전 가이드

·673 단어수·4 분
작성자
Engineer

AI 코드 리뷰 자동화 파이프라인

코드 리뷰는 팀의 코드 품질을 높이는 가장 중요한 활동 중 하나입니다. 하지만 현실에서는 리뷰어가 바쁘거나, 작은 PR도 하루 이상 기다리는 일이 빈번합니다. AI 코드 리뷰를 도입한 뒤 저희 팀에서는 평균 리뷰 대기 시간이 18시간에서 2시간으로 줄었습니다. AI가 명백한 버그, 보안 이슈, 스타일 위반을 먼저 잡아주면, 인간 리뷰어는 설계와 비즈니스 로직에 집중할 수 있습니다.

이 글에서는 GitHub Actions와 Claude API를 사용해서 PR 자동 코드 리뷰 시스템을 구축하는 방법을 실전 코드와 함께 단계별로 설명합니다.


시스템 설계
#

자동 리뷰 시스템의 전체 흐름은 다음과 같습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
PR 생성/업데이트
GitHub Actions 트리거
변경된 파일 diff 추출 (GitHub API)
파일별로 Claude API 호출 (병렬 처리)
리뷰 결과 집계
PR에 인라인 댓글 + 요약 댓글 게시

리뷰 범위를 diff로 제한하는 이유: 전체 파일을 보내면 API 비용이 급증하고, 변경과 무관한 코드에 대한 리뷰가 섞입니다. diff만 보내면 비용을 90% 이상 절약할 수 있습니다.


1단계: GitHub Actions 기본 설정
#

.github/workflows/ai-code-review.yml 파일을 생성합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
name: AI Code Review

on:
  pull_request:
    types: [opened, synchronize, reopened]
    # 리뷰하지 않을 브랜치 제외
    branches-ignore:
      - 'dependabot/**'
      - 'renovate/**'

# 동일 PR에 새 커밋이 오면 이전 실행 취소
concurrency:
  group: ai-review-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  ai-review:
    runs-on: ubuntu-latest
    # 봇이 만든 PR은 리뷰 건너뜀
    if: github.event.pull_request.user.type != 'Bot'

    permissions:
      contents: read
      pull-requests: write

    env:
      ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install anthropic PyGithub python-dotenv

      - name: Run AI Code Review
        run: python .github/scripts/ai_review.py
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO_NAME: ${{ github.repository }}
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          HEAD_SHA: ${{ github.event.pull_request.head.sha }}

2단계: 리뷰 스크립트 작성
#

.github/scripts/ai_review.py 파일을 작성합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import os
import anthropic
from github import Github
from typing import Optional

# 환경 변수
ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]
GITHUB_TOKEN = os.environ["GITHUB_TOKEN"]
REPO_NAME = os.environ["REPO_NAME"]
PR_NUMBER = int(os.environ["PR_NUMBER"])
BASE_SHA = os.environ["BASE_SHA"]
HEAD_SHA = os.environ["HEAD_SHA"]

# 리뷰하지 않을 파일 패턴
SKIP_PATTERNS = [
    ".lock", ".sum", "package-lock.json",
    ".min.js", ".min.css", ".svg", ".png",
    "migrations/", "__pycache__/", ".pyc"
]

# 리뷰 대상 확장자
REVIEW_EXTENSIONS = {
    ".py", ".ts", ".tsx", ".js", ".jsx",
    ".go", ".java", ".rs", ".cpp", ".c",
    ".rb", ".php", ".swift", ".kt"
}

def should_skip(filename: str) -> bool:
    """리뷰를 건너뛸 파일인지 판단"""
    if not any(filename.endswith(ext) for ext in REVIEW_EXTENSIONS):
        return True
    return any(pattern in filename for pattern in SKIP_PATTERNS)

def get_diff_for_file(repo, base_sha: str, head_sha: str, filename: str) -> Optional[str]:
    """특정 파일의 diff를 가져옴"""
    comparison = repo.compare(base_sha, head_sha)
    for file in comparison.files:
        if file.filename == filename:
            return file.patch
    return None

def review_code_with_claude(filename: str, diff: str) -> dict:
    """Claude API로 코드 리뷰 수행"""
    client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)

    system_prompt = """당신은 15년 경력의 시니어 소프트웨어 엔지니어입니다.
코드 diff를 보고 다음을 검토합니다:
1. 버그 및 잠재적 오류
2. 보안 취약점 (SQL injection, XSS, 인증 우회 등)
3. 성능 이슈 (N+1 쿼리, 불필요한 루프, 메모리 누수)
4. 엣지 케이스 미처리
5. 코드 스타일 및 가독성 (심각한 문제만)

응답은 반드시 아래 JSON 형식으로만 하세요:
{
  "issues": [
    {
      "severity": "critical|warning|info",
      "line": 줄 번호 (diff 기준, 없으면 null),
      "description": "문제 설명 (한국어)",
      "suggestion": "개선 방법 (한국어)"
    }
  ],
  "summary": "전체 리뷰 요약 (한국어, 2~3문장)",
  "score": 1~10
}

이슈가 없으면 issues는 빈 배열로, score는 9~10으로 응답하세요.
사소한 스타일 이슈는 severity info로만 표시하고 최대 3개까지만 포함하세요."""

    user_prompt = f"""파일: {filename}

변경 사항 (diff):

{diff[:4000]} # 토큰 절약을 위해 4000자로 제한


    response = client.messages.create(
        model="claude-haiku-3-5",  # 비용 효율적인 모델 사용
        max_tokens=1500,
        system=system_prompt,
        messages=[{"role": "user", "content": user_prompt}]
    )

    import json
    try:
        return json.loads(response.content[0].text)
    except json.JSONDecodeError:
        return {"issues": [], "summary": "리뷰 파싱 실패", "score": 5}

def post_review_comment(pr, body: str):
    """PR에 리뷰 요약 댓글 게시 (upsert 방식)"""
    marker = "<!-- AI-CODE-REVIEW -->"
    full_body = f"{marker}\n{body}"

    # 기존 AI 리뷰 댓글 찾기
    for comment in pr.get_issue_comments():
        if marker in comment.body:
            comment.edit(full_body)
            return

    # 없으면 새로 생성
    pr.create_issue_comment(full_body)

def main():
    g = Github(GITHUB_TOKEN)
    repo = g.get_repo(REPO_NAME)
    pr = repo.get_pull(PR_NUMBER)

    # 변경된 파일 목록
    changed_files = [f for f in pr.get_files() if not should_skip(f.filename)]

    if not changed_files:
        print("리뷰할 파일이 없습니다.")
        return

    all_reviews = []
    total_issues = {"critical": 0, "warning": 0, "info": 0}

    for file in changed_files[:10]:  # 최대 10개 파일만 리뷰
        if not file.patch:
            continue

        print(f"리뷰 중: {file.filename}")
        review = review_code_with_claude(file.filename, file.patch)
        review["filename"] = file.filename
        all_reviews.append(review)

        for issue in review.get("issues", []):
            severity = issue.get("severity", "info")
            total_issues[severity] = total_issues.get(severity, 0) + 1

    # 리뷰 요약 댓글 작성
    avg_score = sum(r.get("score", 5) for r in all_reviews) / len(all_reviews) if all_reviews else 5
    score_emoji = "✅" if avg_score >= 8 else "⚠️" if avg_score >= 6 else "❌"

    summary_lines = [
        f"## {score_emoji} AI 코드 리뷰 결과",
        f"",
        f"**종합 점수**: {avg_score:.1f} / 10",
        f"**검토 파일**: {len(all_reviews)}개",
        f"**이슈 현황**: 🔴 심각 {total_issues['critical']}개 · 🟡 경고 {total_issues['warning']}개 · 🔵 정보 {total_issues['info']}개",
        f"",
        f"---",
        f"",
    ]

    for review in all_reviews:
        filename = review["filename"]
        issues = review.get("issues", [])
        file_summary = review.get("summary", "")

        summary_lines.append(f"### `{filename}`")
        summary_lines.append(f"{file_summary}")

        if issues:
            summary_lines.append("")
            for issue in issues:
                severity_map = {"critical": "🔴", "warning": "🟡", "info": "🔵"}
                icon = severity_map.get(issue["severity"], "🔵")
                line_info = f" (L{issue['line']})" if issue.get("line") else ""
                summary_lines.append(f"- {icon} **{issue['description']}**{line_info}")
                summary_lines.append(f"  - 개선: {issue['suggestion']}")

        summary_lines.append("")

    summary_lines.append("---")
    summary_lines.append("*이 리뷰는 Claude AI가 자동 생성했습니다. 최종 판단은 인간 리뷰어가 합니다.*")

    post_review_comment(pr, "\n".join(summary_lines))
    print(f"리뷰 완료. 평균 점수: {avg_score:.1f}")

if __name__ == "__main__":
    main()

3단계: GitHub Secrets 설정
#

  1. GitHub 리포지토리 → SettingsSecrets and variablesActions
  2. New repository secret 클릭
  3. Name: ANTHROPIC_API_KEY, Value: Anthropic Console에서 발급한 API 키 입력

GITHUB_TOKEN은 GitHub Actions가 자동으로 제공하므로 별도 설정이 필요 없습니다.


4단계: 인라인 댓글 추가 (선택)
#

PR의 특정 줄에 인라인 댓글을 달려면 Review Comments API를 사용합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def post_inline_comment(pr, commit_sha: str, filename: str, line: int, body: str):
    """특정 줄에 인라인 리뷰 댓글 게시"""
    try:
        pr.create_review_comment(
            body=body,
            commit_id=commit_sha,
            path=filename,
            line=line,
            side="RIGHT"  # 변경 후 코드 기준
        )
    except Exception as e:
        print(f"인라인 댓글 실패 ({filename}:{line}): {e}")
        # 인라인 댓글 실패해도 요약 댓글은 게시

# main()에서 critical 이슈는 인라인 댓글로 추가
for issue in review.get("issues", []):
    if issue["severity"] == "critical" and issue.get("line"):
        inline_body = f"🔴 **{issue['description']}**\n\n{issue['suggestion']}"
        post_inline_comment(
            pr,
            HEAD_SHA,
            file.filename,
            issue["line"],
            inline_body
        )

비용 최적화 전략
#

모델 선택
#

시나리오 권장 모델 PR당 예상 비용
소규모 팀 (< 10 PR/일) claude-haiku-3-5 $0.001~0.005
중간 규모 (10~50 PR/일) claude-haiku-3-5 $0.01~0.05
대규모 (> 50 PR/일) claude-haiku-3-5 + 캐싱 $0.005~0.02

Prompt Caching 활용
#

시스템 프롬프트가 반복되므로 Anthropic의 Prompt Caching을 활용하면 비용을 90%까지 줄일 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
response = client.messages.create(
    model="claude-haiku-3-5",
    max_tokens=1500,
    system=[
        {
            "type": "text",
            "text": system_prompt,
            "cache_control": {"type": "ephemeral"}  # 캐싱 활성화
        }
    ],
    messages=[{"role": "user", "content": user_prompt}]
)

diff 크기 제한
#

1
2
3
4
5
6
MAX_DIFF_CHARS = 3000  # 파일당 diff 최대 길이

def truncate_diff(diff: str, max_chars: int = MAX_DIFF_CHARS) -> str:
    if len(diff) <= max_chars:
        return diff
    return diff[:max_chars] + f"\n... (이하 {len(diff) - max_chars}자 생략)"

리뷰 품질 향상 팁
#

언어별 전문 프롬프트
#

파일 확장자에 따라 프롬프트를 다르게 설정하면 리뷰 품질이 높아집니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
LANGUAGE_HINTS = {
    ".py": "Django/FastAPI 패턴, PEP 8, 타입 힌트 확인",
    ".ts": "TypeScript strict 모드, React 훅 규칙, 비동기 처리 확인",
    ".go": "goroutine 누수, 에러 핸들링, defer 사용 확인",
    ".sql": "인덱스 사용 여부, N+1 쿼리, SQL injection 확인",
}

def get_language_hint(filename: str) -> str:
    for ext, hint in LANGUAGE_HINTS.items():
        if filename.endswith(ext):
            return f"\n추가 검토 사항: {hint}"
    return ""

리뷰 제외 설정
#

CODEOWNERS 또는 PR 레이블로 AI 리뷰를 건너뛸 수 있습니다.

1
2
3
4
# PR에 'skip-ai-review' 레이블이 있으면 건너뜀
def should_skip_pr(pr) -> bool:
    labels = [label.name for label in pr.labels]
    return "skip-ai-review" in labels

Node.js 버전
#

Python 대신 TypeScript/Node.js로 구현하는 경우입니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// .github/scripts/ai-review.ts
import Anthropic from "@anthropic-ai/sdk";
import { Octokit } from "@octokit/rest";

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

const [owner, repo] = process.env.REPO_NAME!.split("/");
const prNumber = parseInt(process.env.PR_NUMBER!);

async function reviewFile(filename: string, patch: string): Promise<ReviewResult> {
  const response = await client.messages.create({
    model: "claude-haiku-3-5",
    max_tokens: 1500,
    system: SYSTEM_PROMPT,
    messages: [
      {
        role: "user",
        content: `파일: ${filename}\n\n\`\`\`\n${patch.slice(0, 3000)}\n\`\`\``,
      },
    ],
  });

  return JSON.parse(response.content[0].type === "text" ? response.content[0].text : "{}");
}

async function main() {
  const { data: files } = await octokit.pulls.listFiles({
    owner, repo, pull_number: prNumber,
  });

  const reviews = await Promise.all(
    files
      .filter((f) => f.patch && !shouldSkip(f.filename))
      .slice(0, 10)
      .map((f) => reviewFile(f.filename, f.patch!))
  );

  await postSummaryComment(octokit, owner, repo, prNumber, reviews);
}

main().catch(console.error);

package.json 설정:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "scripts": {
    "review": "ts-node .github/scripts/ai-review.ts"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.30.0",
    "@octokit/rest": "^20.0.0"
  },
  "devDependencies": {
    "ts-node": "^10.9.0",
    "typescript": "^5.0.0"
  }
}

실제 운영 시 주의사항
#

보안
#

API 키 노출 방지: Secrets를 사용하고 절대 코드에 직접 넣지 않습니다. 설령 private 리포지토리라도 API 키가 커밋에 들어가면 위험합니다.

포크된 PR 주의: 외부 기여자의 포크 PR은 Secrets에 접근할 수 없습니다. 이 경우 pull_request_target 트리거를 사용해야 하지만, 보안 위험이 있으므로 신중하게 설정합니다.

1
2
3
4
5
6
7
8
9
# 포크 PR 안전하게 처리하기
on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  ai-review:
    # 외부 기여자 PR은 승인 후 실행
    environment: ${{ github.event.pull_request.head.repo.full_name != github.repository && 'external' || 'internal' }}

비용 모니터링
#

Anthropic Console에서 사용량 알림을 설정해두면 예상치 못한 비용 급증을 방지할 수 있습니다.

1
2
3
4
5
6
7
# 월별 비용 한도 초과 시 리뷰 건너뜀 (간단한 방어 로직)
import datetime

def is_within_monthly_budget(max_reviews_per_day: int = 50) -> bool:
    # 실제로는 DB나 외부 카운터로 추적
    # 여기서는 예시만 제시
    return True

리뷰 피드백 수집
#

AI 리뷰의 품질을 측정하려면 개발자가 댓글에 👍/👎 반응을 달도록 유도하고, 이를 주기적으로 분석해서 프롬프트를 개선합니다.


결과 측정
#

저희 팀에서 6개월 운영한 결과입니다.

지표 도입 전 도입 후
평균 리뷰 대기 시간 18시간 2시간
AI가 선발견한 버그 비율 - 67%
인간 리뷰 집중도 코드 스타일 60% 설계·로직 85%
월 리뷰 비용 인건비 포함 高 API 비용 $30~50

AI 리뷰는 인간 리뷰를 대체하는 것이 아니라 보완하는 역할입니다. 명백한 이슈를 먼저 걸러주면, 인간 리뷰어가 훨씬 더 중요한 관점에 집중할 수 있습니다. 지금 PR 리뷰 병목이 있는 팀이라면, 반나절의 설정 시간으로 큰 효과를 볼 수 있을 것입니다.