# task-2807 — Calculators.tsx 보험나이 근사 결함 수정 (CALC_INS_AGE_FIX)

- **작업 ID**: task-2807
- **팀**: dev6-team (팀장 페룬)
- **작업일**: 2026-07-20
- **저장소**: InsuRo (`/home/jay/projects/InsuRo`, `origin/main` = `cc7476bf`)
- **브랜치**: `task/task-2807-calcage-dev6`
- **워크트리**: `/home/jay/workspace/.worktrees/task-2807-dev6`
- **커밋**: `24f6c94` (정확히 1개)

---

## S (Situation) — 상황

`src/pages/Calculators.tsx` 의 보험나이 계산기는 실사용 화면에 노출되는 값을 계산한다.
회장 승인(2026-07-20) 하에 ANU 실측으로 결함이 확인되었다.

## C (Complication) — 문제

기존 구현이 **일수 나눗셈 근사**를 사용했다:

```js
const totalDays = (today.getTime() - birth.getTime()) / MS_PER_DAY;
const exactYears = totalDays / 365.25;
const insAge = Math.round(exactYears);      // ← 결함
```

`Math.round(일수/365.25)` 는 6개월 경계보다 **하루 일찍** 올림되어, 1년에 이틀 동안 화면에
1살 많은 보험나이가 표시된다.

## Q (Question) — 핵심 질문

근사식을 제거하고, 회장 확정 규칙(만나이 + 마지막생일로부터 6개월 경과 시 +1)을
월 단위 날짜 연산으로 정확히 구현하되, 테스트로 경계를 영구 고정할 수 있는가?

## A (Answer) — 해결

일수 나눗셈을 완전히 제거하고 **연/월/일 정수 컴포넌트 연산**만 사용하는 순수 함수
`calcInsuranceAgeFrom(birthDate, today)` 를 추출했다. 기준일을 인자로 주입받아 단위 테스트가
실행 시각에 의존하지 않는다. 경계 8케이스 + 윤년/월말일/거부 케이스 총 22건으로 고정했다.

---

## 수정 내용

### 수정 파일 (2개, 신규 1개 — new_file_limit 2 준수)

| 파일 | 구분 | 변경 |
|---|---|---|
| `src/pages/Calculators.tsx` | 수정 | +86 / -17 |
| `src/pages/__tests__/Calculators.insuranceAge.test.ts` | 신규 | +137 |

`extension/**`, `server/**` 수정 **0건**. 허용 경로 외 파일 수정 0건.

### 핵심 diff

```diff
-    if (!isValidDate(birthDate)) return;
-    const birth = new Date(birthDate);
-    const today = new Date();
-    let manAge = today.getFullYear() - birth.getFullYear();
-    const mDiff = today.getMonth() - birth.getMonth();
-    const dDiff = today.getDate() - birth.getDate();
-    if (mDiff < 0 || (mDiff === 0 && dDiff < 0)) manAge--;
-    const totalDays = (today.getTime() - birth.getTime()) / MS_PER_DAY;
-    const exactYears = totalDays / 365.25;
-    const insAge = Math.round(exactYears);
-    setInsuranceAge({ age: insAge, manAge });
+    const result = calcInsuranceAgeFrom(birthDate, new Date());
+    if (!result) return;
+    setInsuranceAge(result);
```

신규 순수 함수(요지):

