[Spring AI] LLM-as-a-Judge 구현하여 기능 평가하기

icebox127·2026년 6월 5일

졸업 프로젝트에서 LLM으로 깃허브 분석 리포트, 면접 질문, 답변 피드백을 생성하는 기능을 담당했다. Open AI 연동해서 구현을 완료하고 QA를 하려는데 교수님이 질문 하셨다.

이게 정확한건지 어떻게 아냐?

그러게요,,
그냥 구현하고 몇 번 테스트 했을 때 잘 작동하길래 별 생각 없었는데
이런 식으로는 졸업 논문은 어림도 없었다.

그래서 뒤늦게 정확도 검증 테스트를 할 방법을 2개 생각해 보았다.

  1. 팀원들끼리 루브릭표 기준에 맞춰 여러 번 테스트
  2. 다른 LLM을 활용하여 검증

1번은 우리가 개발자라 객관적으로 테스트하기 어려울 것 같아서 2번을 택했다.

구현 방법

1. Spring AI 설정

dependencies {
    implementation platform("org.springframework.ai:spring-ai-bom:1.0.0")
    implementation "org.springframework.ai:spring-ai-starter-model-openai"
}

2. JudgeService 구현

  • JudgeService
    주어진 prompt에 따라 JudgeResult 타입으로 응답을 반환한다.
@Service
@RequiredArgsConstructor
public class JudgeService {

    private final ChatClient judgeClient;

    public JudgeResult evaluate(String prompt) {
        return judgeClient.prompt()
                .user(prompt)
                .call()
                .entity(JudgeResult.class);
    }
}
  • JudgeResult
    평가 점수, 결과(성공/실패 여부), 이유를 응답하도록 dto를 설정했다.
public record JudgeResult(
        List<Deduction> deductions,
        int score,
        Result result,
        String reason
) {}

3. 프롬프트 생성

평가 관련 프롬프트도 BuildJudgePrompt 클래스로 생성하도록 했다.
1. 응답 형식
2. 리포트 평가 형식 (감점제)
3. 평가할 리포트와, 해당 리포트를 생성할 때 사용된 데이터

세 가지를 활용하여 프롬프트를 생성하는 함수를 구현했다.

@Component
public class BuildJudgePrompt {
	// 응답 형식 관련 (공통)
    private static String BASE_PROMPT = """
        [출력 형식]
        반드시 JSON만 출력한다.
         {
           "deductions": [
             {"item": "A", "count": 0, "detail": ""},
             {"item": "B", "count": 0, "detail": ""},
             {"item": "C", "count": 0, "detail": ""},
             {"item": "D", "count": 0, "detail": ""},
             {"item": "E", "count": 0, "detail": ""}
           ],
           "score": 0,
           "result": "SUCCESS",
           "reason": ""
         }""";
    
    // 리포트 관련
    private static String REPORT_PROMPT = """
        너는 리포트 품질 평가자다. 아래 체크리스트로 감점하여 최종 점수를 계산하라.
    
       [기본 점수: 10]

         [감점 규칙 - 해당 항목 발견 시 즉시 감점]
         A. improvements 항목에 실제 파일명/클래스명/메서드명이 없는 경우 → -2/B. currentStatus가 1문장 이하이거나 데이터 근거 없는 경우 → -1/C. example이 없거나 "~할 수 있습니다" 수준의 추상적 설명인 경우 → -1/D. actionPlan이 없거나 "주기적으로 검토", "재점검" 수준인 경우 → -1/E. improvements 전체가 "테스트 부족", "문서화 필요", "가독성 향상" 같은
            스프링/자바 일반론으로만 구성된 경우 → -3[계산]
       최종 점수 = 10 - 각 감점 합산 (최저 1)
       7점 이상 = SUCCESS, 6점 이하 = FAIL
        
        [반드시 확인할 항목]
        1. 개선 사항마다 실행 계획이 있는가
        2. 개선 사항마다 실제 예시가 있는가
        3. 파일명/클래스명/메서드명이 포함되는가
        4. 일반론만 반복하지 않는가
        5. 프로젝트 고유의 근거가 있는가
        
      
    """;
    
    (생략)
    
