
Spring AI를 활용한 코드 리뷰 (Code Review) 에이전트
요약
Spring AI를 사용하여 Pull Request의 변경 사항을 분석하고 리뷰 코멘트를 작성하는 AI 에이전트 구현 방법을 소개합니다. 모델의 환각을 방지하기 위해 결정론적 코드를 활용한 검증 레이어를 아키텍처에 포함하는 것이 핵심입니다.
핵심 포인트
- Spring AI 기반의 코드 리뷰 에이전트 구축 가이드
- LLM의 환각 및 잘못된 도구 호출을 방지하기 위한 결정론적 검증 레이어 도입
- GitHub CLI를 활용한 PR diff 및 파일 목록 컨텍스트 획득
- 격리된 로컬 워크스페이스 환경에서 에이전트 도구 실행
AI 코드 리뷰 에이전트는 저녁 한나절이면 쉽게 만들 수 있습니다. diff를 가져와서 LLM (Large Language Model)에 보내고, Pull Request (PR)에 대한 코멘트를 반환하도록 요청하면 됩니다.
하지만 이런 종류의 봇은 빠르게 불쾌한 현실에 직면합니다. 모델이 diff에 존재하지 않는 줄을 자신 있게 지목할 수 있습니다. 사소한 발견에 대해 REQUEST_CHANGES를 설정할 수 있습니다. 도구(tools)를 너무 많이 호출할 수 있습니다. 유효해 보이지만 GitHub API를 깨뜨리는 JSON을 반환할 수 있습니다.
아키텍처 원칙은 간단합니다. 모델은 문제를 찾는 것을 돕지만, GitHub에 게시할 내용이 안전한지 결정하는 것은 결정론적 코드 (deterministic code)입니다.
에이전트가 하는 일
에이전트는 저장소(repository)와 Pull Request 번호를 받습니다. 그런 다음 다음 과정을 수행합니다:
- 로컬 워크스페이스 (workspace) 준비
gh를 통해 PR diff 및 변경된 파일 목록 획득- 제한된 저장소 읽기 도구(tools)를 가진 Spring AI 에이전트 실행
- 모델로부터 구조화된 결과(findings) 수신
- diff를 기준으로 결과 검증
- GitHub 리뷰 페이로드 (payload) 생성
- 유효한 인라인 코멘트가 있는 경우
gh api를 통해 리뷰 제출
로컬 워크스페이스 준비
리뷰가 시작되기 전에 애플리케이션은 Pull Request를 위한 새로운 로컬 워크스페이스를 생성합니다. 워크스페이스는 프로젝트 디렉토리 아래에 위치합니다. 각 실행은 깨끗한 상태에서 시작됩니다. 이전 워크스페이스는 제거되고, 새 워크스페이스가 생성되며, 대상 저장소가 그곳에 클론(clone)되고, 요청된 Pull Request가 해당 로컬 클론에서 체크아웃(checkout)됩니다. 이 로컬 체크아웃은 에이전트 도구들이 사용할 수 있는 유일한 저장소 컨텍스트(context)입니다. 모델이 파일을 읽거나, 텍스트를 검색하거나, glob 패턴으로 파일을 찾으라고 요청할 때, 이러한 도구들은 전체 머신이 아닌 해당 체크아웃 내부에서 작동합니다.
PR 컨텍스트 가져오기
모델이 호출되기 전에, 애플리케이션은 GitHub CLI를 통해 두 가지 PR 컨텍스트(PR context)를 수집합니다: 전체 diff(차이점)와 변경된 파일 목록입니다.
String diff = commandRunner.run(List.of(
"gh", "pr", "diff", Integer.toString(prNumber),
"--repo", repo
...
Diff는 주요 리뷰 입력값입니다. 변경된 파일 목록은 리뷰 범위(review scope)를 설명하고 모델이 무엇이 변경되었는지 이해하도록 돕는 용도로만 사용됩니다.
두 값 모두 신뢰할 수 없는 텍스트(untrusted text)로 취급됩니다. 이 값들은 프롬프트(prompt)에 전달되지만, 무엇이 게시될지를 결정하지는 않습니다. 최종 코멘트는 나중에 반드시 검증 레이어(validation layer)를 통과해야 합니다.
모델이 발견 사항(findings)을 반환함
첫 번째 중요한 결정 사항: 모델은 GitHub 페이로드(payload)를 직접 생성해서는 안 됩니다.
대신, 내부 findings 모델을 반환합니다:
public record ReviewFinding(
String path,
int line,
...
모델은 "파일 X의 Y번 라인에서 문제를 발견했습니다"라고 말할 수 있습니다. 하지만 다음과 같은 사항에 대해서는 최종 결정을 내리지 않습니다:
- 해당 라인에 코멘트를 달 수 있는지 여부
- 리뷰가 머지(merge)를 차단해야 하는지 여부
- GitHub에 몇 개의 코멘트가 전송될지 여부
이러한 방식을 통해 LLM은 리뷰어(reviewer)의 역할을 유지하면서도, 최종 Pull Request 리뷰를 구축하는 책임은 지지 않게 됩니다.
수동 도구 루프 (Manual tool loop)
Spring AI 2는 ToolCallingAdvisor를 통해 도구(tools)를 자동으로 실행할 수 있습니다. 일반적인 챗봇의 경우 이는 매우 편리합니다. 모델이 도구를 요청하면 프레임워크가 이를 실행하고, 모델이 대화를 이어가는 방식이기 때문입니다.
하지만 리뷰 에이전트(review agent)에게는 이것만으로는 부족합니다. 리뷰당 도구 호출(tool calls) 횟수에 대한 엄격한 제한이 필요합니다. 그렇지 않으면 잘못된 프롬프트나 모델의 부적절한 결정으로 인해 리뷰가 끝없는 저장소 조사로 변질될 수 있습니다.
예산 제어(Budget control) 또한 중요한 부분입니다. 각 도구 호출은 보통 또 다른 모델 호출로 이어지며, 도구 결과는 다시 컨텍스트(context)에 추가됩니다. 모델이 더 많은 파일과 검색 결과를 요청할수록 다음 리뷰 단계에 참여하는 토큰(tokens)의 양도 늘어납니다. 제한이 없다면 단일 Pull Request(PR)의 비용을 예측하기 어려워집니다.
MAX_TOOL_CALLS 제한은 명확한 경계를 정의합니다. 즉, 에이전트가 몇 가지 구체적인 가설을 검증할 수는 있지만, 리뷰를 해결책을 찾기 위한 무제한적인 탐색으로 만들 수는 없게 합니다.
이를 위해 에이전트는 사용자가 제어하는 도구 실행 방식을 사용합니다. ChatModel을 직접 호출하고, 명시적인 루프(loop) 내에서 ToolCallingManager를 통해 도구 호출을 실행합니다. 애플리케이션이 루프를 소유하고 있으므로, 최종 모델 답변 또한 명시적으로 파싱합니다. 상위 수준의 ChatClient.entity(...) 흐름 대신, 에이전트는 Spring AI의 BeanOutputConverter를 사용합니다. 이는 프롬프트에 예상되는 응답 형식을 추가하고, 최종 모델 메시지를 ReviewFindingsPayload로 변환합니다.
전체 클래스는 다음과 같습니다:
@Service
final class ReviewAgent {
...
변환에 실패하면 아무것도 제출되지 않은 상태에서 리뷰가 중단됩니다. 알 수 없는 열거형(enum) 값은 변환 과정에서 실패하며, 성공적으로 변환된 결과물이라도 나중에 검증 계층(validation layer)을 거치게 됩니다. 이 단계에서 빈 값, 중복된 댓글, 잘못된 diff 좌표, 그리고 0 또는 음수 행 번호 등이 필터링됩니다.
이러한 선택은 ToolCallingAdvisor와 상충하지 않습니다. 재귀적 어드바이저 (recursive advisors)는 여전히 많은 시나리오에서 Spring 네이티브의 훌륭한 기본값으로 유지됩니다. 하지만 여기서는 에이전트가 도구 호출 (tool calls) 횟수와 한 번의 리뷰에 할당된 예산에 대해 더 직접적인 제어를 할 필요가 있으므로, 루프를 애플리케이션 내부로 이동시켰습니다. Spring AI는 현재 간단한 내장 maxToolCalls 기능을 제공하지 않습니다. spring-ai#3333을 참조하세요. 재귀적 어드바이저에 대한 자세한 내용은 Spring article을 참조하십시오.
도구 제한 사항 (Tool limitations)
에이전트는 diff 외부의 컨텍스트를 확인하기 위해 도구가 필요합니다. 예를 들어, 기존 메서드, 사용처, 설정 또는 테스트를 조사해야 할 수도 있습니다.
하지만 도구가 임의의 셸 액세스 (shell access)가 되어서는 안 됩니다. 이 에이전트는 워크스페이스 범위 내에서만 작동하는 세 가지 도구만을 가집니다:
@Tool(description = "현재 워크스페이스에서 UTF-8 텍스트 파일을 읽습니다.")
String readFile(String path) { ... }
...
이 도구들은 의도적으로 제한되어 있습니다:
- 경로는 반드시 워크스페이스 내에 머물러야 합니다.
.git,node_modules,build,target과 같은 무거운 디렉토리는 건너뜁니다.- 소스/설정 확장자만 허용됩니다.
- 파일 크기가 제한됩니다.
- 결과 개수가 제한됩니다.
이러한 안전 경계(safety boundaries)를 통해 모델은 리뷰를 위한 충분한 컨텍스트를 확보하면서도, 모든 것을 자유롭게 스캔할 수는 없도록 합니다.
diff에 대한 댓글 좌표 검증 (Validate comment coordinates against the diff)
GitHub의 인라인 리뷰 댓글 (inline review comment)은 임의의 파일 라인에 배치될 수 없습니다. 댓글은 반드시 PR diff의 올바른 쪽에 존재하는 라인을 대상으로 해야 합니다. 모델은 이를 보장하지 않습니다. diff를 보고 있더라도 한 줄 차이가 나거나 삭제된 라인을 가리킬 수 있습니다. 따라서 코드는 먼저 diff를 파싱하고 허용된 좌표 집합을 구축합니다:
record LineRef(String path, int line) {}
검증 레이어(validation layer)에는 단순하지만 중요한 체크 로직이 있습니다:
if (!allowedLines.contains(new LineRef(path, finding.line()))) {
continue;
}
만약 (path, line)이 diff의 오른쪽(RIGHT) 측에 존재하지 않는다면, 해당 탐지 결과(finding)는 제외됩니다. 이는 두 가지 문제를 동시에 방지합니다:
- 하나의 잘못된 인라인 댓글(inline comment) 때문에 GitHub이 리뷰 전체를 거부하는 것을 방지합니다.
- 모델이 diff 범위를 벗어난 임의의 라인에 댓글을 달 수 없도록 합니다.
파서(parser)는 의도적으로 범위를 좁게 설정했습니다. 즉, 가능한 모든 Git diff 기능을 완전히 이해하려고 시도하지 않습니다. 대신 GitHub이 인라인 리뷰 댓글로 수용할 수 있는 오른쪽(RIGHT) 측 좌표만을 추출합니다. 실제로 이는 다음과 같은 여러 엣지 케이스(edge cases)를 신중하게 처리해야 함을 의미합니다: 한 파일 내의 여러 헌크(hunks), 삭제된 파일, \ No newline at end of file 마커, 이름이 변경된 파일, 공백이나 따옴표가 포함된 경로, 바이너리 파일, 그리고 파일 모드 업데이트와 같은 메타데이터 전용 변경 사항 등입니다. 파서가 유효한 오른쪽(RIGHT) 측 라인으로 매핑할 수 없는 모든 항목은 댓글을 달 수 없는 것으로 처리해야 합니다.
코드가 REQUEST_CHANGES를 선택함
모델은 blocking=true를 반환할 수 있지만, 최종 GitHub 이벤트는 여전히 결정론적(deterministically)으로 계산됩니다.
이유는 간단합니다: REQUEST_CHANGES는 강력한 부작용(side effect)을 수반합니다. 따라서 검증 없이 모델에 위임해서는 안 됩니다.
코드에는 머지(merge)를 차단할 수 있도록 허용된 카테고리 화이트리스트(whitelist)가 정의되어 있습니다:
private static final Set<FindingCategory> BLOCKING_CATEGORIES = Set.of(
FindingCategory.bug,
FindingCategory.security,
...
최종 이벤트는 이미 검증을 통과한 탐지 결과(findings)만을 바탕으로 계산됩니다:
var hasBlockingFinding = validFindings.stream()
.anyMatch(finding -> finding.blocking()
&& BLOCKING_CATEGORIES.contains(finding.category()));
...
설령 모델이 duplication이나 test_gap을 차단(blocking)으로 표시하더라도, 해당 탐지 결과는 PR을 차단할 수 없습니다.
이 작은 규칙이 에이전트의 신뢰성을 크게 변화시킵니다. 모델은 권장할 수 있지만, 머지 차단 정책은 코드에 남아 있습니다.
제출 전 필터링
GitHub 페이로드(payload)를 생성하기 전에, 게시 결과가 예측 가능하도록 탐지 결과(findings)는 몇 가지 추가 검사를 거칩니다:
path와body의 공백이 제거(trimmed)됩니다;- 빈 값은 삭제됩니다;
- 너무 긴 본문 텍스트는 축약됩니다;
- 중복 항목은 제거됩니다;
- 인라인 코멘트(inline comments)는 최대 10개까지만 전송됩니다.
검증이 완료되면, 애플리케이션은 최종 GitHub 리뷰 페이로드(payload)를 생성합니다:
{
"event": "COMMENT",
"body": "AI-assisted review",
...
이 시점에서 페이로드는 더 이상 모델의 가공되지 않은 출력(raw model output)이 아닙니다. 애플리케이션의 검사를 통과한 코멘트들만을 포함하게 됩니다.
결론
실제 적용 시, 이것이 바로 신뢰 경계(trust boundary)입니다. 모델은 변경 사항을 분석하고 탐지 결과(findings)를 제안하는 반면, 애플리케이션은 예산(budget)을 강제하고, 출력을 검증하며, 무엇을 게시할지 결정합니다.
이 경계가 중요한 이유는 PR(Pull Request) 리뷰가 단순히 해롭지 않은 데모 흐름이 아니기 때문입니다. 결과물은 실제 Pull Request에 게시되며, 잘못된 코멘트는 리뷰어의 시간을 낭비할 수 있고, 잘못된 REQUEST_CHANGES는 머지(merge) 결정에 영향을 미칠 수 있습니다.
Spring AI 2는 도구 사용 에이전트(tool-using agents)를 위한 견고한 기반을 제공하지만, 프로덕션 워크플로우(production workflows)에는 여전히 제한 사항, 검증 및 부수 효과(side effects)에 대한 애플리케이션 수준의 제어가 필요합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기