# task-3019 — 트렌드 헬스체크 경고 신뢰성 회복

- **레벨**: Lv.2 · **팀**: dev7-team (이참나) · **PR**: #257 (draft, 머지는 ANU)
- **브랜치**: `task-3019` · **base**: `934168e` · **head**: `4f200e3`

## S — 상황 (Situation)

2026-08-25 경보 배선이 복구되어(t3008) **2026-08-26 06:00 부터 트렌드 헬스체크 경고가 회장 채팅방에 실제로 도달**한다.
그런데 헬스체크는 `트렌드 수집: 0/2,000건` 을 보고하는 반면, 수집은 정상 동작 중이었다.
첫날부터 오탐이 나가면 복구한 경보 체계의 신뢰가 깨진다.

## C — 복잡성 (Complication)

"수집이 되는데 왜 헬스체크는 0 인가"가 미규명이었다.
ANU 명세는 `daily_trend_collect.py:408` 의 `total_upserted += len(upsert_rows)` 가
"보낸 행 수를 센다"는 점을 원인 후보로 지목했다.

## Q — 질문 (Question)

(1) 보낸 행 수 / (2) 실제 DB 반영량 / (3) 헬스체크가 본 값 — 셋 중 어디가 어긋나는가?

## A — 답 (Answer): 원인은 (3) 하나다. KST/UTC 시간대 혼선이다.

### ① 집계 기준 대조표 (2026-08-25 06:00 KST 실행 1회 기준)

| 구분 | 무엇을 | 값 | 근거 |
|---|---|---|---|
| ① | upsert 에 보낸 행 수 len(upsert_rows) | **29,917** | /tmp/trend-pipeline-logs/trend-2026-08-25.log 최종행 |
| ② | 실제 DB 반영량 (해당 실행분) | **29,917** | keyword_trends 에서 collected_at='2026-08-24T21:00:03.921791+00:00' count exact |
| ③ | 헬스체크가 본 수치 | **0** | /tmp/trend-pipeline-logs/health-2026-08-25.log |

**③ 헬스체크의 대상 · 기간 · 필터 · 산식**

| 항목 | 값 |
|---|---|
| 대상 테이블 | keyword_trends |
| 필터 | collected_at >= '2026-08-25T00:00:00Z' |
| 산식 | date.today() (KST 달력일) 뒤에 T00:00:00Z 접미 — daily_health_check.py:238-239 |
| 임계값 | TREND_MIN=1800 (메시지 분모 /2,000 은 하드코딩된 거짓값) |

### ② 불일치 원인 단정 — **확정** (추정 아님)

**①과 ②가 29,917 로 정확히 일치한다. 데이터 유실 0건. 어긋난 곳은 ③ 하나뿐이다.**

- 서버 TZ = **KST(UTC+9)** (실측 `Asia/Seoul (KST, +0900)`), 파이프라인 cron = **매일 06:00 KST**
- 수집기는 `collected_at = datetime.now(timezone.utc)` 로 쓴다 → **이것은 올바른 동작이다**
- 06:00 KST 실행 → 저장값 = **전일 21:00 UTC**
- 헬스체크는 KST 달력일에 `Z`(UTC) 접미사를 붙여 창을 만든다 → 실제 창 시작 = **09:00 KST**
- **파이프라인은 창이 열리기 3시간 전에 돈다 → 구조적으로 매일 0건.** 간헐 결함이 아니다.

**1st Why**: 헬스체크가 0건을 봤다 → 조회 창이 실행 시각보다 3시간 늦게 열린다
**2nd Why**: 창이 늦게 열린다 → KST 달력일 문자열에 UTC 접미사 `Z` 를 붙였다
**3rd Why**: 그런 코드가 남았다 → 달력일 산출과 창 경계 산출이 **서로 다른 시간대 전제**로 분리돼 있었다

### ★ ANU 명세 전제 반증 (확정)

명세는 `total_upserted` 를 원인 후보로 지목했으나 **①=②=29,917 로 정확히 일치**한다.
**카운터는 이번 오탐의 원인이 아니다.** (다만 검증 항목 5의 봉인 요구에 따라 함께 강화했다.)

## 수정 파일별 검증 상태

| 파일 | 변경 요약 | 검증 방법 | 상태 |
|---|---|---|---|
| /tmp/wt-3019/server/scripts/daily_health_check.py | resolve_health_window() 신설 — KST 달력일과 UTC 창 경계를 한 함수에서 함께 산출. 로그에 둘 다 출력. 거짓 분모 /2,000 제거 | 프로덕션 DB 읽기전용 실조회 29,917건 + 변이 12건 FAIL | verified |
| /tmp/wt-3019/server/scripts/daily_trend_collect.py | total_upserted 를 응답 기준 실제 반영량으로 전환. 보냄≠반영 시 둘 다 로그. representation 부재 시 조용한 0 대신 확인불가 노출 | pytest 40건 + 변이 4건 FAIL | verified |
| /tmp/wt-3019/server/tests/test_task3019_health_check_window.py | 신규 40 테스트 (봉인 2종 포함) | pytest 40 passed | verified |

