본문으로 건너뛰기

시험 서비스

이 페이지는 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.javalibs/internal-api를 통해 노출되는 동기 internal API
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/messaging/JobRequestForwarder.javaSpring Modulith outbox에서 RabbitMQ로 보내는 bridge
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/externalgrading-svc, report-svc용 HTTP client
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/storage/OmrMinioStorageAdapter.javaOMR 이미지와 생성 artifact용 MinIO storage
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/persistence/RedisCallbackDeduplicationAdapter.javaRedis 기반 callback deduplication
lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/servicecommand, query, grading, statistics, callback, sweeper service
lumie-backend/modules/exam/src/main/java/com/lumie/exam/domain/entityExam, ExamTemplate, ExamResult, QuestionResult, OmrGradingJob, ReportGenerationJob
lumie-backend/libs/internal-api/src/main/java/com/lumie/exam/api/ExamService.javainternal monolith 계약
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sqlexams, exam_results, exam_templates, job table 등의 baseline
lumie-backend/app/src/main/resources/db/migration/public/V24__add_version_to_domain_mutable_entities.sqlexam_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

공개 인터페이스

SurfaceEntrypoints
Exam CRUDGET/POST /v1/exams, GET /v1/exams/{id}, GET /v1/exams/{id}/full, PATCH /v1/exams/{id}, DELETE /v1/exams/{id}
Template CRUDGET/POST /v1/exam-templates, GET/PATCH/DELETE /v1/exam-templates/{id}
Synchronous OMRPOST /v1/exams/{examId}/results/omr
Batch OMRPOST /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 correctionsGET /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 artifactsGET /v1/exams/{examId}/results/{resultId}/omr-image, GET /v1/exams/results/export.csv
ReportsGET /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
StatisticsGET /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

내부 인터페이스

TypePathNotes
Synchronous internal APIlibs/internal-api/.../ExamService.javalinkUnregisteredResultsByPhone(...)만 노출
Internal HTTP read surface/internal/omr/exams/{id}/fullworker가 전체 시험 메타데이터를 읽는 경로
Internal HTTP read surface/internal/reports/...worker가 report 통계 및 결과를 읽는 경로
Spring Modulith listenerStudentRegisteredListenerstudent registration commit 후 미등록 결과 backfill
RabbitMQ callback consumergrading.omr-callback, report.generation-callback처리 전에 tenant context 복원

internal API는 의도적으로 좁습니다. 다른 모듈은 프로세스 내부 write 계약을 통해 시험을 만들거나 수정하지 않습니다.

집계와 테이블

AggregateTable참고
Examexams정답표, 배점 맵, 문항 타입, grading mode, 합격 점수 저장
ExamTemplateexam_templates템플릿 전용 배점 및 문항 메타데이터
ExamResultexam_results등록 학생 결과와 phone-only 미등록 결과를 모두 지원
QuestionResultquestion_results시험 결과의 문항별 채점 상태
OmrGradingJobomr_grading_jobs배치 이미지 처리 개수와 입력 object key 추적
ReportGenerationJobreport_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 기반이며 먼저 OmrGradingJob row를 저장합니다.

예시 계약

이 예시는 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>"
}

리포트 배치와 콜백

POST /v1/exams/15/reports/batch
Content-Type: application/json

{
"studentIds": [101, 102]
}
// RabbitMQ body consumed by ReportGenerationCallbackListener from report.generation-callback
{
"jobId": 812,
"examId": 15,
"studentId": 101,
"tenantSlug": "acme",
"reportIndex": 0,
"totalReports": 2,
"success": true,
"error": null,
"reportBytes": "<base64-pdf>"
}

의존성과 경계

Dependency존재 이유
StudentServicestudent ID 검증, student phone/name 해석, 미등록 결과 backfill
BillingServiceMetricType.OMR_MONTHLY를 통한 OMR 사용량 게이트
TenantServicecallback과 stale-job sweep용 tenant context 복원
OmrServicePort, ReportServicePort동기 채점 또는 internal report 조회를 위한 worker HTTP 계약
OmrStoragePortpresigned upload, OMR 이미지 저장, ZIP 저장
RabbitMQ via libs/messagingbatch job dispatch와 callback
Redis중복 callback 억제

실패, 재시도, 멱등성

  • 동기 채점은 EXAM_NOT_FOUND, STUDENT_NOT_FOUND, OMR_QUOTA_EXCEEDED, OMR_GRADING_FAILED로 즉시 실패합니다.
  • batch confirm은 tenant 범위 MinIO prefix 밖의 object key를 거부합니다.
  • 공개 batch 변경 endpoint는 ResultController, ReportController를 통해 Idempotency-Key를 받습니다.
  • RabbitMQ publish 실패 시 Spring Modulith event publication은 미완료 상태로 남기 때문에, 재시작 시 재시도됩니다.
  • 중복 callback은 결과를 두 번 쓰는 대신 Redis deduplication으로 무시됩니다.
  • 이미 종료 상태인 job에 대한 늦은 callback은 로그만 남기고 건너뜁니다.
  • ExamJobTimeoutSweeper는 2시간이 지난 PROCESSING job을 FAILED로 표시하여 frontend polling이 종료될 수 있게 합니다.

관측성

이 모듈은 백엔드에서 가장 풍부한 구조화 job logging을 가집니다.

  • JobRequestForwarder의 enqueue 로그
  • 두 Rabbit listener의 callback 수락/거부/실패/성공 로그
  • ExamJobTimeoutSweeper의 stale-job warning
  • grading, report generation, result edit 주변의 일반 command/query 로그

이 모듈은 Gradle build를 통해 spring-boot-starter-actuatormicrometer-registry-prometheus에도 의존합니다.

검증

이 모듈을 변경하거나 문서를 코드와 대조할 때는 다음 명령을 사용합니다.

./gradlew :modules:exam:test
./gradlew :app:test --tests '*Exam*'
cd lumie-document/docusaurus && npm run build

예상 성공 신호:

  • Gradle이 BUILD SUCCESSFUL로 종료되고, exam 모듈 테스트가 여전히 result, report, callback 처리 경로를 다룹니다.
  • lumie-document/docusaurus에서 npm run build가 broken-link나 MDX parse 실패 없이 0으로 종료됩니다.
  • JobAcceptedResponseLocation header를 statusUrl에 그대로 반영하고, callback listener가 여전히 grading.omr-callback, report.generation-callback에 바인딩됩니다.

관련 페이지