Skip to content

refactor: AI 네트워크 호출이 transaction.atomic 내부에서 실행되는 문제 개선 - #50

Merged
wngjs8114 merged 5 commits into
devfrom
47-refactor-ai_transactionatomic
Aug 5, 2026
Merged

refactor: AI 네트워크 호출이 transaction.atomic 내부에서 실행되는 문제 개선#50
wngjs8114 merged 5 commits into
devfrom
47-refactor-ai_transactionatomic

Conversation

@6ye0m

@6ye0m 6ye0m commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

close #47

작업 내용

task_extractor.py: analyze_study_material()을 fetch_extracted_tasks()(AI 호출, 트랜잭션 없음) / save_extracted_tasks()(DB 저장, 짧은 트랜잭션)로 분리
analysis_orchestrator.py: _run_analysis_and_estimate()도 동일 구조로 재설계 (기존 외부 트랜잭션이 AI 호출을 계속 감싸고 있던 문제 해결)
tests.py: 기존 mock 패치 대상 재조정 + AITransactionIsolationTestCase 신규 추가 (AI 호출 시점에 실제로 트랜잭션이 없는지 직접 검증)

검증 결과

python manage.py test exams.tests.AITransactionIsolationTestCase → Ran 2 tests, OK
python manage.py test → Ran 153 tests, OK

@wngjs8114

Copy link
Copy Markdown
Collaborator

AI 네트워크 호출을 transaction.atomic 밖으로 분리하고, StudyTask 저장과 예상시간 계산만 짧은 트랜잭션으로 묶은 방향은 좋습니다.

TransactionTestCase에서 _call_ai() 실행 시 connection.in_atomic_block=False를 직접 확인하고, 예상시간 계산 실패 시 생성된 StudyTask가 롤백되는 테스트를 추가한 것도 확인했습니다.

다만 네트워크 호출과 DB 저장 사이에 입력 데이터가 변경될 수 있는 경쟁 상태가 남아 있어, 머지 전에 보완이 필요해 보입니다.

1. AI 호출 이후 저장 전에 입력 데이터가 그대로인지 다시 확인해주세요

현재 흐름은 다음과 같습니다.

study_material.extracted_text로 AI 호출네트워크 응답 대기_save_tasks_with_estimates()에서 작업 저장

AI 호출 중에 StudyMaterial.extracted_text가 변경되더라도, 기존 요청은 이전 텍스트로 생성된 AI 결과를 그대로 저장하게 됩니다.

현재 material_extractanalysis_status=PROCESSING 여부를 검사하지 않기 때문에, 다른 탭이나 요청에서 PDF 추출을 다시 실행하면 이 상황이 발생할 수 있습니다.

예를 들면 다음과 같습니다.

  1. 기존 추출 텍스트 A로 AI 분석 시작
  2. AI 응답 대기 중 PDF 재추출로 텍스트가 B로 변경
  3. 먼저 시작한 AI 분석이 A를 기준으로 생성한 StudyTask를 저장
  4. DB에는 추출 텍스트 B와 분석 결과 A가 함께 남음

DB 저장 트랜잭션에서 StudyMaterial을 다시 조회하고, 아래 조건을 확인하는 처리가 필요합니다.

material = (
    StudyMaterial.objects
    .select_for_update()
    .select_related("exam")
    .get(pk=study_material_id)
)

if material.analysis_status != MaterialStatus.PROCESSING:
    raise StaleAnalysisRequestError(...)

if material.extracted_text != analyzed_text:
    raise StaleAnalysisRequestError(...)

_run_analysis_and_estimate()에서 AI에 전달한 analyzed_text_save_tasks_with_estimates()에도 넘겨 비교하는 방식이 적절해 보입니다.

추가로 화면·View에서도 AI 분석 중에는 PDF 재추출이나 입력 내용 변경을 막아주면 좋지만, View 방어만으로 끝내지 않고 저장 직전 DB 상태를 재검증해야 합니다.

2. 예상시간은 저장 시점의 최신 speed_factor를 사용해주세요

현재 예상시간 계산에서 아래 객체를 사용합니다.

exam = study_material.exam
speed_factor=exam.speed_factor

