시험 서비스
이 페이지는 lumie-backend/modules/exam을 다룹니다. 현재 monolith에서 이
모듈은 동기 요청 처리와 RabbitMQ 기반 worker orchestration을 모두 소유하는
유일한 백엔드 도메인 모듈입니다.
책임
exam 모듈은 다음을 소유합니다.
- 시험 정의와 정답표
- 재사용 가능한 시험 템플릿
- 시험 결과와 문항별 채점 데이터
- 단일 이미지 동기 OMR 채점
- 배치 OMR 채점 job, callback, 결과 저장
- 배치 report 생성 job, ZIP 조립, report 다운로드
- 시험, 학생, 반 수준의 분석
소스 경로
| Path | 역할 |
|---|---|
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/web | 시험, 템플릿, 결과, 통계, report용 공개 HTTP controller |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/messaging | 채점 및 report 완료용 RabbitMQ callback consumer |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/internal/ExamServiceAdapter.java | libs/internal-api를 통해 노출되는 동기 internal API |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/messaging/JobRequestForwarder.java | Spring Modulith outbox에서 RabbitMQ로 보내는 bridge |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/external | grading-svc, report-svc용 HTTP client |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/storage/OmrMinioStorageAdapter.java | OMR 이미지와 생성 artifact용 MinIO storage |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/persistence/RedisCallbackDeduplicationAdapter.java | Redis 기반 callback deduplication |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/service | command, query, grading, statistics, callback, sweeper service |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/domain/entity | Exam, ExamTemplate, ExamResult, QuestionResult, OmrGradingJob, ReportGenerationJob |
lumie-backend/libs/internal-api/src/main/java/com/lumie/exam/api/ExamService.java | internal monolith 계약 |
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sql | exams, exam_results, exam_templates, job table 등의 baseline |
lumie-backend/app/src/main/resources/db/migration/public/V24__add_version_to_domain_mutable_entities.sql | exam_templates에 optimistic-lock version 추가 |
lumie-backend/app/src/main/resources/db/migration/public/V44__create_exam_ai_analyses.sql | 이 모듈의 주 write surface 바깥에 있는 exam 측 AI cache table |
공개 인터페이스
| Surface | Entrypoints |
|---|---|
| Exam CRUD | GET/POST /v1/exams, GET /v1/exams/{id}, GET /v1/exams/{id}/full, PATCH /v1/exams/{id}, DELETE /v1/exams/{id} |
| Template CRUD | GET/POST /v1/exam-templates, GET/PATCH/DELETE /v1/exam-templates/{id} |
| Synchronous OMR | POST /v1/exams/{examId}/results/omr |
| Batch OMR | POST /v1/exams/{examId}/results/omr/batch/presign, POST /v1/exams/{examId}/results/omr/batch/confirm, GET /v1/exams/{examId}/omr-jobs/{jobId}, GET /v1/exams/{examId}/omr-jobs/{jobId}/results, GET /v1/exams/{examId}/omr-jobs |
| Results and corrections | GET /v1/exams/{examId}/results, GET /v1/students/{studentId}/results, GET /v1/results/{resultId}/questions, PATCH /v1/question-results/{id}, DELETE /v1/exams/{examId}/results/{resultId} |
| OMR artifacts | GET /v1/exams/{examId}/results/{resultId}/omr-image, GET /v1/exams/results/export.csv |
| Reports | GET /v1/reports/students/{studentId}/exams/{examId}, POST /v1/exams/{examId}/reports/batch, GET /v1/exams/{examId}/reports/jobs/{jobId}, GET /v1/exams/{examId}/reports/jobs/{jobId}/download |
| Statistics | GET /v1/statistics/exams/{examId}, .../grades, .../results-summary, .../choices, .../class-comparison, .../item-analysis, GET /v1/statistics/students/{studentId}/rank, .../stability, .../type-growth, .../normalized, POST /v1/statistics/students/{studentId}/goal-simulation, GET /v1/statistics/dashboard |
내부 인터페이스
| Type | Path | Notes |
|---|---|---|
| Synchronous internal API | libs/internal-api/.../ExamService.java | linkUnregisteredResultsByPhone(...)만 노출 |
| Internal HTTP read surface | /internal/omr/exams/{id}/full | worker가 전체 시험 메타데이터를 읽는 경로 |
| Internal HTTP read surface | /internal/reports/... | worker가 report 통계 및 결과를 읽는 경로 |
| Spring Modulith listener | StudentRegisteredListener | student registration commit 후 미등록 결과 backfill |
| RabbitMQ callback consumer | grading.omr-callback, report.generation-callback | 처리 전에 tenant context 복원 |
internal API는 의도적으로 좁습니다. 다른 모듈은 프로세스 내부 write 계약을 통해 시험을 만들거나 수정하지 않습니다.
집계와 테이블
| Aggregate | Table | 참고 |
|---|---|---|
Exam | exams | 정답표, 배점 맵, 문항 타입, grading mode, 합격 점수 저장 |
ExamTemplate | exam_templates | 템플릿 전용 배점 및 문항 메타데이터 |
ExamResult | exam_results | 등록 학생 결과와 phone-only 미등록 결과를 모두 지원 |
QuestionResult | question_results | 시험 결과의 문항별 채점 상태 |
OmrGradingJob | omr_grading_jobs | 배치 이미지 처리 개수와 입력 object key 추적 |
ReportGenerationJob | report_generation_jobs | 학생별 report 생성 상태와 최종 ZIP key 추적 |
동일 aggregate 안에서 두 가지 결과 identity를 지원합니다.
- registered:
student_id가 존 재하며 authoritative phone은 student record에서 복사됨 - unregistered:
student_id는 null이고phone_number가 이후StudentRegisteredEvent가 backfill할 때까지 reconciliation key가 됨
런타임 흐름
일괄 OMR 채점
Outbox 루프가 보여주는 것
for (int i = 0; i < imageKeys.size(); i++) {
eventPublisher.publishEvent(new OmrGradingRequestedEvent(
savedJob.getId(), examId, tenantSlug,
imageKeys.get(i), i, imageKeys.size()));
}
이 코드는 ResultCommandService.confirmBatchOmrGrading(...)에서 나옵니다.
job row와 이미지별 publish event가 같은 트랜잭션 안에 기록되고, 그 뒤
JobRequestForwarder가 commit 후 이를 RabbitMQ로 보냅니다.
핵심 계약
OMR 저장 계약
- presigned batch upload는
tmp/<storageTenantId>/omr/<batchKey>/<sanitizedFileName>아래에 저장됩니다. - batch confirm은 예상 tenant prefix로 시작하지 않거나
..를 포함하는 object key를 거부합니다. - 결과 삭제는 row를 제거하기 전에 저장된 OMR 이미지를 MinIO에서 best-effort로 삭제합니다.
콜백 계약
- 두 callback consumer 모두 message payload에
tenantSlug를 요구합니다. - repository 접근 전에
tenantSlug를 다시tenantId로 해석합니다. - OMR callback은 Redis 24시간 TTL과 함께
omr:cb:{jobId}:{imageIndex}로, report callback은report:cb:{jobId}:{reportIndex}로 deduplicate합니다.
단일 이미지와 일괄 채점 비교
processOmrGrading(...)는 여전히 동기식이며OmrServicePort.gradeOmrImage(...)를 HTTP로 호출합니다.- batch grading은 queue 기반이며 먼저
OmrGradingJobrow를 저장합니다.
예시 계약
이 예시는 ResultController, ReportController, JobAcceptedResponse,
OmrBatchConfirmRequest, OmrGradingCallbackRequest,
OmrJobStatusResponse, ReportBatchRequest, ReportCallbackRequest에서
직접 가져왔습니다.
일괄 OMR 확인
POST /v1/exams/15/results/omr/batch/confirm
Idempotency-Key: omr-batch-15
Content-Type: application/json
{
"batchKey": "20260614-01",
"objectKeys": [
"tmp/15/omr/20260614-01/page-1.png",
"tmp/15/omr/20260614-01/page-2.png"
]
}
HTTP/1.1 202 Accepted
Location: /v1/exams/15/omr-jobs/341
{
"jobId": 341,
"statusUrl": "/v1/exams/15/omr-jobs/341"
}
OMR 콜백 페이로드
// RabbitMQ body consumed by OmrGradingCallbackListener from grading.omr-callback
{
"jobId": 341,
"examId": 15,
"tenantSlug": "acme",
"imageKey": "tmp/15/omr/20260614-01/page-1.png",
"imageIndex": 0,
"totalImages": 2,
"success": true,
"error": null,
"phoneNumber": "01012345678",
"totalScore": 92,
"grade": 1,
"results": [
{
"questionNumber": 1,
"studentAnswer": "3",
"correctAnswer": "3",
"score": 5,
"earnedScore": 5,
"questionType": "MULTIPLE_CHOICE"
}
]
}
작업 상태 폴링
GET /v1/exams/15/omr-jobs/341
HTTP/1.1 200 OK
{
"jobId": 341,
"examId": 15,
"status": "PROCESSING",
"totalImages": 2,
"processedImages": 1,
"successCount": 1,
"failCount": 0,
"savedCount": 1,
"createdAt": "<timestamp>"
}