     public String buildReportJudgePrompt(GitAnalysisResult result, String contentJson) {

        StringBuilder sb = new StringBuilder();
        sb.append(REPORT_PROMPT);
        sb.append(BASE_PROMPT);

        sb.append("다음은 개발자의 Git 활동 데이터와 이를 바탕으로 생성한 요약본이다.\n\n");

        sb.append("\n주요 커밋\n");
        for (MajorCommit mc : result.getMajorCommits()) {
            sb.append("- ").append(mc.getMessage()).append("\n");
            sb.append("  diff:\n");
            sb.append(mc.getDiff()).append("\n\n");
        }

        // diff 반영
        sb.append("\n[diff summary]\n");

        for (DiffStructureParser.DiffSummary summary : result.getSummaryList()) {
            sb.append("- 변경 파일 수: ")
                    .append(summary.getFileCount())
                    .append("\n");

            sb.append("  추가 라인: ")
                    .append(summary.getAddedLines())
                    .append("\n");

            sb.append("  삭제 라인: ")
                    .append(summary.getDeletedLines())
                    .append("\n");

            for (DiffStructureParser.ChangedFile file : summary.getChangedFiles()) {
                sb.append("    * ")
                        .append(file.getFileName())
                        .append(" (+")
                        .append(file.getAddedLines())
                        .append(", -")
                        .append(file.getDeletedLines())
                        .append(")\n");
            }

            sb.append("\n");
        }

        sb.append("생성된 리포트 내용: %s".formatted(contentJson));

        sb.append("다음 Git 분석 데이터를 기반으로 개발자 분석 리포트를 평가하라.\n" +
                "\n" +
                "반드시 아래 조건을 모두 지켜라:\n" +
                "\n" +
                "1. 반드시 JSON 형식으로만 응답하라.\n" +
                "2. JSON 외의 텍스트(설명, 마크다운, 코드블록, 주석) 절대 포함 금지.\n" +
                "3. 모든 필드는 반드시 채워라\n" +
                "4. key 이름은 절대 변경하지 마라.\n" +
                "5. 문자열은 모두 큰따옴표(\"\") 사용.\n" +
                "6. 숫자는 숫자 타입으로 작성 (따옴표 금지).\n" +
                "7. JSON 문법 오류 발생 시 실패로 간주한다.\n" +
                "8. 추측성 말투를 쓰지 말것.\n" +
                "\n" +
                "---\n" +
                "\n" +
                "다음 구조를 정확히 따라라:\n" +
                "{\n" +
                "  \"score\": 6,\n" +
                "  \"result\": \"SUCCESS 또는 FAIL\",\n" +
                "  \"reason\": \"근거 부족, 예시 없음등 이유 1~2줄\"\n" +
                "}" );
        return sb.toString();
    }

4. JudgeSerive 활용

깃허브 분석 레포트 반환 값(content)를 저장하기 전, 한 번 평가하는 과정을 거친다.

try {
        List<GitCommit> commits = gitLogParser.getCommits(repoPath);
        List<GitCommit> userCommits = commits.stream()
                    .filter(c -> c.getAuthorEmail().equals(email) || c.getAuthorEmail().contains(event.githubId()))
                    .toList();

        GitAnalysisResult result = gitAnalysisService.analyze(GitRepoParser.getRepoFullName(gitUrl), repoPath, commits, userCommits);

        String problemPrompt = buildProblemPrompt.buildReportProblemPrompt(result);
        ProblemList extractedProblems = gptService.makeReportProblem(problemPrompt);
        String prompt = buildPrompt.buildReportPrompt(result, event.githubId(), extractedProblems);
        ReportWrapper content = getContent(prompt);
        String contentJson;

        try {
             contentJson = objectMapper.writeValueAsString(content);
             // LLM-as-a-Judge
             String judgePrompt = buildJudgePrompt.buildReportJudgePrompt(result, contentJson);
             JudgeResult judgeResult = judgeService.evaluate(judgePrompt);
             log.info("분석 요약본: {}",contentJson);
             log.info("평가 점수: {}",judgeResult.score());
             log.info("평가 결과: {}",judgeResult.result());
             log.info("평가 이유: {}",judgeResult.reason());
             
       (생략)
       report.updateContent(contentJson);
                String description = content.getContent().getPurpose();
                report.updateDescription(description);
                report.updateGenerationStatus(GenerationStatus.DONE);
                log.info("리포트 저장 완료: {}", LocalDateTime.now());

참고 자료

profile
감자의 공부기록🥔

0개의 댓글