study_material.exam이 AI 호출 전에 로드된 객체라면, 분석 중 진행 기록 등으로 speed_factor가 변경돼도 이전 값이 사용될 수 있습니다.

저장 트랜잭션 안에서 StudyMaterialExam을 다시 조회하고, 저장 시점의 최신 exam.speed_factor로 계산하는 편이 안전합니다.

3. 롤백 테스트에 기존 작업 보존 검증도 추가해주세요

현재 롤백 테스트는 예상시간 계산 실패 후 StudyTask 개수가 0인지 확인합니다.

self.assertEqual(
    StudyTask.objects.filter(study_material=self.material).count(),
    0,
)

그런데 save_extracted_tasks()는 기존 미확정·미수정 작업을 먼저 삭제하고 새 작업을 생성합니다.

따라서 기존 작업이 존재하는 상태에서 예상시간 계산이 실패했을 때, 새 작업 생성뿐 아니라 기존 작업 삭제도 함께 롤백되는지 확인하는 테스트가 필요합니다.

old_task = StudyTask.objects.create(
    exam=self.exam,
    study_material=self.material,
    title="기존 작업",
    ...
)

# estimate_task_minutes 실패

self.assertTrue(
    StudyTask.objects.filter(pk=old_task.pk, title="기존 작업").exists()
)

이번 리팩터링의 핵심이 DB 저장 단계의 원자성을 유지하는 것이므로, 이 경우까지 테스트하면 의도가 더 명확해질 것 같습니다.

4. 트랜잭션 테스트가 실제 AI 환경 설정에 의존하지 않게 해주세요

현재 테스트의 spy 함수는 기존 _call_ai()를 다시 호출합니다.

original_call_ai = task_extractor._call_ai

def spy_call_ai(prompt):
    observed_in_atomic_block.append(connection.in_atomic_block)
    return original_call_ai(prompt)

기본 설정에서는 AI_MOCK_MODE=True라 동작하지만, 테스트 실행 환경에서 해당 설정이 False로 덮어써지면 실제 Gemini API를 호출할 수 있습니다.

이 테스트의 목적은 네트워크 응답 자체가 아니라 트랜잭션 유무 확인이므로, 고정 응답을 직접 반환하는 편이 안전합니다.

def spy_call_ai(prompt):
    observed_in_atomic_block.append(connection.in_atomic_block)
    return task_extractor._MOCK_RESPONSE

또는 테스트 클래스에 @override_settings(AI_MOCK_MODE=True)를 명시해도 될 것 같습니다.

구조 분리와 기본 롤백 처리는 잘 되어 있습니다. 위 입력 변경 경쟁 상태와 관련 테스트만 보완되면 머지해도 될 것 같습니다.

@6ye0m

6ye0m commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

말씀하신 4가지 다 반영했습니다.

  1. 입력 변경 재검증
    _save_tasks_with_estimates()가 저장 직전에 StudyMaterial을 select_for_update()로 다시 조회해서, analysis_status가 여전히 PROCESSING인지 + extracted_text가 AI 호출 당시(analyzed_text로 전달)와 동일한지 확인합니다. 둘 중 하나라도 다르면 StaleAnalysisRequestError를 던지고 아무것도 저장하지 않습니다.

  2. speed_factor 최신값 사용
    같은 재조회로 얻은 current.exam.speed_factor를 쓰도록 변경했습니다. AI 호출 전에 로드해둔 오래된 exam 객체는 더 이상 예상시간 계산에 쓰지 않습니다.

  3. 롤백 테스트 - 기존 작업 보존 검증 추가
    test_existing_unconfirmed_task_preserved_when_save_rolls_back 추가해서, 예상시간 계산 실패로 트랜잭션이 롤백될 때 save_extracted_tasks()가 삭제하려던 기존 미확정 작업도 함께 복구되는지 확인했습니다.

  4. 테스트가 실제 AI 환경에 의존하지 않도록 수정
    spy_call_ai가 기존 _call_ai()를 다시 호출하는 대신 task_extractor._MOCK_RESPONSE를 직접 반환하도록 바꿨고, 관련 테스트 2개에 @override_settings(AI_MOCK_MODE=True)도 명시했습니다.