**임계값 숫자는 바꾸지 않았다.** 특히 SATURATION_MIN 을 낮추면 아래 진짜 결함을 은폐하게 된다.

## L1 스모크테스트

프로덕션 Supabase 직접 조회. **GET 6회 / 쓰기 0회** (httpx 레벨에서 non-GET 하드 차단).
`main()` 미호출 — 경보 발송 경로 미접촉(실제 텔레그램 발송 방지).

- **서버 재시작**: 해당없음 (cron 배치 스크립트, 상주 서비스 아님)
- **API 응답 확인**:

| 창 | 실측 | 기대 | 판정 |
|---|---|---|---|
| 새 창 '2026-08-24T15:00:00+00:00' | **29,917** | 29,917 | 일치 |
| 옛 창 '2026-08-25T00:00:00Z' | **0** | 0 | 일치 |
| 대조 '2026-08-24T15:00:00Z' | **29,917** | 새 창과 동일 | 일치 |

실제 요청 URL 원문 (httpx 캡처):
```
GET /rest/v1/keyword_trends?select=id&collected_at=gte.2026-08-24T15%3A00%3A00%2B00%3A00  HTTP/2 206
GET /rest/v1/keyword_trends?select=id&collected_at=gte.2026-08-25T00%3A00%3A00Z           HTTP/2 200
```
→ **`+` 가 `%2B` 로 정상 인코딩**된다. 공백 오해석 없음. 구현자가 "미실증"으로 남긴 리스크를 실증으로 반증했다.

- **스크린샷**: 해당없음 (백엔드 배치, UI 없음)

**★ 불일치 해소 실증**: 같은 실행분(29,917건)에 대해 헬스체크 결과가 0 → 29,917 로 바뀌어
**DB 실측과 일치**한다. 검증 항목 3 충족.

## 오탐 없음 검증 (검증 항목 4)

- `season_calendar_check.py` 확인: 이 스크립트는 `season_calendar.json` 의 MM-DD 기간으로
  `keyword_pool.season_tags` 만 갱신하며 **헬스체크의 임계값·조회 창에 전혀 관여하지 않는다.**
  즉 "시즌이라 적게 수집됐다"는 자동 완화 경로가 애초에 존재하지 않는다.
  `test_health_check_thresholds_are_season_independent` 가 그 경로가 생기는 것 자체를 금지한다.
- 임계값 정확히 일치하는 값은 경고가 아님(`test_exactly_at_threshold_is_not_a_warning`).
- 정상일(29,917/3,500/3,500/0) → 경고 None.

## 봉인 (검증 항목 5) — 변이로 실증

| 변이 | 내용 | 결과 |
|---|---|---|
| M1 | resolve_health_window 를 옛 `KST일+Z` 로 원복 | **12 failed** |
| M1b | main() 을 옛 date.today()+Z 방식으로 복원 | **1 failed** |
| M2 | applied = len(resp.data) → applied = sent 로 위조 | **2 failed** |
| M2b | 호출부를 total_upserted += len(upsert_rows) 로 복원 | **2 failed** |

**카운트가 다시 "보낸 행 수"로 되돌아가면 FAIL 한다.** 항진명제 아님을 변이로 실증.
(M2b 봉인은 docstring 인용문 오탐을 피하려 `ast.AugAssign` 노드 검사로 구현)

## 회귀 (검증 항목 6) — base 재측정 기준선

별도 clean worktree 에서 base 를 직접 측정(워킹트리 오염 배제).

| | 결과 |
|---|---|
| base 934168e | **2755 passed, 4 skipped** (178.98s) |
| after 4f200e3 | **2795 passed, 4 skipped** (169.16s) |
| 델타 | **+40 passed · 신규 fail 0 · skipped 동일** |

## trip-wire 5종 실측

| 항목 | 실측 | 판정 |
|---|---|---|
| Critical7 | **0건** (아래 t3013 건은 원격 부재로 재판정) | PASS |
| PII net-new | **0건** (추가 884줄 전수) | PASS |
| 회귀 실패 | **0건** | PASS |
| forbidden_paths 침범 | **0건** (9개 패턴 전수) | PASS |
| nonce task-3019 일치 | 커밋·스크립트·테스트 파일명·본문 전부 일치 | PASS |

