---
task_id: task-2925
team: dev1-team
qc_verdict: PASS_WITH_WARN
level: 2
priority: P1
---

# task-2925 보고서 — InsuRo 정보성키워드 502 hotfix (load_dotenv 순서 버그)

## Situation (상황)
프로덕션(api.insuro.biz) `POST /api/insuro/infokeyword/generate` 가 **502 Bad Gateway**.
서버 로그: worker(8100)가 `401 Unauthorized {"detail":"Invalid or missing API key"}` 반환 → 프록시가 502로 전달. 회장 긴급 장애(정보성키워드 30개 추천 전면 실패).

## Complication (문제)
근본 원인은 **키 불일치가 아니라 `main.py`의 코드 실행 순서 버그**.
- `server/main.py` 구 41~45행: 모듈 로드 시점에 `_infokeyword_http_client = http_client.AsyncClient(... headers={"X-API-Key": os.getenv("INFORKEYWORD_API_KEY", "")})` — 인증 헤더를 **즉시** 평가.
- 구 52~54행: `load_dotenv(.env)` 가 클라이언트 생성 **뒤**에 위치.
- 결과: 클라이언트 생성 시점엔 `INFORKEYWORD_API_KEY`가 환경에 없어 **빈 문자열("")이 X-API-Key 헤더에 박제** → worker 401 → 502.

## Question (핵심 질문)
동작 변경 없이(불필요한 리팩터 없이) 순서만 교정하여 클라이언트 헤더가 실제 21자 키를 갖도록 할 수 있는가?

## Answer (해결)
`load_dotenv` 3줄을 httpx 클라이언트 생성 **이전**(slowapi import 직후)으로 이동. `.env` 하나만 로드하는 기존 동작 유지, **순서만** 교정하는 surgical fix.

---

## 수정 파일 / diff
`server/main.py` (순수 이동, 4 insert / 4 delete):

```diff
@@ import 직후 (신규 40~42행) @@
+from pathlib import Path
+from dotenv import load_dotenv
+load_dotenv(dotenv_path=Path(__file__).resolve().parents[1] / ".env")
+
 _naver_http_client = http_client.AsyncClient(timeout=15.0)
 _infokeyword_http_client = http_client.AsyncClient(
     base_url="http://127.0.0.1:8100",
@@ 기존 위치(구 52~54행) 중복 3줄 삭제 @@
-from pathlib import Path
-from dotenv import load_dotenv
-load_dotenv(dotenv_path=Path(__file__).resolve().parents[1] / ".env")
```

신규: `server/tests/test_infokeyword_dotenv_order.py` (회귀 방지 — 클라이언트 X-API-Key non-empty & len≥21, .env 없으면 skip 방어).

grep 검증(라인번호): `load_dotenv`(42) < `_naver_http_client`(44) < `_infokeyword_http_client`(45) — 순서 교정 반영 확인.

## 보고 필수 8항목
1. **수정 파일/라인 diff**: `server/main.py` load_dotenv 3줄 이동(위 diff). 신규 테스트 1파일.
2. **import 순서 교정 내용**: load_dotenv를 httpx 클라이언트 생성 이전으로 이동. `os` import(20행)는 이미 상단이라 조건 충족.
3. **로컬 import 검증**: `python3 -c "import main; ..."` → `_infokeyword_http_client.headers['X-API-Key']` **LEN=21, EMPTY=False** (빈 문자열 아님, 실제 키 로드).
4. **서버 재기동 방식**: 라이브 수동 setsid 프로세스(PID 3320093, cwd=/home/jay/projects/InsuRo/server, uvicorn :8001) graceful 교체 — 아래 "배포" 섹션 참조.
5. **`/infokeyword/generate` 실제 응답코드**: 수정본 서버에서 클라이언트→worker(8100) 직접 호출 = **422**(body validation, `topic` 필드 누락 — 정상), **NOT 401** → 인증 통과 입증. (엔드포인트 자체는 admin JWT 필요 → 무인증 401은 auth 계층, 변동 없음. Supabase 실 JWKS 서명이라 로컬 admin 토큰 위조 불가 = 환경 제약.)
6. **`/api/status` 200 여부**: 배포 후 라이브 200 확인 (아래 배포 섹션).
7. **회귀 테스트 결과**: `pytest tests/` → **1299 passed**, 유일 실패=`test_cors_fail_closed_when_ext_origin_unset` (pre-existing, base stash 후에도 재현, dotenv 순서 무관). 신규 테스트 포함 나머지 GREEN.
8. **PR/머지 상태**: PR #201 (Jeon-Jonghyuk/InsuRo). CI 전 게이트 GREEN(ci/diagnostic/e2e/qc/gemini-gate/guards). 아래 머지 섹션.

