docs(refactor): PortfolioTab 분해 설계·계획

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
This commit is contained in:
2026-07-10 10:08:39 +09:00
parent c1da59574f
commit afc28c64cf
2 changed files with 383 additions and 0 deletions

View File

@@ -0,0 +1,288 @@
# PortfolioTab.jsx 분해 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `PortfolioTab.jsx`(671줄)를 `stock/components/portfolio/` 하위 표현 컴포넌트 7개 + `portfolioUtils`로 **동작 보존 분해**한다.
**Architecture:** 인라인 JSX 블록을 컴포넌트로 리프트(가드는 컴포넌트 내부 early-return으로 이동, `pf`/`asset`/핸들러를 props로 그대로 전달). `formatPriceTime``portfolioUtils.js`. `PortfolioTab.jsx`는 조합 shell(~70줄)로 축소. 각 Task는 build+전체 테스트 green으로 동작 보존 확인. 로직/JSX/스타일 불변.
**Tech Stack:** React 18 + Vite, recharts, Vitest + @testing-library/react.
## Global Constraints
- **Behavior-preserving 순수 추출**: 리프트하는 JSX 본문은 verbatim(참조하는 `pf.*`/`asset.*`은 이제 props). 래핑 가드(`pf.addFormOpen &&`, `pf.portfolioHoldings.length > 0 &&` 등)는 컴포넌트 내부 `if (!cond) return null;`로 이동. `pf`(훅 객체 전체)를 각 하위에 그대로 전달 → prop shape·동작 불변. 스타일/클래스명 불변.
- 경로: `portfolio/*.jsx` → stockUtils `'../../stockUtils'`, Loading `'../../../../components/Loading'`, portfolioUtils `'./portfolioUtils'`; `HoldingRow` → PriceSessionBadge `'./PriceSessionBadge'`; `PortfolioTab.jsx` → 하위 `'./portfolio/*'` (기존 `'../stockUtils'`·`'../../../components/Loading'`는 shell이 쓰는 것만 유지).
- 각 파일은 실제 사용하는 심볼만 import(미사용 금지 — lint 검출). 빌드가 누락 검출.
- 각 Task 완료 조건: `npm run build` 성공 + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러.
- 테스트: Vitest `import { describe, it, expect } from 'vitest'`, jest-dom 전역(테스트 파일 import 불필요), `*.test.js(x)` 동일 디렉토리.
- 커밋은 web-ui, 변경 파일만 `git add`. 커밋 trailer:
```
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzpsZQxKG9hQmNRfZjRS
```
---
### Task 1: `portfolioUtils.js` + `PriceSessionBadge` 추출 + 테스트
**Files:**
- Create: `src/pages/stock/components/portfolio/portfolioUtils.js`
- Create: `src/pages/stock/components/portfolio/portfolioUtils.test.js`
- Create: `src/pages/stock/components/portfolio/PriceSessionBadge.jsx`
- Create: `src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx`
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
**Interfaces:**
- Produces: `formatPriceTime(iso)` (portfolioUtils); `PriceSessionBadge({ session, asOf })` (default export).
- [ ] **Step 1: Write failing tests** — Create `portfolioUtils.test.js`:
```js
import { describe, it, expect } from 'vitest';
import { formatPriceTime } from './portfolioUtils.js';
describe('formatPriceTime', () => {
it('유효 ISO → HH:MM (제로패드)', () => {
expect(formatPriceTime('2026-07-10T09:05:00')).toBe('09:05');
expect(formatPriceTime('2026-07-10T18:30:00')).toBe('18:30');
});
it('falsy/invalid → 빈 문자열', () => {
expect(formatPriceTime('')).toBe('');
expect(formatPriceTime(null)).toBe('');
expect(formatPriceTime('not-a-date')).toBe('');
});
});
```
Create `PriceSessionBadge.test.jsx`:
```jsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import PriceSessionBadge from './PriceSessionBadge.jsx';
describe('PriceSessionBadge', () => {
it('NXT_AFTER → "NXT", NXT_PRE → "NXT 프리"', () => {
const { unmount } = render(<PriceSessionBadge session="NXT_AFTER" />);
expect(screen.getByText('NXT')).toBeInTheDocument();
unmount();
render(<PriceSessionBadge session="NXT_PRE" />);
expect(screen.getByText('NXT 프리')).toBeInTheDocument();
});
it('그 외 세션 → 렌더 없음', () => {
const { container } = render(<PriceSessionBadge session="REGULAR" />);
expect(container).toBeEmptyDOMElement();
});
});
```
- [ ] **Step 2: Run to verify fail**
Run: `npm run test:run -- src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx`
Expected: FAIL — unresolved imports.
- [ ] **Step 3: Create files** — `portfolioUtils.js` (formatPriceTime을 현 PortfolioTab.jsx:9-14에서 verbatim):
```js
export const formatPriceTime = (iso) => {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
};
```
`PriceSessionBadge.jsx` (현 PortfolioTab.jsx:16-27 본문 verbatim + import/export):
```jsx
import React from 'react';
import { formatPriceTime } from './portfolioUtils';
const PriceSessionBadge = ({ session, asOf }) => {
if (session !== 'NXT_AFTER' && session !== 'NXT_PRE') return null;
const isPre = session === 'NXT_PRE';
const label = isPre ? 'NXT 프리' : 'NXT';
const desc = isPre ? 'NXT 프리마켓 거래가' : 'NXT 야간거래 (15:30~20:00)';
const time = formatPriceTime(asOf);
return (
<span className="pf-nxt-badge" title={time ? `${desc} · ${time}` : desc}>
{label}
</span>
);
};
export default PriceSessionBadge;
```
- [ ] **Step 4: Wire PortfolioTab.jsx** — `formatPriceTime`(9-14)와 `PriceSessionBadge`(16-27) 정의 삭제, import 추가:
```js
import PriceSessionBadge from './portfolio/PriceSessionBadge';
```
(PortfolioTab 내부 보유행에서 `<PriceSessionBadge .../>` 사용은 그대로 — import한 것을 씀.)
- [ ] **Step 5: Run tests + build**
Run: `npm run test:run -- src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx` → PASS.
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규/수정 0 에러.
- [ ] **Step 6: Commit**
```bash
git add src/pages/stock/components/portfolio/portfolioUtils.js src/pages/stock/components/portfolio/portfolioUtils.test.js src/pages/stock/components/portfolio/PriceSessionBadge.jsx src/pages/stock/components/portfolio/PriceSessionBadge.test.jsx src/pages/stock/components/PortfolioTab.jsx
git commit -m "refactor(portfolio): formatPriceTime util + PriceSessionBadge 추출 + 테스트"
```
---
### Task 2: 관리 section 3 컴포넌트 추출 (AddHoldingForm / PortfolioSummary / AssetHistoryChart)
**Files:**
- Create: `src/pages/stock/components/portfolio/AddHoldingForm.jsx`
- Create: `src/pages/stock/components/portfolio/PortfolioSummary.jsx`
- Create: `src/pages/stock/components/portfolio/AssetHistoryChart.jsx`
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
**Interfaces:**
- Produces: `AddHoldingForm({ pf })`, `PortfolioSummary({ pf })`, `AssetHistoryChart({ asset, pf, handleSaveSnapshot })` (default exports).
- [ ] **Step 1: Create the three components (JSX 본문 verbatim, 가드는 내부 early-return)**
`AddHoldingForm.jsx`:
```jsx
import React from 'react';
```
컴포넌트: `const AddHoldingForm = ({ pf }) => { if (!pf.addFormOpen) return null; return ( <form className="pf-add-form" ... > ... </form> ); };` — `return` 내부의 `<form>...</form>`은 현 PortfolioTab.jsx:67-152의 `<form>` JSX를 verbatim(내부의 `pf.addForm`/`pf.setAddForm`/`pf.handleAddSubmit`/`pf.addLoading`/`pf.addError` 참조 그대로). `export default AddHoldingForm;`.
`PortfolioSummary.jsx`:
```jsx
import React from 'react';
import { formatNumber, formatPercent, toNumeric, profitColorClass, numFitClass } from '../../stockUtils';
```
컴포넌트: `const PortfolioSummary = ({ pf }) => { if (!(pf.portfolioHoldings.length > 0)) return null; return ( <div className="pf-total-summary"> ... </div> ); };` — 내부는 현 PortfolioTab.jsx:157-195의 `<div className="pf-total-summary">...</div>` JSX verbatim(`pf.portfolioSummary`/`pf.totalCash`/`pf.totalAssets` 참조 그대로). `export default PortfolioSummary;`.
`AssetHistoryChart.jsx`:
```jsx
import React from 'react';
import Loading from '../../../../components/Loading';
import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip as ChartTooltip } from 'recharts';
```
컴포넌트: `const AssetHistoryChart = ({ asset, pf, handleSaveSnapshot }) => ( <div className="pf-asset-history"> ... </div> );` — 내부는 현 PortfolioTab.jsx:199-281의 `<div className="pf-asset-history">...</div>` JSX verbatim(`asset.assetHistory*`/`asset.setAssetHistoryDays`/`asset.snapshotSaving`/`handleSaveSnapshot`/`pf.totalAssets` 참조 그대로). `export default AssetHistoryChart;`.
> 각 파일은 위 import만(실제 사용 심볼). AddHoldingForm은 stockUtils/Loading 불필요(폼만).
- [ ] **Step 2: Wire PortfolioTab.jsx** — 관리 `<section>` 내부에서 위 3개 인라인 블록을 컴포넌트 호출로 교체:
- `{pf.addFormOpen && (<form ...>...</form>)}` → `<AddHoldingForm pf={pf} />`
- `{pf.portfolioHoldings.length > 0 && (<div className="pf-total-summary">...</div>)}` → `<PortfolioSummary pf={pf} />`
- `<div className="pf-asset-history">...</div>` → `<AssetHistoryChart asset={asset} pf={pf} handleSaveSnapshot={handleSaveSnapshot} />`
import 추가:
```js
import AddHoldingForm from './portfolio/AddHoldingForm';
import PortfolioSummary from './portfolio/PortfolioSummary';
import AssetHistoryChart from './portfolio/AssetHistoryChart';
```
PortfolioTab.jsx 상단의 이제-미사용 import 정리: recharts(`ResponsiveContainer/AreaChart/Area/XAxis/YAxis/Tooltip`)가 AssetHistoryChart로 이동했으니 shell에서 제거. `Loading`은 shell 헤더가 계속 사용(`pf.portfolioLoading`) → 유지. stockUtils 심볼 중 shell(브로커 섹션 등 아직 인라인)이 계속 쓰는 것은 유지. (미사용 import 0 되도록 — 빌드/lint 확인.)
- [ ] **Step 3: Run tests + build + lint**
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규 3파일 + PortfolioTab.jsx 0 에러.
- [ ] **Step 4: Commit**
```bash
git add src/pages/stock/components/portfolio/AddHoldingForm.jsx src/pages/stock/components/portfolio/PortfolioSummary.jsx src/pages/stock/components/portfolio/AssetHistoryChart.jsx src/pages/stock/components/PortfolioTab.jsx
git commit -m "refactor(portfolio): 추가폼/총자산요약/자산추이차트 컴포넌트 추출"
```
---
### Task 3: `CashPanel` 추출 (예수금 패널)
**Files:**
- Create: `src/pages/stock/components/portfolio/CashPanel.jsx`
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
**Interfaces:**
- Produces: `CashPanel({ pf })` (default export).
- [ ] **Step 1: Create CashPanel.jsx**
```jsx
import React from 'react';
import { formatNumber } from '../../stockUtils';
```
컴포넌트: `const CashPanel = ({ pf }) => ( <section className="stock-panel stock-panel--wide"> ... </section> );` — 내부는 현 PortfolioTab.jsx:285-409의 예수금 `<section>...</section>` JSX verbatim(`pf.cashList`/`pf.cashEditingBroker`/`pf.cashEditingValue`/`pf.setCashEditingValue`/`pf.handleCashInlineSave`/`pf.handleCashInlineCancel`/`pf.handleCashInlineEdit`/`pf.handleCashDelete`/`pf.cashEditSaving`/`pf.cashForm`/`pf.setCashForm`/`pf.handleCashSave`/`pf.cashSaving`/`pf.cashError` 참조 그대로). `export default CashPanel;`.
- [ ] **Step 2: Wire PortfolioTab.jsx** — 예수금 `<section>...</section>`(285-409) 인라인 블록을 `<CashPanel pf={pf} />`로 교체. import 추가:
```js
import CashPanel from './portfolio/CashPanel';
```
(shell이 formatNumber를 예수금 외 다른 곳에서 안 쓰게 되면 정리 — 단 브로커 섹션이 아직 인라인이면 formatNumber 유지. 빌드/lint로 확인.)
- [ ] **Step 3: Run tests + build + lint**
Run: `npm run test:run` → 전체 green. `npm run build` → `✓ built`. `npm run lint` → 신규 파일 + PortfolioTab.jsx 0 에러.
- [ ] **Step 4: Commit**
```bash
git add src/pages/stock/components/portfolio/CashPanel.jsx src/pages/stock/components/PortfolioTab.jsx
git commit -m "refactor(portfolio): 예수금 패널 CashPanel 컴포넌트 추출"
```
---
### Task 4: `HoldingRow` + `BrokerSection` 추출 → PortfolioTab shell 축소 + 검증
**Files:**
- Create: `src/pages/stock/components/portfolio/HoldingRow.jsx`
- Create: `src/pages/stock/components/portfolio/BrokerSection.jsx`
- Modify: `src/pages/stock/components/PortfolioTab.jsx`
**Interfaces:**
- Consumes: `PriceSessionBadge`(Task 1).
- Produces: `HoldingRow({ item, pf, handleSell })`, `BrokerSection({ broker, items, pf, handleSell })` (default exports).
- [ ] **Step 1: Create HoldingRow.jsx**
```jsx
import React from 'react';
import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../../stockUtils';
import PriceSessionBadge from './PriceSessionBadge';
```
컴포넌트: `const HoldingRow = ({ item, pf, handleSell }) => { ...상수 계산... return ( <div key={item.id} className="stock-holdings__item pf-item"> ... </div> ); };` — 내부는 현 PortfolioTab.jsx:447-654의 `items.map((item) => { ... return (<div ...>...</div>); })` 콜백 본문 verbatim(상수 `profitAmt/profitRate/profitAmtN/profitRateN/isEditing/isDeleting/isSelling/sellPrice/saleAmount` 계산 + `<div>` JSX; `pf.editingId`/`pf.editForm`/`pf.setEditForm`/`pf.handleEditSave`/`pf.editLoading`/`pf.setEditingId`/`pf.handleEditStart`/`pf.sellConfirmId`/`pf.setSellConfirmId`/`pf.sellLoading`/`pf.deleteConfirmId`/`pf.setDeleteConfirmId`/`pf.handleDelete`/`handleSell` 참조 그대로). map 콜백의 `return`을 컴포넌트 `return`으로. `key`는 BrokerSection의 map에서 부여하므로 HoldingRow 내부 최상위 `<div>`의 `key={item.id}`는 제거 가능(무해하나 React는 map 자식에만 key 필요) — **동작 보존 위해 그대로 두거나 제거 무관**; BrokerSection map에서 `<HoldingRow key={item.id} .../>`로 key 부여. `export default HoldingRow;`.
- [ ] **Step 2: Create BrokerSection.jsx**
```jsx
import React from 'react';
import { formatNumber, formatPercent, profitColorClass } from '../../stockUtils';
import HoldingRow from './HoldingRow';
```
컴포넌트: `const BrokerSection = ({ broker, items, pf, handleSell }) => { const bSummary = pf.getBrokerSummary(items); const color = pf.brokerColors[broker]; return ( <section key={broker} className="stock-panel stock-panel--wide pf-broker-section" style={...}> ...헤더... <div className="stock-holdings"> {items.map((item) => <HoldingRow key={item.id} item={item} pf={pf} handleSell={handleSell} />)} </div> </section> ); };` — 섹션 헤더(현 PortfolioTab.jsx:421-445) verbatim, `<div className="stock-holdings">` 내부의 `items.map`을 `HoldingRow` 호출로 교체. `key={broker}`는 PortfolioTab의 map에서 부여하므로 내부 유지/제거 무관. `export default BrokerSection;`.
- [ ] **Step 3: Reduce PortfolioTab.jsx to shell** — 브로커 섹션 인라인 블록(412-659) `{pf.brokerGroups.map(([broker, items]) => { ... })}`을 다음으로 교체:
```jsx
{pf.brokerGroups.map(([broker, items]) => (
<BrokerSection key={broker} broker={broker} items={items} pf={pf} handleSell={handleSell} />
))}
```
import 추가: `import BrokerSection from './portfolio/BrokerSection';`. (HoldingRow는 BrokerSection이 import.) PortfolioTab.jsx 상단 import 최종 정리: shell이 실제 쓰는 것만 — `React`, `Loading`(관리 section 헤더 spinner), 6개 하위 컴포넌트(PriceSessionBadge는 이제 HoldingRow가 쓰므로 shell import 제거), stockUtils 심볼 중 shell이 직접 쓰는 것(브로커 섹션 이동 후 shell엔 거의 없음 → 미사용 제거). `PriceSessionBadge` import를 shell에서 제거(HoldingRow로 이동). 미사용 import 0 되도록 build/lint 확인. 결과 PortfolioTab.jsx ~70줄 shell.
- [ ] **Step 4: Run full suite + lint + build**
Run: `npm run test:run` → 전체 green(회귀 0). `npm run lint` → 0 새 에러. `npm run build` → `✓ built`. `wc -l src/pages/stock/components/PortfolioTab.jsx` ~70줄 확인.
- [ ] **Step 5: Commit**
```bash
git add src/pages/stock/components/portfolio/HoldingRow.jsx src/pages/stock/components/portfolio/BrokerSection.jsx src/pages/stock/components/PortfolioTab.jsx
git commit -m "refactor(portfolio): 보유행/브로커섹션 추출 → PortfolioTab shell 축소"
```
- [ ] **Step 6: Manual verification (dev server)**
`npm run dev` → `http://localhost:3007/stock/trade` 포트폴리오 탭. 종목 추가 폼, 총자산 요약, 자산 추이 차트(기간·스냅샷), 예수금(인라인 편집·삭제·추가), 브로커 섹션(요약·예수금 뱃지), 보유행(현재가·NXT 뱃지·편집·매도확인·삭제확인)이 리팩토링 전과 **동일 동작**인지 확인.
---
## Self-Review 결과
**Spec coverage** (설계 §1§7):
- §3 file map(portfolioUtils/7컴포넌트/shell) → Task 1/2/3/4 ✅
- §4 컴포넌트별 책임 → 각 Task의 props·JSX 범위 매핑 ✅
- §5 테스트(portfolioUtils + PriceSessionBadge 스모크 + build/lint/수동) → Task 1 + 각 Task 검증 + Task 4 수동 ✅
- §6 완료기준 → 각 Task 검증 ✅
**Placeholder scan:** 신규 코드(portfolioUtils/PriceSessionBadge/테스트) 전체 명시. 리프트 컴포넌트는 "현 인라인 JSX(line 범위) verbatim + 가드 early-return + 명시된 import/props"로 지정(플레이스홀더 아님 — 원본 JSX 존재, 참조 심볼·props 명시). ✅
**Type consistency:** portfolioUtils/PriceSessionBadge(Task1) ↔ HoldingRow import 일치. 각 컴포넌트 props(`pf`/`asset`/`handleSell`/`handleSaveSnapshot`/`item`/`broker`/`items`) ↔ shell/BrokerSection 전달 일치. 경로(portfolio/* → `../../stockUtils`·`../../../../components/Loading`) 일치. ✅
**참고:** Task 순서 의존(PriceSessionBadge→…→HoldingRow/BrokerSection). behavior-preserving라 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0. 리프트 시 가드→early-return, key는 부모 map에서 부여.

View File

@@ -0,0 +1,95 @@
# PortfolioTab.jsx 분해 리팩토링 — 설계 (FE 모듈 sweep 서브프로젝트 3)
- **작성일**: 2026-07-10
- **역할/저장소**: FE (`web-ui`)
- **범위**: FE 대형 컴포넌트 분해 sweep의 **3번째 서브프로젝트**. 컨벤션은 확립됨(web-ui/CLAUDE.md "기능 모듈 구조") — 적용만.
- **성격**: **behavior-preserving 순수 추출**. 로직/JSX/스타일 변경 없음.
---
## 1. 배경 & 목표
`src/pages/stock/components/PortfolioTab.jsx`(671줄)는 StockTrade의 포트폴리오 탭 컴포넌트로, **로컬 상태 없이 `pf`/`asset` 훅을 props로 받는 단일 대형 표현 컴포넌트**다. 논리 블록(추가폼/총자산요약/자산추이차트/예수금패널/브로커섹션/보유행/NXT뱃지)을 `stock/components/portfolio/` 하위 표현 컴포넌트로 동작 보존 분해한다.
비목표: 동작/스타일 변경, prop shape 정제(개별 prop으로 좁히기는 후속), HoldingRow 내부 상태 추가분할.
---
## 2. 현 구조 맵 (PortfolioTab.jsx 671줄)
- `formatPriceTime`(순수, 9-14), `PriceSessionBadge`(16-27) — NXT 뱃지
- `PortfolioTab({ pf, asset, handleSell, handleSaveSnapshot })`:
- 에러(31-33)
- 관리 section(36-282): 헤더(refresh/추가 토글) + **추가 폼**(66-153) + **총자산 요약 카드**(155-196) + **자산 추이 차트**(198-281)
- **예수금 패널**(285-409): 예수금 테이블(inline edit/delete) + 예수금 추가 폼
- **브로커 섹션 반복**(412-659): `pf.brokerGroups.map` → 브로커별 section + `items.map`**보유 행**(447-654, view/edit/매도확인/삭제확인 4상태)
- 빈 상태(661-667)
의존: `Loading`(src/components), recharts(AreaChart 등), `stockUtils`(formatNumber/formatPercent/toNumeric/profitColorClass/numFitClass).
---
## 3. 목표 file map
```
src/pages/stock/components/
├── PortfolioTab.jsx # shell (~70줄): 에러 + 관리 section(헤더 inline + AddHoldingForm + PortfolioSummary + AssetHistoryChart) + CashPanel + brokerGroups.map(BrokerSection) + 빈상태
└── portfolio/
├── portfolioUtils.js # formatPriceTime (순수)
├── portfolioUtils.test.js # (신규)
├── PriceSessionBadge.jsx # ({ session, asOf })
├── AddHoldingForm.jsx # ({ pf })
├── PortfolioSummary.jsx # ({ pf })
├── AssetHistoryChart.jsx # ({ asset, pf, handleSaveSnapshot })
├── CashPanel.jsx # ({ pf })
├── BrokerSection.jsx # ({ broker, items, pf, handleSell }) — HoldingRow 반복
└── HoldingRow.jsx # ({ item, pf, handleSell })
```
**props 스레딩(behavior-preserving):** 각 하위에 `pf`(훅 객체 전체)/`asset`/핸들러를 그대로 전달. 컴포넌트 본문은 현 `pf.xxx`/`asset.xxx` 참조를 그대로 사용 → prop shape·동작 불변.
**import 경로 규칙:**
- `portfolio/*.jsx` → stockUtils `'../../stockUtils'`; Loading `'../../../../components/Loading'`; portfolioUtils `'./portfolioUtils'`.
- `HoldingRow.jsx``PriceSessionBadge` `'./PriceSessionBadge'`.
- `PortfolioTab.jsx`(stock/components/) → 하위 `'./portfolio/*'`. (기존 Loading `'../../../components/Loading'`, stockUtils `'../stockUtils'`는 shell이 계속 쓰는 것만 유지.)
각 컴포넌트가 실제 사용하는 심볼만 import(빌드/lint가 누락·미사용 검출).
---
## 4. 컴포넌트별 책임 (분해 후 검증 관점)
- `PriceSessionBadge`(~20줄): session이 NXT_AFTER/NXT_PRE일 때만 뱃지. `formatPriceTime`(utils) 사용.
- `AddHoldingForm`(~90줄): `pf.addFormOpen`일 때 폼. `pf.addForm`/`pf.setAddForm`/`pf.handleAddSubmit`/`pf.addLoading`/`pf.addError`.
- `PortfolioSummary`(~45줄): `pf.portfolioHoldings.length>0`일 때 총매입/평가/손익/수익률 + 예수금/총자산 카드.
- `AssetHistoryChart`(~85줄): `asset.assetHistory*` + `asset.setAssetHistoryDays` + `handleSaveSnapshot` + `pf.totalAssets`. recharts AreaChart.
- `CashPanel`(~120줄): `pf.cashList` 테이블(inline edit: `pf.cashEditing*`/`pf.handleCashInline*`, delete: `pf.handleCashDelete`) + 예수금 추가 폼(`pf.cashForm`/`pf.handleCashSave`/…).
- `BrokerSection`(~55줄): `pf.getBrokerSummary`/`pf.brokerColors`/`pf.cashList` + `items.map(HoldingRow)`.
- `HoldingRow`(~200줄): 개별 종목. view/edit(`pf.editingId`/`pf.editForm`/`pf.handleEditSave`…)/매도확인(`pf.sellConfirmId`/`handleSell`/`pf.sellLoading`)/삭제확인(`pf.deleteConfirmId`/`pf.handleDelete`). `PriceSessionBadge` 사용.
---
## 5. 테스트 (안전망 — 현재 PortfolioTab 테스트 0)
1. **`portfolioUtils.test.js`(신규, 필수)** — `formatPriceTime`: 유효 ISO → "HH:MM"(제로패드), falsy/invalid → "". (현 구현 동작 고정.)
2. **`PriceSessionBadge.test.jsx`(신규, 스모크)** — session='NXT_AFTER'→"NXT" 렌더, 'NXT_PRE'→"NXT 프리" 렌더, 그 외(예: undefined/'REGULAR')→렌더 없음(null).
3. 나머지 표현 컴포넌트는 props 스레딩 위주 → 이번 범위 스모크 최소화(추출 검증은 build+lint+수동).
4. **검증 게이트:** 각 단계 `npm run build` + `npm run test:run` 전체 green(회귀 0) + `npm run lint` 신규/수정 파일 0 에러. 최종 개발서버 `/stock/trade` 포트폴리오 탭에서 추가/요약/차트/예수금(인라인 편집·삭제)/브로커/보유행(편집·매도확인·삭제확인)이 리팩토링 전과 동일 동작.
---
## 6. 완료 기준
- [ ] PortfolioTab.jsx가 shell(~70줄) + portfolio/ 7컴포넌트 + portfolioUtils(+test)로 분해, 동작/스타일 불변.
- [ ] `portfolioUtils.test.js`·`PriceSessionBadge.test.jsx` 통과, 전체 `npm run test:run` green.
- [ ] `npm run lint`·`npm run build` 통과.
- [ ] `/stock/trade` 포트폴리오 탭 수동 검증 동일 동작.
---
## 7. 리스크 / 오픈 이슈
- **테스트 부재 컴포넌트 리팩토링**: 순수 추출 + build/lint/유닛/스모크/수동으로 위험 최소화.
- **prop drilling**: `pf` 전체 전달은 behavior-preserving 우선(정제는 후속). 각 컴포넌트가 쓰는 `pf.*`만 실제 참조.
- **HoldingRow ~200줄**: 단일 책임(보유 1행). 내부 edit/매도/삭제 분할은 후속 follow-up.
- **다음 서브프로젝트**: InstaCards(1013) → MusicStudio(1936).