POSA_Copyrighter/docs/reviews/2026-07-11-project-review.md
changukyu 2c607e0df6 docs: 2026-07-11 multi-agent review report, remediation checklist, offline extras
- Add the 2026-07-11 multi-agent project-review report and the remediation
  checklist (per-item status, with deferred items and their rationale).
- offline-install: document Ollama model and Playwright Chromium transfer, a
  hash-verified lock, --only-binary, and correct the egress note (image
  comparison fetches arbitrary SSRF-guarded public URLs, not only API hosts).
2026-07-11 16:08:04 +09:00

16 KiB

Copyrighter 프로젝트 코드 리뷰 — 2026-07-11

다중 에이전트 리뷰(리뷰 유닛 9개 + 백로그 전수 대조 1개, 총 33 에이전트) · 신규 CRITICAL/HIGH 발견은 독립 검증자 2명의 적대적 검증(반박 시도)을 통과한 것만 채택. 기준 커밋: 89c1fe4 (Phase 1 HIGH remediation) · 백엔드 테스트: 361 passed (pytest, 3m56s). 지난 리뷰에서 레이트리밋으로 실패했던 arch-concurrency 유닛 재실행 완료 — 이번 신규 HIGH 9건 중 4건이 이 유닛에서 나옴. 신규 발견: HIGH 9 · MEDIUM ~24 · LOW ~12 · CRITICAL 0 (기존 백로그 재발견분 제외).

1. 총평 (Executive Summary)

프로젝트 건강도: 주의 (Phase 1 수습은 성공, 그러나 더 깊은 계층에서 새 HIGH 9건 발견)

2026-06-22 리뷰(66건: CRITICAL 0 / HIGH 6 / MEDIUM 43 / LOW 17) 이후 commit 89c1fe4로 Phase 1 HIGH 5건이 실제로 수정되었음을 코드 레벨에서 확인했다. 그러나 이번 심층 리뷰(동시성·직렬화·시드 경로 집중)에서 신규 HIGH 9건(전건 검증 CONFIRMED), MEDIUM 약 24건, LOW 약 12건이 나왔다. CRITICAL은 이번에도 0건.

지금 가장 큰 리스크 3가지:

  1. 프로덕션 스코어링이 조용히 무력화되어 있음 (H6, H7) — _evidence_payload/_domain_evidence_from_ui(src/rights_filter/server/store_serialization.py:260, 302)가 promoted와 entity 필드를 직렬화에서 누락한다. 모든 프로덕션 점수는 _rescore_submission의 라운드트립을 거치므로, Naver 프로모션 근거(+40)와 Google 웹 엔티티 근거(+40)가 프로덕션에서 항상 0점이다. GUI에는 80% 신뢰도로 표시되면서 위험도는 'low'에 머무는, 이 도구의 핵심 기능(권리 리스크 필터링)의 침묵 결함이다. 89c1fe4의 entity-bucket dedup 수정도 프로덕션 경로에서는 사실상 죽은 코드다.
  2. 긴급 제공자 차단(emergency disable)이 내구성이 없음 (H2, H3) — _sync_provider_payloads(store_enrichment.py:49)는 reload/수집 때마다 저장된 enabled 플래그를 자격증명-연결성 기본값으로 덮어쓰고, _apply_provider_usage_delta(store_persistence.py:238)는 검색 시작 시점에 캡처한 stale payload 전체를 되써서 진행 중이던 검색이 끝나면 차단을 되돌린다. 컴플라이언스 목적의 긴급 정지가 감사 기록 없이 무효화된다.
  3. _write_lock 규율이 절반만 적용됨 (H1, H4) — 89c1fe4는 mark_evidence_status와 promote 경로에만 락을 넣었고, record_decision(sqlite_store.py:569)과 manual_search(store_operations.py:321)는 여전히 무락 RMW다. 결과: 운영자의 승인/반려 결정이 동시 rescore/검색에 의해 조용히 'unreviewed'로 되돌아갈 수 있으며, 감사 로그·워치리스트와 실제 상태가 어긋난다.

