보고서 작업 RLS 트랜잭션 경계
컨텍스트
2026년 6월 6일에 Lumie의 다중 학생 보고서 생성 흐름은 일괄 요청을 수락하고 워커 메시지를 게시하고 콜백을 처리하고 ZIP 파일을 생성했지만 보고서 작업 상태를 폴링하는 동안 UI는 계속 Exam result not found를 표시했습니다.
사고는 제어 영역 읽기 경로에만 영향을 미쳤습니다. 데이터 플레인 작업은 건강했습니다.
POST /v1/exams/{examId}/reports/batch가202를 반환했습니다.report-svc가 보고서를 성공적으로 생성했습니다.- 백엔드 콜백이 작업 상태를 진행시키고
batch_{jobId}.zip를 생성했습니다. GET /v1/exams/{examId}/reports/jobs/{jobId}가 잘못된404를 반환했습니다.
무슨 일이 일어났나
ReportGenerationJob는 RLS 마이그레이션 후 테넌트 범위 행이 되었기 때문에 읽기 전용 JPA 조회에도 활성 Spring 트랜잭션이 필요했습니다. 배치 보고서 상태 엔드포인트는 여전히 @Transactional 외부에서 jobRepository.findById(jobId)를 호출하고 있었습니다. 이는 Postgres가 해당 연결에 대해 app.tenant_id를 수신하지 못했음을 의미합니다.
결과는 외부에서 보면 모순적으로 보였습니다.
- 트랜잭션 도우미 내부 의 쓰기 경로는 여전히 작업 행을 조회하고 변경할 수 있습니다.
- 공용 상태 읽기 경로는 동일한 행을 보이지 않는 것으로 처리하고 누락된 부분을
ExamErrorCode.RESULT_NOT_FOUND에 매핑했습니다.
근본 원인
현재 코드는 lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/service/ReportJobService.java의 해결된 수정 사항을 확인합니다.
getJobStatus(...)는 이제@Transactional(readOnly = true)입니다.getZipBytes(...)는 트랜잭션 외부에서 MinIO I/O를 수행하기 전에 내부Tx구성 요소를 통해 데이터베이스 상태를 로드합니다.
해당 동작은 다음에 소개된 RLS 바인딩 계약에 따라 달라집니다.
lumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.javalumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.java
이러한 구성 요소는 코드 경로가 Spring 트랜잭션에 들어갈 때만 app.tenant_id를 바인딩하므로 "간단한 저장소 읽기"도 RLS 컷오버 이후에는 기능적 정확성 요구사항이 되었습니다.
해결
적용된 수정은 스토리지 I/O를 트랜잭션 경계 외부에 유지하면서 명시적 읽기 전용 트랜잭션으로 테넌트 범위 읽기를 감싸는 것이었습니다. 이는 ZIP 다운로드를 장기 데이터베이스 트랜잭션으로 전환하지 않고도 올바른 RLS 가시성을 복원했습니다.
하나의 드리프트 항목이 현재 소스에 계속 표시됩니다. 공개 누락은 여전히 ExamErrorCode.RESULT_NOT_FOUND에 매핑되므로 누락된 행이 실제로 ReportGenerationJob인 경우에도 사용자 대상 메시지는 여전히 시험 결과 조회 문제처럼 읽을 수 있습니다.
예방
- 테넌트 범위 모듈에서는 코드가 명시적으로 플랫폼 전체에 적용되지 않는 한 JPA 읽기를 트랜잭션 바인딩으로 처리합니다.
- 작업 제어 읽기와 워커 측 데이터 처리라는 두 개의 별도 평면으로 비동기 기능을 디버그합니다.
- 동일한 테넌트 내에서 하나의 경로로 행이 존재하고 다른 경로로 사라지는 경우 RabbitMQ나 스토리지를 비난하기 전에 트랜잭션 경계를 확인하십시오.