변경 파일 전수 3건, 전부 allowed_resources 범위 내:
server/scripts/daily_health_check.py · server/scripts/daily_trend_collect.py · server/tests/**

**QC**: red-team 두 파일 `risk_level: low`, `vulnerabilities: 0`.
code-validator 의 "Execution Test ❌" 는 **선재 아티팩트**로 판정 — base 원본에서도 동일 재현되며,
근본원인은 `code-validator.py:406` 이 모든 스크립트에 리터럴 인자 `test` 를 붙여 실행해
argparse 가 `unrecognized arguments: test` 로 exit 2 하는 검증기 자체 결함이다.

## ★★ 조사 중 발견한 별개 결함 3건 — 보고만 (수정 안 함, 범위 밖)

### 1. 포화도 경고는 오탐이 아니라 **참(true positive)** — 활성 키워드의 71%가 매일 누락
`daily_saturation_collect.py:206` 이 **페이지네이션 없이** `keywords` 를 읽어
PostgREST 기본 상한 **1,000** 에 잘린다. 활성 키워드는 **3,500** (실측 count exact).
6일치 로그 전부 `활성 키워드 로드 완료: 1000개`. 같은 파이프라인의 트렌드 수집기는 페이지네이션을 한다.
→ **이 경고를 임계값 조정으로 끄면 안 된다.** 명세대로 "오탐 제거"만 했다면 진짜 결함을 은폐할 뻔했다.

### 2. 랭킹 실패는 "선행이 정상화되면 자연히 풀린다"가 **아니다** — 8주 연속 화요일에만 실패
`daily_ranking_calc.py:74` 게이트가 `period_end >= today-7일`.
DataLab `timeUnit=week` 의 `period` 는 `~` 없는 **주 시작(월요일) 단일 날짜**라
`period_start == period_end` 로 저장된다(84,354행 전수 확인, 전부 월요일).
화요일 06:00 에는 최신 버킷이 `today-8` 이라 7일 창을 못 넘긴다.
`keyword_rankings` 누락일 실측: 07-07 · 07-14 · 07-21 · 07-28 · 08-04 · 08-11 · 08-18 · 08-25
= **8건 전부 화요일**. 그 외 48일은 매일 1,000건 정상.
→ **명세의 "Step 3 실패는 결과다" 전제 반증.** 트렌드 수집이 정상이어도 화요일엔 계속 실패한다.
   해소하려면 `days=7` → `days=9` 이상 또는 "최신 버킷 존재" 검사로 전환해야 하나
   `daily_ranking_calc.py` 는 forbidden_paths 라 본 task 가 손대지 않았다.

### 3. `period_end` 는 "주의 끝"이 아니라 "주의 시작"이다
컬럼명이 의미를 거짓 표기. `on_conflict=(keyword_id,period_start,period_end)` 유니크 키가 걸려 있어
값 변경은 마이그레이션 검토가 필요하다.

## 이 수정이 바꾸는 것 / 바꾸지 않는 것 (정직한 범위)

내일 06:00 경고는 **사라지지 않는다.** 3줄 중 **트렌드 1줄만** 제거된다.

- 수정 전 reason: `trend_low|saturation_low|ranking_low`
- 수정 후 reason: `saturation_low|ranking_low` ← 둘 다 **진짜 결함**이라 남는 것이 옳다

즉 이 PR 의 성과는 "경고를 없앤 것"이 아니라 **"거짓말 1줄을 제거해 나머지 2줄을 믿을 수 있게 만든 것"** 이다.

## ANU 판단 필요 3건

1. **`SATURATION_MIN=1800` vs 실측 상시 1,000** — 원인은 페이지네이션 누락(별개 task 필요)
2. **화요일 랭킹 실패** — `daily_ranking_calc.py` 수정 권한 필요(별개 task 필요)
3. **task-3013 중복 구현** — `/home/jay/projects/InsuRo/.worktrees/task-3013-dev4` 에
   `daily_trend_collect.py` 미커밋 수정(82 insertions)이 있고 동일한 "보냄 vs 반영" 카운터를 중복 구현 중.
   `git apply --check` 하드 충돌 실증. **단 원격 브랜치·PR 모두 부재**(전수 확인)이므로
   **PR #257 머지에는 지장이 없다.** t3013 재개 시 해당 WIP 는 폐기·재작성이 맞다.
   DRY-RUN 처리가 정반대 결정(t3019=반영량 불산입 / t3013=산입)이라 병합은 위험하다.

## 완료 조건 (DoD) 충족 여부

**충족.** 최근 실행 1회(2026-08-25 06:00 KST) 기준으로 헬스체크 수치의 대상·기간·산식이 문서화되었고,
실제 DB 반영량(29,917)과 대조 가능하며, 수정 후 같은 실행분에서 헬스체크가 29,917 을 반환해
**불일치가 해소**되었다. 남은 2줄 경고는 **원인 있는 차이**로 위에 명확히 설명했다.

## 머지

**PR #257 은 draft. 머지는 ANU 가 한다. 자동 머지 금지.**

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