## L1 스모크테스트 결과
- **서버 재시작**: 성공 — 수정본으로 임시 서버(:8019) 부팅, `Application startup complete`.
- **API 응답 확인**:
  - `/api/status` (임시 :8019) → **200**
  - infokeyword 무인증 → 401(auth 계층, 정상)
  - **수정된 클라이언트 → worker(8100) 직접 호출 → 422 (NOT 401)** = X-API-Key 21자 인증 통과 입증 (502 근본 원인 해소의 결정적 증거)
  - 라이브 재배포 후 `/api/status` → **200** (배포 섹션)
- **스크린샷**: 해당없음 (백엔드 API 작업)

## 배포 (프로덕션 라이브 재기동) — 완료
- **머지**: PR #201 squash 머지 → main `f2b282e` (Jeon-Jonghyuk/InsuRo). CI 전 게이트 GREEN(merge-safety/lock-in/hidden-path 포함), mergeStateStatus=CLEAN.
- **라이브 체크아웃 업데이트**: `/home/jay/projects/InsuRo` main → `f2b282e` ff-pull. `server/main.py:41-42`에 load_dotenv(클라이언트 생성 이전) 반영 확인.
- **graceful 교체**: 수동 setsid 프로세스(PID 3320093) SIGTERM → systemd `insuro-api.service`(동일 WorkingDirectory=server, Restart=always)가 :8001 즉시 승계. **이중구조(수동 vs systemd 크래시루프) 해소** — 이제 systemd 관리 단일 프로세스(Main PID 2040954, uvicorn). 순단 최소.
- **재기동 후 검증**:
  - `/api/status` (:8001) → **200** `{"status":"ok"}`
  - **api.insuro.biz 터널 → 200**
  - infokeyword 무인증 → 401(auth 계층, **NOT 502**)
  - 배포 서버 `_infokeyword_http_client.headers['X-API-Key']` → **LEN=21, EMPTY=False** (수정 라이브 반영)

## 발견 이슈 및 해결
- taskctl state machine 미초기화 → init→dispatch→worktree-ready→run→commit→pr-open 순 전이로 해소.
- worktree_manager `--action pr` 가 main worktree 미커밋 파일 경고로 gh pr create 실패 → 브랜치 push 후 gh로 직접 PR 생성.

## 머지 판단
- **머지 필요**: Yes
- **브랜치**: task/task-2925-dev1
- **워크트리 경로**: /home/jay/projects/InsuRo/.worktrees/task-2925-dev1
- **머지 의견**: surgical 1줄-블록 이동 + 회귀 테스트. CI 전 게이트 GREEN, e2e 통과, High 0건. 회귀 0(pre-existing CORS 제외). 프로덕션 장애 해소 hotfix로 즉시 머지 적합.

## 모델 사용 기록
- 불칸(백엔드): sonnet — 코드 수정 + 테스트 작성.
- 팀장(헤르메스, Opus): 설계/검증/배포/통합 (직접 코딩 없음).

## QC Verdict
PASS_WITH_WARN

QC 게이트 결과 = WARN (비차단, normal 레벨 통과). WARN 항목 3건:
- `tdd_check` = WARN: 긴급 hotfix로 test-first 엄격 미준수(수정과 회귀 테스트 동시 작성). 근본원인 명확·surgical 이동이라 허용.
- `scope_check` = WARN: evidence root(worktree vs workspace) 해석 경고. 실제 변경은 allowed_resources(server/main.py, server/tests/**) 내로 한정 — 위반 아님.
- `claude_md_check` = WARN: 표준 안내성 경고. 차단 사유 아님.
FAIL 0건. (finish-task 초회 관측된 test_runner 1 FAIL = workspace/tests의 pre-existing JSON parse 이슈로 InsuRo 무관.)

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

