AI 기반 문제 자동 채점
1. 채점 아키텍처 개요
주관식 문제는 객관식과 달리 즉시 채점이 불가능하므로, 메시지 큐와 스케줄러를 활용한 비동기 채점 시스템을 구축했습니다. 사용자가 답안을 제출하면 채점 대기 큐에 추가되고, 스케줄러가 주기적으로 큐에서 데이터를 꺼내 AI에게 채점을 요청합니다.
2. 채점 대기 큐 시스템
주관식 답안을 비동기로 처리하기 위한 메시지 큐 인터페이스입니다.
public interface SubmissionMessageQueue {
/**
* 주관식 답안 채점 요청을 일반 우선순위로 채점 대기 큐에 추가하는 메서드
*
* @param data 채점할 데이터
*/
void enqueue(GradingData data);
/**
* 주관식 답안 채점 요청을 높은 우선순위로 채점 대기 큐에 추가하는 메서드
* <p>
* 일반적인 FIFO 순서 대신, 해당 답안을 우선적으로 채점해야 할 때 사용된다.
* 예: 채점 실패 후 재시도, 긴급 요청 등.
*
* @param data 채점할 데이터
*/
void prioritize(GradingData data);
/**
* 채점 대기 큐에서 가장 우선순위가 높은 채점 요청을 꺼내는 메서드
*
* @return 채점할 데이터
* @throws InterruptedException 큐가 비어 있을 때 스레드가 인터럽트되면 발생
*/
GradingData dequeue() throws InterruptedException;
/**
* 채점 대기 큐가 비어 있는지 확인하는 메서드
*
* @return 채점 대기 큐가 비어 있으면 true, 그렇지 않으면 false
*/
boolean isEmpty();
/**
* 현재 채점을 대기 중인 답안의 개수를 반환하는 메서드
*
* @return 채점 대기 큐에서 채점 대기 중인 답안의 수
*/
int size();
}핵심은 prioritize() 메서드입니다. AI 채점 중 오류가 발생하면 해당 답안을 일반 큐가 아닌 우선순위 큐에 다시 넣어 즉시 재시도할 수 있도록 했습니다. 이를 통해 일시적인 네트워크 오류나 API 장애 상황에서도 빠르게 복구하고 사용자 경험을 개선할 수 있습니다.
3. 채점 데이터 전송 객체
채점에 필요한 핵심 정보만을 담은 경량 데이터 클래스입니다.
@Getter
@Builder
public class GradingData {
// 제출 ID
private final Long submissionId;
// 문제 ID
private final Long problemId;
// 문제 제목
private final String problemTitle;
// 문제 내용
private final String problemQuestion;
// 채점 기준
private final List<String> gradingCriteria;
// 예시 답안
private final String sampleAnswer;
// 사용자가 제출한 답변
private final String submittedAnswer;
/**
* Submission 엔티티에서 GradingData 객체를 생성하는 팩토리 메서드
*
* @param submission 채점할 제출 정보
* @return 채점에 필요한 데이터만 포함하는 GradingData 객체
*/
public static GradingData fromSubmission(Submission submission) {
Problem problem = submission.getProblem();
return GradingData.builder()
.submissionId(submission.getId())
.problemId(problem.getId())
.problemTitle(problem.getTitle())
.problemQuestion(problem.getQuestion())
.gradingCriteria(problem.getGradingCriteriaList())
.sampleAnswer(problem.getSampleAnswer())
.submittedAnswer(submission.getSubmittedAnswer())
.build();
}
}정적 팩토리 메서드(Static Factory Method)를 사용하여 Submission 엔티티에서 채점에 필요한 데이터만 추출합니다. 생성자 대신 fromSubmission()이라는 의미있는 이름의 메서드로 객체를 생성함으로써 코드의 가독성을 높이고, 엔티티 전체를 큐에 넣는 대신 필요한 정보만 담은 경량 객체를 사용함으로써 메모리 효율을 높이고 영속성 컨텍스트와의 의존성을 제거했습니다.
4. 답안 제출 처리 로직
사용자가 답안을 제출하면 문제 유형에 따라 즉시 채점하거나 큐에 추가합니다.
@Transactional
public SubmissionResponseDto submitAnswer(User user, Long problemId, SubmissionRequestDto requestDto) {
// 문제 조회
Problem problem = problemRepository.findById(problemId)
.orElseThrow(ProblemNotFoundException::new);
// 답안 제출 객체 생성
Submission submission = Submission.builder()
.user(user)
.problem(problem)
.submittedAt(LocalDateTime.now())
.duration(requestDto.getDuration())
.submittedAnswer(requestDto.getAnswer())
.build();
// 제출 저장 (ID 부여를 위해 먼저 저장)
submission = submissionRepository.save(submission);
// 문제 유형에 따라 처리
if (problem.getType() == ProblemType.MULTIPLE_CHOICE) { // 객관식 문제인 경우
boolean isCorrect = gradeMultipleChoiceSubmission(submission); // 문제를 채점하고
if (isCorrect) { // 사용자가 정답을 맞췄다면
// 사용자가 해당 문제를 이전에 해결한 적이 있는지 확인
boolean alreadySolvedByUser = submissionRepository.existsByUserAndProblemIdAndIsCorrectTrue(user, problemId);
// 문제를 처음 맞춘 경우에만 문제 풀이 횟수 증가
if (!alreadySolvedByUser) {
problem.incrementSolvedCount();
}
}
submission.updateMultipleChoiceGradingResult(isCorrect); // 정답 여부 업데이트
} else { // 주관식 문제인 경우
messageQueue.enqueue(GradingData.fromSubmission(submission)); // 채점 대기 큐에 추가
}
// 응답 생성 및 반환
return SubmissionResponseDto.fromSubmission(submission);
}핵심은 문제 유형에 따른 분기 처리입니다. 객관식은 정답 비교만으로 즉시 채점할 수 있지만, 주관식은 AI 분석이 필요하므로 messageQueue.enqueue()를 통해 비동기 처리 큐에 추가합니다.
5. 채점 스케줄러
6초마다 실행되는 스케줄러가 채점 대기 큐를 확인하고 AI 채점을 수행합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class SubmissionScheduler {
private final SubmissionMessageQueue messageQueue;
private final ProblemRepository problemRepository;
private final SubmissionRepository submissionRepository;
private final AIService aiService;
/**
* 특정 시간 간격으로 주관식 답안 채점 처리하는 스케줄러
*/
@Scheduled(fixedDelay = 6000) // 6초마다 실행
public void processSubjectiveSubmission() throws InterruptedException {
if (!messageQueue.isEmpty()) {
GradingData gradingData = messageQueue.dequeue();
if (gradingData != null) {
gradeSubjectiveSubmission(gradingData);
}
}
}
/**
* 주관식 문제를 채점하는 메서드
*
* @param gradingData 채점할 데이터
*/
private void gradeSubjectiveSubmission(GradingData gradingData) {
try {
// AI 서비스를 통한 채점 요청
GradingResult result = aiService.gradeSubjectiveSubmission(gradingData);
// 제출물 조회
Submission submission = submissionRepository.findById(gradingData.getSubmissionId())
.orElseThrow(SubmissionNotFoundException::new);
if (result.isCorrect()) { // 해당 제출물이 정답인 경우
// 사용자가 해당 문제를 이전에 해결한 적이 있는지 확인
boolean alreadySolvedByUser = submissionRepository.existsByUserAndProblemIdAndIsCorrectTrue(submission.getUser(), gradingData.getProblemId());
// 문제를 처음 맞춘 경우에만 문제 풀이 횟수 증가
if (!alreadySolvedByUser) {
problemRepository.findById(gradingData.getProblemId())
.ifPresent(problem -> {
problem.incrementSolvedCount();
problemRepository.save(problem);
});
}
}
// 채점 결과 업데이트
submission.updateSubjectiveGradingResult(result);
// 채점 결과 저장
submissionRepository.save(submission);
log.info("주관식 문제 제출 #{} 채점 완료: {}점 (정답 여부: {}) / 남은 채점 대기 수: {}", gradingData.getSubmissionId(), result.score(), result.isCorrect(), messageQueue.size());
} catch (Exception e) { // AI 서비스 호출 중 오류 발생 시
log.error("제출 #{} 채점 중 오류 발생: {}", gradingData.getSubmissionId(), e.getMessage());
messageQueue.prioritize(gradingData); // 높은 우선순위로 다시 큐에 넣어 재시도
}
}
}@Scheduled(fixedDelay = 6000)를 사용하여 이전 실행이 완료된 후 6초 뒤에 다음 실행을 시작합니다. 이 값은 Gemini API의 사용 한도 를 고려하여 설정했습니다. 채점 중 오류가 발생하면 messageQueue.prioritize()로 해당 데이터를 우선순위 큐에 넣어 다음 스케줄러 실행 시 즉시 재시도합니다.
6. 주관식 채점 프롬프트
AI가 일관되고 공정하게 채점하도록 상세한 프롬프트를 설계했습니다.
당신은 프로그래밍 주관식 문제 채점 전문가입니다. 다음 문제와 제출된 답안을 분석하여 채점하세요.
[문제 제목]
%s
[문제 내용]
%s
[채점 기준]
%s
[예시 답안]
%s
[제출된 답안]
%s
위 정보를 바탕으로 다음 JSON 형식으로 채점 결과를 제공해주세요:
{
"feedback": "전체적인 피드백",
"criteriaEvaluation": [
{
"criteria": "채점 기준 1",
"score": 0~100 사이의 점수(정수),
"feedback": "해당 기준에 대한 구체적인 피드백"
},
// ... 나머지 채점 기준에 대한 평가
]
}
점수 부여 지침:
1. 각 채점 기준별로 0~100 사이의 점수를 부여하세요.
2. 100점은 해당 기준을 완벽하게 충족했을 때만 부여합니다.
3. 0점은 해당 기준과 전혀 관련이 없거나 기준을 충족하지 못했을 때 부여합니다.
4. 부분 점수는 기준 충족도에 따라 부여합니다.
피드백 작성 지침:
1. 구체적이고 건설적인 피드백을 제공합니다.
2. 잘한 점과 개선할 점을 균형있게 언급합니다.
3. 가능하면 예시나 참고 자료를 제안합니다.
4. 기술적으로 정확하고 교육적인 내용으로 작성합니다.프롬프트는 문제 정보, 채점 기준, 예시 답안, 제출 답안을 포함하며, AI가 각 채점 기준별로 0~100점 사이의 점수와 피드백을 제공하도록 구조화했습니다. 이를 통해 사용자는 단순히 점수만이 아니라 각 평가 요소별로 어떤 부분이 좋았고 어떤 부분을 개선해야 하는지 구체적으로 알 수 있습니다.
7. Gemini AI 채점 로직
Gemini API를 활용하여 주관식 답안을 채점하고 결과를 구조화합니다.
@Override
public GradingResult gradeSubjectiveSubmission(GradingData data) {
try {
// 채점 기준 목록을 번호가 있는 목록 형태로 변환
String formattedCriteria = IntStream.range(0, data.getGradingCriteria().size())
.mapToObj(i -> (i + 1) + ". " + data.getGradingCriteria().get(i))
.collect(Collectors.joining("\n"));
// 프롬프트 생성
String gradingPrompt = String.format(
SUBJECTIVE_GRADING_PROMPT,
data.getProblemTitle(),
data.getProblemQuestion(),
formattedCriteria,
data.getSampleAnswer(),
data.getSubmittedAnswer()
);
// AI에 채점 요청
JsonNode json = getAIGeneratedContent(gradingPrompt);
// 피드백 가져오기
String overallFeedback = json.get("feedback").asText();
JsonNode criteriaEvaluations = json.get("criteriaEvaluation");
// 피드백을 정리하기 위한 빌더
StringBuilder feedbackBuilder = new StringBuilder();
// 평균 점수 계산을 위한 변수
double totalScore = 0;
int criteriaCount = 0;
if (criteriaEvaluations != null && criteriaEvaluations.isArray()) {
for (int i = 0; i < criteriaEvaluations.size(); i++) {
// i번째 채점 기준 평가 정보
JsonNode evaluation = criteriaEvaluations.get(i);
// 평가 정보 파싱
String criteriaName = evaluation.get("criteria").asText();
int criteriaScore = evaluation.get("score").asInt();
String criteriaFeedback = evaluation.get("feedback").asText();
// 평균 점수 계산을 위해 누적
totalScore += criteriaScore;
criteriaCount++;
// 피드백 빌더에 추가
feedbackBuilder.append("## ").append(i + 1).append(". ")
.append(criteriaName)
.append(" (").append(criteriaScore).append("점)\n\n")
.append(criteriaFeedback).append("\n\n");
}
}
// 평균 점수 계산 (소수점 둘째 자리에서 반올림)
double averageScore = 0;
if (criteriaCount > 0) {
averageScore = new BigDecimal(totalScore / criteriaCount)
.setScale(2, RoundingMode.HALF_UP)
.doubleValue();
}
// 종합 평가 추가
feedbackBuilder.insert(0, "# 종합 평가 결과 (" + averageScore + "점)\n\n" + overallFeedback + "\n\n");
// 주관식 문제 해결 여부 판단
boolean isCorrect = averageScore >= PASSING_SCORE;
log.info("제출 #{} 채점 완료: {}점 (정답 여부: {})", data.getSubmissionId(), averageScore, isCorrect);
// 결과 반환
return new GradingResult(averageScore, isCorrect, feedbackBuilder.toString().trim());
} catch (Exception e) {
log.error("제출 #{} 채점 중 오류 발생: {}", data.getSubmissionId(), e.getMessage());
throw new RuntimeException("AI를 통한 주관식 문제 채점에 실패했습니다", e);
}
}핵심은 각 채점 기준별 점수를 평균 내어 최종 점수를 산출하는 부분입니다. AI는 각 기준별로 0~100점 사이의 점수와 피드백을 제공하는데, 이를 모두 합산하여 평균을 계산하고 소수점 둘째 자리에서 반올림하여 최종 점수를 도출합니다. 최종 점수에 따라 정답 여부를 처리하고, 각 기준별 점수와 피드백을 마크다운 형식으로 구조화하여 사용자에게 제공합니다.
8. 채점 결과 데이터 구조
AI 채점 결과를 담는 레코드 클래스입니다.
/**
* 주관식 채점 결과를 담는 레코드
*/
record GradingResult(double score, boolean isCorrect, String feedback) {
}Java 16의 레코드 타입을 사용하여 불변 데이터 클래스를 간결하게 정의했습니다. score는 평균 점수, isCorrect는 합격 여부(70점 이상), feedback은 마크다운 형식의 상세 피드백을 담고 있습니다. 레코드는 자동으로 생성자, getter, equals, hashCode, toString을 제공하므로 보일러플레이트 코드를 줄일 수 있습니다.