GitHub Actions를 활용하여 PR에 자동으로 AI 코드 리뷰를 수행하는 시스템을 직접 구축하고, 실전에서 활용 가능한 수준으로 완성합니다.
2장에서 설계한 아키텍처를 실제로 구현합니다. GitHub Actions 워크플로우로 동작하는 AI 코드 리뷰 시스템을 구축하며, PR이 생성될 때 자동으로 코드 리뷰를 수행하고 결과를 PR 코멘트로 게시합니다.
구현할 시스템의 전체 흐름을 다시 한번 확인합니다.
동작 흐름:
1. 개발자가 PR 생성
2. GitHub Actions 워크플로우 트리거
3. PR의 변경 파일과 diff 수집
4. 파일별 맥락 수집 및 분류
5. LLM API에 리뷰 요청
6. 응답 파싱 및 필터링
7. PR에 인라인 코멘트 및 요약 게시리뷰 시스템의 진입점인 GitHub Actions 워크플로우 파일을 작성합니다.
name: AI Code Review
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
# 봇이 생성한 PR은 스킵
if: github.actor != 'dependabot[bot]'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm install
- name: Run AI Review
env:
GITHUB_TOKEN: $GITHUB_TOKEN_VALUE
ANTHROPIC_API_KEY: $ANTHROPIC_KEY_VALUE
run: node scripts/ai-review.jsGitHub Actions 환경에서 secrets를 환경 변수로 주입할 때, 반드시 GitHub Repository Settings의 Secrets에 API 키를 등록해야 합니다. 코드에 API 키를 직접 포함하면 보안 사고로 이어집니다.
모든 PR을 리뷰할 필요는 없습니다. 의존성 업데이트, 포맷팅 변경, 문서 수정 등은 AI 리뷰의 가치가 낮습니다.
interface FilterConfig {
// 리뷰 제외 파일 패턴
excludePatterns: string[]
// 리뷰 제외 PR 라벨
excludeLabels: string[]
// 최소 변경 줄 수
minChanges: number
// 최대 변경 줄 수 (너무 큰 PR은 분할 권장)
maxChanges: number
}
const defaultConfig: FilterConfig = {
excludePatterns: [
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"*.min.js",
"*.min.css",
"*.generated.*",
"dist/**",
"build/**",
],
excludeLabels: ["skip-review", "dependencies", "formatting"],
minChanges: 5,
maxChanges: 2000,
}
function shouldReviewFile(
filename: string,
config: FilterConfig
): boolean {
return !config.excludePatterns.some(pattern => {
return minimatch(filename, pattern)
})
}GitHub API에서 가져온 PR의 diff를 구조화된 형태로 변환합니다.
interface DiffHunk {
// 원본 파일 시작 줄과 줄 수
oldStart: number
oldLines: number
// 새 파일 시작 줄과 줄 수
newStart: number
newLines: number
// 변경 줄 목록
changes: DiffLine[]
}
interface DiffLine {
type: "add" | "delete" | "context"
lineNumber: number // 새 파일 기준 줄 번호
content: string
}
function parsePatch(patch: string): DiffHunk[] {
const hunks: DiffHunk[] = []
const lines = patch.split("\n")
let currentHunk: DiffHunk | null = null
let newLineNum = 0
for (const line of lines) {
// hunk 헤더: @@ -old,count +new,count @@
const hunkMatch = line.match(
/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/
)
if (hunkMatch) {
currentHunk = {
oldStart: parseInt(hunkMatch[1]),
oldLines: parseInt(hunkMatch[2] || "1"),
newStart: parseInt(hunkMatch[3]),
newLines: parseInt(hunkMatch[4] || "1"),
changes: [],
}
newLineNum = currentHunk.newStart
hunks.push(currentHunk)
continue
}
if (!currentHunk) continue
if (line.startsWith("+")) {
currentHunk.changes.push({
type: "add",
lineNumber: newLineNum,
content: line.slice(1),
})
newLineNum++
} else if (line.startsWith("-")) {
currentHunk.changes.push({
type: "delete",
lineNumber: newLineNum,
content: line.slice(1),
})
} else {
currentHunk.changes.push({
type: "context",
lineNumber: newLineNum,
content: line.slice(1),
})
newLineNum++
}
}
return hunks
}Anthropic API를 호출하여 코드 리뷰를 수행하고, 구조화된 응답을 파싱합니다.
import Anthropic from "@anthropic-ai/sdk"
interface ReviewComment {
file: string
line: number
severity: "critical" | "warning" | "suggestion" | "praise"
category: string
comment: string
suggestion?: string
}
interface ReviewResult {
summary: string
comments: ReviewComment[]
overallRisk: "low" | "medium" | "high"
}
async function reviewWithLLM(
context: ReviewContext
): Promise<ReviewResult> {
const client = new Anthropic()
const systemPrompt = buildSystemPrompt(context)
const userPrompt = buildUserPrompt(context)
const response = await client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: systemPrompt,
messages: [
{ role: "user", content: userPrompt }
],
})
const text = response.content[0].type === "text"
? response.content[0].text
: ""
return parseReviewResponse(text)
}
function buildSystemPrompt(context: ReviewContext): string {
return `당신은 시니어 소프트웨어 엔지니어로서 코드 리뷰를 수행합니다.
## 리뷰 원칙
1. 구체적이고 실행 가능한 피드백을 제공합니다.
2. 문제의 근거를 설명합니다.
3. 가능한 경우 개선된 코드를 제안합니다.
4. 긍정적인 변경도 인정합니다.
5. 추측하지 않습니다. 확실하지 않으면 질문합니다.
## 리뷰 기준
- 정확성: 로직 오류, 엣지 케이스, 타입 안전성
- 보안: 인젝션, 인증, 권한, 민감 정보
- 성능: 불필요한 연산, 메모리 관리
- 가독성: 네이밍, 구조, 복잡도
- 유지보수성: 결합도, 응집도, 테스트 가능성
## 프로젝트 맥락
` + (context.projectContext?.codeStyle || "별도 코딩 컨벤션 없음") + `
## 응답 형식
반드시 다음 JSON 형식으로만 응답하세요.
{
"summary": "전체 변경에 대한 요약 (2-3문장)",
"overallRisk": "low|medium|high",
"comments": [
{
"file": "파일 경로",
"line": 줄번호,
"severity": "critical|warning|suggestion|praise",
"category": "bug|security|performance|readability|maintainability",
"comment": "피드백 내용",
"suggestion": "개선된 코드 (선택)"
}
]
}`
}대규모 PR의 경우 파일별로 병렬 리뷰를 수행하여 지연 시간을 줄입니다.
async function reviewPRParallel(
files: FileChange[],
context: ReviewContext
): Promise<ReviewResult> {
// 파일을 그룹으로 분류
const groups = groupRelatedFiles(files)
// 그룹별 병렬 리뷰
const groupResults = await Promise.all(
groups.map(group => {
const groupContext = {
...context,
changes: group,
}
return reviewWithLLM(groupContext)
})
)
// 결과 병합
return mergeReviewResults(groupResults)
}
function groupRelatedFiles(files: FileChange[]): FileChange[][] {
const groups: FileChange[][] = []
const visited = new Set<string>()
for (const file of files) {
if (visited.has(file.filename)) continue
const group = [file]
visited.add(file.filename)
// 관련 파일 찾기 (같은 디렉토리의 소스 + 테스트)
const baseName = file.filename
.replace(/\.test\.|\.spec\./, ".")
.replace(/\/__tests__\//, "/")
for (const other of files) {
if (visited.has(other.filename)) continue
const otherBase = other.filename
.replace(/\.test\.|\.spec\./, ".")
.replace(/\/__tests__\//, "/")
if (baseName === otherBase || isRelated(file, other)) {
group.push(other)
visited.add(other.filename)
}
}
groups.push(group)
}
return groups
}
function isRelated(a: FileChange, b: FileChange): boolean {
// 같은 디렉토리의 파일은 관련 파일로 간주
const dirA = a.filename.split("/").slice(0, -1).join("/")
const dirB = b.filename.split("/").slice(0, -1).join("/")
return dirA === dirB
}리뷰 결과를 PR의 해당 줄에 인라인 코멘트로 게시합니다. GitHub의 Pull Request Review API를 사용합니다.
import { Octokit } from "@octokit/rest"
async function postReviewComments(
octokit: Octokit,
owner: string,
repo: string,
prNumber: number,
commitSha: string,
result: ReviewResult
): Promise<void> {
// 인라인 코멘트 구성
const comments = result.comments
.filter(c => c.severity !== "praise")
.map(comment => ({
path: comment.file,
line: comment.line,
body: formatComment(comment),
}))
// GitHub Review 생성 (인라인 코멘트 포함)
await octokit.pulls.createReview({
owner,
repo,
pull_number: prNumber,
commit_id: commitSha,
event: "COMMENT",
body: formatSummary(result),
comments,
})
}
function formatComment(comment: ReviewComment): string {
const severityLabel = {
critical: "[Critical]",
warning: "[Warning]",
suggestion: "[Suggestion]",
praise: "[Good]",
}
let body = severityLabel[comment.severity]
+ " " + comment.comment
if (comment.suggestion) {
body += "\n\n```suggestion\n"
+ comment.suggestion
+ "\n```"
}
return body
}
function formatSummary(result: ReviewResult): string {
const riskIndicator = {
low: "Low Risk",
medium: "Medium Risk",
high: "High Risk",
}
const stats = {
critical: result.comments.filter(
c => c.severity === "critical"
).length,
warning: result.comments.filter(
c => c.severity === "warning"
).length,
suggestion: result.comments.filter(
c => c.severity === "suggestion"
).length,
}
return `## AI Code Review
**Risk Level**: ` + riskIndicator[result.overallRisk] + `
**Summary**: ` + result.summary + `
| Severity | Count |
|----------|-------|
| Critical | ` + stats.critical + ` |
| Warning | ` + stats.warning + ` |
| Suggestion | ` + stats.suggestion + ` |
`
}PR에 새로운 커밋이 추가될 때마다 전체 파일을 다시 리뷰하는 것은 비효율적입니다. 점진적 리뷰(Incremental Review)는 이전 리뷰 이후 변경된 부분만 추가 리뷰합니다.
async function incrementalReview(
octokit: Octokit,
owner: string,
repo: string,
prNumber: number
): Promise<void> {
// 이전 AI 리뷰 코멘트 조회
const existingComments = await getExistingAIComments(
octokit, owner, repo, prNumber
)
// 이전 리뷰 이후 변경된 파일만 식별
const lastReviewCommit = existingComments.length > 0
? existingComments[0].commit_id
: null
const newChanges = lastReviewCommit
? await getChangesSinceCommit(
octokit, owner, repo, prNumber, lastReviewCommit
)
: await getAllChanges(octokit, owner, repo, prNumber)
if (newChanges.length === 0) {
console.log("No new changes since last review")
return
}
// 새 변경 사항만 리뷰
const context = await gatherContext(
{ repository: { owner, name: repo } } as PullRequestEvent,
newChanges
)
// 이전 피드백을 맥락으로 제공 (중복 방지)
context.projectContext.recentReviews = existingComments
.map(c => c.body)
.join("\n---\n")
const result = await reviewWithLLM(context)
// 이전 리뷰와 중복되는 피드백 제거
const filtered = filterDuplicateComments(
result.comments,
existingComments
)
result.comments = filtered
if (filtered.length > 0) {
await postReviewComments(
octokit, owner, repo, prNumber,
newChanges[0].sha, result
)
}
}프로젝트별로 리뷰 기준과 동작을 커스터마이징할 수 있도록 설정 파일을 지원합니다.
# AI 코드 리뷰 설정
# 리뷰 모델 설정
model:
provider: anthropic
name: claude-sonnet-4-20250514
maxTokens: 4096
# 파일 필터링
files:
exclude:
- "*.lock"
- "*.min.*"
- "dist/**"
- "*.generated.*"
include:
- "src/**"
- "lib/**"
# 리뷰 기준 커스터마이징
review:
# 최소 심각도 (이 이상만 코멘트)
minSeverity: suggestion
# 최대 코멘트 수
maxComments: 20
# 리뷰 포커스 영역
focus:
- security
- performance
- correctness
# 무시할 패턴
ignore:
- "TODO comments"
- "formatting"
# 프로젝트 맥락
context:
# 코딩 컨벤션 파일 경로
codeStyle: ".github/CODE_STYLE.md"
# 아키텍처 문서 경로
architecture: "docs/ARCHITECTURE.md"
# 알림 설정
notifications:
# critical 이슈 발견 시 슬랙 알림
slack:
enabled: false
webhook: ""
onCritical: trueimport { readFileSync, existsSync } from "fs"
import { parse } from "yaml"
interface ReviewConfig {
model: {
provider: string
name: string
maxTokens: number
}
files: {
exclude: string[]
include: string[]
}
review: {
minSeverity: string
maxComments: number
focus: string[]
ignore: string[]
}
context: {
codeStyle: string
architecture: string
}
}
function loadConfig(): ReviewConfig {
const configPath = ".ai-review.yml"
if (existsSync(configPath)) {
const content = readFileSync(configPath, "utf-8")
const userConfig = parse(content)
return mergeWithDefaults(userConfig)
}
return getDefaultConfig()
}
function mergeWithDefaults(
userConfig: Partial<ReviewConfig>
): ReviewConfig {
const defaults = getDefaultConfig()
return {
model: { ...defaults.model, ...userConfig.model },
files: { ...defaults.files, ...userConfig.files },
review: { ...defaults.review, ...userConfig.review },
context: { ...defaults.context, ...userConfig.context },
}
}GitHub Actions에 배포하기 전에 로컬에서 테스트하는 방법을 설명합니다.
// 로컬 테스트용 스크립트
async function localTest() {
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN,
})
// 특정 PR에 대해 리뷰 실행
const owner = "your-org"
const repo = "your-repo"
const prNumber = 42
// PR 정보 조회
const pr = await octokit.pulls.get({
owner, repo, pull_number: prNumber,
})
// 변경 파일 조회
const files = await octokit.pulls.listFiles({
owner, repo, pull_number: prNumber,
})
console.log("PR: " + pr.data.title)
console.log("Changed files: " + files.data.length)
// 리뷰 실행 (코멘트 게시 없이 결과만 출력)
const changes = files.data.map(f => ({
filename: f.filename,
status: f.status as FileChange["status"],
additions: f.additions,
deletions: f.deletions,
patch: f.patch || "",
language: detectLanguage(f.filename),
category: categorizeChange(
{ filename: f.filename } as FileChange
),
}))
const context = await gatherContext(
{
pullRequest: {
number: prNumber,
title: pr.data.title,
body: pr.data.body || "",
baseBranch: pr.data.base.ref,
headBranch: pr.data.head.ref,
},
repository: { owner, name: repo },
} as PullRequestEvent,
changes
)
const result = await reviewWithLLM(context)
console.log(JSON.stringify(result, null, 2))
}실전에서 자주 발생하는 문제와 해결 방법을 정리합니다.
문제 1: LLM이 유효하지 않은 줄 번호를 반환
원인: diff의 줄 번호와 파일의 줄 번호 혼동
해결: diff 기반 줄 번호 매핑 테이블 구성,
유효하지 않은 줄 번호의 코멘트 필터링
문제 2: JSON 파싱 실패
원인: LLM이 JSON 외 텍스트를 포함하여 응답
해결: JSON 블록만 추출하는 파서 적용,
재시도 로직 추가
문제 3: 비용 초과
원인: 대규모 PR에서 과도한 토큰 사용
해결: 파일 분류를 통한 선택적 리뷰,
토큰 예산 관리 로직 적용
문제 4: 할루시네이션 (존재하지 않는 문제 지적)
원인: LLM의 고유한 한계
해결: confidence 점수 기반 필터링,
실제 코드와 코멘트 내용 교차 검증AI 코드 리뷰는 인간 리뷰를 대체하는 것이 아닙니다. AI가 반복적이고 기계적인 검사를 수행하여 인간 리뷰어가 설계 결정, 비즈니스 로직 검증 등 고수준의 리뷰에 집중할 수 있도록 보완하는 역할입니다.
이 장에서는 AI 코드 리뷰 시스템을 GitHub Actions 기반으로 구현했습니다. diff 파싱, LLM 호출, 응답 파싱, PR 코멘트 게시, 점진적 리뷰, 설정 파일을 통한 커스터마이징까지 실전에 필요한 모든 구성 요소를 다루었습니다.
핵심 구현 사항을 정리합니다.
다음 장에서는 AI 기반 테스트 자동 생성을 다룹니다. 코드 변경을 분석하여 의미 있는 테스트를 자동으로 생성하는 시스템을 구축합니다.
이 글이 도움이 되셨나요?
코드 변경을 분석하여 단위 테스트와 통합 테스트를 자동으로 생성하는 시스템을 구축하고, 테스트 품질을 검증하는 방법을 다룹니다.
LLM이 코드를 이해하고 리뷰 피드백을 생성하는 원리를 분석하고, AI 코드 리뷰 시스템의 아키텍처를 설계합니다.
코드 변경에 따라 API 문서, README, 변경 로그를 AI로 자동 갱신하는 시스템을 구축하고, 문서와 코드의 동기화를 유지하는 전략을 다룹니다.