Grade 서비스 낙관적 잠금 사고
컨텍스트
2026년 7월 10일에 grade-svc는 jobId=135에 대한 100개 이미지 채점을 모두 마쳤지만 백엔드 작업은 processed_images=99, success_count=99 및 saved_count=99를 넘지 못했습니다. 실패한 콜백은 examId=79에 대한 imageIndex=91였고, 기록된 예외는 ExamResult#1168에 대한 ObjectOptimisticLockingFailureException였습니다.
혼란스러운 부분은 워커 측 처리가 정상으로 보였다는 것입니다.
- 채점 로그에 작업 완료율이 100/100으로 표시되었습니다.
- 기본 RabbitMQ 대기열이 비어 있었습니다.
- 실패한 콜백이 DLQ에 있었습니다.
- 해당 페이로드 재생을 중복으로 건너뛰었습니다.
무슨 일이 일어났나
이번 사건은 단지 "낙관적 잠금이 발생했다"는 문제가 아니었습니다. 구조적 실패는 내구성 있는 데이터베이스 작업이 완료되기 전에 콜백 중복 제거가 가시화되었다는 점입니다.
실패 패턴은 다음과 같습니다.
- 백엔드가 콜백을 수락하고 해당 중복 제거 키를 예약했습니다.
ExamResult및 OMR 작업 카운터를 업데이트하는 트랜잭션이 낙관적 잠금 실패에 도달했습니다.- 콜백은 DLQ 또는 수동 개 입을 통해서만 재생할 수 있게 되었지만 재생 시 즉시 중복 제거 마커를 확인하고 페이로드를 건너뛰었습니다.
- 채점 작업이 이미 업스트림에서 완료되었음에도 불구하고 백엔드 작업이 99/100에서 멈춰 있었습니다.
이는 필수 불변성을 깨뜨렸습니다. 콜백은 데이터베이스 부작용이 영구적으로 완료된 후에만 중복으로 계산되어야 합니다.
근본 원인
사고 기록은 콜백을 지연했다가 한꺼번에 해제한 롤아웃 기간과 경합을 연결하지만, 더 깊은 문제는 공유된 시험 결과 행에 대한 정상적인 콜백 동시성이었습니다. 동일한 ExamResult에 닿는 여러 콜백은 일반 프로덕션 트래픽 중에 합법적으로 경합할 수 있으므로 백엔드 콜백 경로는 이를 종단 실패로 처리하는 대신 낙관적 잠금을 허용해야 했습니다.
현재 백엔드 소스는 이 사건으로 인해 lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/service/OmrGradingJobProcessor.java에서 내구성 있는 수정이 이루어졌음을 보여줍니다.
- 콜백은
OptimisticLockingFailureException을 최대 3번까지 재시도합니다. - 트랜잭션이 완료되기 전에 실패하면 중복 제거 키가
callbackDeduplication.forget(...)를 통해 해제됩니다. - 콜백이 성공적으로 계산된 후에만 마무리가 발생합니다.
재시도 및 중복 제거 사례를 포함하여 회귀 적용 범위는 현재 lumie-backend/modules/exam/src/test/java/com/lumie/exam/application/service/OmrGradingJobProcessorTest.java에도 있습니다.
해결
지속 가능한 복구 패턴은 세 부분으로 구성됩니다.
- 즉시 DLQ를 수행하는 대신 짧은 백오프를 사용하여 낙관적 잠금 실패를 재시도합니다.
- 카운터가 커밋되기 전에 콜백 트랜잭션이 실패하면 중복 제거 예약을 지웁니다.
- 성공적인 콜백 상태 전환과 연결된 작업 카운터 및 마무리를 유지합니다.
이러한 보호 기능은 이제 OMR 콜백 프로세서에 존재하므로 2026년 7월 오류 모드는 직접 데이터베이스 편집을 요구하는 대신 안전하게 재생되어야 합니다.
예방
- 콜백 동시성을 롤아웃 전용 엣지 케이스가 아닌 안정된 상태 속성으로 처리합니다.
- 콜백 중복 제거와 지속성 완료를 일치시키세요. 데이터베이스 작업이 실패하더라도 재생이 가능한 상태로 유지되어야 합니다.
- 동일한 작업 ID에 대한 재시도, 중복 재생 및 터미널 상태 마무리를 시뮬레이션하는 테스트를 선호합니다.