Skip to content

♻️ [Refactor/#40] 계산엔진점검#41

Open
jung32111 wants to merge 7 commits into
developfrom
refactor/계산엔진점검

Hidden character warning

The head ref may contain hidden characters: "refactor/\uacc4\uc0b0\uc5d4\uc9c4\uc810\uac80"
Open

♻️ [Refactor/#40] 계산엔진점검#41
jung32111 wants to merge 7 commits into
developfrom
refactor/계산엔진점검

Conversation

@jung32111

Copy link
Copy Markdown
Contributor

🚀 관련 이슈

📝 작업 내용

  • 골든 스냅샷 테스트 추가 — 5개 엔드포인트 대표 입력에 대한 현재 응답을 스냅샷으로 고정 (dcde592)
  • CSV 데이터 누락 수정 — 런타임 필수 데이터 파일 포함 및 기동 시점 존재 검증 추가, 없으면 명시적 에러로 즉시 실패 (9c8a9d1)
  • 금액 단위 원(₩)으로 통일 — API 응답의 모든 금액 필드를 원 단위로 통일. 계산 로직은 유지하고 직렬화 경계에서만 변환 (1cc3b67)
  • 고갈 표현 통일 — 자산 0 미만 클램프, depletion_age null=무고갈로 sentinel 제거, depleted boolean 필드 추가 (3855ebe)
  • timeline 필드명에 단위 명시 — 연 단위 필드명 구분(예: gapannual_gap) (8815486)
  • gender Enum화 — 허용값 외 입력 시 422 반환하도록 검증 추가 (8533b18)
  • 에러 응답 포맷 통일 — 전 엔드포인트 공통 에러 스키마 적용, Pydantic 422를 한국어 메시지로 래핑 (6bdf7ed)

✔️ 체크 리스트

  • Merge 하려는 브랜치가 올바른가? (main branch에 실수로 PR 생성 금지)
  • Merge 하려는 PR 및 Commit들을 로컬에서 실행했을 때 에러가 발생하지 않았는가?

📸 스크린샷 (선택)

💬 리뷰 요구사항(선택)

➕ 추후 계획(선택)

이후 API 계약 정리 작업(단위 통일, 고갈 표현 통일 등)에서 의도치 않은
필드 값 변경을 즉시 감지하기 위한 baseline. TestClient 사용을 위해
httpx를 requirements에 추가.
data/active_income_stats.csv(집계 통계, 개인 단위 레코드 없음)가 .gitignore의
data/ 규칙에 걸려 레포에 없었다. fresh clone 시 /api/employees/simulate가
첫 요청에서 500을 반환하는 문제가 있어, 이 파일만 예외 처리해 커밋한다.
개인 단위 원본 데이터 3종은 계속 제외한다.

추가로 앱 기동(lifespan) 시점에 필수 데이터 파일 존재를 검증해, 파일이
없으면 요청 시점 500 대신 기동 자체가 명확한 에러 메시지와 함께 실패하도록 한다.
/api/retirement/* 5개 요청·응답의 모든 금액 필드가 만원과 원을 혼용하고
있었다(예: employees/simulate는 원, retirement/*는 만원인데 필드명은 동일하게
monthly_pension). 프론트가 두 API를 이어 쓰면 10,000배 오차가 조용히 발생하는
문제였다.

내부 계산 로직(app/services/retirement_service.py)과 상수는 그대로 만원 단위를
쓰고, app/schemas/money.py의 WonAmountInput/WonAmountOutput 타입으로 Pydantic
스키마 경계에서만 원 <-> 만원 변환을 수행하도록 했다. 변환 지점을 한 곳에 모아
서비스 코드는 전혀 건드리지 않았다.

reduction_rules.threshold처럼 고정 상수와 비교되는 필드는 변환 여부에 따라
결과가 실제로 달라지므로(회귀 테스트로 검증: threshold와 정확히 같은 재취업
소득을 원 단위로 넣으면 감액이 0이어야 함), 이를 tests/test_money_unit_consistency.py에
회귀 테스트로 먼저 추가한 뒤 고쳤다.

골든 스냅샷은 동일한 실제 시나리오(예: "월 생활비 250만원")를 유지한 채
입력 리터럴에 10,000을 곱해 새 계약에 맞게 갱신했다.
두 가지 문제를 하나의 이슈로 묶어 처리:

1. timeline의 asset이 고갈 이후에도 매년 음수로 계속 하강했다(100세까지
   최대 -15억원 수준). Recharts에 그대로 꽂으면 축 스케일이 깨져 프론트가
   반드시 잘라내는 전처리를 해야 했다. 고갈 이후 asset을 0으로 클램프한다.

2. scenarios의 depletion_age가 무고갈일 때 MAX_AGE(100)를 sentinel로 써서,
   실제로 100세에 고갈되는 경우와 구분이 불가능했다(diagnosis 등 다른
   엔드포인트는 무고갈 시 null). ScenarioOutcome.depletion_age도 null로
   통일하고, _select_best_scenario의 정렬 기준만 별도로 무고갈을 "가장 늦게
   고갈"로 취급하도록 수정했다(API 응답 값 자체는 null 유지).

추가로 depletion_age is not None을 매번 계산하지 않아도 되도록 depleted: bool
필드를 모든 진단류 응답에 추가했다.

timeline 각 시점에 고갈 여부 플래그를 추가하는 안은 검토했으나, depletion_age가
이미 응답에 있어 프론트가 point.age >= depletion_age로 바로 판단할 수 있으므로
중복 정보라 추가하지 않았다.

회귀 테스트(tests/test_depletion_representation.py)를 먼저 추가해 수정 전
실패를 확인한 뒤 구현했다.
TimelinePoint.income/expense/gap/cumulative_gap이 SimulationResult.monthly_gap
등 월 단위 필드와 이름이 겹쳐(둘 다 "gap"), 프론트가 연/월 어느 쪽인지
필드명만으로 구분할 수 없었다. annual_income/annual_expense/annual_gap/
cumulative_annual_gap으로 이름을 바꿔 연 단위임을 명시했다. 값 자체는
변경하지 않았다(골든 스냅샷으로 rename 전후 값이 동일함을 확인).
gender: str에 제약이 없어 "MALE"(대문자 오타) 같은 값이 get_target_age()의
if gender == "male": ... else: FEMALE 분기를 조용히 타고 여성 목표연령(88세)으로
처리됐다(HTTP 200, 프론트는 오류를 알 방법이 없음).

Gender(str, Enum)로 "male"/"female" 외 값을 422로 거부하도록 했다.
전수 조사 결과 이 "검증 없는 문자열 + 매칭 실패 시 조용한 기본값" 패턴은
gender 하나뿐이었다(employees.py의 current_band/retire_band는 서버가 계산해
내보내는 출력 필드라 사용자 입력 검증 대상이 아님).

회귀 테스트(tests/test_gender_validation.py)를 먼저 추가해 대문자/오타/빈
문자열 입력이 현재 200으로 통과함을 확인한 뒤 고쳤다.
에러가 상황마다 세 가지 다른 모양으로 나가고 있었다: Pydantic 422는
{"detail": [...]} 배열(영문 메시지), 라우터의 HTTPException(400)은
{"detail": "문자열"}, model_validator의 ValueError는 422이지만 메시지 앞에
"Value error, "가 붙었다. 프론트가 error.detail을 그대로 쓸 수 없었다.

app/schemas/errors.py에 {code, message, details} 단일 스키마를 두고,
app/error_handlers.py의 RequestValidationError/HTTPException 핸들러가
모든 에러를 이 형태로 변환하도록 했다. code는 프론트 분기용
(VALIDATION_ERROR/INVALID_INPUT/INTERNAL_ERROR 등), details는 필드별
원인(Pydantic 에러 타입을 한국어로 번역), message는 그대로 사용자에게
보여줄 수 있는 한국어 문장이다. 라우터 코드는 건드리지 않았다(기존처럼
ValueError -> HTTPException(400)만 던지면 핸들러가 알아서 감싼다).

회귀 테스트(tests/test_error_format.py)를 먼저 추가해 세 가지 에러
경로(타입 에러/비즈니스 룰/model_validator) 전부와 5개 엔드포인트에서
현재 포맷이 제각각임을 확인한 뒤 고쳤다.
@jung32111 jung32111 linked an issue Jul 25, 2026 that may be closed by this pull request
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

♻️ [REFACTOR] 계산 엔진 점검

1 participant