# 작업 보고: task-3007 — 증권분석 타임아웃 현실화

- 팀: dev5-team (마르둑)
- 레벨: Lv.2 · 검증 레벨: normal
- 대상 repo: `/home/jay/projects/InsuRo` (external) · base `3dad070`
- 워크트리: `/home/jay/projects/InsuRo/.worktrees/task-3007-dev5` · 브랜치 `task/task-3007-dev5`
- ★ **PR 생성까지가 범위. 머지하지 않았다.**

---

## ★★★ 최우선 보고 — 중복 실행 충돌 (내 PR 은 머지하면 안 된다)

작업 도중 **다른 팀(dev3)이 동일한 task-3007 을 수행해 먼저 머지했다.**

- `997fa5d` **PR #253 MERGED** (2026-08-25 04:54:46Z) — `task-3007: 증권분석 타임아웃 현실화 120→420초`
- 내 PR **#254 OPEN** · `mergeable_state=dirty` (**같은 파일 충돌**)

### 두 해법 비교 (실측 기준)

| | PR #253 (dev3, **머지됨**) | PR #254 (dev5, 내 것) |
|---|---|---|
| 채택값 | **420초** | 300초 |
| 실측 회수 | 5회 | 6회 |
| **관측 최대** | **248.2초** | 179.1초 |
| 관측최대 대비 여유 | 1.69배 | 1.21배 (248.2 기준) |
| 봉인 테스트 | 7건 | 11건 |
| 상수명 / 예외명 | `POLICY_ANALYSIS_TIMEOUT_SEC` / `PolicyAnalysisTimeoutError` | **동일** |
| 프론트 180초 상한 발견 | 발견함 | 발견함 |

### 판정 — **PR #253 이 옳다. 내 PR #254 는 닫아야 한다.**

두 팀이 독립적으로 상수명·예외명·프론트 180초 상한까지 **동일한 결론**에 도달했다.
갈린 것은 값뿐이고, **그 차이는 실측 표본의 차이에서 나왔다**:

- 내 표본 최대 **179.1초**(n=6) → 300 이면 1.68배 여유라고 판단했다
- dev3 표본 최대 **248.2초**(n=5) → 담보 26개짜리 회차가 248초였다
- **합산 n=11 의 최대는 248.2초** → 이 기준에서 **내 300 은 여유 1.21배뿐**이라
  담보가 촘촘한 증권에서 다시 잘린다. dev3 가 자기 주석에 적은 지적이 정확하다.

즉 **내 값 300 은 표본이 작아서 나온 과소 추정**이다. 420 이 맞다.
내 PR 을 머지하면 이미 머지된 420 을 **300 으로 되돌리는 회귀**가 된다.

### ANU 에 요청

1. **PR #254 를 close** (중복·열위). 내가 임의로 닫지 않고 판단을 올린다
2. 다만 내 테스트 중 **dev3 에 없는 봉인 2건**은 별건으로 이식할 가치가 있다:
   - `test_message_does_not_leak_internal_paths_or_traceback` — 실패 문구에 PDF 경로·스택트레이스 유출 금지 (dev3 미보유)
   - `test_timeout_is_bounded` — 상한 600초 봉인, "무한정 늘리지 마라" 지시의 기계적 강제 (dev3 미보유)
3. 아래 §ANU 판단 필요 2건은 **두 팀이 독립적으로 같은 결론**에 도달했으므로 신뢰도가 높다

★ 아래 본문의 실측·검증 기록은 **독립 재현 증거**로서 유효하다.
dev3 의 248.2초를 내 표본이 재현하지 못한 것 자체가 이 경로의 편차가 크다는 증거다.

---

## S — 상황

회장이 스캔본 증권(텍스트 레이어 0자인 이미지 PDF)을 업로드하면 분석이 완주하지 못한다.
`server/policy_analyzer.py` 의 분석 타임아웃이 **120초**로 박혀 있는데, 스캔본 5페이지
실소요는 그 값의 위아래에 걸쳐 있다.

## C — 복잡성

값을 올리는 것 자체는 한 줄이다. 어려운 것은 **얼마로 올릴지를 실측으로 정당화**하고,
값만 올려놓고 실제로는 안 쓰이거나 되돌아가는 것을 막는 일이다. 그리고 이번 건은
**서버 타임아웃만으로 끝나지 않는다**(아래 ANU 판단 필요 참조).

## Q — 핵심 질문

관측 분포를 덮으면서도 무한정이 아닌 값은 얼마인가? 그 값이 되돌아가면 무엇이 잡아내는가?
그리고 이 수정으로 사용자가 실제로 구제되는가?

