본문으로 건너뛰기

시험 서비스

이 페이지는 lumie-backend/modules/exam을 다룹니다. 현재 monolith에서 이 모듈은 동기 요청 처리와 RabbitMQ 기반 워커 orchestration을 모두 소유하는 유일한 백엔드 도메인 모듈입니다.

책임

exam 모듈은 다음을 소유합니다.

  • 시험 정의와 정답표
  • 새 시험에 사용할 테넌트 관리 문항 유형 옵션
  • 재사용 가능한 시험 템플릿
  • 시험 결과와 문항별 채점 데이터
  • 단일 이미지 동기 OMR 채점
  • 배치 OMR 채점 job, 콜백, 결과 저장
  • 배치 report 생성 job, ZIP 조립, report 다운로드
  • 시험, 학생, 반 수준의 분석

소스 경로

경로역할
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/web시험, 템플릿, 결과, 통계, report용 공개 HTTP 컨트롤러
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/messaging채점 및 report 완료용 RabbitMQ 콜백 consumer
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/internal/ExamServiceAdapter.javalibs/internal-api를 통해 노출되는 동기 내부 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 기반 콜백 deduplication
lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/servicecommand, 쿼리, grading, statistics, 콜백, sweeper service
lumie-backend/modules/exam/src/main/java/com/lumie/exam/domain/entityExam, ExamQuestionType, 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 등의 기준선
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이 모듈의 주 쓰기 인터페이스 바깥에 있는 exam 측 AI 캐시 table
lumie-backend/app/src/main/resources/db/migration/public/V70__create_exam_question_types.sql테넌트 범위의 설정 가능한 문항 유형 옵션 추가

공개 인터페이스

인터페이스진입점
Exam CRUDGET/POST /v1/exams, GET /v1/exams/{id}, GET /v1/exams/{id}/full, PATCH /v1/exams/{id}, DELETE /v1/exams/{id}
Question type settingsGET /v1/exam-question-types; 교직원 전용 POST /v1/exam-question-types, DELETE /v1/exam-question-types/{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}, PATCH /v1/exams/{examId}/results/{resultId}/phone, 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

내부 인터페이스

유형경로비고
Synchronous 내부 APIlibs/internal-api/.../ExamService.javalinkUnregisteredResultsByPhone(...)만 노출
Internal HTTP 읽기 인터페이스/internal/omr/exams/{id}/full워커가 전체 시험 메타데이터를 읽는 경로
Internal HTTP 읽기 인터페이스/internal/reports/...워커가 report 통계 및 결과를 읽는 경로
Spring Modulith listenerStudentRegisteredListenerstudent 등록 커밋 후 미등록 결과 backfill
RabbitMQ 콜백 consumergrading.omr-callback, report.generation-callback처리 전에 테넌트 컨텍스트 복원

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

집계와 테이블

집계테이블참고
Examexams정답표, 배점 맵, 문항 타입, grading mode, 합격 점수 저장
ExamQuestionTypeexam_question_types이후 시험 생성에서 사용할 테넌트 관리 문항 유형 목록. 기존 시험은 question_types JSONB snapshot을 유지
ExamTemplateexam_templates템플릿 전용 배점 및 문항 메타데이터
ExamResultexam_results등록 학생 결과와 phone-only 미등록 결과를 모두 지원
QuestionResultquestion_results시험 결과의 문항별 채점 상태
OmrGradingJobomr_grading_jobs배치 이미지 처리 개수와 입력 object key 추적
ReportGenerationJobreport_generation_jobs학생별 report 생성 상태와 최종 ZIP key 추적

동일 aggregate 안에서 다음 결과 identity와 reconciliation 규칙을 지원합니다.

  • row identity: exam_results.id가 모든 결과 row의 authoritative identifier이며, PATCH /v1/exams/{examId}/results/{resultId}/phone 같은 전화번호 수정도 이 resultId로 대상 row를 고릅니다.
  • registered-student uniqueness: V18__rls_baseline.sqlstudent_id IS NOT NULL 조건에서 (exam_id, student_id) partial unique index uq_exam_result_student를 추가하므로, 한 학생은 같은 시험에 하나의 등록 결과 row만 가질 수 있습니다.
  • unregistered reconciliation: student_id가 null인 row의 phone_number는 OMR intake, 수동 수정, StudentRegisteredListener backfill에 쓰이는 reconciliation hint입니다. schema는 이러한 row에 대해 (exam_id, phone_number) 비고유 lookup index idx_exam_results_phone을 유지하며 unique constraint로 취급하지 않습니다.
  • student-delete history: V66__preserve_user_history_on_delete.sqlstudent_id를 null로 만들고 phone_number를 비우며 student_deleted = true로 표시하므로, 삭제 이력 row는 등록 결과도, 재사용 가능한 phone match도 아닙니다.

런타임 흐름

