Files
web-page/docs/superpowers/plans/2026-07-09-fe-module-refactor-realestate.md
2026-07-09 22:54:18 +09:00

18 KiB
Raw Blame History

RealEstate.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: RealEstate.jsx(909줄)를 기능 모듈 구조(shell + hooks/useComplexes + realEstateUtils + components/5)로 동작 보존 분해한다.

Architecture: 순수 추출. 상수·데이터·순수헬퍼→realEstateUtils.js, 표현 컴포넌트 5개→components/*.jsx(verbatim), 데이터 로직(complexes+CRUD)→hooks/useComplexes.js, RealEstate.jsx는 UI 상태+조합 shell(~150줄)로 축소. 각 Task는 코드 이동 후 build+전체 테스트 green으로 동작 보존 확인. 로직/JSX/스타일 불변.

Tech Stack: React 18 + Vite, react-leaflet/leaflet, recharts, Vitest.

Global Constraints

  • Behavior-preserving 순수 추출: 옮기는 컴포넌트/헬퍼 본문(JSX·로직)은 verbatim. import 배선만 추가/변경. 동작·스타일·클래스명 불변.
  • 경로: RealEstate.jsx(src/pages/realestate/) api '../../api'; components/*·hooks/*는 api '../../../api', utils '../realEstateUtils'; shell → utils './realEstateUtils'·hook './hooks/useComplexes'·components './components/*'·CSS './RealEstate.css'. realEstateUtils.jsleafletL만 import(createMarkerIcon), api import 없음.
  • 각 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 동일 디렉토리.
  • 각 컴포넌트/파일은 실제 사용하는 심볼만 import(미사용 금지 — lint 검출). 커밋은 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: realEstateUtils.js — 상수·데이터·순수헬퍼 추출 + 테스트

Files:

  • Create: src/pages/realestate/realEstateUtils.js
  • Test: src/pages/realestate/realEstateUtils.test.js
  • Modify: src/pages/realestate/RealEstate.jsx

Interfaces:

  • Produces: SAMPLE_COMPLEXES, STATUS_CONFIG, PRIORITY_LABELS, EMPTY_FORM, TABS, formatDate(d), formatPrice(v), getDDays(dateStr), createMarkerIcon(status, isSelected).

  • Step 1: Write the failing test — Create src/pages/realestate/realEstateUtils.test.js:

import { describe, it, expect } from 'vitest';
import { formatDate, formatPrice, getDDays } from './realEstateUtils.js';

describe('formatPrice', () => {
  it('만원 표기 / falsy → -', () => {
    expect(formatPrice(4500)).toBe('4,500만원');
    expect(formatPrice(0)).toBe('-');
    expect(formatPrice(null)).toBe('-');
  });
});

describe('getDDays (오늘 기준)', () => {
  const iso = (off) => {
    const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + off);
    const p = (n) => String(n).padStart(2, '0');
    return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
  };
  it('오늘=D-Day, 미래=D-N, 과거=D+N, null→null', () => {
    expect(getDDays(iso(0))).toBe('D-Day');
    expect(getDDays(iso(4))).toBe('D-4');
    expect(getDDays(iso(-2))).toBe('D+2');
    expect(getDDays(null)).toBe(null);
  });
});

describe('formatDate', () => {
  it('유효 날짜 한국어 포맷 / falsy → -', () => {
    expect(formatDate('2026-04-10')).toContain('2026');
    expect(formatDate(null)).toBe('-');
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test:run -- src/pages/realestate/realEstateUtils.test.js Expected: FAIL — Failed to resolve import "./realEstateUtils.js".

  • Step 3: Create realEstateUtils.js — 상단 import L from 'leaflet'; 후, 현 RealEstate.jsx의 아래 항목을 verbatim 이동하고 각각 export:
    • SAMPLE_COMPLEXES (현 RealEstate.jsx의 4개 단지 배열 전체를 그대로 복사) — export const SAMPLE_COMPLEXES = [...]
    • STATUS_CONFIG, PRIORITY_LABELS, EMPTY_FORM, TABS (verbatim) — 각 export const
    • formatDate, formatPrice, getDDays (verbatim) — 각 export const
    • createMarkerIcon (verbatim, L.divIcon 사용) — export const createMarkerIcon = (status, isSelected = false) => {...}

명시적 내용(작은 것들, verbatim):

export const STATUS_CONFIG = {
    '청약예정': { color: '#00d4ff', bg: 'rgba(0,212,255,0.12)' },
    '청약중':   { color: '#34d399', bg: 'rgba(52,211,153,0.12)' },
    '결과발표': { color: '#f59e0b', bg: 'rgba(245,158,11,0.12)' },
    '완료':     { color: '#6b7280', bg: 'rgba(107,114,128,0.10)' },
};
export const PRIORITY_LABELS = { high: '★ 최우선', normal: '보통', low: '낮음' };
export const EMPTY_FORM = {
    name: '', address: '', lat: '', lng: '',
    units: '', types: '', avgPricePerPyeong: '',
    subscriptionStart: '', subscriptionEnd: '', resultDate: '',
    status: '청약예정', priority: 'normal',
    tags: '', naverUrl: '', floorPlanUrl: '', memo: '',
};
export const TABS = ['목록', '일정', '분석'];

export const formatDate = (d) => {
    if (!d) return '-';
    return new Date(d).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
};
export const formatPrice = (v) => {
    if (!v) return '-';
    return `${v.toLocaleString()}만원`;
};
export const getDDays = (dateStr) => {
    if (!dateStr) return null;
    const target = new Date(dateStr);
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    target.setHours(0, 0, 0, 0);
    const diff = Math.ceil((target - today) / (1000 * 60 * 60 * 24));
    if (diff === 0) return 'D-Day';
    if (diff > 0) return `D-${diff}`;
    return `D+${Math.abs(diff)}`;
};
export const createMarkerIcon = (status, isSelected = false) => {
    const cfg = STATUS_CONFIG[status] || STATUS_CONFIG['완료'];
    const size = isSelected ? 18 : 12;
    return L.divIcon({
        className: '',
        html: `<div style="
            width:${size}px;height:${size}px;border-radius:50%;
            background:${cfg.color};
            box-shadow:0 0 ${isSelected ? 16 : 8}px ${cfg.color};
            border:2px solid rgba(255,255,255,${isSelected ? 0.6 : 0.25});
            transition:all 0.2s;
        "></div>`,
        iconSize: [size, size],
        iconAnchor: [size / 2, size / 2],
        popupAnchor: [0, -(size / 2 + 4)],
    });
};

SAMPLE_COMPLEXES는 현 RealEstate.jsx:14-91의 배열을 그대로 옮겨 export const SAMPLE_COMPLEXES = [ ... ];로.

  • Step 4: Wire RealEstate.jsx — 위 항목들의 기존 정의(SAMPLE_COMPLEXES/STATUS_CONFIG/PRIORITY_LABELS/EMPTY_FORM/TABS/formatDate/formatPrice/getDDays/createMarkerIcon)를 삭제하고, import 추가:
import {
    SAMPLE_COMPLEXES, STATUS_CONFIG, PRIORITY_LABELS, EMPTY_FORM, TABS,
    formatDate, formatPrice, getDDays, createMarkerIcon,
} from './realEstateUtils';

(이 시점엔 컴포넌트들이 아직 RealEstate.jsx 내부 → import한 심볼 사용. import L from 'leaflet'는 createMarkerIcon이 utils로 갔으니 RealEstate.jsx에서 더 이상 직접 안 쓰면 제거; 단 다른 곳(예: 없음)에서 안 쓰면 제거. import 'leaflet/dist/leaflet.css'는 지도 렌더 위해 shell 또는 RightPanel에 유지 필요 → 이번 단계는 RealEstate.jsx에 유지.)

  • Step 5: Run tests + build

Run: npm run test:run -- src/pages/realestate/realEstateUtils.test.js → PASS. Run: npm run test:run → 전체 green. npm run build✓ built. npm run lint → 신규/수정 파일 0 에러(미사용 import 없도록).

  • Step 6: Commit
git add src/pages/realestate/realEstateUtils.js src/pages/realestate/realEstateUtils.test.js src/pages/realestate/RealEstate.jsx
git commit -m "refactor(realestate): 상수·데이터·순수헬퍼를 realEstateUtils로 추출 + 테스트"

Task 2: 표현 컴포넌트 5개 추출 (ComplexCard/RightPanel/ScheduleView/PriceAnalysis/ComplexModal)

Files:

  • Create: src/pages/realestate/components/ComplexCard.jsx
  • Create: src/pages/realestate/components/RightPanel.jsx
  • Create: src/pages/realestate/components/ScheduleView.jsx
  • Create: src/pages/realestate/components/PriceAnalysis.jsx
  • Create: src/pages/realestate/components/ComplexModal.jsx
  • Modify: src/pages/realestate/RealEstate.jsx

Interfaces:

  • Consumes (Task 1): utils 심볼.

  • Produces: 각 기본 export — ComplexCard({complex,isSelected,onClick}), RightPanel({complexes,selectedComplex,onSelectComplex,onEdit,onDelete}), ScheduleView({complexes}), PriceAnalysis({complexes}), ComplexModal({complex,onClose,onSave}).

  • Step 1: Create the five components (본문 verbatim + 상단 import)

각 새 파일 = 상단 import 블록 + 현 RealEstate.jsx의 해당 함수 본문 verbatim + export default <Name>;. MapFlyTo는 RightPanel.jsx 안에 내부 컴포넌트로 함께 이동(현 RealEstate.jsx의 MapFlyTo 정의를 RightPanel.jsx 상단에 그대로 두고 RightPanel이 사용). EventItem은 ScheduleView 내부, CustomTooltip은 PriceAnalysis 내부에 이미 중첩되어 있으므로 그대로.

import 블록(각 컴포넌트가 실제 사용하는 심볼만 — 아래는 현 코드 사용 기준):

ComplexCard.jsx:

import React from 'react';
import { STATUS_CONFIG, formatPrice, getDDays } from '../realEstateUtils';

RightPanel.jsx (MapFlyTo 내부 포함):

import React, { useEffect, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import { STATUS_CONFIG, PRIORITY_LABELS, formatDate, formatPrice, createMarkerIcon } from '../realEstateUtils';

ScheduleView.jsx:

import React from 'react';
import { STATUS_CONFIG, formatDate, getDDays } from '../realEstateUtils';

PriceAnalysis.jsx:

import React from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { STATUS_CONFIG, formatDate, formatPrice } from '../realEstateUtils';

ComplexModal.jsx:

import React, { useState } from 'react';
import { STATUS_CONFIG, EMPTY_FORM } from '../realEstateUtils';

실제 본문이 참조하는 심볼만 포함할 것(빌드/lint가 누락·미사용 검출). RightPanel은 leaflet/dist/leaflet.css를 shell에서 로드하므로 별도 import 불필요.

  • Step 2: Wire RealEstate.jsx — 5개 컴포넌트 + MapFlyTo 정의 삭제, import 추가:
import ComplexCard from './components/ComplexCard';
import RightPanel from './components/RightPanel';
import ScheduleView from './components/ScheduleView';
import PriceAnalysis from './components/PriceAnalysis';
import ComplexModal from './components/ComplexModal';

RealEstate.jsx 상단의 이제-미사용 import 정리: MapContainer/TileLayer/Marker/Popup/useMap(react-leaflet), BarChart~Cell(recharts)가 shell에서 더 이상 안 쓰이면 제거(shell은 지도/차트를 직접 렌더하지 않음). import 'leaflet/dist/leaflet.css'는 지도 스타일 위해 shell에 유지(RightPanel이 렌더될 때 필요). useMemo는 shell의 filteredComplexes/stats에서 계속 사용 → 유지.

  • Step 3: Run tests + build + lint

Run: npm run test:run → 전체 green. npm run build✓ built. npm run lint → 신규 5파일 + RealEstate.jsx 0 에러.

  • Step 4: Commit
git add src/pages/realestate/components/ComplexCard.jsx src/pages/realestate/components/RightPanel.jsx src/pages/realestate/components/ScheduleView.jsx src/pages/realestate/components/PriceAnalysis.jsx src/pages/realestate/components/ComplexModal.jsx src/pages/realestate/RealEstate.jsx
git commit -m "refactor(realestate): 카드/지도패널/일정/분석/모달 표현 컴포넌트 추출"

Task 3: useComplexes 훅 추출 → RealEstate를 shell 컨테이너로 + 최종 검증

Files:

  • Create: src/pages/realestate/hooks/useComplexes.js
  • Modify: src/pages/realestate/RealEstate.jsx

Interfaces:

  • Produces: useComplexes() 기본 export → { complexes, addComplex, updateComplex, deleteComplex }.

  • Step 1: Create useComplexes.js — 현 메인의 데이터 로직을 그대로 옮김(UI 관심사는 제외):

import { useState, useEffect } from 'react';
import { apiGet, apiPost, apiPut, apiDelete } from '../../../api';
import { SAMPLE_COMPLEXES } from '../realEstateUtils';

export default function useComplexes() {
  const [complexes, setComplexes] = useState(SAMPLE_COMPLEXES);

  useEffect(() => {
    apiGet('/api/realestate/complexes')
      .then((data) => {
        if (Array.isArray(data) && data.length > 0) setComplexes(data);
      })
      .catch(() => {});
  }, []);

  const addComplex = async (data) => {
    const newComplex = { ...data, id: Date.now() };
    setComplexes((prev) => [...prev, newComplex]);
    try { await apiPost('/api/realestate/complexes', data); } catch { /* noop */ }
  };

  const updateComplex = async (data) => {
    setComplexes((prev) => prev.map((c) => (c.id === data.id ? data : c)));
    try { await apiPut(`/api/realestate/complexes/${data.id}`, data); } catch { /* noop */ }
  };

  const deleteComplex = async (id) => {
    setComplexes((prev) => prev.filter((c) => c.id !== id));
    try { await apiDelete(`/api/realestate/complexes/${id}`); } catch { /* noop */ }
  };

  return { complexes, addComplex, updateComplex, deleteComplex };
}
  • Step 2: Reduce RealEstate.jsx to the shell container — 컴포넌트 본문 최상단 부분(상태 선언 ~ 핸들러 ~ memo)을 아래로 교체. return (...) JSX 블록은 verbatim 유지(이미 complexes/filteredComplexes/stats/selectedComplex/activeTab/filterStatus/handleDelete/handleModalSave/setSelectedComplex/setEditingComplex/setShowModal/setFilterStatus/setActiveTab를 참조 — 이들은 아래 재구성으로 동일하게 제공됨).