추가로 만든 테스트

test_stale_extracted_text_discards_result — 텍스트가 바뀌면 결과가 저장 안 되고 FAILED로 마무리되는지
test_speed_factor_uses_latest_value_at_save_time — 실제로 최신 speed_factor 기준 값이 저장되는지

검증 결과

python manage.py test exams.tests.AITransactionIsolationTestCase → Ran 5 tests, OK
python manage.py test → Ran 156 tests, OK
manage.py check / makemigrations --check → 이상 없음

머지되면 이슈 2(analysis_run_id, 실행 소유권)를 이 위에서 이어가겠습니다.

@wngjs8114

Copy link
Copy Markdown
Collaborator

이전 리뷰에서 요청드린 네 가지 수정사항은 모두 반영된 것을 확인했습니다.

  • 저장 직전 StudyMaterialselect_for_update()로 다시 조회
  • analysis_status와 AI 호출 당시 extracted_text 재검증
  • 저장 시점의 최신 exam.speed_factor 사용
  • 기존 작업 보존 롤백 테스트 및 실제 AI 호출 방지 처리

전체적인 수정 방향은 좋습니다.

다만 PDF 재추출과 동시에 실행되는 경우의 경쟁 상태가 한 가지 남아 있습니다.

현재 PDF 추출은 아래 순서로 진행됩니다.

material.status = PROCESSING
→ PDF 텍스트 추출
→ 완료 후 extracted_text 변경

반면 _save_tasks_with_estimates()는 아래 조건만 검사합니다.

current.analysis_status == MaterialStatus.PROCESSING
current.extracted_text == analyzed_text

따라서 PDF 재추출이 시작됐지만 아직 새 텍스트 추출이 끝나지 않은 시점에는 extracted_text가 기존 값과 동일해서 검증을 통과할 수 있습니다.

기존 텍스트 A로 AI 분석 시작
→ PDF 재추출 시작, 추출 status만 PROCESSING
→ AI 결과 저장 검증 시 extracted_text는 아직 A
→ A 기준 StudyTask 저장
→ 재추출 완료 후 extracted_text가 B로 변경
→ 텍스트 B와 작업 결과 A가 함께 남음

저장 직전 검증에 텍스트 추출 상태도 포함해주세요.

if current.status != MaterialStatus.COMPLETED:
    raise StaleAnalysisRequestError(
        "텍스트 추출 상태가 변경되어 분석 결과를 저장할 수 없습니다."
    )

관련 테스트도 추가 부탁드립니다.

material.status는 PROCESSING이지만
extracted_text는 AI 호출 당시와 동일한 경우에도
AI 결과가 저장되지 않아야 함

추가로 PDF 추출 담당은 BE2이므로, material_extract에서도 AI 분석이 PROCESSING 또는 COMPLETED인 자료의 재추출을 원자적으로 막는 처리가 함께 필요합니다. 객체를 조회한 뒤 파이썬에서만 검사하면 동시 요청 사이의 경쟁 상태가 남을 수 있으므로 조건부 UPDATE 또는 잠금 방식이 적절합니다.

이 부분까지 반영되면 PR #50은 머지해도 될 것 같습니다. analysis_run_id를 통한 실행 소유권 검증은 말씀하신 후속 이슈에서 이어가면 됩니다.

@6ye0m

6ye0m commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

analysis_orchestrator.py — _save_tasks_with_estimates() 저장 직전 검증에 status(텍스트 추출 상태) 확인 추가
기존엔 analysis_status, extracted_text 두 가지만 확인했는데, "PDF 재추출이 시작만 되고 아직 텍스트 자체는 안 바뀐" 시점을 놓치는 빈틈이 있었음
current.status != MaterialStatus.COMPLETED면 StaleAnalysisRequestError로 저장 포기
views.py의 material_extract() (BE2 파일)
status=PROCESSING 또는 analysis_status가 PROCESSING/COMPLETED인 자료의 재추출을 조건부 UPDATE로 원자적으로 차단
추가 수정: 재추출이 성공해서 텍스트가 실제로 바뀌면, 옛 텍스트 기준으로 남아있던 analysis_status/analysis_error_message/analysis_retry_count를 PENDING/None/0으로 초기화 (안 그러면 새 텍스트인데 "재시도" 상태를 이어받는 문제가 있었음). 추출 실패 시엔 텍스트가 안 바뀌므로 건드리지 않음
tests.py
test_stale_when_extraction_reprocessing_even_if_text_unchanged — 텍스트 안 바뀌어도 추출 상태만으로 저장 차단되는지
test_extract_blocked_when_analysis_processing / test_extract_blocked_when_analysis_completed — material_extract 자체가 막히는지
test_extract_success_resets_stale_analysis_state — 재추출 성공 시 분석 상태 초기화 확인