```ts
export const calcInsuranceAgeFrom = (birthDate: string, today: Date): InsuranceAgeResult | null => {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(birthDate)) return null;
  const [byStr, bmStr, bdStr] = birthDate.split("-");
  const by = Number(byStr), bm0 = Number(bmStr) - 1, bd = Number(bdStr);
  if (bm0 < 0 || bm0 > 11) return null;
  if (bd < 1 || bd > daysInMonth(by, bm0)) return null;

  const todayYmd = { y: today.getFullYear(), m: today.getMonth(), d: today.getDate() };
  const birthYmd = { y: by, m: bm0, d: bd };
  if (cmpYMD(birthYmd, todayYmd) > 0) return null;        // 미래 생년월일 거부

  let manAge = todayYmd.y - by;                            // 만나이: 현행 mDiff/dDiff 방식 유지
  const mDiff = todayYmd.m - bm0, dDiff = todayYmd.d - bd;
  if (mDiff < 0 || (mDiff === 0 && dDiff < 0)) manAge--;

  const lastBirthday   = clampYMD(by + manAge, bm0, bd);              // 마지막 생일
  const sixMonthsLater = clampYMD(lastBirthday.y, lastBirthday.m + 6, lastBirthday.d);
  const insAge = manAge + (cmpYMD(todayYmd, sixMonthsLater) >= 0 ? 1 : 0);  // 정확히 6개월이면 올림
  return { age: insAge, manAge };
};
```

### 월 단위 구현 근거

1. **근사 제거**: `MS_PER_DAY`·`365.25`·`365.2425` 기반 연수 환산을 코드·주석 모두에서 제거.
   `grep -rn "365\.25\|365\.2425\|exactYears" src/pages/Calculators.tsx` → **0건** (검증 완료).
   `MS_PER_DAY` import 는 다른 계산기(`calcDays`, 151줄)에서 여전히 사용하므로 유지.
2. **타임존 버그 원천 차단**: 기존 코드는 `new Date("YYYY-MM-DD")`(UTC 파싱)와
   `new Date()`(로컬)를 혼용했다. 신규 구현은 문자열을 `split("-")` 하여 정수로 직접 취하고,
   기준일도 `getFullYear/getMonth/getDate`(로컬 컴포넌트)만 읽어 **시분초·타임존 영향이 0**.
3. **기준일 주입**: 함수 내부에서 현재 시각을 읽는 `new Date()` 호출 없음.
   (`daysInMonth` 의 `new Date(year, m+1, 0)` 은 인자를 명시한 순수 계산 헬퍼로, 시스템 시각을
   읽지 않음 — 제약 위반 아님.)

### 윤년·월말일 처리 결정과 근거

**결정: 대상 월에 없는 일자는 그 달의 말일로 클램프(clamp)한다. 오버플로(다음 달 이월) 없음.**

| 케이스 | 처리 | 결과 |
|---|---|---|
| 8/31 + 6개월 | 2월에 31일 없음 → 말일 클램프 | 2/28 (윤년 2/29). 3/1·3/3 으로 넘기지 않음 |
| 2/29 생일, 비윤년의 마지막 생일 | 2월 말일 클램프 | 2/28 |
| 1/31 + 6개월 | 7월에 31일 존재 | 7/31 (클램프 없음) |

근거: (1) 한국 보험 실무의 월 단위 기산 관행과 일치, (2) 오버플로 방식은 "생일 다음 달"로
넘어가 월 경계 의미가 흐려짐, (3) `Math.min(day, lastDay)` 로 **결정론적**이며 입력에 관계없이
단조 증가. 이 결정은 테스트 4건(2/29생 2건, 8/31생 2건)과 대조군 2건(1/31생)으로 고정했다.

---

## 검증 결과

### 1) 경계 8케이스 — 실행 원문 (`npx vitest run --reporter=verbose`)