일괄 OMR 채점

Outbox 루프가 보여주는 것

출처: lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/service/ResultCommandService.java ResultCommandService.confirmBatchOmrGrading(...).

for (int i = 0; i < imageKeys.size(); i++) {
eventPublisher.publishEvent(new OmrGradingRequestedEvent(
savedJob.getId(), examId, tenantSlug,
imageKeys.get(i), i, imageKeys.size()));
}

job row와 이미지별 publish event가 같은 트랜잭션 안에 기록되고, 그 뒤 JobRequestForwarder가 커밋 후 이를 RabbitMQ로 보냅니다.

핵심 계약

OMR 저장 계약

  • presigned batch upload는 tmp/<storageTenantId>/omr/<batchKey>/<sanitizedFileName> 아래에 저장됩니다.
  • batch confirm은 예상 테넌트 prefix로 시작하지 않거나 ..를 포함하는 object key를 거부합니다.
  • 결과 삭제는 row를 제거하기 전에 저장된 OMR 이미지를 MinIO에서 best-effort로 삭제합니다.

콜백 계약

  • 두 콜백 consumer 모두 message payload에 tenantSlug를 요구합니다.
  • repository 접근 전에 tenantSlug를 다시 tenantId로 해석합니다.
  • OMR callback은 Redis 24시간 TTL과 함께 omr:cb:{jobId}:{imageIndex}로, 리포트 콜백은 report:cb:{jobId}:{reportIndex}로 중복 제거합니다.
  • 일괄 OMR callback의 전화번호가 활성 학생과 자동 매칭되고 그 학생이 같은 시험의 등록 결과를 이미 가지고 있으면, 최신 점수, 문항 결과, OMR job, 이미지 참조로 기존 등록 ExamResult를 제자리에서 재채점해 덮어씁니다. 등록 결과 unique race가 발생해도 해당 row를 다시 확인해 같은 방식으로 덮어쓰며 callback을 거절하지 않습니다.
  • 이 자동 OMR 덮어쓰기는 PATCH /v1/exams/{examId}/results/{resultId}/phone과 다릅니다. 수동 전화번호 수정은 미등록 row를 같은 시험의 기존 학생 결과에 연결하려 하면 계속 DUPLICATE_RESULT를 반환합니다.

단일 이미지와 일괄 채점 비교

  • processOmrGrading(...)는 여전히 동기식이며 OmrServicePort.gradeOmrImage(...)를 HTTP로 호출합니다.
  • batch grading은 queue 기반이며 먼저 OmrGradingJob row를 저장합니다.

결과 전화번호 수정

소스 앵커:

  • controller: lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/web/ResultController.java

  • command path: lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/service/ResultCommandService.java

  • request DTO: lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/dto/request/UpdateResultPhoneRequest.java

  • response DTO: lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/dto/response/ExamResultResponse.java

  • aggregate와 tests: lumie-backend/modules/exam/src/main/java/com/lumie/exam/domain/entity/ExamResult.java, lumie-backend/modules/exam/src/test/java/com/lumie/exam/application/service/ResultCommandServiceTest.java

  • PATCH /v1/exams/{examId}/results/{resultId}/phone은 최소 INSTRUCTOR 역할을 요구합니다.

  • resultId가 대상 row를 선택합니다. ResultCommandService.updateResultPhone(...)은 row를 resultId로 읽은 뒤, 읽힌 row가 전달된 examId에 속하는지 확인합니다.

  • 이 mutation은 삭제되지 않은 미등록 결과만 허용합니다. 삭제 이력 row와 이미 등록된 row는 모두 RESULT_NOT_FOUND로 실패합니다.

  • 요청 body는 phoneNumber만 포함합니다. ResultCommandService는 이를 PhoneNumberUtils.toDigits(...)로 숫자만 남긴 뒤 학생 매칭용 11자리 전화번호로 검증합니다.

  • 활성 학생 match가 없으면 command는 정규화된 전화번호만 쓰고 student_id는 null로 둡니다.

  • 활성 학생 match가 있으면, 그 학생이 같은 시험에 다른 결과를 이미 갖고 있지 않을 때만 row를 연결합니다.

  • 수정한 번호가 비활성 학생 기록에 있으면 command는 이를 명시적으로 실패시킵니다.

  • 같은 examId + phoneNumber를 가진 다른 미등록 row는 duplicate가 아닙니다. 수정 대상 row selector는 계속 resultId입니다.

  • StudentRegisteredListener backfill은 같은 전화번호 후보를 시험별로 묶고, 해당 시험에 미등록 후보가 정확히 하나일 때만 연결합니다. 같은 시험 안에 matching 미등록 row가 여러 개이면 ambiguous하므로 명시적인 result-level correction을 위해 그대로 둡니다.

문항 유형 설정

