Skip to main content

Exam 모듈

시험 생성, OMR 채점, 성적 분석을 담당하는 핵심 모듈입니다. 동기/비동기 OMR 채점, 상대/절대평가, 통계 분석 등 포괄적인 시험 관리 기능을 제공합니다.

모듈 개요

  • 배포: lumie-backend 모놀리스의 modules/exam
  • 데이터베이스: PostgreSQL (멀티테넌트 RLS)
  • 메시징: RabbitMQ (비동기 OMR 채점)
  • 스토리지: MinIO (OMR 이미지 저장)
  • 주요 의존성: BillingService(internal-api), 외부 grading-svc(lumie-worker)

주요 기능

시험 관리

  • 시험 생성 및 관리 (상대평가/절대평가/합불제)
  • 시험 템플릿 관리
  • 문항별 배점 및 정답 설정
  • 등급 체계 설정 (9등급제/5등급제)

OMR 채점

  • 단일 이미지 동기 채점
  • 대량 이미지 비동기 배치 채점
  • Presigned URL 기반 직접 업로드
  • 전화번호 기반 학생 매칭
  • 채점 결과 자동 저장

성적 분석

  • 시험별 통계 (평균, 표준편차, 등급 분포)
  • 학생별 성적 추이 분석
  • 문항별 정답률 분석
  • 학원별 성취도 비교

리포트 생성

  • 학생별 성적표 이미지 생성
  • AI 서비스 연동을 통한 리포트 생성

API 엔드포인트

시험 관리 API

시험 생성

POST /v1/exams
Content-Type: application/json

{
"name": "2024년 3월 모의고사",
"category": "GRADED",
"gradingType": "RELATIVE",
"gradeScale": "NINE_GRADE",
"totalQuestions": 30,
"correctAnswers": {
"1": "2", "2": "4", "3": "1"
},
"questionScores": {
"1": 3, "2": 3, "3": 4
},
"questionTypes": {
"1": "수학", "2": "수학", "3": "영어"
},
"passScore": 60
}

시험 목록 조회

GET /v1/exams?page=0&size=20

시험 상세 조회 (관리자용)

GET /v1/exams/{id}/detail

OMR 채점 API

단일 OMR 채점 (동기)

POST /v1/results/omr-grading/{examId}?studentId=123
Content-Type: multipart/form-data

image: omr_sheet.png

Presigned URL 생성 (배치 업로드용)

POST /v1/results/presigned-urls/{examId}
Content-Type: application/json

{
"fileNames": ["omr1.png", "omr2.png", "omr3.png"]
}

응답:

{
"batchKey": "abc12345",
"uploads": [
{
"fileName": "omr1.png",
"objectKey": "omr/tmp/c704d223/abc12345/omr1.png",
"uploadUrl": "https://minio.example.com/bucket/path?signature=..."
}
]
}

배치 OMR 채점 확인 (비동기)

POST /v1/results/batch-omr-grading/{examId}
Content-Type: application/json

{
"objectKeys": [
"omr/tmp/c704d223/abc12345/omr1.png",
"omr/tmp/c704d223/abc12345/omr2.png"
]
}

응답:

{
"jobId": 456
}

채점 작업 상태 조회

GET /v1/results/omr-jobs/{examId}/{jobId}/status

응답:

{
"jobId": 456,
"examId": 123,
"status": "COMPLETED",
"totalImages": 50,
"processedImages": 50,
"successCount": 48,
"failCount": 2,
"savedCount": 45,
"results": [
{
"fileName": "omr1.png",
"success": true,
"saved": true,
"phoneNumber": "1234-5678",
"studentId": 789,
"studentName": "김학생",
"totalScore": 85,
"grade": 2,
"resultId": 101
}
],
"createdAt": "2024-01-15T10:30:00Z"
}

성적 조회 API

시험 결과 목록

GET /v1/results/exam/{examId}

학생 성적 목록

GET /v1/results/student/{studentId}

문항별 결과

GET /v1/results/{resultId}/questions

OMR 이미지 조회

GET /v1/results/{resultId}/omr-image
Accept: image/png

통계 분석 API

시험 전체 통계

GET /v1/statistics/exam/{examId}

응답 예시:

{
"examId": 1,
"examName": "2024년 3월 모의고사",
"category": "GRADED",
"gradingType": "RELATIVE",
"gradeScale": "NINE_GRADE",
"participantCount": 150,
"average": 72.5,
"highest": 98,
"lowest": 32,
"standardDeviation": 15.2,
"gradeDistribution": [
{"grade": 1, "count": 7, "percentage": 4.7, "cutoffScore": 92},
{"grade": 2, "count": 15, "percentage": 10.0, "cutoffScore": 85}
],
"scoreRangeDistribution": [
{"range": "90-100", "count": 12, "percentage": 8.0},
{"range": "80-89", "count": 35, "percentage": 23.3}
]
}