```
 ✓ 경계 8케이스 (생년월일 1983-08-09) > 2026-02-07 → 42세 (마지막생일+6개월 전날) 1ms
 ✓ 경계 8케이스 (생년월일 1983-08-09) > 2026-02-08 → 42세 (마지막생일+6개월 하루 전) 0ms
 ✓ 경계 8케이스 (생년월일 1983-08-09) > 2026-02-09 → 43세 (마지막생일+6개월 정확히 도달, 올림) 0ms
 ✓ 경계 8케이스 (생년월일 1983-08-09) > 2026-07-20 → 43세 0ms
 ✓ 경계 8케이스 (생년월일 1983-08-09) > 2026-08-09 → 43세 (생일 당일) 0ms
 ✓ 경계 8케이스 (생년월일 1983-08-09) > 2027-02-07 → 43세 0ms
 ✓ 경계 8케이스 (생년월일 1983-08-09) > 2027-02-08 → 43세 0ms
 ✓ 경계 8케이스 (생년월일 1983-08-09) > 2027-02-09 → 44세 (다음 해 6개월 경계 도달) 0ms
 ✓ 윤년 2월 29일생 (2000-02-29) > 2026-08-27 → 26세 (비윤년 마지막생일 2/28 클램프, +6개월=8/28 도달 전) 0ms
 ✓ 윤년 2월 29일생 (2000-02-29) > 2026-08-28 → 27세 (2/28 + 6개월 = 8/28 도달, 올림) 0ms
 ✓ 월말일생 1991-08-31 > 2026-02-27 → 34세 (8/31 + 6개월 = 2/28 클램프 도달 전) 0ms
 ✓ 월말일생 1991-08-31 > 2026-02-28 → 35세 (2/28 클램프 경계 정확히 도달, 올림) 0ms
 ✓ 월말일생 1991-01-31 > 2026-07-30 → 35세 (1/31 + 6개월 = 7/31 도달 전) 0ms
 ✓ 월말일생 1991-01-31 > 2026-07-31 → 36세 (7/31 정확히 도달, 올림) 0ms
 ✓ 생일 당일 > 만나이가 방금 오른 생일 당일에는 보험나이 = 만나이 (6개월 미경과) 0ms
 ✓ 거부 케이스 > 미래 생년월일이면 null 0ms
 ✓ 거부 케이스 > 잘못된 포맷(슬래시)이면 null 0ms
 ✓ 거부 케이스 > 존재하지 않는 월(13월)이면 null 0ms
 ✓ 거부 케이스 > 존재하지 않는 일자(비윤년 2/30)이면 null 0ms
 ✓ 거부 케이스 > 숫자가 아닌 값이 섞이면 null 0ms
 ✓ manAge 값 검증 > 생일이 지난 경우 manAge = 연도차 0ms
 ✓ manAge 값 검증 > 생일이 지나지 않은 경우 manAge = 연도차 - 1 0ms

 Test Files  1 passed (1)
      Tests  22 passed (22)
```

### 2) 결함 재현 대조 (구 구현 vs 신 구현) — 팀장 직접 실행

임시 테스트로 **기존 근사식을 그대로 재현**하여 ANU 실측표와 대조했다(측정 후 임시파일 삭제 확인):

```
날짜         정확규칙  구현(구)  구현(신)  판정
2026-02-07     42       42        42       old=OK new=OK
2026-02-08     42       43        42       old=★불일치 new=OK
2026-02-09     43       43        43       old=OK new=OK
2026-07-20     43       43        43       old=OK new=OK
2026-08-09     43       43        43       old=OK new=OK
2027-02-07     43       43        43       old=OK new=OK
2027-02-08     43       44        43       old=★불일치 new=OK
2027-02-09     44       44        44       old=OK new=OK
```

→ **ANU 실측표와 100% 일치(MATCH)**. 불일치 지점 2건(2026-02-08, 2027-02-08)이 정확히 재현되었고,
신규 구현은 8/8 정확하다. 진단이 실제 결함과 부합함을 관측으로 확인.

### 3) 전체 회귀 — 테스트 카운트 전후

| 시점 | Test Files | Tests |
|---|---|---|
| 수정 전 (baseline) | 34 passed | 412 passed |
| 수정 후 (최종) | 35 passed | **434 passed** |
| 증감 | +1 (신규 테스트 파일) | +22 (신규 테스트) |

```
 Test Files  35 passed (35)
      Tests  434 passed (434)
   Duration  11.96s
```

**기존 412건 전부 유지 — 회귀 0건.** skip / xfail / assertion 완화 **0건**.

`npx tsc --noEmit` → exit 0, 에러 0건.

### 4) 벨레스(QA) 독립 적대적 검증 — G2 게이트