전 회차 대비 평가: 지난 리뷰가 표면(엔드포인트·GUI·개별 함수) 중심이었다면, 이번엔 "전 제출건이 단일 JSON blob + ThreadingHTTPServer" 구조에서 파생되는 lost-update 계열과 직렬화 라운드트립 손실 계열이라는 두 개의 구조적 결함군이 드러났다. 개별 수정보다 패턴 단위 일괄 적용이 필요하다.

2. Phase-1 수정 검증 판정

판정: 5건 모두 커밋 내용대로 존재하고 회귀 테스트 4건 통과. 단, "좁게 맞은 수정" — 리뷰 항목 기준 4 FIXED / 1 PARTIAL.

# 항목 판정 근거
1 mark_evidence_status 원자성 FIXED sqlite_store.py:601-623 — _write_lock + 단일 _transaction, conn 스레딩, 롤백 테스트 존재
2 promote_collection_candidates 락+멱등성 FIXED store_operations.py:699-771 — 락, 충돌-안전 _stable_id, 락 하 already-promoted 가드
3 deactivateCorrectionEntry / provider-retry 정직화 FIXED (단서 有) app.js:3247-3256 실제 API 호출, :3676-3684 정직한 미구현 안내. 단, 연결된 서버 엔드포인트가 비멱등이라 파생 항목 1건이 이미 비활성이면 버튼이 영구 고장 (신규 M16)
4 entity-bucket 점수 dedup PARTIAL risk_scoring.py:102-104 핵심 수정 + 테스트 2건 존재. 번들이던 LOW(카테고리 우선순위 미러링) 미적용. 더 중요하게는 H7로 인해 프로덕션 경로에서는 이 수정 자체가 작동 불능
5 runManualSearch in-flight 가드 FIXED (단서 有) app.js:2384/2390/2436/2460, rerun은 :2488 void 처리. 단, 가드 발동 시 무피드백 + 폼 덮어쓰기(신규 M17), 검색 완료 시 입력창 초기화(LOW)

핵심 교훈: 락 수정의 주석(sqlite_store.py:598-600 "다른 writer가 끼어들지 않는다")은 락을 잡는 writer에 대해서만 참이다. record_decision, manual_search, set_provider_enabled, emergency_disable, collect_keyword_candidates, _sync_submission_provider_state가 락 밖에 남아 수정의 보증을 우회한다.

3. 신규 발견 (유닛 간 dedup 완료, HIGH는 전건 2-표 검증)

HIGH — 9건 전건 CONFIRMED

# 위치 요약 발견 유닛
H1 sqlite_store.py:569 record_decision 무락 RMW — _rescore_submission(store_search_candidates.py:721-731)·bulk sweep과 경합 시 운영자 결정이 'unreviewed'로 조용히 복원, 감사로그/워치리스트와 상태 불일치 fix-verify(M), arch-concurrency(H), store-integrity(M) — 3개 유닛 교차 발견
H2 store_persistence.py:238 _apply_provider_usage_delta가 검색 전 캡처한 provider payload 전체를 되써서 in-flight 검색 종료 시 enable/disable(긴급 차단 포함) 되돌림 arch-concurrency
H3 store_enrichment.py:49 _sync_provider_payloads가 저장된 enabled를 연결성 기본값으로 덮어씀 — 모든 reload/import/upload/수집이 차단을 무감사 해제, collect의 disabled 체크(store_operations.py:575-578)는 항상 무효 arch-concurrency
H4 store_operations.py:321 manual_search가 수 초~수십 초 외부 검색 전에 읽은 stale payload를 :337-347에서 무락 재기록 — 검색 중 내려진 결정/근거상태 덮어씀 arch-concurrency
H5 sqlite_store.py:285 0바이트/손상 이미지 1장이 analysis_runs_for_submission(...)[-1] IndexError로 모든 import/reload/upload를 지속 마비(부분 커밋 잔류, 파일 수동 삭제 전까지 반복) store-integrity
H6 store_serialization.py:260 직렬화가 promoted 누락 → 프로덕션에서 Naver 검색 근거 상시 0점 (GUI엔 80% 표시, contributed=true 거짓 표기) analysis-scoring
H7 store_serialization.py:302 직렬화가 entity 누락 → Google 웹 엔티티 상시 0점, rerun 시 엔티티 기반 쿼리 생성도 불가, 89c1fe4 dedup 수정 프로덕션 무력화 analysis-scoring
H8 index.html:227-229 이미지 패널 버튼(맞춤/1:1/회전) 핸들러 전무 — 죽은 버튼 (합성 단계 재검증: JS 전체에 해당 id 참조 0건) frontend
H9 app.js:2943-2955 후보 '무시'가 로컬 splice뿐 — 서버 엔드포인트 부재(src/ 내 dismiss 0건), 모든 bootstrap 갱신 시 무시한 후보 부활 (합성 단계 재검증 완료) frontend

