Skip to main content

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대master8Gi
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. 메시지 분할: 1메시지 = 200장 → 1메시지 = 1장
  2. 원자적 DB 카운터로 동시 채점 진행률 추적
  3. KEDA ScaledObject 설정 (0→10 레플리카, 큐 길이 기반)
  4. 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겹으로 중첩되어 있었습니다.

  1. ClusterTriggerAuthentication은 크로스 네임스페이스 Secret 참조가 불가. 반드시 같은 네임스페이스의 TriggerAuthentication을 사용해야 합니다.
  2. RabbitMQ Operator가 생성한 default user의 패스워드가 앱 유저의 패스워드와 달랐습니다.
  3. 패스워드에 /=가 포함되어 있어 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)이 존재하지 않습니다.

아키텍처를 전면 재설계해야 했습니다.


2차 아키텍처: 올바른 패턴

핵심 원칙: 컨슈머 = 워커

변경 사항:

항목이전이후
메시지 컨슈머Spring Listenergrading-svc 직접
이미지 접근Spring → HTTP multipartgrading-svc → MinIO 직접 다운로드
결과 저장grading-svc → DB 직접grading-svc → backend 콜백
동시성 제어asyncio.Semaphoreprefetch=1 (메시지 레벨)
스케일링 단위grading-svc 파드 수 (독립적)파드 수 = 컨슈머 수 (1:1)

prefetch=1의 의미: 파드 하나가 메시지 하나만 가져가서 처리를 완료한 후 ack를 보내야 다음 메시지를 받습니다. 파드당 메모리에 항상 이미지 1장만 존재합니다.

# grading-svc: aio-pika 직접 소비
async def main():
connection = await aio_pika.connect_robust(RABBITMQ_URL)
channel = await connection.channel()
await channel.set_qos(prefetch_count=1) # 핵심: 한 번에 1개만

queue = await channel.declare_queue("omr.grading", durable=True)
await queue.consume(process_message)

장애 8: 테넌트 컨텍스트 누락 (relation does not exist)

증상: 콜백 패턴으로 전환 후 backend에서 relation "exam_results" does not exist 에러 발생.

원인: Lumie의 멀티테넌트 구조에서 모든 테넌트 테이블은 {tenant_slug} 스키마 아래에 있습니다. RequestContextFilterX-Tenant-Slug 헤더를 읽어 TenantContextHolder에 설정해야 올바른 스키마로 쿼리가 라우팅됩니다.

grading-svc의 콜백 요청에 헤더가 없었습니다.

# grading-svc: 콜백 요청 시 테넌트 헤더 포함
await client.post(
f"{BACKEND_URL}/internal/omr/callback",
json=result_payload,
headers={
"X-Tenant-Slug": tenant_slug, # 반드시 포함
}
)

장애 9: 시험 상세 API 403

증상: grading-svc가 채점에 필요한 정답 정보를 가져오지 못함.

원인: 기존 /v1/exams/{id}/detail 엔드포인트는 JWT 인증이 필수입니다. grading-svc는 내부 서비스이므로 JWT를 발급받지 않습니다.

해결: 내부 전용 엔드포인트를 별도로 생성했습니다.