학생 성적 목록 (페이지네이션)

GET /v1/statistics/exam/{examId}/students?page=0&size=20

선지별 선택률 분석

GET /v1/statistics/exam/{examId}/choice-distribution

학생 백분위/석차 조회

GET /v1/statistics/student/{studentId}/exam/{examId}/rank

대시보드 통계 조회

GET /v1/statistics/dashboard

학원별 성취도 비교

GET /v1/statistics/exam/{examId}/academy-comparison

리포트 생성 API

학생별 성적표 생성

GET /v1/reports/student/{studentId}/exam/{examId}
Accept: image/jpeg

응답:

HTTP/1.1 200 OK
Content-Type: image/jpeg
Content-Disposition: attachment; filename="report_123_456.jpg"

[JPG 이미지 데이터]

도메인 모델

Exam 엔티티

@Entity
public class Exam extends BaseEntity {
private String name; // 시험명
private ExamCategory category; // GRADED, PASS_FAIL
private GradingType gradingType; // RELATIVE, ABSOLUTE
private GradeScale gradeScale; // NINE_GRADE, FIVE_GRADE
private Integer totalQuestions; // 총 문항 수
private Map<String, String> correctAnswers; // 정답 (문항번호 -> 정답)
private Map<String, Integer> questionScores; // 배점 (문항번호 -> 점수)
private Map<String, String> questionTypes; // 문항 유형 (문항번호 -> 유형)
private Integer passScore; // 합격 점수 (합불제용)

// 비즈니스 메서드
public int calculateTotalPossibleScore();
public String getCorrectAnswerForQuestion(int questionNumber);
public String getQuestionType(int questionNumber);
public int getScoreForQuestion(int questionNumber);
}

ExamResult 엔티티

@Entity
public class ExamResult extends BaseEntity {
private Exam exam; // 시험
private Long studentId; // 학생 ID
private Integer totalScore; // 총점
private Integer grade; // 등급 (절대평가/합불제만 저장)
private String omrImageKey; // OMR 이미지 저장 경로
private List<QuestionResult> questionResults; // 문항별 결과

// 비즈니스 메서드
public boolean isPassed();
public int getCorrectCount();
public int getIncorrectCount();
public void setOmrImageKey(String omrImageKey);
}

OmrGradingJob 엔티티

@Entity
public class OmrGradingJob extends BaseEntity {
private Exam exam; // 시험
private String tenantSlug; // 테넌트 슬러그
private OmrJobStatus status; // 작업 상태
private Integer totalImages; // 총 이미지 수
private Integer processedImages; // 처리된 이미지 수
private Integer successCount; // 성공 수
private Integer failCount; // 실패 수
private Integer savedCount; // 저장된 결과 수
private List<String> imageKeys; // 이미지 키 목록
private List<Map<String, Object>> results; // 처리 결과

// 비즈니스 메서드
public void startProcessing();
public void incrementProcessed(boolean success, boolean saved);
public void complete(List<Map<String, Object>> results);
public void fail(List<Map<String, Object>> results);
}

값 객체 (Value Objects)

ExamCategory

public enum ExamCategory {
GRADED, // 등급제 (1-9등급 또는 1-5등급)
PASS_FAIL // 합불제 (합격/불합격)
}

GradingType

public enum GradingType {
RELATIVE, // 상대평가 (백분위 기반)
ABSOLUTE // 절대평가 (점수 기반)
}

GradeScale

public enum GradeScale {
NINE_GRADE, // 9등급제 (기존 수능)
FIVE_GRADE // 5등급제 (2028학년도 수능부터)
}

OmrJobStatus

public enum OmrJobStatus {
PENDING, // 대기중
PROCESSING, // 처리중
COMPLETED, // 완료
FAILED // 실패
}

비즈니스 로직

등급 계산 시스템

상대평가 등급 계산

public int calculateRelativeGrade(int score, List<Integer> allScores, GradeScale gradeScale) {
long higherCount = allScores.stream().filter(s -> s > score).count();
double percentile = (double) higherCount / allScores.size() * 100;
return getGradeFromPercentile(percentile, gradeScale);
}

// 9등급제 백분위 기준
private static final double[] NINE_GRADE_PERCENTILES = {4, 11, 23, 40, 60, 77, 89, 96, 100};