팀 테스터가 **구현 출력을 베끼지 않고 규칙으로 손계산한 기대값**으로 21개 적대적 케이스를
임시 테스트로 직접 실행 검증했다. **결함 0건.**

| 검증 축 | 케이스 | 결과 |
|---|---|---|
| 12월생 +6개월 연도 이월 | 1990-12-25생 / 2026-06-24→35, 06-25→36 | 일치 |
| 말일생 클램프 (3/31→9/30) | 1990-03-31생 / 2026-09-29→36, 09-30→37 | 일치 |
| 말일생 클램프 (5/31→11/30) | 1988-05-31생 / 2026-11-29→38, 11-30→39 | 일치 |
| 2/29생 × 윤년 기준일 | 2000-02-29생 / 2024-08-28→24, 08-29→25 | 일치 |
| 영아 5·6·7개월 경계 | 2026-01-20생 / 07-19→0, 07-20→1 | 일치 |
| 출생 당일 신생아 | 2026-07-20생 / 동일일자 → age 0 (null 아님) | 일치 |
| **시분초 혼입 Date** | `new Date(2026,1,8,23,59,59)` = `00:00:00` | 일치(무관) |
| 미래 생년월일 경계 | 기준일 +1일 → null | 일치 |
| 포맷/윤년 유효성 | `1990-2-01`·`19900101`·비윤년 `1990-02-29` → 모두 null | 일치 |

기존 테스트 파일 점검: `.skip` / `.todo` / `xfail` / `toBeCloseTo` 등 **완화 패턴 0건**,
전부 `toBe` / `toBeNull` 직접 단언. 검증 후 임시파일 삭제 및 작업트리 클린 확인.

### 5) 빌드 검증

```
✓ built in 13.11s
PWA v1.2.0  precache 172 entries (5868.93 KiB)
```
`dist/` 생성 타임스탬프: **2026-07-20 12:30**. 빌드 **성공**.

---

## L1 스모크테스트 결과

- **서버 재시작**: 성공 — `npx vite preview --port 4899` 로 프로덕션 빌드(dist) 기동
- **API 응답 확인**: `curl -s -o /dev/null -w "HTTP %{http_code}" http://localhost:4899/tools/calculators` → **HTTP 200**
- **스크린샷**: `/home/jay/workspace/memory/reports/assets/task-2807-l1.png` (431,651 bytes)

### 실브라우저 검증 (Playwright MCP)

`/tools/calculators` 는 **AuthGuard 로 `/login` 리다이렉트**되어, 자격증명 없이는 UI 입력 경로로
계산 버튼을 누를 수 없었다. 따라서 화면 클릭 대신 **실제 브라우저에서 프로덕션 번들 청크를
직접 import** 하여 배포 산출물의 동작을 검증했다 (테스트 러너가 아닌 실 런타임 검증):

```js
const mod = await import('/assets/Calculators-CzF6zxri.js');
mod.calcInsuranceAgeFrom('1983-08-09', new Date(2026,1,8))
```

브라우저 실행 결과:

```
2026-02-07 기대=42 실제=42 manAge=42 OK
2026-02-08 기대=42 실제=42 manAge=42 OK
2026-02-09 기대=43 실제=43 manAge=42 OK
2026-07-20 기대=43 실제=43 manAge=42 OK
2026-08-09 기대=43 실제=43 manAge=43 OK
2027-02-07 기대=43 실제=43 manAge=43 OK
2027-02-08 기대=43 실제=43 manAge=43 OK
2027-02-09 기대=44 실제=44 manAge=43 OK
미래 생년월일 → null / 잘못된 포맷 → null
```

- **콘솔 에러 0건** (경고 1건 — 기존 PWA 관련, 이번 변경과 무관)
- `export default Calculators` 유지 확인 → lazy 라우트(`/tools/calculators`) 로딩 정상
- **브라우저 정리**: `browser_close` 호출 완료. preview 서버 종료 확인(잔존 0).
  Chrome 프로세스는 MCP 서버 관리 인스턴스이며 CPU 1.6% 정상(좀비 렌더러 없음).