## A — 해결

**타임아웃 120초 → 300초**(상수화), 시간 초과 전용 예외 타입 신설, 회귀 봉인 테스트 11건 추가.
스캔본 실호출 **3/3 완주**로 실증했다.

★ 단, 위 최우선 보고대로 **이 해법은 이미 머지된 PR #253(420초)에 열위이며 머지하면 안 된다.**
프론트 폴링 상한 180초 때문에 **서버 수정만으로는 완결되지 않는다** — ANU 판단이 필요하다.

---

## 1. 수정 전 실패 재현 (실측)

동일 샘플(회장 제공 KB손보 증권 · 5페이지 · **텍스트 0자** 실측 확인)로 실호출.

| 항목 | 결과 |
|---|---|
| 소요 | **120.1초** |
| 결과 | `Exception: 증권 분석 타임아웃 (120초)` |
| 판정 | **재현 성공** — 명세대로 잘린다 |

## 2. 실제 소요 분포 (상한 해제 후 실측)

타임아웃 상한을 걷어내고 **같은 입력**을 반복 실호출했다.

| 회차 | 소요(초) | 결과 | 담보 추출 |
|---|---|---|---|
| 상한해제 #1 | 145.0 | ok | 17건 |
| 상한해제 #2 | 120.8 | ok | 18건 |
| 상한해제 #3 | 86.4 | ok | 16건 |
| 수정후 #1 | 139.0 | ok | 16건 |
| 수정후 #2 | **179.1** | ok | 16건 |
| 수정후 #3 | 120.6 | ok | 18건 |

- **최소 86.4 · 최대 179.1 · 평균 131.8 · 중앙값 129.9** (n=6, 전건 완주)
- 같은 입력인데 **편차 2.07배** — LLM 지연의 fat tail
- 기존 120초 기준으로는 **6회 중 5회(83%)가 잘린다**

★ **명세 수치와의 차이(정직 보고)**: 명세는 task-3005 실측을 "85~139초"로 적었으나,
내 재측정에서 **179.1초**가 나왔다. 명세 상한(139)보다 **40초 더 크다**.
따라서 값 선정은 명세 수치가 아니라 **내 실측 179.1초**를 기준으로 삼았다.

## 3. 값 선정 근거 (수치)

```
POLICY_ANALYSIS_TIMEOUT_SEC = 300
```

| 근거 | 수치 |
|---|---|
| 관측 최대 | 179.1초 |
| 채택값 / 관측최대 | **1.68배** |
| 관측 편차 | 2.07배 (86.4 → 179.1) |
| 하한 기각: 200초라면 | 여유 1.12배뿐 → 편차 2.07배 흡수 불가 |
| 상한 억제 | 멈춘 CLI 의 최악 점유를 **5분**으로 묶음 (무한정 금지) |

같은 파일의 다른 타임아웃: **없음**(`grep timeout` 결과 이 경로 1곳뿐). 무관한 값은 건드리지 않았다.

## 4. 실패 사유 노출 (② 항목)

**수정한 것** — 문구가 실제 적용값에서 파생되도록:

```python
raise PolicyAnalysisTimeoutError(
    f"증권 분석 시간 초과 — 제한 {POLICY_ANALYSIS_TIMEOUT_SEC}초를 넘겨 중단했습니다. "
    "스캔(이미지) 증권은 분석이 오래 걸립니다."
)
```

- 기존 문구는 `"...(120초)"` **하드코딩**이었다. 값만 바꿨다면 로그가 거짓을 말했을 것이다(잠재 버그 제거)
- `except Exception` 과 구분되는 **전용 예외 타입** 신설 → 호출부가 시간초과를 코드버그와 구분 가능
- 내부 경로·스택트레이스 **미노출**(테스트로 봉인)
- **조용한 실패 신규 생성 0건** — 실패를 성공처럼 보이게 하는 응답/문구를 만들지 않았다

★ **범위 밖이라 못 고친 것 (ANU 판단 필요)**:
`server/main.py:7115` 이 개별 증권 실패를 `except Exception` 으로 잡아
**고정 문구**(`"이 증권은 분석에 실패했습니다. 잠시 후 다시 시도해 주세요."` +
`error_code: POLICY_ANALYSIS_FAILED`)로 치환한다. 예외 메시지는 **버려진다**.
즉 사용자 화면에서 시간초과와 다른 실패를 **여전히 구분할 수 없다**.
이는 task-2966 이 의도적으로 넣은 예외원문 유출 차단 장치이고, `main.py` 는
**forbidden_paths** 라 손대지 않았다. 내 수정은 서버 로그(`logger.exception`)까지는
정확한 사유를 전달한다(운영자 진단 가능).