// 5등급제 백분위 기준 (2028학년도 수능)
private static final double[] FIVE_GRADE_PERCENTILES = {10, 30, 70, 90, 100};

절대평가 등급 계산

// 9등급제 절대평가 점수 기준
private static final int[] NINE_GRADE_ABSOLUTE_SCORES = {90, 80, 70, 60, 50, 40, 30, 20, 0};

// 5등급제 절대평가 점수 기준
private static final int[] FIVE_GRADE_ABSOLUTE_SCORES = {80, 60, 40, 20, 0};

OMR 채점 프로세스

동기 채점

public ExamResultResponse processOmrGrading(Long examId, Long studentId, byte[] imageData) {
// 1. 시험 및 학생 정보 검증
Exam exam = examRepository.findById(examId).orElseThrow();

// 2. OMR 할당량 확인
if (!billingService.hasOmrQuota()) {
throw new ExamException(ExamErrorCode.OMR_QUOTA_EXCEEDED);
}

// 3. 외부 채점 서비스 호출
OmrGradingResult omrResult = omrService.gradeOmrImage(
imageData, exam.getCorrectAnswers(), exam.getQuestionScores(), exam.getQuestionTypes());

// 4. 결과 저장
ExamResult result = ExamResult.create(exam, studentId, omrResult.totalScore());
for (OmrQuestionResult qr : omrResult.results()) {
result.addQuestionResult(QuestionResult.create(
qr.questionNumber(), qr.studentAnswer(), qr.correctAnswer(), qr.score()));
}

// 5. 등급 계산 (절대평가만)
gradeCalculator.calculateAndAssignGradeIfAbsolute(exam, result);

// 6. 할당량 차감
billingService.consumeOmrQuota();

return ExamResultResponse.from(resultRepository.save(result));
}

비동기 배치 채점 (Presigned URL 방식)

public OmrPresignResponse generatePresignedUrls(Long examId, OmrPresignRequest request) {
// 1. 시험 존재 확인
examRepository.findById(examId).orElseThrow();

// 2. 배치 키 생성
String tenantSlug = TenantContextHolder.getRequiredTenant();
String tenantId = extractTenantId(tenantSlug);
String batchKey = UUID.randomUUID().toString().substring(0, 8);

// 3. 각 파일에 대한 Presigned URL 생성
List<OmrPresignResponse.PresignedUpload> uploads = request.fileNames().stream()
.map(fileName -> {
String sanitized = sanitizeFileName(fileName);
String objectKey = "omr/tmp/" + tenantId + "/" + batchKey + "/" + sanitized;
String uploadUrl = storagePort.getPresignedPutUrl(objectKey, 900); // 15분

return new OmrPresignResponse.PresignedUpload(fileName, objectKey, uploadUrl);
})
.toList();

return new OmrPresignResponse(batchKey, uploads);
}

public OmrJobResponse confirmBatchOmrGrading(Long examId, OmrBatchConfirmRequest request) {
// 1. 시험 조회
Exam exam = examRepository.findById(examId).orElseThrow();

// 2. 오브젝트 키 검증
String tenantId = extractTenantId(TenantContextHolder.getRequiredTenant());
for (String key : request.objectKeys()) {
if (!key.startsWith("omr/tmp/" + tenantId + "/") || key.contains("..")) {
throw new ExamException(ExamErrorCode.INVALID_EXAM_DATA);
}
}

// 3. 채점 작업 생성
List<String> imageKeys = request.objectKeys();
OmrGradingJob job = OmrGradingJob.create(exam, tenantSlug, imageKeys.size(), imageKeys);
OmrGradingJob savedJob = jobRepository.save(job);

// 4. RabbitMQ 메시지 발송 (트랜잭션 커밋 후)
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
OmrGradingJobMessage message = new OmrGradingJobMessage(
savedJob.getId(), examId, tenantSlug, imageKeys);
rabbitTemplate.convertAndSend(
LUMIE_COMMANDS_EXCHANGE, GRADING_OMR_REQUEST_ROUTING_KEY, message);
}
});

return new OmrJobResponse(savedJob.getId());
}

통계 계산

시험 통계 계산