출처: ExamQuestionTypeController.java, ExamQuestionTypeService.java, TenantCreatedExamQuestionTypeListener.java, Exam.java, ExamTemplate.java, V70__create_exam_question_types.sql.

  • ExamQuestionTypeControllerGET /v1/exam-question-types, 교직원 전용 POST /v1/exam-question-typesDELETE /v1/exam-question-types/{id}를 노출합니다.
  • ExamQuestionTypeService는 tenant의 옵션 목록만 exam_question_types에 저장합니다.
  • V70은 기존 tenant의 기본 옵션을 백필하고, TenantCreatedExamQuestionTypeListener는 이후 생성되는 tenant에 같은 기본값을 주입합니다.
  • ExamExamTemplate은 문항별 유형명을 계속 question_types JSONB에 저장합니다. 이 snapshot은 exam_question_types와 foreign key로 연결되지 않으므로, 옵션 삭제는 이후 선택 목록에서만 제거됩니다.

학생별 성적 필터

시험 상세 통계 엔드포인트는 다음 선택 필터를 받습니다.

  • GET /v1/statistics/exams/{examId}

  • GET /v1/statistics/exams/{examId}/grades

  • GET /v1/statistics/exams/{examId}/class-comparison

  • GET /v1/statistics/exams/{examId}/item-analysis

  • GET /v1/statistics/exams/{examId}/choices

  • classId: 해당 클래스에 활성 수강 중인 등록 학생만 반환합니다.

  • hasActiveEnrollment: true면 활성 수강이 하나 이상인 등록 학생만 반환하고, false면 그 학생들을 제외하되 미등록 결과 row는 유지합니다.

  • isActive: 현재 학생 상태가 이 boolean과 일치하는 등록 학생만 반환합니다. 이 필터가 있으면 미등록 및 삭제 이력 결과 row는 제외합니다.

classIdhasActiveEnrollment를 함께 전달하면 classId가 우선합니다. 학생별 성적의 순위, 백분위, 상대등급은 필터를 적용하기 전에 시험의 모든 결과를 기준으로 계산합니다. 전체 통계, 반 비교, 문항 분석, 선지 분포 집계는 필터링된 응시자 집합을 기준으로 계산합니다.

학습 리포트 결과 요약

출처: StatisticsController.java, StudentGradeQueryService.java, StudentResultSummaryResponse.java, ClassService.java, ClassEnrollmentRepository.java.

  • GET /v1/statistics/exams/{examId}/results-summary는 학습 리포트 대시보드에 표시할 학생 결과마다 StudentResultSummaryResponse를 반환합니다.
  • 각 summary에는 리포트 생성에 쓰는 점수 필드와 함께, 학생의 활성 클래스 enrollment를 { classId, className } 항목으로 담은 classes 목록이 포함됩니다.
  • 삭제되었거나 등록되지 않은 학생은 studentId를 비워 두고 classes를 빈 목록으로 반환합니다. 따라서 과거 결과 행은 계속 표시하면서 현재 클래스 소속을 암시하지 않습니다.
  • 클래스 enrollment는 class 모듈에서 일괄 조회하며, 학생별 클래스 조회가 반복되지 않도록 classEntity를 함께 로드합니다.

예시 계약

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

결과 전화번호 수정

PATCH /v1/exams/15/results/927/phone
Content-Type: application/json

{
"phoneNumber": "010-3098-5821"
}
HTTP/1.1 200 OK

{
"id": 927,
"examId": 15,
"examName": "Season 3 Week 1 Mock Exam",
"studentId": 605,
"studentName": "Student A",
"phoneNumber": "01030985821",
"isRegistered": true,
"score": 82,
"totalPossibleScore": 100,
"grade": 2,
"correctCount": 37,
"incorrectCount": 8,
"hasOmrImage": true,
"createdAt": "<timestamp>"
}

작업 상태 폴링

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>"
}

의존성과 경계

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

실패, 재시도, 멱등성

  • 동기 채점은 EXAM_NOT_FOUND, STUDENT_NOT_FOUND, OMR_QUOTA_EXCEEDED, OMR_GRADING_FAILED로 즉시 실패합니다.
  • batch confirm은 테넌트 범위 MinIO prefix 밖의 object key를 거부합니다.
  • 공개 batch 변경 엔드포인트는 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의 콜백 수락/거부/실패/성공 로그
  • ExamJobTimeoutSweeper의 stale-job warning
  • grading, report generation, result edit 주변의 일반 command/쿼리 로그

이 모듈은 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, 콜백 처리 경로를 다룹니다.
  • lumie-document/docusaurus에서 npm run build가 broken-link나 MDX parse 실패 없이 0으로 종료됩니다.
  • JobAcceptedResponseLocation header를 statusUrl에 그대로 반영하고, 콜백 listener가 여전히 grading.omr-callback, report.generation-callback에 바인딩됩니다.

관련 페이지