## 5. 회귀 봉인 + 변이 실증 (③ 항목)

`server/tests/test_policy_analysis_timeout_task3007.py` — **11건 전건 PASS**

봉인 하한 `SEAL_FLOOR_SEC = 240` (관측최대 179.1 대비 1.34배 여유).
★ 최초 180으로 잡았다가 **실측 179.1초가 나와 0.9초 차이밖에 안 되는 것을 확인하고 240으로 정정**했다.

**변이 실증 (실제 수행)** — 값을 `300 → 120` 으로 되돌린 뒤 pytest 실행:

```
FAILED ...TestTimeoutValueSeal::test_timeout_must_stay_above_measured_ceiling
FAILED ...TestTimeoutValueSeal::test_timeout_is_not_the_regressed_value
FAILED ...TestTimeoutValueSeal::test_timeout_covers_measured_ceiling_with_margin
FAILED ...TestTimeoutFailureIsLegible::test_message_states_the_actual_limit
4 failed, 7 passed
```
→ **복원 후 11 passed**. `git diff` **공란**으로 커밋본과 바이트 동일 복원 확인.

봉인 범위: ① 값 하한/상한·사고값 복귀 금지 ② `wait_for` 가 상수를 실제로 받는지 ·
소스에 숫자 리터럴 재삽입 금지 ③ 예외 타입·자식 프로세스 kill·문구의 값 일치·
사유 노출·내부경로 미노출.

## 6. 텍스트레이어 경로 무회귀

전용 텍스트 PDF 픽스처가 없어 **합성 픽스처를 생성**(전부 가공 데이터, PII 0)해 실호출.

| 입력 | 텍스트량 | 소요 | 결과 |
|---|---|---|---|
| 합성 텍스트레이어 증권 1p | 362자 | **18.7초** | ok · 담보 **12/12 전건** 추출 |
| 스캔본 5p (대조) | 0자 | 86~179초 | ok |

→ 텍스트 경로는 **4.6~9.6배 빠르며 영향 없음**. 근본 원인이 스캔 경로임을 교차 확인.

## 7. pytest 회귀 (base 재측정 기준선)

clean worktree 2개로 **동일 조건** 비교(`.env` 동일 복사, `-p no:randomly`).

| | base `3dad070` | branch task-3007 | Δ |
|---|---|---|---|
| passed | 2692 | **2703** | **+11** (신규 봉인 테스트와 정확히 일치) |
| failed | 5 | **5** | **0** |
| skipped | 3 | 3 | 0 |

실패 5건은 **base 에서도 동일한 이름으로 실패**하는 선재 결함이다(내 변경과 무관):
`scripts/tests/test_standby_fallback.py` 4건(네이버 키) ·
`tests/test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset` 1건(env 조건부 CORS).

→ **회귀 실패 0건**.

## L1 스모크테스트

- **서버 재시작**: **성공** — 워크트리에서 `uvicorn main:app` 로컬 기동(127.0.0.1:8099).
  ★ 프로덕션 `insuro-api` 유닛은 **건드리지 않았다**(배포는 ANU 가 task-3008 검증 후 일괄).
- **API 응답 확인**: `curl /api/status` → **HTTP 200**
  `{"status":"ok","sha":"98470c82...","sha_short":"98470c8","ready":true,"db":"ok"}`
  → 서빙 sha 가 내 브랜치 HEAD 와 일치. 기동 로그 에러 **0건**.
- **스크린샷**: 해당없음 (백엔드 전용 변경, UI 변화 없음)
- ★ 추가 — **실코드 경로 실호출 7회**(모의 아님): `run_policy_analysis` → 실제 `claude` CLI
  서브프로세스 → 실제 PDF. pytest PASS 가 아니라 **실동작**으로 검증했다.

---

## 수정 파일별 검증 상태

| 파일 | 변경 내용 | grep 검증 | 상태 |
|---|---|---|---|
| server/policy_analyzer.py | 타임아웃 120→300 상수화, 시간초과 전용 예외, 문구 파생화 (+44/-2) | grep "POLICY_ANALYSIS_TIMEOUT_SEC" OK | verified |
| server/policy_analyzer.py | 시간초과 전용 예외 타입 신설 | grep "PolicyAnalysisTimeoutError" OK | verified |
| server/tests/test_policy_analysis_timeout_task3007.py | 회귀 봉인 테스트 11건 신규 (+182) | grep "SEAL_FLOOR_SEC" OK | verified |
| server/tests/test_policy_analysis_timeout_task3007.py | 실측 관측최대 상수 반영 | grep "MEASURED_MAX_SEC" OK | verified |

