OMR 채점 서비스 스케일링 트러블슈팅
이 문서는 grading-svc의 대용량 동시 채점 처리 과정에서 발생한 연쇄 장애와 그 해결 과정을 기록합니다. 단순한 메모리 부족 문제로 시작했지만, 아키텍처 안티패턴을 발견하고 수정하는 과정까지 이어졌습니다.
배경
Lumie는 학원 운영자가 OMR(Optical Mark Recognition) 답안지를 업로드하면 자동으로 채점하는 기능을 제공합니다.
부하 시나리오:
- 사용자 1명이 한 번에 최대 200장 업로드
- 100명이 동시에 업로드 = 최대 20,000장 동시 처리
초기 스택:
Python/FastAPI + OpenCV → grading-svc
Spring Boot → 백엔드 (멀티테넌트, 결제 처리)
RabbitMQ + KEDA → 비동기 큐 및 오토스케일링
MinIO → 이미지 오브젝트 스토리지
k3s (4노드: 1 master + 3 workers) → 컨테이너 오케스트레이션
문제 정의: 600MB라는 가정
작업 시작 전 예측한 메모리 소비량은 다음과 같았습니다.
예상: 1 채점 요청 × ~600MB = 100명 동시 = 60GB → 불가능
이 수치는 OpenCV가 이미지를 처리할 때마다 라이브러리 전체를 로드한다는 잘못된 가정에서 출발했습니다. 실제로는 Python의 import 캐싱 메커니즘 덕분에 라이브러리는 프로세스 시작 시 단 한 번만 메모리에 올라갑니다.
실측
90장을 실제로 처리하면서 kubectl top pod로 측정한 결과:
| 측정 항목 | 수치 |
|---|---|
| 유휴 상태 (라이브러리 로드 후) | ~100–150Mi |
| 이미지 처리 중 (피크) | +50–100Mi |
| 실제 피크 합산 | ~157Mi |
예측의 1/4도 안 되는 수치였습니다. 이 측정값이 이후 모든 리소스 계획의 기준이 됩니다.
클러스터 가용 용량
| 노드 | 역할 | 메모리 |
|---|---|---|
| 1대 | master | 8Gi |
| 3대 | worker (worker-2, worker-3, worker-4) | 12Gi × 3 = 36Gi |
| 합계 | 44Gi |
기존 워크로드를 제외한 신규 워크로드 가용량은 약 9Gi. 157Mi 기준으로 최대 57개 파드를 동시 실행할 수 있습니다.
1차 아키텍처: 안티패턴
초기 설계는 다음과 같았습니다.
문제의 핵심: Spring이 메시지 컨슈머이고, grading-svc는 실제 워커입니다. 이 두 역할이 분리되어 있으면 Spring이 HTTP 요청을 쏟아붓는 속도를 grading-svc가 통제할 수 없습니다.
1차 개선에서는 다음을 시도했습니다.
- 메시지 분할: 1메시지 = 200장 → 1메시지 = 1장
- 원자적 DB 카운터로 동시 채점 진행률 추적
- KEDA ScaledObject 설정 (0→10 레플리카, 큐 길이 기반)
- grading-svc에
asyncio.Semaphore적용 (동시 OpenCV 처리 수 제한)
이 시도는 올바른 방향이었지만, 아래에서 설명할 9개의 연쇄 장애를 겪었습니다.
연쇄 장애 분석
장애 1: KEDA 이미지 Pull 실패
증상: KEDA operator pod가 ImagePullBackOff 상태로 기동 불가.
원인: 내부 레지스트리(Zot)의 on-demand sync 설정에 ghcr.io/kedacore/** prefix가 누락되어 있었습니다.
해결:
# zot config - replicationConfig
- name: ghcr-sync
urls:
- https://ghcr.io
onDemand: true
content:
- prefix: "kedacore/**" # 추가
장애 2: KEDA RabbitMQ 인증 실패 (403)
증상: KEDA가 RabbitMQ 메트릭을 읽지 못하고 ScaledObject가 동작하지 않음.
원인이 3겹으로 중첩되어 있었습니다.
ClusterTriggerAuthentication은 크로스 네임스페이스 Secret 참조가 불가. 반드시 같은 네임스페이스의TriggerAuthentication을 사용해야 합니다.- RabbitMQ Operator가 생성한 default user의 패스워드가 앱 유저의 패스워드와 달랐습니다.
- 패스워드에
/와=가 포함되어 있어 AMQP URI 파싱이 실패했습니다.
해결:
apiVersion: keda.sh/v1alpha1
kind: TriggerAuthentication
metadata:
name: rabbitmq-trigger-auth
namespace: lumie # grading-svc와 동일 네임스페이스
spec:
secretTargetRef:
- parameter: host
name: rabbitmq-grading-auth
key: host # URL 인코딩된 URI: amqp://user:p%2Fword%3D@host/vhost
패스워드의 /는 %2F, =는 %3D로 URL 인코딩해야 합니다.
장애 3: Scale-to-zero 후 Cold Start 실패
증상: 큐에 메시지가 쌓였다가 갑자기 connection refused 발생.
원인: minReplicaCount: 0으로 설정되어 있 어, Spring 컨슈머가 큐를 빠르게 소진하면 KEDA가 grading-svc를 0으로 스케일 다운합니다. 이후 새 메시지가 도착해 Spring이 HTTP 요청을 보낼 때 grading-svc 파드가 아직 기동 중이라 요청이 실패합니다.
해결: minReplicaCount: 1로 변경. Scale-to-zero는 이론상 매력적이지만, HTTP 업스트림이 존재하는 구조에서는 cold start latency가 실제 장애로 이어집니다.
장애 4: PostgreSQL Native Query 파싱 실패
증상: appendResult 호출 시 SQL 예외 발생, 그로 인해 catch 블록의 incrementFail이 호출되어 실패 카운트가 2배로 증가.
원인: PostgreSQL의 캐스트 문법 ::jsonb를 Spring JPA가 파라미터명 바인딩으로 오해합니다.
-- 실패: Spring JPA가 ::jsonb를 파라미터로 해석
UPDATE exam_results SET result = result || :resultJson::jsonb WHERE id = :id
-- 성공: CAST() 함수 문법 사용
UPDATE exam_results SET result = result || CAST(:resultJson AS jsonb) WHERE id = :id
또한 예외 발생 시 catch 블록에서 incrementFail이 중복 호출되는 문제는 incremented 플래그를 도입해 해결했습니다.
장애 5: OOMKilled (384Mi 제한)
증상: grading-svc 파드가 OOMKilled로 재시작 반복.
원인 분석:
유휴 측정값 157Mi에 여유를 더해 384Mi를 설정했지만, 실제 부하 패턴을 고려하지 않았습니다.
Spring concurrency=5-10 → 5개 HTTP 요청 동시 도착
→ FastAPI가 요청 바디(이미지 bytes)를 모두 메모리에 수신
→ asyncio.Semaphore가 OpenCV 처리를 2개로 제한해도 이미지 bytes는 이미 메모리에 존재
계산:
이미지 5장 × ~5MB = ~25MB (수신 버퍼)
OpenCV 처리 2개 × ~200MB = ~400MB
라이브러리 기반 = ~150MB
----------------------------
합계 = ~575MB → 384Mi 초과 → OOM
해결: 메모리 한도를 512Mi → 1Gi로 상향. 하지만 이것은 증상 치료일 뿐이었습니다. 근본 원인은 아직 해결되지 않았습니다.
장애 6: CPU 스로틀링 (100m 제한)
증상: 이미지 1장 처리 시간이 ~1.5초에서 ~7초로 급증.
원인: Guaranteed QoS를 달성하기 위해 request == limit으로 CPU를 100m으로 설정했습니다. OpenCV의 이미지 처리는 CPU 집약적 연산이라 100m로는 심각한 스로틀링이 발생합니다.
흥미롭게도 Kyverno 정책이 이미 경고하고 있었습니다.
Policy violation: CPU limits should not be set
Reason: CPU limits cause throttling for bursty workloads
해결: CPU limit 제거, request만 유지.
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "1Gi" # memory limit만 유지 (Burstable QoS)
# cpu limit 없음
Kubernetes에서 메모리는 압축 불가능한 리소스(OOM 시 kill)이므로 limit이 필요하지만, CPU는 압축 가능한 리소스(스로틀링)이므로 limit을 두면 오히려 성능이 저하됩니다.
장애 7: 여전히 OOMKilled (512Mi)
증상: CPU 스로틀링을 해결했는데도 OOM이 지속됨.
이 시점에서 장애 5와 6은 증상이었고, 진짜 원인은 아키텍처에 있다는 사실을 인식했습니다.
Spring은 메시지를 소비하는 속도만큼 HTTP 요청을 grading-svc에 쏟아냅니다. asyncio.Semaphore는 OpenCV 처리 동시성을 제한하지만, FastAPI는 이미 모든 요청 바디를 메모리에 수신한 상태입니다. 컨슈머(Spring)와 실제 워커(grading-svc)가 분리되어 있으면 자연스러운 배압(backpressure)이 존재하지 않습니다.
아키텍처를 전면 재설계해야 했습니다.