public ExamStatisticsResponse getExamStatistics(Long examId) {
Exam exam = examRepository.findById(examId).orElseThrow();
List<ExamResult> results = resultRepository.findByExamId(examId);

if (results.isEmpty()) {
return createEmptyStatistics(exam);
}

List<Integer> scores = results.stream().map(ExamResult::getTotalScore).toList();

// 기본 통계
double average = statisticsCalculator.calculateMean(scores);
double stdDev = statisticsCalculator.calculateStandardDeviation(scores);
int highest = scores.stream().max(Integer::compareTo).orElse(0);
int lowest = scores.stream().min(Integer::compareTo).orElse(0);

// 등급 분포 (상대평가는 동적 계산)
List<GradeDistribution> gradeDistribution = calculateGradeDistribution(exam, results);

// 점수 구간별 분포
List<ScoreRangeDistribution> scoreRangeDistribution = calculateScoreRangeDistribution(scores);

// 유형별 정답률
List<TypeAccuracy> typeAccuracy = calculateExamTypeAccuracy(exam, results);

// 오답률 TOP 문항
List<TopIncorrectQuestion> topIncorrect = calculateExamTopIncorrect(exam, results);

return new ExamStatisticsResponse(/* ... */);
}

외부 서비스 연동

Grading Service 연동

@Component
public class OmrServiceClient implements OmrServicePort {

public OmrGradingResult gradeOmrImage(byte[] imageData, Map<String, String> correctAnswers,
Map<String, Integer> questionScores,
Map<String, String> questionTypes) {

MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("image", new ByteArrayResource(imageData) {
@Override
public String getFilename() {
return "omr_image.png";
}
});
body.add("correct_answers", toJson(correctAnswers));
body.add("question_scores", toJson(questionScores));
body.add("question_types", toJson(questionTypes));

ResponseEntity<OmrResponse> response = restTemplate.postForEntity(
omrServiceUrl + "/grade", body, OmrResponse.class);

if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
return mapToResult(response.getBody());
}

throw new ExamException(ExamErrorCode.OMR_GRADING_FAILED);
}
}

Report Service 연동

@Component
public class ReportServiceClient implements ReportServicePort {

public byte[] generateReport(Long studentId, Long examId, String tenantSlug) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Tenant-Slug", tenantSlug);

String url = String.format("%s/generate-report?student_id=%d&exam_id=%d",
reportServiceUrl, studentId, examId);

ResponseEntity<byte[]> response = restTemplate.exchange(
url, HttpMethod.GET, new HttpEntity<>(headers), byte[].class);

if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
return response.getBody();
}

throw new ExamException(ExamErrorCode.REPORT_GENERATION_FAILED);
}
}

Academy Service 연동

// StudentService를 주입받아 in-process 호출
@Component
@RequiredArgsConstructor
public class AcademyInternalAdapter implements LoadStudentsByIdsPort {

private final StudentService studentService;

@Override
public List<StudentService.StudentData> getStudentsByIds(List<Long> studentIds) {
String tenantSlug = TenantContextHolder.getRequiredTenant();
return studentService.getStudentsByIds(tenantSlug, studentIds);
}
}

Billing Service 연동

// BillingService를 주입받아 in-process 호출
@Component
@RequiredArgsConstructor
public class BillingInternalAdapter implements CheckOmrQuotaPort {

private final BillingService billingService;

@Override
public boolean hasOmrQuota() {
String tenantSlug = TenantContextHolder.getRequiredTenant();
BillingService.QuotaResult result =
billingService.checkQuota(tenantSlug, "OMR_MONTHLY_QUOTA");
return result.allowed();
}
}

메시징 시스템

RabbitMQ 설정

@Configuration
public class RabbitMqConfig {

@Bean
public Queue gradingOmrRequestQueue() {
return QueueBuilder.durable(GRADING_OMR_REQUEST_QUEUE)
.withArgument("x-queue-type", "quorum")
.withArgument("x-dead-letter-exchange", LUMIE_DLX_EXCHANGE)
.withArgument("x-message-ttl", 600000)
.build();
}
}

메시지 리스너

@RabbitListener(queues = RabbitMqConstants.GRADING_OMR_REQUEST_QUEUE)
public void handleOmrGradingJob(OmrGradingJobMessage message) {
try {
TenantContextHolder.setTenant(message.tenantSlug());

processor.startProcessing(message.jobId());

List<Map<String, Object>> results = new ArrayList<>();
for (String imageKey : message.imageKeys()) {
Map<String, Object> result = processor.processImageAndUpdateProgress(
message.jobId(), message.examId(), imageKey, message.tenantSlug());
results.add(result);
}

processor.completeJob(message.jobId(), results);

// MinIO 임시 이미지 정리
storagePort.deleteObjects(message.imageKeys());

} finally {
TenantContextHolder.clear();
}
}

스토리지 관리

MinIO 연동

@Component
public class OmrMinioStorageAdapter implements OmrStoragePort {

public String putObject(String objectKey, byte[] data, String contentType) {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(objectKey)
.stream(new ByteArrayInputStream(data), data.length, -1)
.contentType(contentType)
.build()
);
return objectKey;
}