RealEstate.jsx 상단 import는 다음으로 정리(현 사용 기준): React 훅(useState, useMemo), Link, utils(SAMPLE_COMPLEXES는 이제 훅이 씀 → shell에서 제거 가능; STATUS_CONFIG/TABS는 shell 렌더에서 사용 → 유지, 그 외 유틸 중 shell이 직접 쓰는 것만), useComplexes, 5개 컴포넌트, import 'leaflet/dist/leaflet.css', import './RealEstate.css'. (미사용 import 제거로 lint 0.)

컴포넌트 함수 본문(선언부) 교체:

const RealEstate = () => {
    const { complexes, addComplex, updateComplex, deleteComplex } = useComplexes();
    const [selectedComplex, setSelectedComplex] = useState(null);
    const [activeTab, setActiveTab] = useState('목록');
    const [filterStatus, setFilterStatus] = useState('전체');
    const [showModal, setShowModal] = useState(false);
    const [editingComplex, setEditingComplex] = useState(null);

    const handleAdd = (data) => {
        addComplex(data);
        setShowModal(false);
    };

    const handleUpdate = (data) => {
        updateComplex(data);
        if (selectedComplex?.id === data.id) setSelectedComplex(data);
        setEditingComplex(null);
        setShowModal(false);
    };

    const handleDelete = (id) => {
        if (!confirm('삭제하시겠습니까?')) return;
        deleteComplex(id);
        if (selectedComplex?.id === id) setSelectedComplex(null);
    };

    const handleModalSave = (data) => {
        if (editingComplex) {
            handleUpdate({ ...editingComplex, ...data });
        } else {
            handleAdd(data);
        }
    };

    const filteredComplexes = useMemo(() => {
        if (filterStatus === '전체') return complexes;
        return complexes.filter((c) => c.status === filterStatus);
    }, [complexes, filterStatus]);

    const stats = useMemo(() => ({
        total: complexes.length,
        upcoming: complexes.filter((c) => c.status === '청약예정').length,
        active: complexes.filter((c) => c.status === '청약중').length,
        avgPrice: complexes.length
            ? Math.round(complexes.reduce((s, c) => s + c.avgPricePerPyeong, 0) / complexes.length)
            : 0,
    }), [complexes]);

    return (
        // ── 현 RealEstate.jsx의 return JSX 전체를 verbatim 유지 ──
    );
};

