과제 서비스
이 페이지는 lumie-backend/modules/assignment를 다룹니다.
책임
assignment 모듈은 다음을 담당합니다.
- 명시적인 수업 및 student 대상을 갖는 테넌트 범위 과제
/v1/assignments아래의 staff 작성 워크플로/v1/assignments/me아래의 학생 대시보드 조회와 자기 제출 API- 활성 과제를 위한 시험 연계 수동 답안 제출 저장
- 과제 마감일, active/closed 상태, 정답 공개 토글
- 다른 백엔드 모듈이 사용하는 내부 조회 API
소스 경로
| 경로 | 역할 |
|---|---|
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/adapter/in/web/AssignmentController.java | staff 및 student 과제 HTTP 라우트 |
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/adapter/in/web/AssignmentSubmissionController.java | staff 제출물 관리 라우트 |
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/application/service/AssignmentCommandService.java | 과제 생성, 대상 교체, 자기 제출, 채점 규칙 |
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/application/service/AssignmentQueryService.java | staff 조회, 학생 가시성 조회, 학생용 제출물/결과 조회 |
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/application/service/AssignmentLifecycleService.java | 마감이 지난 ACTIVE 과제를 CLOSED로 영속화 |
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/adapter/in/scheduling/AssignmentDeadlineScheduler.java | ShedLock으로 보호되는 테넌트-aware 마감 과제 종료 스케줄러 |
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/domain/entity/Assignment.java | 과제 aggregate, 워크플로 플래그, 가시성 플래그, 호환성용 class_id 컬럼 |
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/domain/entity/AssignmentTarget.java | CLASS 및 STUDENT 타기팅을 위한 표준 대상 행 |
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/domain/entity/AssignmentSubmission.java | 답안 맵과 시험 결과 연결을 담는 제출 aggregate |
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/domain/repository/AssignmentRepository.java | 과제 목록 조회와 마감 초과 과제 bulk close update |
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/domain/repository/AssignmentTargetRepository.java | 가시성 조회와 대상 교체 영속화 |
lumie-backend/libs/internal-api/src/main/java/com/lumie/assignment/api/AssignmentService.java | 모놀리스 내부 조회 계약 |
lumie-backend/libs/internal-api/src/main/java/com/lumie/exam/api/ExamService.java | 내부 시험 메타데이터 및 수동 채점 의존성 |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/service/ResultCommandService.java | EXAM_MANUAL 과제가 소비하는 인라인 수동 답안 채점 경로 |
lumie-backend/app/src/main/resources/db/migration/public/V81__assignment_redesign_expand.sql | 과제 워크플로 컬럼, assignment_targets, 시험 연계 제출 필드를 추가 |
lumie-backend/app/src/main/resources/db/migration/public/V82__assignment_redesign_tenant_safe_indexes.sql | 리디자인용 테넌트-safe 인덱스와 복합 foreign key를 추가 |
lumie-backend/app/src/main/resources/db/migration/public/V83__drop_assignment_title_description.sql | 레거시 과제 title 및 description 컬럼 제거 |
lumie-backend/app/src/main/resources/db/migration/public/V84__drop_assignment_type_and_draft.sql | 과제 type과 DRAFT 상태 제거 |
lumie-backend/app/src/main/resources/db/migration/public/V85__assignment_exam_only_active_guard.sql | 비시험 레거시 행을 닫고 active 과제를 시험 연계 과제로 제한 |
lumie-backend/app/src/main/resources/db/migration/public/V86__assignment_exam_fk_restrict.sql | 과제가 참조하는 시험 삭제를 제한 |
lumie-backend/app/src/main/resources/db/migration/public/V89__require_assignment_due_date.sql | 레거시 null 마감일을 백필하고 assignments.due_date를 필수화 |
lumie-backend/app/src/main/resources/db/migration/public/V90__assignment_overdue_close_index.sql | 마감 과제 종료 쿼리가 사용하는 partial index 추가 |
공개 인터페이스
| 사용 표면 | 엔트리포인트 |
|---|---|
| Staff 작성 | POST /v1/assignments, GET /v1/assignments, GET /v1/assignments/{id}, PATCH /v1/assignments/{id}, DELETE /v1/assignments/{id}, GET /v1/assignments/class/{classId} |
| 학생 대시보드 | GET /v1/assignments/me, GET /v1/assignments/{id}/me, PUT /v1/assignments/{id}/me/submission |
| Staff 제출물 관리 | GET /v1/assignments/{assignmentId}/submissions, GET /v1/assignments/{assignmentId}/submissions/status, POST /v1/assignments/{assignmentId}/submissions, PATCH /v1/assignments/{assignmentId}/submissions/{submissionId}, GET /v1/assignments/{assignmentId}/submissions/student/{studentId} |
| 과제 통계 | GET /v1/assignments/statistics/dashboard |
내부 인터페이스
AssignmentService는 여전히 조회 메서드만 제공합니다.
getAssignment(...)getAssignmentsByClass(...)
이번 리디자인은 과제 채점을 별도의 서비스 경계로 분리하지 않았습니다.
EXAM_MANUAL 채점은 내부 ExamService를 통해 모듈러 모놀리스 안에
머뭅니다.
Aggregate와 테이블
| 집계 | 테이블 | 참고 |
|---|---|---|
Assignment | assignments | 시험 연계 워크플로 모드, 필수 마감일, 상태, 정답 공개 설정, active 행의 필수 linked_exam_id, 그리고 새 대상 행과 함께 호환성용 class_id를 저장 |
AssignmentTarget | assignment_targets | 표준 대상 행입니다. target_type=CLASS와 target_type=STUDENT는 모두 테넌트 RLS와 함께 target_id를 사용합니다 |
AssignmentSubmission | assignment_submissions | (assignment_id, student_id)에 대해 unique이며, 객관식 답안 맵, exam_result_id, 점수/합격 필드, 피드백 metadata를 저장합니다 |
AssignmentResponse는 호환성용 classId와 새로운
targetClassIds / targetStudentIds 목록을 모두 노출합니다. 호환성
컬럼은 오래된 수업 중심 조회를 위해 남아 있지만, 학생 가시성 계약은
이제 assignment_targets에서 옵니다.
대상 타기팅과 가시성
- 최소 한 개의 대상이 필요합니다. class와 student 대상 목록이 모두
비어 있으면
TARGET_REQUIRED로 실패합니다. CreateAssignmentRequest.classId는 여전히 허용되며, 이후targetClassIds에 병합됩니다.AssignmentCommandService.legacyClassId(...)는 첫 번째 수업 대상을 선택하거나, 개별 타기팅된 학생의 첫 등록 class를 추론해assignments.class_id를 채운 상태로 유지합니다.GET /v1/assignments/me는 auth 사용자 ID에서 현재 학생을 해석하고, 직접 student 대상과 등록 수업 대상을 합친 뒤, 제출 결과를 계속 볼 수 있도록 종료된 행을 포함한 가시 과제를 반환합니다.GET /v1/assignments/{id}/me는 비대상 과제를ASSIGNMENT_NOT_VISIBLE뒤로 숨깁니다. 종료된 과제는 여전히 읽을 수 있지만canSubmit은false가 됩니다.GET /v1/assignments/{assignmentId}/submissions/status는 대상 학생을 모두 펼친 다음, 저장된 최신 제출물이 있으면 이를 덧씌웁니다. 저장된 행이 없는 학생은PENDING으로 보고됩니다.
워크플로 모드와 채점 규칙
- 생성과 수정에는 유효한
linkedExamId가 필요합니다. - 활성 과제는
submissionMode=EXAM_MANUAL과evaluationMode=EXAM_AUTO를 사용합니다.AssignmentCommandService.requireExamManualWorkflow(...)는 그 외 active 워크플로를INVALID_STATUS로 거부합니다. CreateAssignmentRequest.dueDate는 필수입니다. 공개POST요청에 이 값이 없으면 request-body validation에서 실패합니다. 내부 create/update 경로와 도메인 aggregate는 null 마감일을DUE_DATE_REQUIRED로 거부합니다.- 마감이 지난
ACTIVE과제는CLOSED로 영속화됩니다. 스케줄러가 주기적으로 종료 처리하고, 단건 과제 상세 및 제출 경로도 과제 row를 읽기 전에 assignment-scoped close guard를 실행하므로 UI가 스케줄러 간격에 의존하지 않습니다. - 목록 응답도
dueDate로 effective status를 계산하므로, 스케줄러가 상태를 영속화하기 전에도 마감 초과 행은 목록에서CLOSED로 표시됩니다. - 학생 및 staff 제출 payload는 문항 번호 문자열을 키로 하는 맵인
answers를 사용합니다. - 채점은 내부 시험 모듈을 통해 인라인으로 실행되며, 같은 요청 경로 안에
assignment_submissions행과exam_results행을 모두 저장합니다. - 과제가
EXAM_AUTO를 사용할 때는PATCH /submissions/{submissionId}로 하는 Staff 채점이 차단됩니다. - Staff 작성 UI는 시험 관리와 같은 시험지 생성 필드를 사용해 새 시험지를 만들고, 과제 row에는 과제 전용 마감일, 대상, 상태, 정답 공개 설정만 저장합니다.
소스 앵커:
lumie-backend/modules/assignment/src/main/java/com/lumie/assignment/application/service/AssignmentCommandService.java#submitCurrentStudent
ExamService.ManualGradeResult result = examService.gradeManualAnswers(
TenantContextHolder.getRequiredTenant(),
assignment.getLinkedExamId(),
studentId,
request.answers());
submission.submitExam(request.answers(), result.examResultId(), result.totalScore(),
result.passed(), result.grade() != null ? String.valueOf(result.grade()) : null);
이 경로는 동기식이며 프로세스 내부에서 끝납니다. RabbitMQ event를
발행하거나, omr_grading_jobs를 생성하거나, OMR 이미지를 업로드하거나,
lumie-worker의 grading-svc를 호출하지 않습니다.
결과 가시성 계약
활성 과제 계약은 점수, pass/fail, 문항별 유형과 정오를 항상 공개합니다. 등급은
과제에서 사용하지 않으므로 showGrade=false입니다. 학생 대상 결과에서
설정 가능한 유일한 토글은 showCorrectAnswers입니다.
AssignmentQueryService.studentSubmissionResponse(...)는 학생 대상 조회에서
점수와 pass/fail을 노출합니다. AssignmentCommandService.filteredQuestionResults(...)는
문항별 유형, 정오, 점수를 포함하고, showCorrectAnswers=true일 때만
correctAnswer를 포함합니다.
즉 다음이 성립합니다.
GET /v1/assignments/{id}/me는 현재 학생의 최신 과제 제출과 과제-scoped 문항 결과를 반환할 수 있습니다.PUT /v1/assignments/{id}/me/submission은 저장된 제출물과 함께 같은 가시성 boolean을 반환하므로, 대시보드는 어떤 필드를 즉시 공개할지 결정할 수 있습니다.- 피드백은 이 플래그로 마스킹되지 않습니다. 값이 있으면 그대로 노출됩니다.
런타임 흐름
Staff 생성 또는 수정 흐름
- 요청에서 상태, 대상 ID, 마감일, 정답 공개 설정, nested 시험 payload를 파싱합니다.
- 각 수업 대상은
ClassService로, 각 student 대상은StudentService로 검증합니다. ExamService를 통해 연결 시험을 생성하거나 검증합니다.- 요청된
ACTIVE상태가 이미 마감 시간을 지났다면 과제를 즉시 종료합니다. - 호환성용
class_id를 포함한 시험 연계assignments행을 저장합니다. - 과제의 기존
assignment_targets행을 삭제하고 교체 집합을 삽입합니다.
학생 자기 제출 흐름
- 대상 과제가 마감이 지났고 아직
ACTIVE이면CLOSED로 영속화합니다. - 테넌트 slug와 auth 사용자 ID에서 현재 학생 ID를 해석합니다.
ACTIVE가 아닌 과제는ASSIGNMENT_CLOSED로 거부합니다.- 비대상이거나 숨겨진 과제는
ASSIGNMENT_NOT_VISIBLE로 거부합니다. - 기존
(assignment_id, student_id)제출 행이 있으면 재사용하고, 없으면 생성합니다. ExamService.gradeManualAnswers(...)를 호출하고,answers와exam_result_id를 저장하며, 과제-scopedquestionResults를 구성합니다.- 저장된 제출물과 활성 가시성 플래그를 담은
AssignmentSubmissionResultResponse를 반환합니다.
학생용 PUT /v1/assignments/{id}/me/submission 경로는 현재 학생의 첫 제출만
받습니다. (assignment_id, student_id) 행이 이미 있으면 같은 경로는
DUPLICATE_SUBMISSION을 발생시키며, 프론트엔드는 제출 form을 제거하고 채점
결과를 표시합니다.
대표 계약 예시
이 요청 형태는 CreateAssignmentRequest, AssignmentResponse, 그리고
lumie-frontend/src/features/assignment-management/create-assignment/ui/CreateAssignmentForm.tsx의
관리자 과제 폼과 일치합니다.
Staff가 EXAM_MANUAL 과제를 생성하는 경우
POST /v1/assignments
{
"classId": 12,
"status": "ACTIVE",
"dueDate": "2026-07-15T14:59:00Z",
"submissionMode": "EXAM_MANUAL",
"evaluationMode": "EXAM_AUTO",
"exam": {
"name": "중간고사",
"category": "GRADED",
"gradingType": "GRADED",
"gradeScale": "NINE_GRADE",
"totalQuestions": 2,
"correctAnswers": {
"1": "2",
"2": "4"
},
"questionScores": {
"1": 50,
"2": 50
},
"questionTypes": {
"1": "객관식",
"2": "객관식"
}
},
"targetClassIds": [12],
"targetStudentIds": [301],
"showScore": true,
"showPassFail": true,
"showGrade": false,
"showQuestionResults": true,
"showCorrectAnswers": false
}
{
"id": 88,
"classId": 12,
"dueDate": "2026-07-15T14:59:00Z",
"maxScore": null,
"attachments": null,
"status": "ACTIVE",
"submissionMode": "EXAM_MANUAL",
"evaluationMode": "EXAM_AUTO",
"linkedExamId": 44,
"targetClassIds": [12],
"targetStudentIds": [301],
"showScore": true,
"showPassFail": true,
"showGrade": false,
"showQuestionResults": true,
"showCorrectAnswers": false,
"createdAt": "2026-07-10T03:00:00Z",
"updatedAt": "2026-07-10T03:00:00Z"
}
학생이 EXAM_MANUAL 과제에 답을 제출하는 경우
이 응답 형태는 SubmitAssignmentRequest,
AssignmentSubmissionResultResponse, 그리고
StudentAssignmentSubmissionResultSchema와 일치합니다.
PUT /v1/assignments/88/me/submission
{
"answers": {
"1": "2",
"2": "4"
}
}
{
"submission": {
"id": 410,
"assignmentId": 88,
"studentId": 301,
"content": null,
"attachments": null,
"answers": {
"1": "2",
"2": "4"
},
"examResultId": 902,
"score": 95,
"passed": true,
"gradeValue": null,
"feedback": null,
"submittedAt": "2026-07-10T03:12:00Z",
"gradedAt": "2026-07-10T03:12:00Z",
"status": "GRADED",
"createdAt": "2026-07-10T03:12:00Z",
"updatedAt": "2026-07-10T03:12:00Z"
},
"showScore": true,
"showPassFail": true,
"showGrade": false,
"showQuestionResults": true,
"showCorrectAnswers": false,
"questionResults": [
{
"questionNumber": 1,
"selectedChoice": "2",
"correct": true,
"questionType": "객관식",
"score": 50,
"correctAnswer": null
}
]
}
showCorrectAnswers=false이면 questionResults[].correctAnswer는 null입니다.
true이면 같은 과제-scoped 결과 목록에 정답 키가 포함됩니다.
의존성과 경계
| 의존성 | 존재 이유 |
|---|---|
ClassService | 수업 대상을 검증하고, 등록 class를 조회하며, 호환성용 class_id를 도출 |
StudentService | 대상 student를 검증하고 auth 사용자 ID에서 현재 학생을 해석 |
ExamService | 연결된 시험을 검증하고, 학생 상세 라우트에 시험 메타데이터를 노출하며, 수동 답안을 인라인으로 채점 |
assignment 모듈은 여전히 모듈러 모놀리스 내부의 백엔드 모듈입니다. 이 모듈은 워커 messaging, 외부 OMR HTTP 호출, 백그라운드 채점 job을 소유하지 않습니다.
실패 모드
- 대상 ID가 유효하지 않으면
CLASS_NOT_FOUND또는STUDENT_NOT_FOUND - 정규화 후 수업 또는 student 대상이 하나도 남지 않으면
TARGET_REQUIRED EXAM_MANUAL생성 또는 수정 시 유효한 연결 시험이 없으면EXAM_NOT_FOUND- 학생이 비대상 과제를 읽거나 제출하면
ASSIGNMENT_NOT_VISIBLE - 학생이
ACTIVE가 아닌 과제에 제출하면ASSIGNMENT_CLOSED - 필요한 답안이 없거나 staff가 exam-auto 과제를 수동 채점하려 하면
INVALID_SUBMISSION DUPLICATE_SUBMISSION은 staff 또는 student 제출 경로가 같은(assignment_id, student_id)에 대해 두 번째 제출을 받으면 발생- 내 부 create/update 경로나 도메인 메서드가 null 마감일을 저장하려 하면
DUE_DATE_REQUIRED. 공개POST요청에서dueDate가 없으면 command 처리 전에 request validation으로 실패하고,PATCH요청에서dueDate를 생략하면 기존 마감일을 유지합니다. - 요청 enum이 과제, 제출, 모드, 평가 enum에 매핑되지 않으면
INVALID_STATUS - JSONB 첨부나 답안 변환에 실패하면
JSON_SERIALIZATION_FAILED
관측성과 재시도 동작
AssignmentCommandService는 과제 생성, 수정, 삭제, 제출 생성, 제출 갱신 이벤트를 로그로 남깁니다.AssignmentLifecycleService는 마감 과제 종료 이벤트를 로그로 남기고,AssignmentDeadlineScheduler는 스케줄 실행으로 row가 변경되면 테넌트 slug와 종료한 과제 수를 로그로 남깁니다.- 학생용
PUT /me/submission흐름에는 queue, 워커 handoff, broker retry가 없습니다. EXAM_MANUAL채점은 요청 트랜잭션 경계 안에 포함됩니다. 이 표면에서의 유일한 재시도 메커니즘은 클라이언트 측 HTTP 재시도입니다.- 학생 라우트는 성공한 제출 이후 retry-idempotent하지 않습니다. 같은 과제와 학생에 대한 두 번째 제출은 채점이나 저장 전에 거부됩니다.
검증
./gradlew :modules:assignment:test
./gradlew :modules:exam:test --tests '*ResultCommandService*'
rg -n "assignment_targets|submission_mode|evaluation_mode|linked_exam_id|show_score|exam_result_id|due_date" \
lumie-backend/app/src/main/resources/db/migration/public/V81__assignment_redesign_expand.sql \
lumie-backend/app/src/main/resources/db/migration/public/V82__assignment_redesign_tenant_safe_indexes.sql \
lumie-backend/app/src/main/resources/db/migration/public/V85__assignment_exam_only_active_guard.sql \
lumie-backend/app/src/main/resources/db/migration/public/V89__require_assignment_due_date.sql \
lumie-backend/app/src/main/resources/db/migration/public/V90__assignment_overdue_close_index.sql
cd lumie-document/docusaurus && npm run build
예상 성공 신호:
- Gradle이
BUILD SUCCESSFUL로 끝납니다. - assignment 모듈 테스트와
ResultCommandService테스트가 실패 없이 통과합니다. rg가V81의 리디자인 컬럼과 테이블,V82의 테넌트-safe FK/인덱스 이름을 찾습니다.- Docusaurus가
backend/assignment-svc에 대해 MDX 또는 broken-link 오류 없이 완료됩니다.