// backend: Spring Security permitAll 설정
@RestController
@RequestMapping("/internal/omr")
public class OmrInternalController {

@GetMapping("/exams/{examId}/detail")
public ResponseEntity<ExamDetailResponse> getExamDetail(
@PathVariable Long examId
) {
// JWT 검증 없이 내부 서비스용으로 응답
return ResponseEntity.ok(examService.getDetail(examId));
}
}
# SecurityConfig: /internal/** 경로 인증 제외
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/internal/**").permitAll()
.anyRequest().authenticated()
);

추가 개선: Race Condition 해결

장애 10: 병렬 처리 시 중복 결과 생성

증상: 89장을 넣었는데 90명이 성공으로 기록됨.

원인: 같은 학생의 OMR이 다른 pod에서 동시에 처리될 때 race condition 발생.

Thread 1: findByExamIdAndStudentId → 없음 → create → save
Thread 2: findByExamIdAndStudentId → 없음 → create → save (중복!)

순차 처리에서는 발생하지 않던 문제가 병렬화 이후 드러났습니다.

해결: DB에 unique constraint 추가 + 중복 insert 시 retry.

CREATE UNIQUE INDEX uq_exam_result_student
ON exam_results (exam_id, student_id)
WHERE student_id IS NOT NULL;
try {
savedResult = resultRepository.save(examResult);
} catch (DataIntegrityViolationException e) {
// 중복 → 기존 레코드를 찾아서 update
examResult = resultCommandService.resolveResult(exam, studentId, omrResult);
savedResult = resultRepository.save(examResult);
}

추가 개선: 일시적 에러 자동 재시도

장애 11: DNS resolution 실패 (간헐적)

증상: 89장 중 1장이 [Errno -2] Name or service not known으로 실패. 원장이 해당 OMR을 다시 넣어야 함.

원인: pod가 갑자기 10개로 스케일 업되면서 동시 DNS 쿼리가 CoreDNS에 몰려 간헐적 실패 발생.

해결: 3번 재시도 + exponential backoff. 에러 종류를 구분하지 않고 단순 retry.

MAX_RETRIES = 3

for attempt in range(1, MAX_RETRIES + 1):
try:
# MinIO 다운로드 → 시험 정보 조회 → OpenCV 채점
result = await grade(...)
break
except Exception as e:
if attempt < MAX_RETRIES:
await asyncio.sleep(2 * attempt) # 2초, 4초
continue
# 3번 다 실패하면 fail 콜백
callback_payload["success"] = False

Netflix의 Hystrix/Resilience4j와 동일한 접근 — 에러 종류를 구분하는 최적화보다 단순한 retry가 실용적입니다.


추가 개선: Warm Pool 튜닝

문제: 첫 번째 배치가 항상 느림

KEDA가 큐 길이를 감지하고 pod를 올려도 cold start(pod 생성 + OpenCV 로딩)에 ~40초가 걸립니다. 그 사이 기존 pod가 대부분을 처리해서 추가 pod가 의미 없었습니다.

실측 데이터:

jobId이미지 수참여 pod실제 활용 pod비고
52 (89장)897개4개 (95%)스케일 업 pod는 소량만 처리
54 (170장)1704개4개 (100%)cold start, 스케일 업 전에 종료
55 (170장)17010개10개 (균등)이미 warm 상태
56 (89장)8910개10개 (균등)이미 warm 상태

핵심 인사이트: 원장님들은 채점을 한 번만 넣고 결과를 확인합니다. 두 번째 배치는 없으므로 KEDA 스케일 업의 혜택을 받을 수 없습니다. min 값이 곧 실제 처리 pod 수입니다.

Startup Probe 경로 오류

이 과정에서 startup probe가 path: /로 설정되어 404를 반환하는 문제도 발견. HTTP 엔드포인트를 제거하면서 root 경로가 사라졌기 때문. path: /health로 수정.

최종 설정 결정

89장 / 6 pod / 1.4장·초 = ~11초
170장 / 6 pod / 1.4장·초 = ~20초
6,000장(30명×200) → KEDA가 15개로 스케일 → ~5분

최종 결과

성능

지표개선 전1차 개선최종
170장 처리 시간수 분 (실패 포함)24초 (10 pod warm)~20초 (6 pod 상시)
89장 처리 시간수 분~16초 (4 pod)~11초 (6 pod 상시)
파드당 피크 메모리OOMKilled at 512Mi~150Mi~200Mi
실패율높음 (OOM, 403, DNS)간헐적0% (자동 재시도)
중복 결과없음 (순차)발생DB unique constraint로 방지

리소스 구성 (최종)

# grading-svc Deployment
resources:
requests:
memory: "384Mi"
cpu: "100m"
limits:
memory: "384Mi"
# CPU limit 없음 — burst 허용

# KEDA ScaledObject
minReplicaCount: 6 # warm pool — 첫 요청부터 6장 동시 처리
maxReplicaCount: 15 # 피크 대응 (30명 × 200장)
cooldownPeriod: 300 # 5분 후 scale down
triggers:
- type: rabbitmq
metadata:
queueName: grading.omr-request
value: "5"
  • 상시: 6 pod × 384Mi = 2.3Gi
  • 피크: 15 pod × 384Mi = 5.76Gi
  • 기존 60GB 예측 대비 26배 절약 (상시 기준)

핵심 교훈

1. 측정하지 않은 수치는 가정일 뿐

600MB라는 초기 가정은 근거가 없었습니다. 실측 157Mi는 전혀 다른 숫자였고, 이 차이가 모든 이후 의사결정을 바꿨습니다. 최적화는 항상 측정에서 시작합니다.

2. 컨슈머 = 워커가 자연스러운 큐 패턴

메시지 컨슈머와 실제 워커를 분리하면 그 사이에 통제할 수 없는 중간 단계가 생깁니다. 큐 기반 아키텍처에서 올바른 패턴은 컨슈머가 곧 워커여야 합니다. 이렇게 해야 KEDA의 "파드 수 = 처리 용량" 공식이 성립합니다.

3. prefetch=1은 동시성 제어의 자연스러운 방법

asyncio.Semaphore는 코드 레벨의 동시성 제어이고, prefetch=1은 메시지 프로토콜 레벨의 동시성 제어입니다. 후자가 훨씬 근본적입니다. 파드가 처리 중인 메시지를 ack하기 전까지 브로커가 다음 메시지를 보내지 않으므로, 파드 메모리에는 항상 1개의 작업만 존재합니다.

4. CPU limit은 스로틀링을 유발한다

메모리는 압축 불가능한 리소스(초과 시 OOM Kill), CPU는 압축 가능한 리소스(초과 시 스로틀링)입니다. CPU 집약적 워크로드에 CPU limit을 설정하면 처리 속도가 급격히 저하됩니다. Kyverno 정책 경고를 무시하지 마세요.

5. Warm Pool이 KEDA 스케일링보다 중요할 수 있다

KEDA의 동적 스케일링은 반복 요청에 효과적입니다. 하지만 "한 번 넣고 끝"인 사용 패턴에서는 cold start(~40초) 동안 기존 pod가 대부분을 처리해버려서 추가 pod가 의미 없습니다. min 값이 곧 실제 처리 능력이라는 사실을 실측으로 확인했습니다.

6. 병렬화하면 순차 시절에 없던 버그가 나온다

순차 처리에서는 같은 학생의 OMR이 시간차로 처리되어 자연스럽게 upsert됩니다. 병렬화하면 동시에 "없음" → 동시에 "생성" → 중복. DB unique constraint는 병렬 시스템의 안전망입니다.

7. 단순한 retry가 에러 분류보다 실용적이다

DNS 실패, 네트워크 타임아웃, 이미지 파싱 에러를 분류해서 retry 여부를 결정하는 것보다, 무조건 3번 retry하고 다 실패하면 fail이 더 간단하고 동일한 효과를 냅니다. Netflix도 이 접근을 씁니다.


관련 문서