public String getPresignedPutUrl(String objectKey, int expirySeconds) {
return presignMinioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(bucketName)
.object(objectKey)
.expiry(expirySeconds, TimeUnit.SECONDS)
.build()
);
}

public byte[] getObject(String objectKey) {
try (InputStream stream = minioClient.getObject(
GetObjectArgs.builder().bucket(bucketName).object(objectKey).build())) {
return stream.readAllBytes();
}
}
}

이미지 저장 경로 구조

omr/
├── tmp/{tenantId}/{batchKey}/ # 임시 업로드 (배치 처리용)
│ ├── image1.png
│ └── image2.png
└── results/{tenantId}/{examId}/{studentId} # 영구 저장

설정

애플리케이션 설정

spring:
application:
name: exam-svc

rabbitmq:
host: localhost
port: 5672
username: guest
password: guest

servlet:
multipart:
max-file-size: 20MB
max-request-size: 50MB

minio:
endpoint: http://localhost:9000
external-endpoint: http://localhost:9000 # Presigned URL용
access-key: minioadmin
secret-key: minioadmin
bucket-name: lumie-files

lumie:
services:
grading:
url: http://localhost:8001
report:
url: http://localhost:8002

통합 설정

exam 모듈은 독립적인 설정 파일이 없습니다. 모든 설정은 app/src/main/resources/application.yaml에서 통합 관리됩니다. 모듈 간 통신(BillingService, StudentService)은 in-process 메서드 호출로 처리됩니다.

운영 환경의 Python 외부 서비스 URL:

lumie:
services:
grading:
url: ${GRADING_SVC_URL:http://localhost:8001}
report:
url: ${REPORT_SVC_URL:http://localhost:8002}

에러 처리

비즈니스 예외

public enum ExamErrorCode implements ErrorCode {
// 404 Not Found
EXAM_NOT_FOUND("EXAM_001", "시험을 찾을 수 없습니다", 404),
TEMPLATE_NOT_FOUND("TEMPLATE_001", "시험 템플릿을 찾을 수 없습니다", 404),
RESULT_NOT_FOUND("RESULT_001", "시험 결과를 찾을 수 없습니다", 404),
STUDENT_NOT_FOUND("STUDENT_001", "학생을 찾을 수 없습니다", 404),
OMR_IMAGE_NOT_FOUND("OMR_002", "OMR 이미지를 찾을 수 없습니다", 404),

// 400 Bad Request
INVALID_EXAM_DATA("EXAM_002", "잘못된 시험 데이터입니다", 400),

// 403 Forbidden
OMR_QUOTA_EXCEEDED("QUOTA_001", "OMR 할당량을 초과했습니다", 403),

// 500 Internal Server Error
OMR_GRADING_FAILED("OMR_001", "OMR 채점에 실패했습니다", 500),
REPORT_GENERATION_FAILED("REPORT_001", "리포트 생성에 실패했습니다", 500);
}

성능 최적화

배치 조회 최적화

// 학생 정보 배치 조회로 N+1 문제 해결
private Map<Long, StudentInfo> fetchStudentMap(List<ExamResult> results) {
List<Long> studentIds = results.stream()
.map(ExamResult::getStudentId)
.distinct()
.toList();
return studentServicePort.getStudentInfoBatch(studentIds);
}

통계 계산 최적화

// 상대평가 등급 계산 시 정렬된 점수 목록 재사용
List<Integer> sortedScores = allScores.stream()
.sorted(Comparator.reverseOrder())
.toList();

// 백분위 기반 커트라인 계산
int cutoffIndex = (int) Math.ceil(sortedScores.size() * percentileThreshold / 100.0) - 1;
return sortedScores.get(Math.max(0, Math.min(cutoffIndex, sortedScores.size() - 1)));

모니터링

주요 메트릭

  • OMR 채점 처리량 및 성공률
  • 배치 작업 완료 시간
  • 외부 서비스 호출 응답 시간
  • MinIO 스토리지 사용량
  • RabbitMQ 큐 길이

로그 예시

{
"timestamp": "2024-01-15T14:30:00.000Z",
"level": "INFO",
"logger": "com.lumie.exam.application.service.OmrGradingJobProcessor",
"message": "OMR grading job completed: jobId=123",
"tenantSlug": "inst-demo",
"jobId": "123",
"examId": "456",
"processedImages": 50,
"successCount": 48,
"savedCount": 45
}

관련 문서

Exam Service는 교육 기관의 핵심 평가 업무를 디지털화하여 효율적이고 정확한 시험 관리를 가능하게 하는 중요한 서비스입니다.