handleAdd/handleUpdate/handleDelete는 비동기 await를 훅으로 옮겼으므로 shell에선 동기(비-async). 원래도 optimistic 갱신 후 UI(setShowModal/setSelected)를 처리하고 API는 fire-and-forget이었으므로 동작 동일(setComplexes→UI→API 순서 보존). handleModalSave/filteredComplexes/stats와 return JSX는 원본 그대로.

  • Step 3: Run full suite + lint + build

Run: npm run test:run → 전체 green(회귀 0). npm run lint → 0 새 에러. npm run build✓ built. wc -l src/pages/realestate/RealEstate.jsx 로 shell 축소(~150줄) 확인.

  • Step 4: Commit
git add src/pages/realestate/hooks/useComplexes.js src/pages/realestate/RealEstate.jsx
git commit -m "refactor(realestate): 데이터 로직 useComplexes 훅 추출 → RealEstate shell 축소"
  • Step 5: Manual verification (dev server)

npm run devhttp://localhost:3007/realestate/property 접속. 3탭(목록/일정/분석) 전환, 목록 카드 선택→우측 지도 flyTo+상세, 필터, 단지 추가/편집/삭제 모달, 지도 마커 클릭이 리팩토링 전과 동일 동작인지 확인. 청약 대시보드 링크(/realestate) 동작 확인.


