LLM 기반 코드 분석을 CI/CD 파이프라인에 통합하는 방법을 학습합니다. PR별 자동 분석, 품질 게이트, 기술 부채 대시보드와 GitHub Actions 구축을 다룹니다.
코드 분석이 일회성 이벤트가 아닌 지속적인 프로세스가 되려면 CI/CD 파이프라인에 통합되어야 합니다. 모든 코드 변경이 자동으로 분석되고, 품질 기준을 충족하지 못하면 머지가 차단되는 체계가 필요합니다.
첫째, 빠른 피드백. 개발자가 PR을 올린 직후 분석 결과를 받아야 합니다. 분석이 30분 걸리면 개발자는 다른 작업으로 넘어가고, 피드백의 효과가 급감합니다.
둘째, 점진적 개선. 기존 코드베이스의 모든 문제를 한꺼번에 차단하면 개발이 멈춥니다. "새로 추가되는 코드"에 대해서만 규칙을 적용하고, 기존 문제는 별도 계획으로 해소합니다.
셋째, 실행 가능한 제안. "복잡도가 높습니다"라는 경고보다 "이 함수를 세 개로 분리하세요"라는 구체적 제안이 효과적입니다. LLM이 바로 이 역할을 합니다.
name: Code Analysis
on:
pull_request:
branches: [main, develop]
paths:
- "src/**"
- "lib/**"
permissions:
contents: read
pull-requests: write
jobs:
analyze:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: 의존성 설치
run: pip install -r requirements-analysis.txt
- name: 변경 파일 목록 추출
id: changed
run: |
FILES=$(git diff --name-only origin/main...HEAD -- '*.py' '*.ts')
echo "files=$FILES" >> $GITHUB_OUTPUT
- name: AST 기반 메트릭 분석
run: |
python scripts/analyze_metrics.py \
--files="${{ steps.changed.outputs.files }}" \
--output=metrics.json
- name: 코드 스멜 탐지
run: |
python scripts/detect_smells.py \
--metrics=metrics.json \
--output=smells.json
- name: LLM 심층 분석
if: steps.changed.outputs.files != ''
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
python scripts/llm_analysis.py \
--smells=smells.json \
--output=analysis.json
- name: 품질 게이트 평가
id: gate
run: |
python scripts/quality_gate.py \
--analysis=analysis.json \
--policy=.quality-gate.yml
- name: PR 코멘트 작성
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const analysis = JSON.parse(
fs.readFileSync('analysis.json', 'utf8')
);
const report = buildReport(analysis);
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: report,
});#!/usr/bin/env python3
"""PR 변경 파일의 메트릭을 분석하는 스크립트"""
import argparse
import ast
import json
from pathlib import Path
def analyze_file(filepath: str) -> dict:
source = Path(filepath).read_text()
tree = ast.parse(source)
functions = []
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
complexity = calculate_complexity(node)
functions.append({
"name": node.name,
"line_start": node.lineno,
"line_end": node.end_lineno,
"complexity": complexity,
"param_count": len(node.args.args),
"line_count": (node.end_lineno or node.lineno) - node.lineno + 1,
})
return {
"filepath": filepath,
"total_lines": len(source.splitlines()),
"function_count": len(functions),
"functions": functions,
"avg_complexity": (
sum(f["complexity"] for f in functions) / len(functions)
if functions else 0
),
"max_complexity": (
max(f["complexity"] for f in functions)
if functions else 0
),
}
def calculate_complexity(node: ast.AST) -> int:
complexity = 1
for child in ast.walk(node):
if isinstance(child, (ast.If, ast.While, ast.For, ast.ExceptHandler)):
complexity += 1
elif isinstance(child, ast.BoolOp):
complexity += len(child.values) - 1
return complexity
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--files", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
files = [f.strip() for f in args.files.split() if f.strip()]
results = []
for filepath in files:
if filepath.endswith(".py") and Path(filepath).exists():
try:
results.append(analyze_file(filepath))
except SyntaxError:
results.append({
"filepath": filepath,
"error": "구문 분석 실패",
})
with open(args.output, "w") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
if __name__ == "__main__":
main()품질 게이트는 코드가 머지되기 위해 충족해야 하는 최소 기준입니다. 정책은 YAML로 정의합니다.
# 품질 게이트 정책
version: 1
# 새 코드에만 적용되는 규칙
new_code:
max_complexity: 15
max_function_length: 50
max_file_length: 400
max_parameters: 5
min_test_coverage: 0.8
# 전체 코드베이스 규칙 (경고만)
overall:
max_avg_complexity: 8
max_tech_debt_hours: 200
min_code_health: 6
# 게이트 동작
actions:
block:
- new_code.max_complexity
- new_code.max_function_length
warn:
- new_code.max_parameters
- overall.max_avg_complexity
info:
- overall.max_tech_debt_hours
# 예외 허용
exceptions:
files:
- "**/*_test.py"
- "**/*.test.ts"
- "**/migrations/**"#!/usr/bin/env python3
"""품질 게이트 평가 스크립트"""
import argparse
import json
from dataclasses import dataclass
import yaml
@dataclass
class GateResult:
passed: bool
blockers: list[str]
warnings: list[str]
info: list[str]
class QualityGate:
def __init__(self, policy_path: str):
with open(policy_path) as f:
self.policy = yaml.safe_load(f)
def evaluate(self, analysis: list[dict]) -> GateResult:
blockers = []
warnings = []
info_items = []
new_code_rules = self.policy.get("new_code", {})
for file_result in analysis:
if file_result.get("error"):
continue
filepath = file_result["filepath"]
# 예외 파일 건너뛰기
if self._is_exception(filepath):
continue
for func in file_result.get("functions", []):
# 복잡도 검사
max_cx = new_code_rules.get("max_complexity", 15)
if func["complexity"] > max_cx:
blockers.append(
f"{filepath}:{func['line_start']} "
f"- {func['name']}의 순환 복잡도가 "
f"{func['complexity']}입니다 (최대: {max_cx})"
)
# 함수 길이 검사
max_len = new_code_rules.get("max_function_length", 50)
if func["line_count"] > max_len:
blockers.append(
f"{filepath}:{func['line_start']} "
f"- {func['name']}이(가) {func['line_count']}줄입니다 "
f"(최대: {max_len})"
)
# 매개변수 수 검사
max_params = new_code_rules.get("max_parameters", 5)
if func["param_count"] > max_params:
warnings.append(
f"{filepath}:{func['line_start']} "
f"- {func['name']}의 매개변수가 "
f"{func['param_count']}개입니다 (권장: {max_params}개 이하)"
)
return GateResult(
passed=len(blockers) == 0,
blockers=blockers,
warnings=warnings,
info=info_items,
)
def _is_exception(self, filepath: str) -> bool:
exceptions = self.policy.get("exceptions", {}).get("files", [])
from fnmatch import fnmatch
return any(fnmatch(filepath, pattern) for pattern in exceptions)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--analysis", required=True)
parser.add_argument("--policy", required=True)
args = parser.parse_args()
with open(args.analysis) as f:
analysis = json.load(f)
gate = QualityGate(args.policy)
result = gate.evaluate(analysis)
print(f"품질 게이트: {'통과' if result.passed else '실패'}")
if result.blockers:
print(f"\n차단 사항 ({len(result.blockers)}건):")
for b in result.blockers:
print(f" - {b}")
if result.warnings:
print(f"\n경고 ({len(result.warnings)}건):")
for w in result.warnings:
print(f" - {w}")
if not result.passed:
exit(1)
if __name__ == "__main__":
main()품질 게이트는 팀의 성숙도에 맞게 점진적으로 강화해야 합니다. 처음에는 극단적인 경우(복잡도 30 이상, 함수 100줄 이상)만 차단하고, 팀이 적응하면 기준을 점차 낮춥니다. 갑자기 엄격한 기준을 적용하면 개발 속도가 급감하고 팀의 반발을 초래합니다.
품질 게이트를 통과하지 못한 코드에 대해 LLM이 구체적인 리팩터링 제안을 PR 코멘트로 작성합니다.
#!/usr/bin/env python3
"""LLM 기반 심층 분석 및 리팩터링 제안"""
import argparse
import json
from anthropic import Anthropic
REVIEW_PROMPT = """코드 리뷰어로서 다음 코드 변경을 분석하세요.
파일: {filepath}
문제가 있는 함수:
{code}
감지된 메트릭:
- 순환 복잡도: {complexity}
- 줄 수: {line_count}
- 매개변수 수: {param_count}
다음 형식으로 리뷰를 작성하세요:
## 문제 요약
(한 문장으로 핵심 문제를 설명)
## 리팩터링 제안
(구체적인 개선 방안을 코드와 함께 제시)
### 예상 효과
(리팩터링 후 개선되는 메트릭)"""
def analyze_with_llm(
smells_data: list[dict],
api_key: str,
) -> list[dict]:
client = Anthropic(api_key=api_key)
results = []
for file_data in smells_data:
filepath = file_data["filepath"]
for smell in file_data.get("smells", []):
prompt = REVIEW_PROMPT.format(
filepath=filepath,
code=smell["code"],
complexity=smell.get("complexity", "N/A"),
line_count=smell.get("line_count", "N/A"),
param_count=smell.get("param_count", "N/A"),
)
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
results.append({
"filepath": filepath,
"line": smell.get("line_start", 0),
"review": response.content[0].text,
"severity": smell.get("severity", "medium"),
})
return results
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--smells", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
import os
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
print("ANTHROPIC_API_KEY가 설정되지 않았습니다")
exit(1)
with open(args.smells) as f:
smells_data = json.load(f)
results = analyze_with_llm(smells_data, api_key)
with open(args.output, "w") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
if __name__ == "__main__":
main()생성된 분석 결과를 PR 코멘트로 변환하면 다음과 같은 형태가 됩니다.
## Code Analysis Report
### 품질 게이트: 실패
**차단 사항 (2건)**
- `src/services/order.py:45` - process_order의 순환 복잡도가 23입니다 (최대: 15)
- `src/services/order.py:45` - process_order이(가) 78줄입니다 (최대: 50)
---
### process_order 리팩터링 제안
**문제 요약**: 주문 처리 함수가 유효성 검사, 가격 계산, 재고 관리, 알림까지
다섯 가지 책임을 가지고 있어 순환 복잡도가 23에 달합니다.
**리팩터링 제안**: 각 책임을 별도 함수로 추출하고,
오케스트레이션 패턴을 적용하세요.
**예상 효과**: 순환 복잡도 23에서 5 이하로, 함수 길이 78줄에서 15줄 이하로 감소#!/usr/bin/env python3
"""분석 결과를 시계열 데이터로 저장"""
import json
from datetime import datetime
from pathlib import Path
def collect_trend_data(analysis_path: str, output_dir: str):
with open(analysis_path) as f:
analysis = json.load(f)
today = datetime.now().strftime("%Y-%m-%d")
trend_entry = {
"date": today,
"total_files": len(analysis),
"total_functions": sum(
len(f.get("functions", []))
for f in analysis if not f.get("error")
),
"avg_complexity": round(
sum(
f.get("avg_complexity", 0)
for f in analysis if not f.get("error")
) / max(len(analysis), 1),
2,
),
"max_complexity": max(
(f.get("max_complexity", 0) for f in analysis),
default=0,
),
"high_complexity_count": sum(
1 for f in analysis
for func in f.get("functions", [])
if func.get("complexity", 0) > 15
),
"smell_count": sum(
len(f.get("smells", []))
for f in analysis
),
}
# 기존 트렌드 데이터에 추가
output_path = Path(output_dir) / "trends.json"
trends = []
if output_path.exists():
trends = json.loads(output_path.read_text())
trends.append(trend_entry)
# 최근 90일만 유지
if len(trends) > 90:
trends = trends[-90:]
output_path.write_text(
json.dumps(trends, indent=2, ensure_ascii=False)
)트렌드 데이터는 단순한 현재 상태보다 훨씬 가치 있습니다. "현재 코드 스멜이 10건"보다 "지난 8주간 코드 스멜이 25건에서 10건으로 감소"가 팀의 노력을 더 잘 보여줍니다. 이런 가시적 개선은 코드 품질 투자에 대한 경영진의 지지를 확보하는 데에도 도움이 됩니다.
기존 SonarQube 파이프라인에 LLM 분석을 추가하여 더 정교한 코드 리뷰를 구현합니다.
name: SonarQube + AI Analysis
on:
pull_request:
branches: [main]
jobs:
sonar:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: SonarQube 스캔
uses: sonarsource/sonarqube-scan-action@v2
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- name: SonarQube 결과 대기
uses: sonarsource/sonarqube-quality-gate-action@v1
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
- name: SonarQube 이슈 추출
run: |
curl -s -u "${{ secrets.SONAR_TOKEN }}:" \
"${{ secrets.SONAR_HOST_URL }}/api/issues/search?componentKeys=my-project&resolved=false" \
> sonar-issues.json
- name: LLM 기반 이슈 분석 및 수정 제안
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
python scripts/enhance_sonar_issues.py \
--issues=sonar-issues.json \
--output=enhanced-analysis.json
- name: PR 코멘트 작성
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const analysis = JSON.parse(
fs.readFileSync('enhanced-analysis.json', 'utf8')
);
// PR 코멘트 작성 로직CI/CD에 코드 분석을 통합하면 코드 품질 관리가 일회성 활동이 아닌 지속적 프로세스가 됩니다. 핵심은 빠른 피드백, 점진적 개선, 실행 가능한 제안의 세 가지 원칙입니다.
GitHub Actions 워크플로우로 PR별 자동 분석을 구축하고, YAML 기반 품질 게이트로 머지 기준을 정의하며, LLM이 구체적인 리팩터링 제안을 PR 코멘트로 제공합니다. 트렌드 대시보드는 시간에 따른 코드 품질 변화를 가시화하여 팀의 개선 노력을 증명합니다.
마지막 10장에서는 지금까지 학습한 모든 기법을 종합하여 실전 LLM 코드 분석 파이프라인을 구축합니다. AST 추출에서 스멜 감지, 리팩터링 제안, 검증, 적용까지 전체 과정을 하나의 프로젝트로 완성하고, 레거시 프로젝트 현대화 사례와 도입 가이드를 제공합니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
AST 추출부터 코드 스멜 감지, 리팩터링 제안, 검증, 적용까지 전체 파이프라인을 구축하는 실전 프로젝트입니다. 레거시 프로젝트 현대화 사례와 도입 가이드를 포함합니다.
LLM을 활용한 아키텍처 분석, 순환 의존성 감지, 레이어 위반 탐지, 마이크로서비스 경계 제안과 아키텍처 다이어그램 자동 생성을 학습합니다.
SAST와 LLM을 결합한 보안 취약점 탐지, OWASP Top 10 자동 검출, 취약점 자동 수정 제안과 CI/CD 보안 게이트 구축을 학습합니다.