docs: 모듈 분리 리팩토링 계획서 추가

2026-04-08 v1 QT-POS 동기화 작업을 통해 드러난 근본 문제 (pharmon-web
과 stats-api 간 통계 로직 중복) 를 해결하기 위한 3단계 리팩토링 로드맵.

주요 내용:
- 현재 구조의 7가지 문제점 (P1~P7) 분석
- 4레이어 목표 아키텍처 (db → core → queries → api)
- pharma-stats-core 공통 패키지 분리 전략 (옵션 A/B/C 비교)
- 1단계: pharmacy-stats-api 내부 정돈 (pharmon-web 손대지 않음)
- 2단계: pharma-stats-core 분리 + pharmon-web 통합
- 3단계: v2 (PMPLUS20) 재동기화 + 선택적 독립 repo
- 리스크 & 완화 방안 (한글 경로 editable install 이슈 등)
- 이번 마이그레이션에서 학습한 설계 근거 (GPPOS 7항 공식,
  CD_SUNAB 1:N 함정, 보훈 세분화 문자열 키 등)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
청춘약국
2026-04-08 20:58:43 +09:00
parent 8abc67a0af
commit e674c775b5

263
docs/REFACTOR_PLAN.md Normal file
View File

@@ -0,0 +1,263 @@
# pharmacy-stats-api 모듈화 리팩토링 계획서
작성일: 2026-04-08
작성 시점 배경: v1_pharmit3000.py 를 QT-POS sales_stats_dialog.py 와 1:1 동기화 완료 직후.
이번 마이그레이션을 통해 "두 프로젝트 간 통계 로직 중복" 이라는 근본 문제가 드러났고, 다음 동기화 때는 수동 포팅이 반복되지 않도록 구조를 재설계한다.
---
## 1. 현재 구조와 문제점
### 1.1 현재 레이아웃
```
pharmacy-stats-api/
├── app.py 322 lines ← Flask 라우트 + 라벨 dict + 유틸
├── config.py 22 lines ← DB 커넥션 설정
├── queries/
│ ├── v1_pharmit3000.py 368 lines ← 커넥션/캐시/쿼리/집계/유틸 전부
│ └── v2_pmplus20.py ~250 lines ← v1 구버전 복제 (미동기화)
└── templates/stats.html
```
그리고 외부 의존 중복:
```
pharmon-web/
└── sales_stats_dialog.py (_GUBUN_LABEL, _build_margin_cache,
_query, _calc_age, _empty_row, _add …)
```
### 1.2 구체적 문제
| # | 문제 | 증상 |
|---|------|------|
| **P1** | **로직 중복** — pharmon-web `sales_stats_dialog.py` 의 쿼리/공식/캐시 빌더가 `queries/v1_pharmit3000.py` 에 통째로 복제됨 | QT-POS 에서 버그 고치면 stats-api 도 수동 포팅 필요 (이번 CD_SUNAB 이중계산 버그가 정확히 이 케이스) |
| **P2** | **라벨 dict 3중 중복**`_GUBUN_LABEL` (pharmon-web) / `GUBUN_LABEL` (app.py) / 프론트 js | 보훈 세분화(`4_1~4_4`) 추가 때 한 군데 놓침 → 날 UI 노출 |
| **P3** | **단일 파일 비대화**`v1_pharmit3000.py` 368줄에 커넥션, 캐시, SQL, 집계, 유틸이 섞임 | 테스트 단위 분리 불가, 변경 시 diff 가독성 낮음 |
| **P4** | **엔드포인트 6개 × 2버전 중복**`app.py` 의 v1/v2 라우트가 거의 동일 (day_from/to 파싱, 에러처리, label 붙이기) | 라우트 추가 시 12곳 수정 |
| **P5** | **v2 (PMPLUS20) 미동기화** — v1 은 2026-04-08 재작성했지만 v2 는 구버전 로직 그대로 | v1/v2 비교 API 가 사실상 무의미 |
| **P6** | **테스트 없음** — GPPOS 공식 (`sales_amt`, `ins_drug`, `claim_amt`), `_calc_age` 등이 regression 없이 수동 검증에만 의존 | 다음 리팩토링 때 수치 틀어져도 못 잡음 |
| **P7** | **DB 연결 구문 하드코드**`v1_*` / `v2_*` 각자 `get_connection()` 작성 | 드라이버 변경 시 양쪽 수정 |
---
## 2. 목표 아키텍처
### 2.1 설계 원칙
1. **Single Source of Truth**: 통계 공식·라벨·나이계산 같은 "도메인 로직" 은 한 곳에서만 정의하고, pharmon-web(PyQt) 과 pharmacy-stats-api(Flask) 가 동일 모듈을 import.
2. **Version 경계를 버전 모듈 안으로 가둔다**: v1(PharmIT3000) / v2(PMPLUS20) 차이는 "SQL 과 컬럼 매핑" 뿐 — 공통 집계/공식 로직은 버전 무관.
3. **레이어 분리**: `db``core``queries``api` 의 단방향 의존. 상위 레이어만 하위를 import.
4. **과도한 추상화 금지**: 당장 중복 제거에 필요한 만큼만 쪼갠다. ORM 이나 Repository 패턴은 도입하지 않는다 (MSSQL 읽기 전용, 쿼리 복잡도 낮음).
### 2.2 목표 레이아웃
```
pharmacy-stats-api/
├── app.py ← create_app() factory + main. 라우트 직접 정의 안함
├── config.py ← PHARMIT3000_CONFIG, PMPLUS20_CONFIG
├── requirements.txt
├── docs/
│ ├── REFACTOR_PLAN.md (이 문서)
│ ├── PMPLUS20_MIGRATION_GUIDE.md
│ └── MODULE_BOUNDARY.md (레이어 경계 설명, 2단계 때 작성)
├── db/ ← 레이어 1. 순수 DB 커넥션
│ ├── __init__.py
│ └── connection.py ← get_connection(cfg), query(conn_cfg, sql, params)
├── core/ ← 레이어 2. 버전 독립 도메인 로직 (★재사용 핵심★)
│ ├── __init__.py
│ ├── labels.py ← GUBUN_LABEL, TIME_LABEL, PAY_LABEL, AGE_LABEL
│ ├── formula.py ← GPPOS 공식: sales_amt, ins_drug, ins_prep, claim_amt
│ ├── aggregator.py ← empty_row(), add_row(), StatsAccumulator 클래스
│ ├── margin.py ← build_margin_cache() — 쿼리 주입형 (Callable[[sql,params],rows])
│ ├── age.py ← calc_age(panum, ref_date)
│ └── classifier.py ← classify_gubun(), classify_time(), classify_pay()
├── queries/ ← 레이어 3. 버전별 SQL + core 조합
│ ├── __init__.py
│ ├── v1_pharmit3000.py ← PS_MAIN SQL + get_sales_stats() (core 호출)
│ └── v2_pmplus20.py ← TBSID040_03 SQL + get_sales_stats() (core 호출)
├── api/ ← 레이어 4. Flask Blueprint
│ ├── __init__.py
│ ├── common.py ← _default_from/to, _parse_date, 공통 응답 래퍼
│ ├── v1.py ← Blueprint("v1", url_prefix="/v1")
│ ├── v2.py ← Blueprint("v2", url_prefix="/v2")
│ └── compare.py ← Blueprint("compare", url_prefix="/api")
├── templates/
│ └── stats.html
└── tests/ ← 레이어 2 로직 regression 테스트
├── test_formula.py ← sales_amt/ins_drug 공식 (QT-POS 스크린샷 고정값)
├── test_age.py ← 1900/2000 세기, 윤년
├── test_labels.py ← 보훈 4_1~4_4 매핑
└── test_margin.py ← last_cost > user_price*5 경계
```
### 2.3 의존 그래프
```
api/* ──┐
queries/v1, v2
core/* ──┐
db/*
config.py
```
`core/` 는 DB 를 직접 호출하지 않고, `margin.build_margin_cache(rows, query_fn)` 처럼 콜러블을 주입받는다. → **core 는 pharmon-web 에서도 그대로 import 가능** (핵심 재사용 포인트).
---
## 3. pharmon-web 과의 공유 전략
### 3.1 3가지 옵션 비교
| 옵션 | 방법 | 장점 | 단점 | 권장 |
|------|------|------|------|------|
| A. 복붙 유지 | 지금처럼 수동 동기화 | 0 작업 | 또 틀어짐. 매번 이번 같은 사고 | × |
| B. **로컬 경로 import** | `pharma-stats-core` 를 별도 폴더로 두고 양쪽에서 `sys.path` 또는 editable install (`pip install -e ../pharma-stats-core`) | 저비용, 즉시 동기화, PyInstaller 에서도 hidden-import 처리만 추가하면 됨 | 배포 환경에서 경로 신경써야 함 | **★ 2단계에서 채택** |
| C. 독립 패키지 + git submodule | `pharma-stats-core` 독립 repo, 둘 다 submodule 로 포함 | 버전 고정 가능, 장기적으로 깔끔 | 초기 설정 비용, PyInstaller 빌드 스크립트 수정 | 3단계 (선택적) |
### 3.2 최종 그림 (옵션 B 채택 시)
```
c:\Users\청춘약국\source\
├── pharma-stats-core\ ← 신규 (core/ 를 여기로 이전)
│ ├── pyproject.toml (editable install 용)
│ └── pharma_stats_core/
│ ├── __init__.py
│ ├── labels.py
│ ├── formula.py
│ ├── aggregator.py
│ ├── margin.py
│ ├── age.py
│ └── classifier.py
├── pharmacy-stats-api\
│ └── queries/v1_pharmit3000.py ← `from pharma_stats_core import ...`
└── pharmon-web\
└── sales_stats_dialog.py ← `from pharma_stats_core import ...`
```
**pharmon-web PyInstaller 영향**:
- `pos_gui.spec``hiddenimports=['pharma_stats_core.formula', ...]` 추가
- `datas=[('../pharma-stats-core/pharma_stats_core', 'pharma_stats_core')]` 로 번들 포함
- 또는 editable install 후 PyInstaller 가 정상 탐색하도록 `--collect-all pharma_stats_core`
---
## 4. 마이그레이션 로드맵 (3단계)
### ▶ 1단계: pharmacy-stats-api 내부 정리 (독립 진행, pharmon-web 손대지 않음)
**목표**: P2, P3, P4, P7 해결. pharmon-web 과의 중복(P1)은 아직 손대지 않음.
| 순서 | 작업 | 영향 파일 | 검증 |
|------|------|-----------|------|
| 1-1 | `db/connection.py` 생성, `get_connection(cfg)` 하나로 통합 | 신규 | 스모크: v1/api/stats 기존과 동일 응답 |
| 1-2 | `core/labels.py` 로 GUBUN_LABEL 이전, app.py 에서 import | app.py, core/labels.py | `/v1/api/stats/insurance` 라벨 동일 |
| 1-3 | `core/age.py`, `core/aggregator.py` 추출 (_calc_age, _empty_row, _add) | v1_pharmit3000.py 에서 제거 | 수치 동일 |
| 1-4 | `core/formula.py` 에 GPPOS 공식 함수로 추출 — `calc_sales_amt(row)`, `calc_ins_drug(row)` 등 | v1_pharmit3000.py | 기존 응답 diff 0 |
| 1-5 | `core/margin.py``build_margin_cache(rows, query_fn)` 추출 (DB 주입형) | v1_pharmit3000.py | 1/3 스크린샷 수치 재검증 |
| 1-6 | `core/classifier.py` 에 gubun/time/pay 분류 로직 추출 | v1_pharmit3000.py | 보험별/시간별/결제별 모두 동일 |
| 1-7 | `api/v1.py`, `api/v2.py`, `api/compare.py` Blueprint 로 분리. app.py 는 factory 만 | app.py, api/*.py | 모든 엔드포인트 동일 응답 |
| 1-8 | `api/common.py``_default_from/to`, `stats_response(result, version)` 헬퍼 | v1.py/v2.py 중복 제거 | 회귀 없음 |
| 1-9 | `tests/test_formula.py` 추가 — QT-POS 1/3 스크린샷 수치를 고정값 assert | tests/ | `pytest tests/` 통과 |
**1단계 완료 조건**:
- `v1_pharmit3000.py` 가 < 150 줄 (현재 368 줄)
- `app.py` 가 < 50 줄
- 모든 엔드포인트 응답이 리팩토링 전과 1:1 동일 (기준: 2026-04-08 기준 1/3~4/8 응답)
- pytest green
**1단계에서 하지 않는 것**:
- pharmon-web 수정 — 아직 `core/` 는 stats-api 내부에 머무름
- v2 로직 재작성 — 구조만 맞추고 내용은 유지
---
### ▶ 2단계: pharma-stats-core 분리 (pharmon-web 통합)
**목표**: P1 해결. 양쪽이 같은 코어를 import.
| 순서 | 작업 | 검증 |
|------|------|------|
| 2-1 | `c:\Users\청춘약국\source\pharma-stats-core\` 신규 폴더, `pharma_stats_core/` 패키지 + `pyproject.toml` | `pip install -e .` 성공 |
| 2-2 | `pharmacy-stats-api/core/*``pharma_stats_core/*` 로 이동, stats-api 에서 editable install | stats-api 전 엔드포인트 회귀 0 |
| 2-3 | pharmon-web `sales_stats_dialog.py``_GUBUN_LABEL`, `_calc_age`, `_empty_row`, `_add`, `_build_margin_cache`, GPPOS 공식을 `pharma_stats_core` import 로 교체 | QT-POS 통계 다이얼로그 기존 스크린샷과 1:1 동일 |
| 2-4 | pharmon-web PyInstaller spec 업데이트 (`hiddenimports` / `--collect-all`) | 번들 exe 에서 통계 정상 동작 |
| 2-5 | `pharma-stats-core``tests/` 이관, CI 없이 로컬 pytest 만 | green |
| 2-6 | `docs/MODULE_BOUNDARY.md` 작성 — "core 는 Qt/Flask 둘 다 의존 금지" 원칙 문서화 | 문서 리뷰 |
**2단계 완료 조건**:
- pharmon-web 에서 `grep "GUBUN_LABEL\|_calc_age\|_build_margin_cache"``sales_stats_dialog.py` 에 없음 (전부 core 경유)
- stats-api v1/v2 응답 회귀 0
- QT-POS 통계 다이얼로그 회귀 0 (스크린샷 수치 동일)
- PyInstaller 빌드 성공 + 런타임 정상
---
### ▶ 3단계: v2 (PMPLUS20) 재동기화 + 선택적 확장
**목표**: P5, P6 해결.
| 순서 | 작업 |
|------|------|
| 3-1 | `queries/v2_pmplus20.py` 를 v1 과 동일 패턴으로 재작성. SQL 만 v2 테이블(`TBSID040_03`) 로 바뀌고, 집계/공식은 `pharma_stats_core` 그대로 사용 |
| 3-2 | v2 의 테이블/컬럼 매핑 차이 (`MPRE_TYPE``PreGubun`, `DRUG_SEQ``PreSerial` 등) 를 `queries/v2_pmplus20.py` 상단에서 row 딕셔너리 normalize 로 흡수 → core 에는 "v1 스키마" 하나만 존재 |
| 3-3 | `/api/compare` 가 실제로 v1/v2 매출 비교 의미있는 수치 반환 |
| 3-4 | (선택) `pharma-stats-core` 를 git submodule 또는 별도 repo 로 승격 — 옵션 C 전환 |
---
## 5. 리스크 & 완화
| 리스크 | 영향 | 완화 |
|--------|------|------|
| 1단계 리팩토링 중 수치 틀어짐 | 매출 통계 오차 | 각 단계마다 1/3 스크린샷 기준값을 `tests/test_formula.py` 에 고정 후 비교 |
| pharmon-web PyInstaller 에서 core 못 찾음 | QT-POS 통계 다이얼로그 에러 | 2-4 단계에서 dev 빌드로 먼저 검증, hidden import 리스트는 ANALYSIS_02_libraries.md 패턴 참고 |
| v2 포팅이 미뤄져 compare API 망가짐 | 비교 기능 부정확 | 3단계 전까지 compare 엔드포인트에 "v2 is legacy, may differ" 경고 플래그 붙여둠 |
| 1단계에서 너무 많이 쪼개서 과설계 | 읽기 어려워짐 | core 파일당 < 100줄 목표, 기능별로 1파일 1책임. 억지로 클래스화 하지 않음 |
| editable install 이 한글 경로 문제 일으킴 | pip install -e 실패 | 실패 시 `sys.path.append('../pharma-stats-core')` fallback (pharmon-web 에 이미 패턴 있음) |
---
## 6. 이번 마이그레이션에서 배운 것 (설계 근거)
2026-04-08 작업을 통해 확인한 사실:
1. **QT-POS 가 Source of Truth**: 통계 로직은 pharmon-web 에서 먼저 수정되고, stats-api 는 후행 동기화. → core 는 pharmon-web 의 인터페이스를 기준으로 설계한다.
2. **GPPOS 공식은 절대 단순화 금지**: `sales_amt = PRICE_C+PRICE_P+S_FASTMON+S_TEMP3+SE_PRICE_C+SE_PRICE_P+SE_BOHUN_C` — 7항 가산 공식. 중간에 하나라도 빠지면 1원 단위 오차 발생. → `core/formula.py` 는 docstring 에 GPPOS2 델파이 레거시 근거 명시.
3. **CD_SUNAB 1:N 함정**: LEFT JOIN 하면 행 중복 → 금액 이중계산. 반드시 `OUTER APPLY TOP 1` 패턴. → core 가 아닌 queries/v1 쪽 SQL 에 고정.
4. **보훈 세분화 키는 문자열 `'4_1'`**: 숫자 아님. dict 키. → `core/labels.py` + `core/classifier.py` 둘 다에서 문자열로만 처리.
5. **라벨 누락은 UI 에 raw 코드 노출**: dict.get(code, code) fallback 이 역설적으로 버그 은폐. → 2-2 단계에서 라벨 통합 후, 미매핑 코드는 `?` 대신 로그 경고 남기는 것도 검토.
---
## 7. 의사결정 필요 항목
다음 사항은 1단계 시작 전에 사용자 확답 필요:
- [ ] **1단계를 언제 시작할지** — 지금 바로? 아니면 QT-POS 추가 기능 작업 후?
- [ ] **editable install vs sys.path** — pharmon-web 한글 경로(`청춘약국`) 에서 `pip install -e` 검증 필요. 실패 시 sys.path 경로 주입 방식으로 폴백.
- [ ] **테스트 기준값 스냅샷** — 2026-04-08 1/3 데이터를 "황금 데이터" 로 고정할지, 아니면 매 분기 갱신할지.
- [ ] **v2 폐기 여부** — PMPLUS20 마이그레이션이 현재 진행 중이 아니라면 v2 모듈을 stub 으로 두는 것도 옵션.
---
## 8. 참고
- pharmon-web 분석 문서: `pharmon-web/docs/ANALYSIS_03_database.md` (MSSQL 스키마)
- pharmon-web 분석 문서: `pharmon-web/docs/ANALYSIS_02_libraries.md` (PyInstaller hidden imports)
- 이번 v1 동기화 변경점: `queries/v1_pharmit3000.py` 상단 docstring 1~32줄
- GPPOS 델파이 원본 참고: `Unit_Statistics.pas` (pharmon-web 레거시)