Self-Review 결과

Spec coverage (설계 §1§7):

  • §3 file map(utils/hook/components 5/shell) → Task 1/2/3
  • §4 useComplexes(데이터만, UI 경계는 shell) → Task 3 (confirm·selectedComplex 동기화 shell 유지)
  • §5 테스트(realEstateUtils.test + build/lint/수동) → Task 1 + 각 Task 검증 + Task 3 수동
  • §6 완료기준 → 각 Task 검증

Placeholder scan: 신규 코드(utils 작은 것들/훅/테스트/shell 선언부)는 전체 코드 명시. SAMPLE_COMPLEXES·표현 컴포넌트·return JSX는 "현 정의 verbatim 이동 + 명시된 import"로 지정(플레이스홀더 아님 — 원본 존재, import 심볼 명시).

Type consistency: utils export(Task1) ↔ Task2/3 import 일치. 컴포넌트 기본 export ↔ shell import 일치. useComplexes 반환 {complexes,addComplex,updateComplex,deleteComplex} ↔ shell 사용 일치. 경로(shell ../../api 아님— shell은 api 직접 안 씀; components/hooks ../../../api) 일치.

참고: Task 순서 의존(utils→components→hook/shell). behavior-preserving라 RED는 "신규 파일 미존재"로 성립, GREEN은 추출 후 전체 회귀 0.