**L1 판정: 통과** — 실제 빌드 산출물이 실제 브라우저에서 8/8 정확한 값을 반환함을 관측.

**한계 명시**: 로그인 자격증명이 없어 UI 폼 입력→버튼 클릭 경로는 미검증. 다만 컴포넌트가
`calcInsuranceAgeFrom(birthDate, new Date())` 를 그대로 호출하는 3줄 어댑터이며(diff 참조),
번들 레벨에서 함수 동작이 검증되었으므로 잔여 위험은 낮다고 판단한다.

---

## 인지 검증 (직접 관측한 것만 기재)

| 주장 | 검증 수단 | 결과 |
|---|---|---|
| 근사식 제거됨 | `grep -rn "365\.25\|365\.2425\|exactYears"` | 0건 |
| Edit 실제 반영 | `grep -n "calcInsuranceAgeFrom\|clampYMD\|cmpYMD"` | 11건 매치 |
| 파일 비어있지 않음 | `wc -l` | Calculators.tsx 447줄 / 테스트 137줄 |
| 커밋 1개 | `git log --oneline -2` | `24f6c94` 1개 (부모 = `cc7476b`) |
| 커밋 파일 범위 | `git show --stat` | 2 files, +223 / -17 |
| 테스트 실제 통과 | 팀장이 `npx vitest run` 직접 재실행 | 434 passed |
| 진단이 실제 결함과 일치 | 구/신 대조표 실행 | ANU 실측 2건 불일치 재현 |
| 배포 산출물 동작 | 실브라우저 번들 import | 8/8 OK |

QC PASS 만으로 완료 판정하지 않았으며, 팀원 보고를 팀장이 전 항목 재실행하여 대조했다.

---

## MATCH / GAP

### MATCH
- ANU 실측 8케이스 전부 일치, 불일치 2건 재현 후 해소
- 근사식 완전 제거 (`Math.round(exactYears)` 잔존 0)
- 순수 함수 분리 + 기준일 주입 완료
- 만나이 계산 현행(mDiff/dDiff) 유지
- 커밋 정확히 1개, PR 0, main merge 0
- 허용 경로 외 수정 0 (`extension/**`·`server/**` 무변경)
- 회귀 0건 (412 → 434, 기존 전부 유지)

### GAP
- **UI 폼 클릭 경로 미검증** — 로그인 자격증명 부재(위 "한계 명시" 참조). 잔여 위험 낮음.
- **월말일 클램프는 정책 결정** — 표준 규격 문서가 아니라 실무 관행 + 결정론성을 근거로 팀장이
  확정한 것이다. 회장/아누가 오버플로 방식을 원하면 테스트 4건 수정으로 전환 가능하다.
  (해당 케이스는 8/31·2/29 생일자에 한해 연 1~2일 차이)

---

## 머지 판단

- **머지 필요**: **No (팀 자체 머지 금지)** — task 지시서 `merge_policy: "none"`, "PR 생성 0 · main merge 0"
- **브랜치**: `task/task-2807-calcage-dev6`
- **워크트리 경로**: `/home/jay/workspace/.worktrees/task-2807-dev6`
- **베이스**: `cc7476bfbd9f6b3ae3c99f9a7136cc9f94cd3ada` (= 당시 `origin/main`)
- **머지 의견**: 커밋 1개, 변경 파일 2개, 테스트 434 PASS, 빌드 성공, 타입 클린, 회귀 0건.
  변경 범위가 단일 페이지의 순수 함수로 국한되어 충돌 가능성 낮음. **머지 판단은 아누에게 위임**.
  Lv.2 G3 지시(PR 생성)는 task 지시서의 절대 제약(PR 0)이 우선한다고 판단하여 PR을 만들지 않았다.

## 완료 게이트 결과 (finish-task.sh)

