본문으로 건너뛰기

채점

목적

grading-svc는 운영용 OMR 채점 워커입니다. 이 워커는 메시지당 스캔된 답안 이미지 1장을 채점하며, OpenCV와 NumPy를 사용해 용지를 정규화하고, 선택된 버블을 감지하고, 백엔드가 제공한 정답 키로 점수를 계산한 뒤, 완료 콜백을 발행합니다.

이 페이지는 reference 문서입니다. 워커, 백엔드 exam 통합, 큐 계약, OMR 운영을 변경하는 개발자를 위해 작성되었습니다. 섹션 전반의 worker 모델은 Workers Overview를 참조하세요.

소스 경로

경로역할
lumie-worker/services/grading/main.pyFastAPI 앱, lifespan wiring, /health, /metrics, consumer task 시작
lumie-worker/services/grading/src/schema.pyPydantic 메시지 및 콜백 payload 계약
lumie-worker/services/grading/src/usecase.pyGradeOMRUseCase, idempotency 검사, 이미지 다운로드, 시험 메타데이터 조회, 채점 오케스트레이션
lumie-worker/services/grading/src/domain/omr.py순수 OpenCV/NumPy OMR 처리 및 채점 로직
lumie-worker/services/grading/src/mq/consumer.pyRabbitMQ 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.javaOMR 채점 작업을 발행하는 백엔드 publisher
lumie-backend/modules/exam/src/main/java/com/lumie/exam/adapter/in/messaging/OmrGradingCallbackListener.java백엔드 RabbitMQ 콜백 consumer

공개 표면

grading-svc 자체는 운영용 HTTP 엔드포인트만 노출합니다.

엔드포인트목적
GET /healthliveness/readiness probe, {"status":"healthy"} 반환
GET /metrics공유 metrics app에서 마운트한 Prometheus scrape 엔드포인트

운영 채점 워크플로는 RabbitMQ 기반입니다.

방향큐 또는 routing key소유자
Backend to workergrading.omr-request, routing key grading.omr.requestJobRequestForwarder가 명령을 발행
Worker to backendrouting key grading.omr.callbackgrading-svc가 콜백 발행
Backend callback consumegrading.omr-callbackOmrGradingCallbackListener가 완료 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.pyOMRCommand로 검증됩니다.

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_EXCHANGERabbitMqConstants.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 플로우는 다음과 같습니다.

  1. Redis가 활성화된 경우 (jobId, imageIndex) 기준으로 Redis dedupe cache 확인
  2. imageKey를 사용해 MinIO에서 이미지 바이트 다운로드
  3. /internal/omr/exams/{examId}/full에서 백엔드 내부 API로 시험 메타데이터 조회
  4. OpenCV와 NumPy가 CPU 집약적이므로 asyncio.to_thread(...)에서 grade_omr_from_bytes(...) 실행
  5. 향후 재전달을 위해 결과를 Redis에 저장
  6. MQ adapter가 콜백 payload를 만들 수 있도록 원시 채점 결과 반환

도메인 함수는 이미지를 2480 x 3508 크기의 A4 캔버스로 정규화하고, 상단 마커 사각형으로 deskew를 수행하며, blur, gamma correction, CLAHE, sharpening으로 전처리하고, 마킹된 버블 밀도를 추정하며, 전화번호를 추출하고, 문제별 결과를 계산하며, totalScore에서 절대 등급을 도출합니다.

설정

services/grading/src/config.py가 이 워커의 설정 소스입니다.

설정기본값비고
RABBITMQ_QUEUEgrading.omr-requestrun_consumer(...)가 소비하는 큐
RABBITMQ_PREFETCH_COUNT3consumer pod당 동시 처리 중인 메시지 수
MINIO_ENDPOINTlocalhost:9000이미지 오브젝트 스토어 엔드포인트
MINIO_BUCKETlumieOMR 이미지 오브젝트 버킷
LUMIE_BACKEND_URLrequired내부 시험 메타데이터용 백엔드 base URL
LUMIE_INTERNAL_HMAC_SECRETrequired서명된 /internal/** 호출용 공유 secret
REDIS_ENABLEDtrueRedis 없이 로컬 실행할 때만 비활성화
OTEL_ENABLEDtrueOTLP 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을 노출합니다.

MetricLabels의미
omr_grading_totalresult, tenant성공, cache hit, handler failure 결과 수
omr_grading_duration_secondsresultuse case 종단 간 소요 시간
omr_grading_inflightnone현재 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.pyPOST /api/temp/grading을 노출하며 2026-07-01 이후 health가 아닌 요청을 거부
lumie-worker/contracts/omr-grading-v1.yamlPOST /api/temp/grading을 문서화
lumie-backend/modules/exam/.../OmrServiceClient.javaGRADING_SVC_URL + /api/omr/grade 호출
lumie-infra/applications/lumie/backend/common-values.yamlGRADING_SVC_URLhttp://grading-svc.lumie-worker.svc:8000으로 설정

이 드리프트가 해결되기 전까지 동기 경로를 안정적인 grading-svc 엔드포인트로 설명하면 안 됩니다. 동기 경로를 변경할 때는 백엔드 클라이언트, 워커 라우트, 계약 YAML, 인프라 값을 함께 업데이트해야 합니다.

검증

변경한 레이어를 가장 좁게 덮는 검사를 사용하세요.

cd lumie-worker
pytest services/grading/tests

예상 성공 신호:

  • pytest0으로 종료
  • 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.callbackservices/grading/src/adapters/callback_mq.py에 나타남
  • 백엔드가 OmrGradingCallbackListener에서 큐 grading.omr-callback으로 콜백을 소비함
  • 백엔드 OmrGradingJobProcessorphoneNumber가 없는 success=true를 실패로 취급하므로 콜백 예시는 이 필드를 유지해야 함

lumie-document의 문서 전용 수정이라면 lumie-document/docusaurus에서 Docusaurus build를 실행하고 깨진 링크나 오래된 로컬라이즈 페이지 경고를 확인하세요.