절대경로 기준: 위 파일은 모두 `/home/jay/projects/InsuRo/` 하위이며,
워크트리 실경로는 `/home/jay/projects/InsuRo/.worktrees/task-3007-dev5/` 이다.

### 3 Step Why

- **1st Why** — 왜 스캔본 분석이 실패했나? 타임아웃이 120초인데 실소요가 86~179초라 분포의 대부분이 잘렸다.
- **2nd Why** — 왜 120초였나? 텍스트 PDF 기준으로 잡힌 값이다(실측 18.7초). 스캔본은 페이지를 이미지로 정독해 4.6~9.6배 걸린다는 점이 반영된 적이 없다.
- **3rd Why** — 왜 아무도 못 잡았나? 값을 지키는 테스트가 없었고, 실패 문구가 `(120초)` 하드코딩이라 값이 바뀌어도 로그가 거짓을 말할 구조였다. 그래서 이번에 값·사용처·문구를 한꺼번에 봉인했다.

## trip-wire 5종 (실측치)

| 항목 | 실측 | 근거 |
|---|---|---|
| Critical7 | **0** | 신규 Critical 결함 없음 |
| PII net-new | **0** | diff 추가줄 정규식 스캔 6종 0건. `sk-` 9건은 `ta**sk-**3007` 오탐으로 경계보정 후 0 확인. 샘플 PDF git 추가 **0건** |
| 회귀 실패 | **0** | base 5 failed == branch 5 failed (동일 이름) |
| forbidden_paths 침범 | **0** | 변경 2파일 모두 allowed glob 내. `server/silson/**`·`server/scripts/**`·`main.py`·`src/**`·`supabase/**`·`extension/**`·`.github/**` 침범 0 |
| nonce | **task-3007** | 일치 |

## 금지사항 준수

- LLM 프롬프트(`build_policy_prompt`·`DEFAULT_COVERAGES`) **미변경** ✅
- 담보명 리네임 **손대지 않음** ✅
- `server/silson/**`·`server/scripts/**`·systemd·`src/**`·`supabase/**`·`extension/**` **미변경** ✅
- 샘플 PDF git 추가 **0건** · 보고서/로그에 PII **0건** ✅ (실측 하네스는 소요시간·건수만 기록)
- 프로덕션 배포 **미수행** ✅ · **머지 미수행** ✅

---

## ★ ANU 판단 필요 (2건)

### 1. 프론트 폴링 180초 상한 — 서버 수정만으로 완결되지 않는다

`src/pages/PolicyAnalysis.tsx:261` `pollStatus`:
```ts
const maxAttempts = 60;
await new Promise((r) => setTimeout(r, 3000));   // 60 × 3초 = 180초
...
toast.error("분석 시간이 초과되었습니다");
```

- 실측 6회 중 **1회(179.1초)가 이미 이 경계에 닿았다**
- 서버가 300초까지 완주해 결과를 **DB 에 저장해도**, 180초를 넘긴 건은
  사용자 화면에 "분석 시간이 초과되었습니다" 로 보인다
- 게다가 `analyze-policy` 는 파일을 **순차 분석**한다 → **2건 이상 업로드 시
  현재 값으로도 180초 예산이 구조적으로 초과**된다
- `src/**` 는 forbidden_paths 라 착수하지 않았다. **후속 태스크 필요**

### 2. 사용자에게 시간초과 사유가 도달하지 않음

`main.py:7115` 의 고정 문구가 예외 메시지를 버린다(§4 참조).
`error_code` 를 `POLICY_ANALYSIS_TIMEOUT` 으로 분기하려면 `main.py` 수정이 필요하다.
task-2966 의 유출 차단 의도를 깨지 않는 선에서의 설계 판단이라 ANU 결정 사항으로 올린다.

---

## 비고

- 커밋 3건(micro-commit): `2b7dcd7` 코드 · `add35a8` 테스트 · `98470c8` 실측 반영 정정
- 실측 하네스(`/tmp/t3007/measure.py`)와 합성 픽스처는 **커밋하지 않았다**(일회성 검증 도구)
- 총 실호출 **8회**(스캔 7 + 텍스트 1), 누적 약 20분

## 세션 통계
- 총 도구 호출: 0회

