본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 3장: AI 코드 리뷰 실전 구축 - GitHub Actions 통합
2026년 1월 23일·AI / ML·

3장: AI 코드 리뷰 실전 구축 - GitHub Actions 통합

GitHub Actions를 활용하여 PR에 자동으로 AI 코드 리뷰를 수행하는 시스템을 직접 구축하고, 실전에서 활용 가능한 수준으로 완성합니다.

18분1,124자7개 섹션
devtoolsautomationcode-qualitydevopsllm
공유
ai-dev-workflow3 / 10
12345678910
이전2장: AI 코드 리뷰 자동화 - 원리와 아키텍처다음4장: AI 기반 테스트 자동 생성

구현 개요

2장에서 설계한 아키텍처를 실제로 구현합니다. GitHub Actions 워크플로우로 동작하는 AI 코드 리뷰 시스템을 구축하며, PR이 생성될 때 자동으로 코드 리뷰를 수행하고 결과를 PR 코멘트로 게시합니다.

구현할 시스템의 전체 흐름을 다시 한번 확인합니다.

text
동작 흐름:
 
  1. 개발자가 PR 생성
  2. GitHub Actions 워크플로우 트리거
  3. PR의 변경 파일과 diff 수집
  4. 파일별 맥락 수집 및 분류
  5. LLM API에 리뷰 요청
  6. 응답 파싱 및 필터링
  7. PR에 인라인 코멘트 및 요약 게시

GitHub Actions 워크플로우 구성

워크플로우 파일 작성

리뷰 시스템의 진입점인 GitHub Actions 워크플로우 파일을 작성합니다.

.github/workflows/ai-review.yml
yaml
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.js
Warning

GitHub Actions 환경에서 secrets를 환경 변수로 주입할 때, 반드시 GitHub Repository Settings의 Secrets에 API 키를 등록해야 합니다. 코드에 API 키를 직접 포함하면 보안 사고로 이어집니다.

리뷰 대상 필터링

모든 PR을 리뷰할 필요는 없습니다. 의존성 업데이트, 포맷팅 변경, 문서 수정 등은 AI 리뷰의 가치가 낮습니다.

src/filter.ts
typescript
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)
  })
}

핵심 리뷰 엔진 구현

diff 파싱과 구조화

GitHub API에서 가져온 PR의 diff를 구조화된 형태로 변환합니다.

src/diff-parser.ts
typescript
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
}

LLM 호출과 응답 파싱

Anthropic API를 호출하여 코드 리뷰를 수행하고, 구조화된 응답을 파싱합니다.

src/llm-reviewer.ts
typescript
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의 경우 파일별로 병렬 리뷰를 수행하여 지연 시간을 줄입니다.

src/parallel-review.ts
typescript
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에 리뷰 결과 게시

인라인 코멘트 생성

리뷰 결과를 PR의 해당 줄에 인라인 코멘트로 게시합니다. GitHub의 Pull Request Review API를 사용합니다.

src/github-commenter.ts
typescript
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)는 이전 리뷰 이후 변경된 부분만 추가 리뷰합니다.

src/incremental-review.ts
typescript
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-review.yml
yaml
# 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: true
src/config-loader.ts
typescript
import { 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에 배포하기 전에 로컬에서 테스트하는 방법을 설명합니다.

src/local-test.ts
typescript
// 로컬 테스트용 스크립트
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))
}

일반적인 문제와 해결

실전에서 자주 발생하는 문제와 해결 방법을 정리합니다.

text
문제 1: LLM이 유효하지 않은 줄 번호를 반환
  원인: diff의 줄 번호와 파일의 줄 번호 혼동
  해결: diff 기반 줄 번호 매핑 테이블 구성,
        유효하지 않은 줄 번호의 코멘트 필터링
 
문제 2: JSON 파싱 실패
  원인: LLM이 JSON 외 텍스트를 포함하여 응답
  해결: JSON 블록만 추출하는 파서 적용,
        재시도 로직 추가
 
문제 3: 비용 초과
  원인: 대규모 PR에서 과도한 토큰 사용
  해결: 파일 분류를 통한 선택적 리뷰,
        토큰 예산 관리 로직 적용
 
문제 4: 할루시네이션 (존재하지 않는 문제 지적)
  원인: LLM의 고유한 한계
  해결: confidence 점수 기반 필터링,
        실제 코드와 코멘트 내용 교차 검증
Info

AI 코드 리뷰는 인간 리뷰를 대체하는 것이 아닙니다. AI가 반복적이고 기계적인 검사를 수행하여 인간 리뷰어가 설계 결정, 비즈니스 로직 검증 등 고수준의 리뷰에 집중할 수 있도록 보완하는 역할입니다.

정리

이 장에서는 AI 코드 리뷰 시스템을 GitHub Actions 기반으로 구현했습니다. diff 파싱, LLM 호출, 응답 파싱, PR 코멘트 게시, 점진적 리뷰, 설정 파일을 통한 커스터마이징까지 실전에 필요한 모든 구성 요소를 다루었습니다.

핵심 구현 사항을 정리합니다.

  • diff를 구조화된 형태로 파싱하여 LLM에 효과적으로 전달합니다
  • 파일별 병렬 리뷰로 지연 시간을 최소화합니다
  • 점진적 리뷰로 중복 피드백을 방지합니다
  • 설정 파일로 프로젝트별 리뷰 기준을 커스터마이징합니다

다음 장에서는 AI 기반 테스트 자동 생성을 다룹니다. 코드 변경을 분석하여 의미 있는 테스트를 자동으로 생성하는 시스템을 구축합니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#devtools#automation#code-quality#devops#llm

관련 글

AI / ML

4장: AI 기반 테스트 자동 생성

코드 변경을 분석하여 단위 테스트와 통합 테스트를 자동으로 생성하는 시스템을 구축하고, 테스트 품질을 검증하는 방법을 다룹니다.

2026년 1월 25일·18분
AI / ML

2장: AI 코드 리뷰 자동화 - 원리와 아키텍처

LLM이 코드를 이해하고 리뷰 피드백을 생성하는 원리를 분석하고, AI 코드 리뷰 시스템의 아키텍처를 설계합니다.

2026년 1월 21일·20분
AI / ML

5장: AI 기반 문서화 자동화

코드 변경에 따라 API 문서, README, 변경 로그를 AI로 자동 갱신하는 시스템을 구축하고, 문서와 코드의 동기화를 유지하는 전략을 다룹니다.

2026년 1월 27일·19분
이전 글2장: AI 코드 리뷰 자동화 - 원리와 아키텍처
다음 글4장: AI 기반 테스트 자동 생성

댓글

목차

약 18분 남음
  • 구현 개요
  • GitHub Actions 워크플로우 구성
    • 워크플로우 파일 작성
    • 리뷰 대상 필터링
  • 핵심 리뷰 엔진 구현
    • diff 파싱과 구조화
    • LLM 호출과 응답 파싱
    • 파일별 병렬 리뷰
  • PR에 리뷰 결과 게시
    • 인라인 코멘트 생성
    • 점진적 리뷰
  • 설정 파일을 통한 커스터마이징
  • 실전 테스트와 디버깅
    • 로컬 테스트
    • 일반적인 문제와 해결
  • 정리