채점
목적
grading-svc는 운영용 OMR 채점 워커입니다. 이 워커는 메시지당 스캔된 답안 이미지 1장을 채점하며, OpenCV와 NumPy 를 사용해 용지를 정규화하고, 선택된 버블을 감지하고, 백엔드가 제공한 정답 키로 점수를 계산한 뒤, 완료 콜백을 발행합니다.
이 페이지는 reference 문서입니다. 워커, 백엔드 exam 통합, 큐 계약, OMR 운영을 변경하는 개발자를 위해 작성되었습니다. 섹션 전반의 worker 모델은 Workers Overview를 참조하세요.
소스 경로
| 경로 | 역할 |
|---|---|
lumie-worker/services/grading/main.py | FastAPI 앱, lifespan wiring, /health, /metrics, consumer task 시작 |
lumie-worker/services/grading/src/schema.py | Pydantic 메시지 및 콜백 payload 계약 |
lumie-worker/services/grading/src/usecase.py | GradeOMRUseCase, idempotency 검사, 이미지 다운로드, 시험 메타데이터 조회, 채점 오케스트레이션 |
lumie-worker/services/grading/src/domain/omr.py | 순수 OpenCV/NumPy OMR 처리 및 채점 로직 |
lumie-worker/services/grading/src/mq/consumer.py | RabbitMQ consumer adapter 및 채점 전용 콜백 payload 빌더 |
lumie-worker/libs/common/mq.py | 공유 ack, nack, reject, retry, 콜백 수명주기 |
lumie-worker/contracts/mq-schemas-v1.yaml | 수동 유지 비동기 MQ 계약 참고 자료 |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/out/messaging/JobRequestForwarder.java | OMR 채점 작업을 발행하는 백엔드 publisher |
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/messaging/OmrGradingCallbackListener.java | 백엔드 RabbitMQ 콜백 consumer |
공개 표면
grading-svc 자체는 운영용 HTTP 엔드포인트만 노출합니다.
| 엔드포인트 | 목적 |
|---|---|
GET /health | liveness/readiness probe, {"status":"healthy"} 반환 |
GET /metrics | 공유 metrics app에서 마운트한 Prometheus scrape 엔드포인트 |
운영 채점 워크플로는 RabbitMQ 기반입니다.
| 방향 | 큐 또는 routing key | 소유자 |
|---|---|---|
| Backend to worker | 큐 grading.omr-request, routing key grading.omr.request | JobRequestForwarder가 명령을 발행 |
| Worker to backend | routing key grading.omr.callback | grading-svc가 콜백 발행 |
| Backend callback consume | 큐 grading.omr-callback | OmrGradingCallbackListener가 완료 payload 소비 |
콜백 큐 이름과 routing key는 의도적으로 다릅니다. 이 플로우를 바꿀 때는 백엔드 listener 큐, worker routing key, broker binding을 하나의 계약으로 취급해야 합니다.
런타임 플로우
시작은 import 시점이 아니라 FastAPI lifespan 내부에서 이뤄집니다. main.py는 adapter container를 만들고, callback publisher를 연결하며, run_consumer(...)를 백그라운드 task로 시작합니다. 종료 시에는 consumer를 취소하고, callback 연결을 닫고, 공유 httpx.AsyncClient를 닫습니다.
메시지 계약
들어오는 메시지는 services/grading/src/schema.py의 OMRCommand로 검증됩니다.
class OMRCommand(BaseModel):
model_config = ConfigDict(populate_by_name=True, extra="ignore")
job_id: int = Field(alias="jobId")
exam_id: int = Field(alias="examId")
tenant_slug: str = Field(alias="tenantSlug", min_length=1)
image_key: str = Field(alias="imageKey", min_length=1)
image_index: int = Field(alias="imageIndex", ge=0)
total_images: int = Field(alias="totalImages", ge=1)
schema_version: int = Field(alias="schemaVersion", default=1)
성공 콜백에는 jobId, examId, tenantSlug, imageKey, imageIndex, totalImages, success=true, phoneNumber, totalScore, grade, 문제별 results가 포함됩니다. 실패 콜백은 가능한 경우 같은 작업 필드를 유지하고, success=false로 설정하며, error를 담고, 채점 결과 필드는 null 또는 0 기본값으로 설정합니다.
예시 inbound command:
{
"jobId": 7,
"examId": 42,
"tenantSlug": "acme",
"imageKey": "tmp/acme/omr/abc/sheet1.png",
"imageIndex": 0,
"totalImages": 3,
"schemaVersion": 1
}
예시 성공 콜백:
{
"jobId": 7,
"examId": 42,
"tenantSlug": "acme",
"imageKey": "tmp/acme/omr/abc/sheet1.png",
"imageIndex": 0,
"totalImages": 3,
"success": true,
"error": null,
"phoneNumber": "01012345678",
"totalScore": 88,
"grade": 2,
"results": [
{
"questionNumber": 1,
"studentAnswer": "A",
"correctAnswer": "A",
"score": 5,
"earnedScore": 5,
"questionType": "blank"
}
]
}
예시 실패 콜백:
{
"jobId": 7,
"examId": 42,
"tenantSlug": "acme",
"imageKey": "tmp/acme/omr/abc/sheet1.png",
"imageIndex": 0,
"totalImages": 3,
"success": false,
"error": "download failed",
"phoneNumber": null,
"totalScore": 0,
"grade": 0,
"results": null
}
백엔드 publisher는 OmrGradingRequestedEvent에서 명령을 만들고 RabbitMqConstants.LUMIE_COMMANDS_EXCHANGE로 RabbitMqConstants.GRADING_OMR_REQUEST_ROUTING_KEY를 사용해 전송합니다.
rabbitTemplate.convertAndSend(
RabbitMqConstants.LUMIE_COMMANDS_EXCHANGE,
RabbitMqConstants.GRADING_OMR_REQUEST_ROUTING_KEY,
message);
처리 모델
GradeOMRUseCase.execute(...)가 오케스트레이션 경계입니다. MQ 메시지 파싱, broker 메시지 ack/nack, 환경 변수 읽기, 콜백 payload 구성은 여기서 하지 않습니다. 그런 책임은 mq/consumer.py, config.py, 공유 MQ 유틸리티에 있습니다.
use case 플로우는 다음과 같습니다.
- Redis가 활성화된 경우
(jobId, imageIndex)기준으로 Redis dedupe cache 확인 imageKey를 사용해 MinIO에서 이미지 바이트 다운로드/internal/omr/exams/{examId}/full에서 백엔드 내부 API로 시험 메타데이터 조회- OpenCV와 NumPy가 CPU 집약적이므로
asyncio.to_thread(...)에서grade_omr_from_bytes(...)실행 - 향후 재전달을 위해 결과를 Redis에 저장
- MQ adapter가 콜백 payload를 만들 수 있도록 원시 채점 결과 반환
도메인 함수는 이미지를 2480 x 3508 크기의 A4 캔버스로 정규화하고, 상단 마커 사각형으로 deskew를 수행하며, blur, gamma correction, CLAHE, sharpening으로 전처리하고, 마킹된 버블 밀도를 추정하며, 전화번호를 추출하고, 문제별 결과를 계산하며, totalScore에서 절대 등급을 도출합니다.
설정
services/grading/src/config.py가 이 워커의 설정 소스입니다.
| 설정 | 기본값 | 비고 |
|---|---|---|
RABBITMQ_QUEUE | grading.omr-request | run_consumer(...)가 소비하는 큐 |
RABBITMQ_PREFETCH_COUNT | 3 | consumer pod당 동시 처리 중인 메시지 수 |
MINIO_ENDPOINT | localhost:9000 | 이미지 오브젝트 스토어 엔드포인트 |
MINIO_BUCKET | lumie | OMR 이미지 오브젝트 버킷 |
LUMIE_BACKEND_URL | required | 내부 시험 메타데이터용 백엔드 base URL |
LUMIE_INTERNAL_HMAC_SECRET | required | 서명된 /internal/** 호출용 공유 secret |
REDIS_ENABLED | true | Redis 없이 로컬 실행할 때만 비활성화 |
OTEL_ENABLED | true | OTLP export 활성화 |
MinIO 자격 증명, 백엔드 URL, HMAC secret은 누락 시 의도적으로 즉시 실패합니다. Redis는 use-case 경계에서 fail-open입니다. deduper가 연결되지 않아도 채점은 계속 진행됩니다.
실패, 재시도, 멱등성
libs/common/mq.py::universal_process가 이 워커와 다른 MQ 워커의 broker 수명주기를 담당합니다.
| 실패 지점 | 동작 |
|---|---|
| 잘못된 메시지 본문 | requeue 없이 reject하여 DLQ 라우팅 허용 |
| 재시도 후 handler 실패 | 실패 콜백을 만들고 발행한 뒤 원래 메시지를 ack |
| 재시도 후에도 콜백 발행 실패 | broker delivery-limit 정책이 DLQ로 옮길 수 있도록 nack(requeue=True) |
| 예기치 못한 수명주기 오류 | 메시지가 아직 처리되지 않았다면 nack(requeue=True) |
재시도 기본값은 선형 backoff를 가진 3회 시도입니다. 워커 측 idempotency는 (jobId, imageIndex) 단위이므로 RabbitMQ 재전달과 Spring Modulith outbox 재발행이 같은 이미지 명령을 안전하게 재생할 수 있습니다.
관측성
워커는 공유 MQ 수명주기를 통해 구조화된 백그라운드 작업 로그를 내보내고, services/grading/src/observability/metrics.py를 통해 서비스 metric을 노출합니다.
| Metric | Labels | 의미 |
|---|---|---|
omr_grading_total | result, tenant | 성공, cache hit, handler failure 결과 수 |
omr_grading_duration_seconds | result | use case 종단 간 소요 시간 |
omr_grading_inflight | none | 현재 use case 내부에 있는 메시지 수 |
Tracing은 omr.job_id, omr.exam_id, omr.image_index, omr.total_images, tenant.slug를 포함한 grade_omr span을 추가합니다. 공유 observability 헬퍼는 aio-pika를 자동 계측하지 않으므로, MQ 수명주기 가시성은 구조화 로그와 명시적 span으로 유지해야 합니다.
동기 HTTP 계약 드리프트
AcademyWebsite 마이그레이션 경로를 위한 별도의 임시 HTTP 서비스 temp-omr-grading이 있습니다. 이것은 운영 grading-svc 워커가 아닙니다.
현재 확인한 소스는 서로 일치하 지 않습니다.
| 소스 | 주장 |
|---|---|
lumie-worker/services/temp-omr-grading/main.py | POST /api/temp/grading을 노출하며 2026-07-01 이후 health가 아닌 요청을 거부 |
lumie-worker/contracts/omr-grading-v1.yaml | POST /api/temp/grading을 문서화 |
lumie-backend/modules/exam/.../OmrServiceClient.java | GRADING_SVC_URL + /api/omr/grade 호출 |
lumie-infra/applications/lumie/backend/common-values.yaml | GRADING_SVC_URL을 http://grading-svc.lumie-worker.svc:8000으로 설정 |
이 드리프트가 해결되기 전까지 동기 경로를 안정적인 grading-svc 엔드포인트로 설명하면 안 됩니다. 동기 경로를 변경할 때는 백엔드 클라이언트, 워커 라우트, 계약 YAML, 인프라 값을 함께 업데이트해야 합니다.
검증
변경한 레이어를 가장 좁게 덮는 검사를 사용하세요.
cd lumie-worker
pytest services/grading/tests
예상 성공 신호:
pytest가0으로 종료services/grading/tests/test_grade_omr_usecase.py가 cache-hit 및 fresh-grading 경로를 통과services/grading/tests/test_worker_event_logging.py가 여전히 큐 이름grading.omr-request를 확인
계약 변경 시에는 다음 통합 지점도 함께 확인합니다.
rg -n "grading\\.omr|OmrServiceClient|/api/(temp/grading|omr/grade)" \
lumie-worker lumie-backend lumie-infra
이 계약의 정확한 검증 대상:
grading.omr-request가 worker config와 백엔드JobRequestForwarder에 나타남grading.omr.callback이services/grading/src/adapters/callback_mq.py에 나타남- 백엔드가
OmrGradingCallbackListener에서 큐grading.omr-callback으로 콜백을 소비함 - 백엔드
OmrGradingJobProcessor는phoneNumber가 없는success=true를 실패로 취급하므로 콜백 예시는 이 필드를 유지해야 함
lumie-document의 문서 전용 수정이라면 lumie-document/docusaurus에서 Docusaurus build를 실행하고 깨진 링크나 오래된 로컬라이즈 페이지 경고를 확인하세요.