코드 리뷰는 팀의 코드 품질을 높이는 가장 중요한 활동 중 하나입니다. 하지만 현실에서는 리뷰어가 바쁘거나, 작은 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 설정
#
GitHub 리포지토리 → Settings → Secrets and variables → Actions
New repository secret 클릭
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 리뷰 병목이 있는 팀이라면, 반나절의 설정 시간으로 큰 효과를 볼 수 있을 것입니다.