검증 결과 강등 (HIGH → MEDIUM)

# 위치 요약 판정
D1 store_serialization.py:349 skip-사유 키워드 휴리스틱이 컴플라이언스 skip을 FAILURE(+30)로 오복원 — 단, 현 wiring에서는 트리거 문자열이 도달 불가능(compliance 플래그가 어댑터 존재와 동치, env_clients.py:99-118). 잠재 지뢰로서 MEDIUM. 수정은 H6/H7과 같은 방법(도메인 source enum 영속화)으로 함께 처리 DOWNGRADE (1표 REFUTED, 1표 DOWNGRADE)

MEDIUM — 주요 항목 (유닛 판정 그대로)

동시성/저장소

  • M1 store_operations.py:584 — collect_keyword_candidates 무락 전체 삭제+재적재: promote와 경합 시 404 또는 stale 'promoted' 행 부활, 어댑터 예외 시 이전 후보 전멸 (fix-verify·arch-concurrency·store-integrity 3중 발견)
  • M2 sqlite_store.py:630/647 — set_provider_enabled/emergency_disable 무락 RMW(usage 증분 유실, 비원자 sweep) (arch-concurrency·store-integrity)
  • M3 store_enrichment.py:56-71 — _sync_submission_provider_state 전 테이블 무락 sweep이 무관한 제출건의 동시 갱신까지 되돌림
  • M4 sqlite_store.py:233 — seed가 _write_lock을 쥔 채 레코드당 외부 HTTP 수행 → 대량 import 동안 락 필요 엔드포인트 전부 무한 대기
  • M5 http_app.py:387-396 — 업로드 경로 exists()/write TOCTOU: 동명 파일 동시 업로드 시 한쪽 바이트 소실
  • M6 store_queue.py:92-119 — 큐 활성 전환/레거시 이관에 감사 이벤트 전무
  • M7 store_schema.py:249 — CHECK 위반 레거시 행 1건이 서버 기동을 영구 차단(수복 경로 없음)
  • M8 image_store.py:93 — 동일 stem 파일(a.jpg/a.png)이 한 제출건으로 병합, 한 이미지는 미심사 통과
  • M9 store_queue.py:119 — 레거시 무큐 제출건이 새 큐에 입양된 뒤 prune에서 근거까지 연쇄 삭제 (심사 이력 소실 시나리오)

HTTP 보안 (신규만)

  • M10 http_app.py:277 — 음수 Content-Length가 10MB 캡 우회 → rfile.read(-1) EOF까지 읽어 메모리 고갈
  • M11 http_app.py:65 — 핸들러 timeout 부재 + 무제한 스레드 → slowloris로 스레드 고갈