@wngjs8114

Copy link
Copy Markdown
Collaborator

이전 리뷰에서 요청드린 PDF 재추출 진행 상태 검증, 재추출 조건부 UPDATE, 재추출 성공 시 AI 분석 상태 초기화와 관련 테스트가 모두 반영된 것을 확인했습니다.

다만 PDF 추출 시작과 AI 분석 시작이 동시에 성공할 수 있는 경쟁 상태가 하나 남아 있습니다.

현재 material_analyze()는 파이썬에서 material.status == COMPLETED를 확인한 뒤 _start_processing()을 호출하지만, _start_processing()의 조건부 UPDATE는 analysis_status만 검사하고 추출 status는 검사하지 않습니다.

따라서 아래 순서가 가능합니다.

AI 분석 요청이 status=COMPLETED 확인
→ PDF 재추출 요청이 status=PROCESSING 전환 성공
→ AI 분석 요청이 analysis_status=PENDING만 확인하고 PROCESSING 전환 성공
→ PDF 추출과 AI 분석이 동시에 실행

저장 직전 검증으로 StudyTask 저장은 막을 수 있지만, 이후 _finish_failure()가 조건 없이 analysis_status=FAILED를 저장하므로 재추출 성공 후 초기화된 PENDING 상태를 이전 AI 요청이 다시 덮어쓸 수 있습니다.

최초 분석과 재시도 양쪽 _start_processing() 조건에 아래 추출 완료 조건을 추가해주세요.

status=MaterialStatus.COMPLETED

이렇게 하면 재추출 측은 analysis_status=PROCESSING을 차단하고, AI 분석 측은 status=PROCESSING을 차단해서 어느 요청이 먼저 시작하든 다른 요청이 원자적으로 실패하게 됩니다.

관련 테스트도 단순 순차 호출이 아니라, AI 분석이 상태를 읽은 직후 PDF 추출이 먼저 PROCESSING을 차지하는 순서를 재현해 추가 부탁드립니다.

추가로 추출 status=PROCESSING일 때 현재 메시지가 이미 분석 중인 자료입니다.로 되어 있는데, AI 분석 상태와 구분하기 위해 이미 PDF 텍스트를 추출 중인 자료입니다.로 수정해주세요.

마지막으로 material_extract()는 BE2 담당 코드이므로, 이번 연동 방어 변경은 BE2에게도 명시적으로 확인받은 뒤 머지하면 좋겠습니다.

@6ye0m

6ye0m commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

지적하신 경쟁 상태 반영했습니다.

  1. _start_processing()에 status=MaterialStatus.COMPLETED 조건 추가
    최초 분석(is_retry=False)/재시도(is_retry=True) 양쪽 조건부 UPDATE에 모두 넣었습니다. 이제 material_extract()의 조건부 UPDATE(analysis_status가 PROCESSING/COMPLETED면 재추출 차단)와 서로 대칭을 이뤄서, 어느 요청이 먼저 DB에 도달하든 다른 요청은 원자적으로 실패합니다.

  2. 에러 메시지 분기 추가
    analyze_and_estimate()/retry_analysis() 둘 다, 시작 실패 사유가 "추출이 진행 중이라서"인지 "analysis_status 조건 때문"인지 구분해서 안내하도록 수정했습니다.

  3. 요청하신 정확한 순서를 재현하는 테스트 추가

test_initial_analysis_blocked_when_extraction_wins_race_after_status_check
test_retry_blocked_when_extraction_wins_race_after_status_check