```
qc_result: WARN (비차단 — tdd_check / duplicate_check / claude_md_check WARN)
impact_scanner : PASS      ci_preflight   : PASS (tsc exit=0, vitest exit=0)
l1_smoketest   : PASS      goal_assertions: PASS (npx vitest run)
unresolved_gate: PASS      scope-guard    : PASS (2 files in scope)
git_evidence   : PASS (커밋 1건, uncommitted 0, 변경 파일 2건)
merge-base     : PASS (base = origin/main 최신 cc7476bf)
merge block    : FINALIZE-ONLY 스킵 (merge_policy=none honored) — PR 0 · merge 0
G4 Gemini gate : PASS/soft     G3/Codex: Lv.2 이하 스킵
```

## 발견 이슈 및 해결

1. **주석에 `365.25` 문자열 잔존** — 구현에는 없으나 주석 설명문에 남아 근사식 grep 검사에 걸릴
   소지가 있었다. → 주석을 "일수 나눗셈 기반 연수 환산"으로 수정. grep 0건 확인 후 커밋.
2. **worktree 의존성 부재** — 신규 worktree에 `node_modules` 없음. → `npm install` 수행(코드 변경 아님).
   baseline·최종 측정 모두 동일 환경에서 수행하여 비교 유효성 확보.
3. **finish-task.sh 1차 실행 실패 (QC evidence root 오지정)** — `project_path` 인자 없이 호출하여
   QC evidence root 가 `/home/jay/workspace` 로 잡혔고, 커밋이 InsuRo worktree 에 있어
   `git_evidence` 가 `COMMIT_EXISTS 0건 + uncommitted 38건`으로 FAIL 했다.
   → worktree 경로를 `project_path` 로 전달하여 해결(git_evidence PASS).
   재시도 소진을 피하려고 `--gate` 없는 dry-run 으로 먼저 원인을 좁힌 뒤 재실행했다.
4. **finish-task.sh 2차 실행 GOAL-GATE TIMEOUT** — `goal_assertion_exec_isolated` 가 호출 cwd 에서
   명령을 실행하는데, cwd 가 `/home/jay/workspace`(package.json·vitest 없음)여서
   `npx vitest run` 이 네트워크 패키지 다운로드로 빠져 기본 30초 타임아웃(fail-closed)에 걸렸다.
   (같은 명령이 CI-PREFLIGHT 에서는 2초에 exit=0 — 이 차이가 cwd 가설을 지목했다.)
   → **worktree 를 cwd 로 두고** `GOAL_CMD_TIMEOUT=300` 으로 재실행 → exit=0, goal_assertions PASS.
   ⚠️ **후속 제안(아누 판단)**: JS 프로젝트 task 에서 goal_assertions 를 쓰면 동일 함정이 재현된다.
   `goal_assertion_exec_isolated` 가 `PROJECT_PATH` 로 cd 한 뒤 실행하도록 보정하면 근본 해소된다.
5. **잔존 아티팩트 알림** — 1차 실패 시 생성된
   `memory/events/task-2807.supervisor-crash-marker.json` (12:38) 은 위 3번 원인에 의한
   **오탐 잔존물**이다. 최종 실행은 exit=0 이며 `.done`(12:51)이 정상 생성되었다.
   증거 파일이므로 임의 삭제하지 않고 보고만 한다.
6. **그 외 미해결 이슈 없음.**

## 모델 사용 기록

| 팀원 | 역할 | 모델 | 비고 |
|---|---|---|---|
| 스바로그 | 백엔드/로직 구현 + 테스트 작성 | sonnet | 날짜 경계 로직 — haiku 부적합 |
| 벨레스 | 독립 QA 적대적 검증 | sonnet | 결함 탐색 — haiku 부적합 |
| 페룬(팀장) | 설계 결정(클램프 정책)·검증 재실행·통합·커밋 | opus | 직접 코딩 없음 |

라다(프론트)·모코시(UX/UI)는 이번 작업이 순수 계산 로직 수정이라 미소집.
디자인 작업 없음 → 디자인팀 호출 불필요.

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