스코어링/프로모터

  • M12 search_result_promoter.py:17 — blog/web "no results" 근거가 가드를 빠져나가 쿼리 텍스트 매칭으로 0.8 승격 (0건 검색이 80% 신뢰도로 표시)
  • M13 search_result_promoter.py:36 — 마커가 영어 11개뿐: 한국어 Naver 결과 체계적 미탐 + 'iu'/'game'/'cover' 오탐
  • M14 risk_scoring.py:38-42 — promoted Naver 결과 아이템당 +40 무상한 적층 (쿼리 1건 결과 2개면 high 밴드) — H6 수정 시 즉시 프로덕션 노출되므로 동시 수정 필수
  • M15 evidence_enrichment.py:58 — (인메모리 경로 한정) KB 전 항목이 쿼리 예산 잠식, 'rejected:{id}' 항목이 문자 그대로 검색어화

프론트엔드

  • M16 app.js:3249 — deactivate 루프 비멱등·부분 실패 시 refresh 없음 → 이미 비활성 항목 1건이 버튼 영구 고장 (fix-verify·frontend)
  • M17 app.js:2484 — rerun이 폼을 덮어쓴 뒤 무피드백 no-op
  • M18~M23 — addKnowledgeEntry의 수집탭 초기화(:2879), executeSuggestedQueries 미처리 rejection(:1052), runCandidateCollection 선-클리어(:3005), 영구 잔존 apiError 배너(:234), 8개 클릭 mutation의 in-flight 가드 부재(:3185 등), renderCorrections의 derivedEntries 무가드 참조(:2030, renderAll 전체 중단 가능)

LOW (~12건, 대표): 검색 완료 시 입력 wipe(app.js:2448), batch re-promote 무음 폐기(store_operations.py:713), keep-alive 커넥션 Set-Cookie 잔류(http_app.py:264), Content-Length 파싱 오류 내부 메시지 노출(:277), FK check 미수행(store_schema.py:218), face-crop 보존기한 mtime 기준(store_enrichment.py:202), 죽은 category 분기(risk_scoring.py:87), exact: 지문 비교 불능(fingerprints.py:28), 프론트 'undefined' 렌더/스테일 패널/CSS 누락 클래스 등.