View가 material.status==COMPLETED를 확인한 시점(인메모리 객체는 COMPLETED로 남음) 직후 DB에서 PDF 재추출이 먼저 status=PROCESSING을 차지하는 상황을 그대로 재현해서, _start_processing()이 인메모리 값이 아니라 DB를 다시 조건부로 확인하는지 검증했습니다.

  1. 메시지 문구 수정
    material_extract()의 "이미 분석 중인 자료입니다." → "이미 PDF 텍스트를 추출 중인 자료입니다."

  2. material_analysis_status() 최종 스키마 반영
    BE2와 합의한 대로 stage/extraction_status/extraction_error_message/analysis_status/analysis_error_message/failed_stage/retry_count/retry_remaining으로 통합했습니다. material_extract() 자체 코드는 안 건드렸습니다(BE2 확인 완료).

검증 결과

python manage.py test → Ran 171 tests, OK
python manage.py check / makemigrations --check → 이상 없음

@wngjs8114

Copy link
Copy Markdown
Collaborator

이전 리뷰에서 요청드린 PDF 추출·AI 분석 시작 경쟁 상태는 반영된 것을 확인했습니다.

  • _start_processing()의 조건부 UPDATE에 status=COMPLETED 추가
  • 최초 분석·재시도 양쪽 경쟁 상태 테스트 추가
  • PDF 추출 진행 중 메시지 구분
  • 통합 상태 조회 응답 스키마 반영

위 방향은 적절합니다.

다만 재추출 성공 시 분석 상태를 초기화하는 처리에 한 가지 보완이 필요합니다.

현재 material_extract()는 새로 추출된 텍스트가 기존 extracted_text와 같은지 확인하지 않고, 추출에 성공하기만 하면 아래 필드를 무조건 초기화합니다.

material.analysis_status = MaterialStatus.PENDING
material.analysis_error_message = None
material.analysis_retry_count = 0

이 경우 AI 분석 재시도 2회를 모두 사용한 자료도 같은 PDF를 다시 추출하면 retry_count=0으로 돌아가서 재시도 제한을 우회할 수 있습니다.

PR 설명대로 “텍스트가 실제로 변경된 경우”에만 분석 상태를 초기화하도록 기존 텍스트와 새 추출 결과를 비교해주세요.

previous_extracted_text = material.extracted_text

...

if extracted != previous_extracted_text:
    material.analysis_status = MaterialStatus.PENDING
    material.analysis_error_message = None
    material.analysis_retry_count = 0

관련해서 다음 테스트도 추가하면 좋겠습니다.

  • 기존 텍스트와 재추출 결과가 동일함
  • 기존 analysis_status=FAILED
  • 기존 analysis_retry_count=2
  • 재추출 성공 후에도 FAILED, retry_count=2가 유지됨

이 부분까지 반영되면 PR #50은 머지해도 될 것 같습니다.

@6ye0m

6ye0m commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

material_extract()에서 재추출 직전 previous_extracted_text로 기존 텍스트를 미리 캡처해두고, 재추출 성공 시 extracted != previous_extracted_text일 때만 analysis_status/analysis_error_message/analysis_retry_count를 초기화하도록 변경했습니다. 텍스트가 동일하면(같은 PDF 재업로드 등) 기존 분석 상태를 그대로 유지합니다.

python
previous_extracted_text = material.extracted_text
...
if extracted != previous_extracted_text:
material.analysis_status = MaterialStatus.PENDING
material.analysis_error_message = None
material.analysis_retry_count = 0

테스트 추가

test_extract_success_keeps_analysis_state_when_text_unchanged — analysis_status=FAILED, analysis_retry_count=2(재시도 소진)인 자료가 동일한 텍스트로 재추출에 성공해도 FAILED/retry_count=2가 그대로 유지되는지 확인했습니다. 기존 test_extract_success_resets_stale_analysis_state(텍스트가 실제로 바뀌는 케이스)는 그대로 유지됩니다.

검증 결과

python manage.py test → Ran 172 tests, OK
python manage.py check / makemigrations --check → 이상 없음

@wngjs8114
wngjs8114 merged commit 48d3e78 into dev Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: AI 네트워크 호출이 transaction.atomic 내부에서 실행되는 문제 개선

2 participants