(knownBacklog 재발견분 — ensure_queue UPDATE 키 버그(store_queue.py:114), rerun 경로 무락(backlog #12), CSP/쿠키/rate-limit 등 sec-http 6건, 프론트 5건 — 은 위 표에서 제외, 4절에 반영)

4. 백로그 현황 (2026-06-22 로드맵 26개 항목)

집계: FIXED 4 / PARTIAL 3 / OPEN 20 / REGRESSED 0 (backlog-status 유닛 전수 코드 대조 결과)

  • Phase 1 (1-5): 1,2,3,5 FIXED / 4 PARTIAL (LOW 서브픽스 누락 + H7로 프로덕션 무력)
  • Phase 2 보안 (6-11): 전 6건 OPEN — 앱셸 CSP/XFO 부재(http_app.py:314-345), 원시 토큰 쿠키·Secure 부재(:291), 토큰 엔트로피/rate-limit 부재, UrllibJsonTransport 무제한 read(http_json.py:39), Origin CSRF 폴백 부재, Google API 키 URL 노출(cloud_vision_web_detection.py:57)
  • Phase 3 (12-20): 16 PARTIAL(LLM 사유만 필터, skip 사유는 여전히 잔류 — risk_scoring.py:57-63) / 나머지 8건 OPEN (12 rerun 무락, 13 ensure_queue, 14 무음 예외, 15 lastFailure 오귀속, 17 GUI 가드, 18 dedup 핫패스 전체 스캔, 19 EXIF 무경고 폴백, 20 reasons 가변 list)
  • Phase 4 (21-24): 전건 OPEN — 21은 89c1fe4에서 의도적 유예된 HIGH #6(auto* 메가함수, store_enrichment.py:391/:557)
  • Phase 5 (25-26): 25 PARTIAL(롤백·dedup 테스트 2건 추가, 미디어 401/untrusted 헤더/safeUrl/SSRF/E2E 등 다수 미작성) / 26 OPEN(offline-install.md에 Ollama·Playwright·lock 해시 부재)
  • Phase 6 LOW 배치: 전건 OPEN

5. 차기 스프린트 권고 (risk-per-effort 순)

  1. [최우선] 근거 직렬화 라운드트립 복구 — store_serialization.py _evidence_payload/_domain_evidence_from_ui에 promoted·promotionReason·entity(·category) 필드 추가 + 도메인 EvidenceSource enum 직접 영속화(키워드 휴리스틱은 레거시 폴백으로 강등). H6·H7·D1을 한 번에 해소. store 레벨 회귀 테스트(auto-search → _rescore_submission → riskScore > 0 단언) 필수. 작은 diff, 핵심 기능 복구.
  2. [동시 필수] 프로모터 정합성 — 1번을 배포하면 M12(0건 검색 0.8 승격)·M13(영어 마커)·M14(+40 무상한 적층)가 곧바로 프로덕션 점수에 노출된다. search_result_promoter.py의 no-results 구조적 매칭 + 쿼리 텍스트 스캔 제외 + NAVER_SEARCH 기여 상한/쿼리별 dedup(risk_scoring.py)을 같은 PR로.
  3. [한 줄 수정] seed IndexError 가드 — sqlite_store.py:285에서 빈 run 리스트 시 'failed' 밴드 제출건 생성+감사 이벤트 후 continue. 0바이트 파일 1장으로 전 import 마비되는 H5 제거.
  4. provider enabled 내구성 — _sync_provider_payloads가 저장된 enabled 보존(연결 안 될 때만 강제 off), _apply_provider_usage_delta는 최신 행에 델타 병합(stale payload 전체 되쓰기 금지), _auto_* 드라이버가 DB 플래그 확인, set_provider_enabled/emergency_disable에 _write_lock+단일 트랜잭션. H2·H3·M2 해소 — 긴급 정지의 신뢰성 회복.
  5. _write_lock 일괄 적용 — record_decision(H1), manual_search(H4, 외부 I/O는 락 밖·RMW는 락 안), rerun_enrichment/run_auto_search(backlog #12), collect_keyword_candidates(M1), _sync_submission_provider_state 필드 단위 UPDATE 전환(M3). 패턴은 mark_evidence_status에 이미 있어 기계적 적용. 동시에 M4(seed 락 협소화) 검토.
  6. GUI 조작 신뢰성 배치 — H8(버튼 구현 또는 제거), H9(dismiss 서버 영속화 또는 최소 클라 필터), M16(엔드포인트 멱등화 + finally refresh), M22(공용 withInFlightGuard 헬퍼로 8개 mutation + setDecision(backlog #17) 일괄), M23(derivedEntries || []). 단일 프론트 PR.
  7. HTTP DoS 즉효 수정 + Phase 2 착수 — 음수/비정수 Content-Length 거부(M10, 2줄), 핸들러 timeout 설정(M11, 1줄)을 즉시. 이어서 백로그 Phase 2(items 6-11)를 보안 PR 하나로 — CSP/nosniff 헤더, 쿠키 분리, 토큰 엔트로피, UrllibJsonTransport 응답 캡.
  8. 마이그레이션/보존 안전망 — M7(제약 재구축 시 coerce/격리 후 진행), M9(레거시 이관 시 파일 존재 검증 또는 prune 면제), M8(stem 충돌 시 확장자 포함 id), M6(ensure_queue 감사 이벤트).

핵심 메시지: Phase 1은 제대로 수행됐지만, 이번 리뷰의 결론은 "함수 단위 수정에서 패턴 단위 수정으로 전환하라"이다. (a) 직렬화 필드 손실 1개 패턴이 HIGH 2건+MEDIUM 1건을, (b) 무락 RMW 1개 패턴이 HIGH 3건+MEDIUM 5건을 만들었다. 위 1·4·5번을 패턴 단위로 닫으면 신규 HIGH 9건 중 7건이 사라진다.