Files
web-page/docs/superpowers/plans/2026-07-09-realestate-listings.md

47 KiB
Raw Blame History

실매물 매물·안전마진 UI 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/listings 신규 페이지에 실매물(매매/전세) 매물 목록·판정 tier·안전마진 체커·예산 계산기·조건 편집을 구현한다.

Architecture: 전용 모듈 src/pages/listings/. 순수헬퍼(tier/금액) → api.js 헬퍼 8종 → 자립형 탭 3개(매물/판정도구/조건, 각자 api 호출·Subscription 탭 패턴) → Listings shell + routes 배선. 각 탭은 vi.mock('../../../api')로 스모크 테스트.

Tech Stack: React 18 + Vite, Vitest + @testing-library/react, 기존 apiGet/apiPost/apiPut 헬퍼.

Global Constraints

  • API는 항상 상대경로 /api/realestate/*, 전부 src/api.jsapiGet/apiPost/apiPut 경유.
  • regulation_flags/reasons는 이미 배열 — JSON.parse 금지, 그대로 .map 렌더.
  • 어떤 응답 필드가 객체여도 JSX 자식으로 직접 렌더 금지 (tier/금액/문자열/배열-칩만 렌더).
  • disclaimer 응답에 있으면 반드시 노출 (safety-check·budget 결과).
  • 금액은 만원 단위. 판정 색: 🟢#34d399/🟡#f59e0b/🔴#f87171/#94a3b8.
  • tier: 임차 safety_tier=안전/주의/위험/보류, 매매 valuation_tier=저평가/시세/고가/보류. 표본<3→"보류".
  • 응답 방어적 파싱: data.listings ?? [], data.matches ?? [], data.criteria ?? data.
  • 테스트: Vitest import { describe, it, expect } from 'vitest', 실행 npm run test:run, *.test.js(x) 동일 디렉토리. jest-dom은 src/test-setup.js에서 전역 등록됨(테스트 파일에 import 불필요).
  • 스타일: Subscription CSS 변수 재사용(--text-bright/--text-dim/--text-muted/--surface/--surface-raised/--line/--radius-sm), 클래스 lst-*.
  • 커밋은 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: 순수 헬퍼 listingsUtils.js (tier/금액 매핑)

Files:

  • Create: src/pages/listings/listingsUtils.js
  • Test: src/pages/listings/listingsUtils.test.js

Interfaces:

  • Produces: tierMeta(kind:'safety'|'valuation', tier:string) => {emoji, color, label}; formatMoney(manwon) => string; formatRatio(ratio) => string; SAFETY_TIER_META, VALUATION_TIER_META.

  • Step 1: Write the failing test — Create src/pages/listings/listingsUtils.test.js:

import { describe, it, expect } from 'vitest';
import { tierMeta, formatMoney, formatRatio } from './listingsUtils.js';

describe('tierMeta', () => {
  it('safety 4종 이모지·색·label', () => {
    expect(tierMeta('safety', '안전')).toMatchObject({ emoji: '🟢', color: '#34d399', label: '안전' });
    expect(tierMeta('safety', '주의').emoji).toBe('🟡');
    expect(tierMeta('safety', '위험').color).toBe('#f87171');
    expect(tierMeta('safety', '보류').emoji).toBe('⚪');
  });
  it('valuation 4종', () => {
    expect(tierMeta('valuation', '저평가')).toMatchObject({ emoji: '🟢', color: '#34d399', label: '저평가' });
    expect(tierMeta('valuation', '시세').emoji).toBe('🟡');
    expect(tierMeta('valuation', '고가').color).toBe('#f87171');
  });
  it('미정의 tier는 회색⚪ + 원문 label, undefined는 빈 label', () => {
    expect(tierMeta('safety', '없는거')).toMatchObject({ emoji: '⚪', color: '#94a3b8', label: '없는거' });
    expect(tierMeta('valuation', undefined).label).toBe('');
  });
});

describe('formatMoney (만원 정밀 표기)', () => {
  it('만/억 경계', () => {
    expect(formatMoney(9999)).toBe('9,999만');
    expect(formatMoney(10000)).toBe('1억');
    expect(formatMoney(15000)).toBe('1억 5,000만');
    expect(formatMoney(32000)).toBe('3억 2,000만');
  });
  it('null/빈값/NaN → -', () => {
    expect(formatMoney(null)).toBe('-');
    expect(formatMoney('')).toBe('-');
    expect(formatMoney('abc')).toBe('-');
  });
});

describe('formatRatio', () => {
  it('비율 → 퍼센트 1자리', () => {
    expect(formatRatio(0.812)).toBe('81.2%');
    expect(formatRatio(1.05)).toBe('105.0%');
  });
  it('null → -', () => {
    expect(formatRatio(null)).toBe('-');
  });
});
  • Step 2: Run test to verify it fails

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

  • Step 3: Write the implementation — Create src/pages/listings/listingsUtils.js:
/* 실매물 판정 tier / 금액 포맷 순수 헬퍼 */
const C = { green: '#34d399', yellow: '#f59e0b', red: '#f87171', gray: '#94a3b8' };

export const SAFETY_TIER_META = Object.freeze({
  '안전': { emoji: '🟢', color: C.green },
  '주의': { emoji: '🟡', color: C.yellow },
  '위험': { emoji: '🔴', color: C.red },
  '보류': { emoji: '⚪', color: C.gray },
});

export const VALUATION_TIER_META = Object.freeze({
  '저평가': { emoji: '🟢', color: C.green },
  '시세':   { emoji: '🟡', color: C.yellow },
  '고가':   { emoji: '🔴', color: C.red },
  '보류':   { emoji: '⚪', color: C.gray },
});

export const tierMeta = (kind, tier) => {
  const map = kind === 'valuation' ? VALUATION_TIER_META : SAFETY_TIER_META;
  if (Object.hasOwn(map, tier)) return { ...map[tier], label: tier };
  return { emoji: '⚪', color: C.gray, label: tier ?? '' };
};

export const formatMoney = (manwon) => {
  if (manwon == null || manwon === '') return '-';
  const n = Number(manwon);
  if (Number.isNaN(n)) return '-';
  const eok = Math.floor(n / 10000);
  const rest = n % 10000;
  if (eok > 0 && rest > 0) return `${eok}${rest.toLocaleString('ko-KR')}만`;
  if (eok > 0) return `${eok}억`;
  return `${n.toLocaleString('ko-KR')}만`;
};

export const formatRatio = (ratio) => {
  if (ratio == null) return '-';
  const n = Number(ratio);
  if (Number.isNaN(n)) return '-';
  return `${(n * 100).toFixed(1)}%`;
};
  • Step 4: Run test to verify it passes

Run: npm run test:run -- src/pages/listings/listingsUtils.test.js Expected: PASS (3 describe 전부).

  • Step 5: Commit
git add src/pages/listings/listingsUtils.js src/pages/listings/listingsUtils.test.js
git commit -m "feat(listings): 실매물 tier/금액 순수 헬퍼 + 테스트"

Task 2: API 헬퍼 8종 (src/api.js)

Files:

  • Modify: src/api.js (파일 끝에 추가)

Interfaces:

  • Produces: getListings(params), collectListings(), getListingsCollectStatus(), getListingsCriteria(), putListingsCriteria(body), getListingsMatches(), safetyCheck(body), budgetCalc(body).

  • Step 1: Add API helperssrc/api.js 맨 끝(마지막 export 뒤)에 추가:

// ── Realestate Listings / 안전마진 / 예산 (실매물) ──
// GET /api/realestate/listings → { listings: [...] } (+판정 join)
export const getListings = ({ dong, deal_type, tier, matched_only, page = 1, size = 20 } = {}) => {
  const q = new URLSearchParams();
  if (dong) q.set('dong', dong);
  if (deal_type) q.set('deal_type', deal_type);
  if (tier) q.set('tier', tier);
  if (matched_only) q.set('matched_only', 'true');
  q.set('page', String(page));
  q.set('size', String(size));
  return apiGet(`/api/realestate/listings?${q.toString()}`);
};
export const collectListings          = ()     => apiPost('/api/realestate/listings/collect');
export const getListingsCollectStatus = ()     => apiGet('/api/realestate/listings/collect/status');
export const getListingsCriteria      = ()     => apiGet('/api/realestate/listings/criteria');
export const putListingsCriteria      = (body) => apiPut('/api/realestate/listings/criteria', body);
export const getListingsMatches       = ()     => apiGet('/api/realestate/listings/matches');
export const safetyCheck              = (body) => apiPost('/api/realestate/safety-check', body);
export const budgetCalc               = (body) => apiPost('/api/realestate/budget', body);
  • Step 2: Verify no syntax break

Run: npm run test:run -- src/api Expected: (api.js has no dedicated test) — instead run npm run build 2>&1 | tail -3 Expected: ✓ built (no syntax error). If build not desired here, run node --check is N/A for ESM+JSX; rely on Task 6 build. Minimum: npx eslint src/api.js → 0 new errors.

  • Step 3: Commit
git add src/api.js
git commit -m "feat(listings): realestate 실매물/안전마진/예산 API 헬퍼 8종"

Task 3: ListingsTab.jsx (매물 목록 + 필터 + 수집)

Files:

  • Create: src/pages/listings/components/ListingsTab.jsx
  • Test: src/pages/listings/components/ListingsTab.test.jsx

Interfaces:

  • Consumes (Task 1): tierMeta, formatMoney, formatRatio. (Task 2): getListings, collectListings, getListingsCollectStatus.

  • Produces: ListingsTab 기본 export (자립형, props 없음).

  • Step 1: Write the failing smoke test — Create src/pages/listings/components/ListingsTab.test.jsx:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';

vi.mock('../../../api', () => ({
  getListings: vi.fn(),
  collectListings: vi.fn(),
  getListingsCollectStatus: vi.fn(),
}));
import { getListings, getListingsCollectStatus } from '../../../api';
import ListingsTab from './ListingsTab.jsx';

beforeEach(() => {
  vi.clearAllMocks();
  getListingsCollectStatus.mockResolvedValue({ status: 'never_run' });
});

describe('ListingsTab', () => {
  it('빈 목록이면 안내 문구', async () => {
    getListings.mockResolvedValue({ listings: [] });
    render(<ListingsTab />);
    await waitFor(() => expect(screen.getByText(/매물이 없습니다|수집/)).toBeInTheDocument());
  });

  it('매물+판정 tier·배열 필드를 크래시 없이 렌더 (매매=valuation, 배열 그대로)', async () => {
    getListings.mockResolvedValue({ listings: [{
      id: 1, complex_name: '상도더샵', dong: '상도동', deal_type: '매매',
      price: 95000, valuation_tier: '고가', ratio: 1.08,
      regulation_flags: ['토지거래허가'], reasons: ['호가가 최근 실거래 대비 8% 높음'],
    }] });
    render(<ListingsTab />);
    await waitFor(() => expect(screen.getByText('상도더샵')).toBeInTheDocument());
    expect(screen.getByText(/고가/)).toBeInTheDocument();
    expect(screen.getByText('토지거래허가')).toBeInTheDocument();
    expect(screen.getByText(/호가가 최근 실거래/)).toBeInTheDocument();
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test:run -- src/pages/listings/components/ListingsTab.test.jsx Expected: FAIL — Failed to resolve import "./ListingsTab.jsx".

  • Step 3: Write the component — Create src/pages/listings/components/ListingsTab.jsx:
import React, { useEffect, useState } from 'react';
import { getListings, collectListings, getListingsCollectStatus } from '../../../api';
import { tierMeta, formatMoney, formatRatio } from '../listingsUtils';

const DEAL_TYPES = ['전체', '전세', '반전세', '매매'];

const ListingCard = ({ l }) => {
  const isSale = l.deal_type === '매매';
  const tv = isSale ? l.valuation_tier : l.safety_tier;
  const meta = tierMeta(isSale ? 'valuation' : 'safety', tv);
  const price = l.price ?? l.deposit ?? l.amount;
  const flags = Array.isArray(l.regulation_flags) ? l.regulation_flags : [];
  const reasons = Array.isArray(l.reasons) ? l.reasons : [];
  return (
    <div className="lst-card">
      <div className="lst-card__head">
        {tv && <span className="lst-tier" style={{ color: meta.color }}>{meta.emoji} {meta.label}</span>}
        <strong className="lst-card__name">{l.complex_name || l.name || l.dong || '(매물)'}</strong>
        {l.deal_type && <span className="lst-card__deal">{l.deal_type}</span>}
        {l.dong && <span className="lst-card__dong">{l.dong}</span>}
      </div>
      <div className="lst-card__body">
        {price != null && <span className="lst-card__price">{formatMoney(price)}</span>}
        {l.area != null && <span className="lst-card__area">{l.area}</span>}
        {l.ratio != null && <span className="lst-card__ratio">비율 {formatRatio(l.ratio)}</span>}
      </div>
      {flags.length > 0 && (
        <div className="lst-flags">{flags.map((f, i) => <span key={i} className="lst-flag">{f}</span>)}</div>
      )}
      {reasons.length > 0 && <div className="lst-card__reasons">{reasons.join(' · ')}</div>}
    </div>
  );
};

const ListingsTab = () => {
  const [listings, setListings] = useState([]);
  const [dealType, setDealType] = useState('전체');
  const [matchedOnly, setMatchedOnly] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const [collectStatus, setCollectStatus] = useState(null);
  const [collecting, setCollecting] = useState(false);

  const load = async () => {
    setLoading(true);
    setError('');
    try {
      const data = await getListings({
        deal_type: dealType === '전체' ? undefined : dealType,
        matched_only: matchedOnly,
        size: 50,
      });
      setListings(Array.isArray(data) ? data : (data.listings ?? []));
    } catch (e) {
      setError(e?.message ?? String(e));
      setListings([]);
    } finally {
      setLoading(false);
    }
  };

  const loadStatus = async () => {
    try { setCollectStatus(await getListingsCollectStatus()); } catch { /* noop */ }
  };

  useEffect(() => { load(); }, [dealType, matchedOnly]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => { loadStatus(); }, []);

  const handleCollect = async () => {
    setCollecting(true);
    try {
      await collectListings();
      setTimeout(() => { loadStatus(); load(); setCollecting(false); }, 3000);
    } catch (e) {
      setError(e?.message ?? String(e));
      setCollecting(false);
    }
  };

  return (
    <div className="lst-tab">
      <div className="lst-toolbar">
        <div className="lst-filter">
          {DEAL_TYPES.map((d) => (
            <button key={d} type="button" className={`lst-filter-btn ${dealType === d ? 'is-active' : ''}`}
              onClick={() => setDealType(d)}>{d}</button>
          ))}
        </div>
        <div className="lst-toolbar__right">
          <button type="button" className={`lst-filter-btn ${matchedOnly ? 'is-active' : ''}`}
            onClick={() => setMatchedOnly((v) => !v)}>매칭만</button>
          <button type="button" className="lst-filter-btn is-primary" onClick={handleCollect} disabled={collecting}>
            {collecting ? '수집 중…' : '수집 실행'}
          </button>
        </div>
      </div>

      {collectStatus && (
        <p className="lst-status">
          수집 상태: {collectStatus.status === 'never_run' ? '미실행' : collectStatus.status}
          {collectStatus.total_count != null && ` · 총 ${collectStatus.total_count}건`}
          {collectStatus.error && <span style={{ color: '#f87171' }}> · 오류: {collectStatus.error}</span>}
        </p>
      )}

      {error && <p className="lst-error">{error}</p>}

      {loading ? (
        <p className="lst-empty">불러오는 </p>
      ) : listings.length === 0 ? (
        <p className="lst-empty">조건에 맞는 매물이 없습니다. 수집 실행으로 매물을 모아보세요.</p>
      ) : (
        <div className="lst-grid">
          {listings.map((l) => <ListingCard key={l.id ?? `${l.complex_name}-${l.dong}`} l={l} />)}
        </div>
      )}
    </div>
  );
};

export default ListingsTab;
  • Step 4: Run test to verify it passes

Run: npm run test:run -- src/pages/listings/components/ListingsTab.test.jsx Expected: PASS (2 케이스).

  • Step 5: Commit
git add src/pages/listings/components/ListingsTab.jsx src/pages/listings/components/ListingsTab.test.jsx
git commit -m "feat(listings): 매물 목록 탭(필터·판정 tier·수집) + 스모크 테스트"

Task 4: ToolsTab.jsx (안전마진 체커 + 예산 계산기)

Files:

  • Create: src/pages/listings/components/ToolsTab.jsx
  • Test: src/pages/listings/components/ToolsTab.test.jsx

Interfaces:

  • Consumes (Task 1): tierMeta, formatMoney, formatRatio. (Task 2): safetyCheck, budgetCalc.

  • Produces: ToolsTab 기본 export (자립형).

  • Step 1: Write the failing smoke test — Create src/pages/listings/components/ToolsTab.test.jsx:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';

vi.mock('../../../api', () => ({ safetyCheck: vi.fn(), budgetCalc: vi.fn() }));
import ToolsTab from './ToolsTab.jsx';

beforeEach(() => vi.clearAllMocks());

describe('ToolsTab', () => {
  it('안전마진·예산 카드 헤딩과 disclaimer 안내를 렌더', () => {
    render(<ToolsTab />);
    expect(screen.getByText('안전마진 체커')).toBeInTheDocument();
    expect(screen.getByText('예산 계산기')).toBeInTheDocument();
    // 제출 버튼 존재
    expect(screen.getAllByRole('button', { name: /판정|계산/ }).length).toBeGreaterThan(0);
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test:run -- src/pages/listings/components/ToolsTab.test.jsx Expected: FAIL — Failed to resolve import "./ToolsTab.jsx".

  • Step 3: Write the component — Create src/pages/listings/components/ToolsTab.jsx:
import React, { useState } from 'react';
import { safetyCheck, budgetCalc } from '../../../api';
import { tierMeta, formatMoney, formatRatio } from '../listingsUtils';

const DEAL_TYPES = ['전세', '반전세', '매매'];

/* ── 안전마진 체커 ── */
const SafetyCard = () => {
  const [form, setForm] = useState({ area: '', deal_type: '전세', amount: '', dong: '' });
  const [res, setRes] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));

  const submit = async (e) => {
    e.preventDefault();
    if (!form.area || !form.amount) return;
    setLoading(true); setError(''); setRes(null);
    try {
      const r = await safetyCheck({
        area: Number(form.area), deal_type: form.deal_type,
        amount: Number(form.amount), dong: form.dong || undefined,
      });
      setRes(r);
    } catch (e2) { setError(e2?.message ?? String(e2)); }
    finally { setLoading(false); }
  };

  const isSale = form.deal_type === '매매';
  const meta = res ? tierMeta(isSale ? 'valuation' : 'safety', res.tier) : null;

  return (
    <section className="lst-panel">
      <h3 className="lst-panel__title">안전마진 체커</h3>
      <p className="lst-panel__sub">면적·거래유형·금액으로 전세가율/호가율 적정성을 즉시 판정합니다.</p>
      <form className="lst-form" onSubmit={submit}>
        <input className="lst-input" placeholder="면적(m²)" value={form.area}
          onChange={(e) => set('area', e.target.value)} />
        <select className="lst-input" value={form.deal_type} onChange={(e) => set('deal_type', e.target.value)}>
          {DEAL_TYPES.map((d) => <option key={d} value={d}>{d}</option>)}
        </select>
        <input className="lst-input" placeholder="금액(만원)" value={form.amount}
          onChange={(e) => set('amount', e.target.value)} />
        <input className="lst-input" placeholder="동(선택)" value={form.dong}
          onChange={(e) => set('dong', e.target.value)} />
        <button className="lst-filter-btn is-primary" type="submit" disabled={loading || !form.area || !form.amount}>
          {loading ? '판정 중…' : '판정'}
        </button>
      </form>
      {error && <p className="lst-error">{error}</p>}
      {res && (
        <div className="lst-result">
          <div className="lst-result__row">
            <span className="lst-tier" style={{ color: meta.color }}>{meta.emoji} {meta.label}</span>
            {res.ratio != null && <span>비율 {formatRatio(res.ratio)}</span>}
            {res.median != null && <span>중앙값 {formatMoney(res.median)}</span>}
            {res.sample != null && <span>표본 {res.sample}</span>}
            {res.is_toheo && <span className="lst-flag">토지거래허가</span>}
          </div>
          {res.disclaimer && <p className="lst-disclaimer"> {res.disclaimer}</p>}
        </div>
      )}
    </section>
  );
};

/* ── 예산 계산기 ── */
const BudgetCard = () => {
  const [form, setForm] = useState({
    equity: '', annual_income: '', is_homeless: true, is_householder: true, is_first_home: false, target_dong: '',
  });
  const [res, setRes] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));

  const submit = async (e) => {
    e.preventDefault();
    if (!form.equity) return;
    setLoading(true); setError(''); setRes(null);
    try {
      const r = await budgetCalc({
        equity: Number(form.equity),
        annual_income: form.annual_income ? Number(form.annual_income) : null,
        is_homeless: form.is_homeless ? 1 : 0,
        is_householder: form.is_householder ? 1 : 0,
        is_first_home: form.is_first_home ? 1 : 0,
        target_dong: form.target_dong || undefined,
      });
      setRes(r);
    } catch (e2) { setError(e2?.message ?? String(e2)); }
    finally { setLoading(false); }
  };

  return (
    <section className="lst-panel">
      <h3 className="lst-panel__title">예산 계산기</h3>
      <p className="lst-panel__sub">자기자본·소득 기준 전세/매매 한도와 지역 규제를 계산합니다.</p>
      <form className="lst-form" onSubmit={submit}>
        <input className="lst-input" placeholder="자기자본(만원)" value={form.equity}
          onChange={(e) => set('equity', e.target.value)} />
        <input className="lst-input" placeholder="연소득(만원, 선택)" value={form.annual_income}
          onChange={(e) => set('annual_income', e.target.value)} />
        <input className="lst-input" placeholder="대상 동(선택)" value={form.target_dong}
          onChange={(e) => set('target_dong', e.target.value)} />
        <label className="lst-check"><input type="checkbox" checked={form.is_homeless}
          onChange={(e) => set('is_homeless', e.target.checked)} /> 무주택</label>
        <label className="lst-check"><input type="checkbox" checked={form.is_householder}
          onChange={(e) => set('is_householder', e.target.checked)} /> 세대주</label>
        <label className="lst-check"><input type="checkbox" checked={form.is_first_home}
          onChange={(e) => set('is_first_home', e.target.checked)} /> 생애최초</label>
        <button className="lst-filter-btn is-primary" type="submit" disabled={loading || !form.equity}>
          {loading ? '계산 중…' : '계산'}
        </button>
      </form>
      {error && <p className="lst-error">{error}</p>}
      {res && (
        <div className="lst-result">
          {res.jeonse && (
            <div className="lst-result__block">
              <strong>전세</strong>
              <span>대출한도 {formatMoney(res.jeonse.loan_limit)}</span>
              <span>최대 보증금 {formatMoney(res.jeonse.max_deposit)}</span>
              {res.jeonse.notes && <span className="lst-note">{res.jeonse.notes}</span>}
            </div>
          )}
          {res.purchase && (
            <div className="lst-result__block">
              <strong>매매</strong>
              {res.purchase.ltv_pct != null && <span>LTV {res.purchase.ltv_pct}%</span>}
              <span>대출한도 {formatMoney(res.purchase.loan_cap)}</span>
              <span>최대 매수가 {formatMoney(res.purchase.max_price)}</span>
              {res.purchase.dsr_note && <span className="lst-note">{res.purchase.dsr_note}</span>}
              {Array.isArray(res.purchase.regulation_flags) && res.purchase.regulation_flags.length > 0 && (
                <div className="lst-flags">{res.purchase.regulation_flags.map((f, i) => <span key={i} className="lst-flag">{f}</span>)}</div>
              )}
            </div>
          )}
          {res.region && (
            <div className="lst-result__block">
              <strong>지역</strong>
              {res.region.is_toheo && <span className="lst-flag">토지거래허가</span>}
              {res.region.is_regulated && <span className="lst-flag">규제지역</span>}
              {res.region.notes && <span className="lst-note">{res.region.notes}</span>}
            </div>
          )}
          {res.disclaimer && <p className="lst-disclaimer"> {res.disclaimer}</p>}
        </div>
      )}
    </section>
  );
};

const ToolsTab = () => (
  <div className="lst-tools">
    <SafetyCard />
    <BudgetCard />
  </div>
);

export default ToolsTab;
  • Step 4: Run test to verify it passes

Run: npm run test:run -- src/pages/listings/components/ToolsTab.test.jsx Expected: PASS (1 케이스).

  • Step 5: Commit
git add src/pages/listings/components/ToolsTab.jsx src/pages/listings/components/ToolsTab.test.jsx
git commit -m "feat(listings): 판정 도구 탭(안전마진 체커+예산 계산기, disclaimer) + 스모크"

Task 5: CriteriaTab.jsx (조건 편집)

Files:

  • Create: src/pages/listings/components/CriteriaTab.jsx
  • Test: src/pages/listings/components/CriteriaTab.test.jsx

Interfaces:

  • Consumes (Task 2): getListingsCriteria, putListingsCriteria.

  • Produces: CriteriaTab 기본 export (자립형).

  • Step 1: Write the failing smoke test — Create src/pages/listings/components/CriteriaTab.test.jsx:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';

vi.mock('../../../api', () => ({ getListingsCriteria: vi.fn(), putListingsCriteria: vi.fn() }));
import { getListingsCriteria } from '../../../api';
import CriteriaTab from './CriteriaTab.jsx';

beforeEach(() => {
  vi.clearAllMocks();
  getListingsCriteria.mockResolvedValue({
    id: 1, dongs: ['신대방동', '상도동'], deal_types: ['전세', '매매'], max_deposit: 32000,
    max_sale_price: null, min_area: 40, house_types: ['아파트'], min_safety_tier: null,
    notify_enabled: 1, equity: null, annual_income: null, is_homeless: 1, is_householder: 0, is_first_home: 0,
  });
});

describe('CriteriaTab', () => {
  it('criteria를 로드해 자기자본·연소득 입력과 동 목록을 렌더', async () => {
    render(<CriteriaTab />);
    await waitFor(() => expect(screen.getByLabelText(/자기자본/)).toBeInTheDocument());
    expect(screen.getByLabelText(/연소득/)).toBeInTheDocument();
    expect(screen.getByDisplayValue('신대방동, 상도동')).toBeInTheDocument();
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test:run -- src/pages/listings/components/CriteriaTab.test.jsx Expected: FAIL — Failed to resolve import "./CriteriaTab.jsx".

  • Step 3: Write the component — Create src/pages/listings/components/CriteriaTab.jsx:
import React, { useEffect, useState } from 'react';
import { getListingsCriteria, putListingsCriteria } from '../../../api';

const DEAL_TYPES = ['전세', '반전세', '매매'];
const HOUSE_TYPES = ['아파트', '오피스텔', '빌라'];
const SAFETY_TIERS = ['', '안전', '주의', '위험'];

const toList = (s) => String(s ?? '').split(',').map((x) => x.trim()).filter(Boolean);
const numOrNull = (v) => (v === '' || v == null ? null : Number(v));

const CriteriaTab = () => {
  const [c, setC] = useState(null);       // 편집 중 폼 상태 (dongs/house_types는 문자열/배열)
  const [loading, setLoading] = useState(true);
  const [saving, setSaving] = useState(false);
  const [msg, setMsg] = useState('');

  useEffect(() => {
    (async () => {
      setLoading(true);
      try {
        const data = await getListingsCriteria();
        setC({ ...data, dongs: (data.dongs ?? []).join(', ') });
      } catch (e) {
        setMsg('불러오기 실패: ' + (e?.message ?? e));
      } finally { setLoading(false); }
    })();
  }, []);

  const set = (k, v) => setC((prev) => ({ ...prev, [k]: v }));
  const toggleArr = (k, val) => setC((prev) => {
    const arr = Array.isArray(prev[k]) ? prev[k] : [];
    return { ...prev, [k]: arr.includes(val) ? arr.filter((x) => x !== val) : [...arr, val] };
  });

  const save = async () => {
    setSaving(true); setMsg('');
    try {
      const payload = {
        dongs: toList(c.dongs),
        deal_types: c.deal_types ?? [],
        house_types: c.house_types ?? [],
        max_deposit: numOrNull(c.max_deposit),
        max_sale_price: numOrNull(c.max_sale_price),
        min_area: numOrNull(c.min_area),
        equity: numOrNull(c.equity),
        annual_income: numOrNull(c.annual_income),
        min_safety_tier: c.min_safety_tier || null,
        notify_enabled: c.notify_enabled ? 1 : 0,
        is_homeless: c.is_homeless ? 1 : 0,
        is_householder: c.is_householder ? 1 : 0,
        is_first_home: c.is_first_home ? 1 : 0,
      };
      const updated = await putListingsCriteria(payload);
      setC({ ...updated, dongs: (updated.dongs ?? []).join(', ') });
      setMsg('저장 완료');
      setTimeout(() => setMsg(''), 2000);
    } catch (e) {
      setMsg('저장 실패: ' + (e?.message ?? e));
    } finally { setSaving(false); }
  };

  if (loading || !c) return <p className="lst-empty">불러오는 </p>;

  return (
    <section className="lst-panel">
      <div className="lst-panel__head">
        <div>
          <h3 className="lst-panel__title">매물 매칭 조건</h3>
          <p className="lst-panel__sub">자기자본·연소득을 넣어야 매매 예산상한·전세 max_deposit이 매칭에 반영됩니다.</p>
        </div>
        <div className="lst-panel__actions">
          {msg && <span className={msg.startsWith('저장 완료') ? 'lst-ok' : 'lst-error'}>{msg}</span>}
          <button className="lst-filter-btn is-primary" onClick={save} disabled={saving}>
            {saving ? '저장 중…' : '저장'}
          </button>
        </div>
      </div>

      <div className="lst-criteria">
        <label className="lst-field">
          <span>관심  (쉼표 구분)</span>
          <input className="lst-input" value={c.dongs} onChange={(e) => set('dongs', e.target.value)} />
        </label>

        <div className="lst-field">
          <span>거래 유형</span>
          <div className="lst-chks">
            {DEAL_TYPES.map((d) => (
              <label key={d} className="lst-check"><input type="checkbox"
                checked={(c.deal_types ?? []).includes(d)} onChange={() => toggleArr('deal_types', d)} /> {d}</label>
            ))}
          </div>
        </div>

        <div className="lst-field">
          <span>주택 유형</span>
          <div className="lst-chks">
            {HOUSE_TYPES.map((h) => (
              <label key={h} className="lst-check"><input type="checkbox"
                checked={(c.house_types ?? []).includes(h)} onChange={() => toggleArr('house_types', h)} /> {h}</label>
            ))}
          </div>
        </div>

        <label className="lst-field">
          <span>전세 최대 보증금 (만원)</span>
          <input className="lst-input" value={c.max_deposit ?? ''} onChange={(e) => set('max_deposit', e.target.value)} />
        </label>
        <label className="lst-field">
          <span>매매 예산 상한 (만원)</span>
          <input className="lst-input" value={c.max_sale_price ?? ''} onChange={(e) => set('max_sale_price', e.target.value)} />
        </label>
        <label className="lst-field">
          <span>최소 면적 ()</span>
          <input className="lst-input" value={c.min_area ?? ''} onChange={(e) => set('min_area', e.target.value)} />
        </label>
        <label className="lst-field">
          <span>자기자본 (만원)</span>
          <input className="lst-input" value={c.equity ?? ''} onChange={(e) => set('equity', e.target.value)} />
        </label>
        <label className="lst-field">
          <span>연소득 (만원)</span>
          <input className="lst-input" value={c.annual_income ?? ''} onChange={(e) => set('annual_income', e.target.value)} />
        </label>
        <label className="lst-field">
          <span>최소 안전등급</span>
          <select className="lst-input" value={c.min_safety_tier ?? ''} onChange={(e) => set('min_safety_tier', e.target.value)}>
            {SAFETY_TIERS.map((t) => <option key={t} value={t}>{t || '제한 없음'}</option>)}
          </select>
        </label>

        <div className="lst-field lst-field--chks">
          <label className="lst-check"><input type="checkbox" checked={!!c.is_homeless}
            onChange={(e) => set('is_homeless', e.target.checked)} /> 무주택</label>
          <label className="lst-check"><input type="checkbox" checked={!!c.is_householder}
            onChange={(e) => set('is_householder', e.target.checked)} /> 세대주</label>
          <label className="lst-check"><input type="checkbox" checked={!!c.is_first_home}
            onChange={(e) => set('is_first_home', e.target.checked)} /> 생애최초</label>
          <label className="lst-check"><input type="checkbox" checked={!!c.notify_enabled}
            onChange={(e) => set('notify_enabled', e.target.checked)} /> 텔레그램 알림</label>
        </div>
      </div>
    </section>
  );
};

export default CriteriaTab;
  • Step 4: Run test to verify it passes

Run: npm run test:run -- src/pages/listings/components/CriteriaTab.test.jsx Expected: PASS (1 케이스).

  • Step 5: Commit
git add src/pages/listings/components/CriteriaTab.jsx src/pages/listings/components/CriteriaTab.test.jsx
git commit -m "feat(listings): 조건 편집 탭(equity·소득 포함 부분 PUT) + 스모크"

Task 6: Listings shell + 스타일 + 라우팅 배선 + 문서

Files:

  • Create: src/pages/listings/Listings.jsx
  • Create: src/pages/listings/Listings.css
  • Modify: src/routes.jsx (lazy import + appRoutes 항목)
  • Modify: src/pages/subscription/Subscription.jsx (헤더에 "실매물 →" 링크 — 안전한 최소 삽입)
  • Modify: CLAUDE.md (web-ui 루트, 페이지 구조 표 + API 표)
  • Test: src/pages/listings/Listings.test.jsx

Interfaces:

  • Consumes (Task 3/4/5): ListingsTab, ToolsTab, CriteriaTab.

  • Produces: Listings 기본 export (라우트 엘리먼트).

  • Step 1: Write the failing smoke test — Create src/pages/listings/Listings.test.jsx:

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';

vi.mock('./components/ListingsTab.jsx', () => ({ default: () => <div>LISTINGS_TAB</div> }));
vi.mock('./components/ToolsTab.jsx', () => ({ default: () => <div>TOOLS_TAB</div> }));
vi.mock('./components/CriteriaTab.jsx', () => ({ default: () => <div>CRITERIA_TAB</div> }));
vi.mock('react-router-dom', () => ({ Link: ({ children }) => <a>{children}</a> }));
import Listings from './Listings.jsx';

describe('Listings', () => {
  it('기본 매물 탭 렌더 + 탭 전환', () => {
    render(<Listings />);
    expect(screen.getByText('LISTINGS_TAB')).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '판정 도구' }));
    expect(screen.getByText('TOOLS_TAB')).toBeInTheDocument();
    fireEvent.click(screen.getByRole('button', { name: '조건' }));
    expect(screen.getByText('CRITERIA_TAB')).toBeInTheDocument();
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test:run -- src/pages/listings/Listings.test.jsx Expected: FAIL — Failed to resolve import "./Listings.jsx".

  • Step 3: Write the shell — Create src/pages/listings/Listings.jsx:
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import ListingsTab from './components/ListingsTab';
import ToolsTab from './components/ToolsTab';
import CriteriaTab from './components/CriteriaTab';
import './Listings.css';

const TABS = [
  { id: 'listings', label: '매물' },
  { id: 'tools', label: '판정 도구' },
  { id: 'criteria', label: '조건' },
];

const Listings = () => {
  const [tab, setTab] = useState('listings');
  return (
    <div className="lst">
      <header className="lst-header">
        <div>
          <p className="lst-kicker">실매물 · 안전마진</p>
          <h1>매물 · 안전마진</h1>
          <p className="lst-sub">매매/전세 실매물 수집 · 전세가율·호가율 판정 · 예산 계산.</p>
        </div>
        <Link className="lst-filter-btn" to="/realestate">청약으로 </Link>
      </header>

      <div className="lst-tabbar">
        {TABS.map((t) => (
          <button key={t.id} type="button"
            className={`lst-tabbtn ${tab === t.id ? 'is-active' : ''}`}
            onClick={() => setTab(t.id)}>{t.label}</button>
        ))}
      </div>

      {tab === 'listings' && <ListingsTab />}
      {tab === 'tools' && <ToolsTab />}
      {tab === 'criteria' && <CriteriaTab />}

      <p className="lst-foot-disclaimer">
         모든 판정·계산은 참고용 근사치입니다. 등기부 선순위·토지거래허가·정책 수치는 반드시 원문/전문가로 확인하세요.
      </p>
    </div>
  );
};

export default Listings;
  • Step 4: Write the stylesheet — Create src/pages/listings/Listings.css:
/* 실매물 매물·안전마진 (lst-*) — Subscription 다크 토큰 재사용 */
.lst { display: grid; gap: 18px; padding: 20px; max-width: 1100px; margin: 0 auto; }
.lst-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.lst-kicker { margin: 0 0 4px; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; color: #f43f5e; }
.lst-header h1 { margin: 0; font-size: 24px; color: var(--text-bright); }
.lst-sub { margin: 6px 0 0; color: var(--text-muted); font-size: 13px; }

.lst-tabbar { display: flex; gap: 6px; border-bottom: 1px solid var(--line); }
.lst-tabbtn { border: none; background: none; color: var(--text-dim); padding: 10px 16px; cursor: pointer; font-size: 14px; border-bottom: 2px solid transparent; }
.lst-tabbtn.is-active { color: var(--text-bright); border-bottom-color: #f43f5e; }

.lst-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
.lst-toolbar__right { display: flex; gap: 6px; }
.lst-filter { display: flex; gap: 4px; flex-wrap: wrap; }
.lst-filter-btn { border: 1px solid var(--line); background: var(--surface); color: var(--text-dim); border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; text-decoration: none; }
.lst-filter-btn.is-active { color: var(--text-bright); border-color: #f43f5e; }
.lst-filter-btn.is-primary { color: #fff; background: #f43f5e; border-color: #f43f5e; }
.lst-filter-btn:disabled { opacity: 0.5; cursor: default; }

.lst-status { font-size: 12px; color: var(--text-muted); margin: 0; }
.lst-error { color: #f9b6b1; border: 1px solid rgba(249,182,177,0.4); border-radius: 12px; padding: 10px; background: rgba(249,182,177,0.1); margin: 0; font-size: 13px; }
.lst-ok { color: #34d399; font-size: 12px; }
.lst-empty { color: var(--text-muted); text-align: center; padding: 32px 0; }

.lst-grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.lst-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 12px 14px; display: grid; gap: 6px; }
.lst-card__head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.lst-tier { font-size: 12px; font-weight: 700; }
.lst-card__name { font-size: 14px; color: var(--text-bright); }
.lst-card__deal { font-size: 11px; color: #60a5fa; }
.lst-card__dong { font-size: 11px; color: var(--text-muted); }
.lst-card__body { display: flex; gap: 10px; flex-wrap: wrap; font-size: 12px; color: var(--text-dim); }
.lst-card__price { color: #f59e0b; font-weight: 600; }
.lst-card__reasons { font-size: 11px; color: var(--text-muted); }
.lst-flags { display: flex; gap: 4px; flex-wrap: wrap; }
.lst-flag { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: rgba(244,63,94,0.12); color: #f87171; }

.lst-tools { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.lst-panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 16px; display: grid; gap: 10px; }
.lst-panel__head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
.lst-panel__actions { display: flex; gap: 8px; align-items: center; }
.lst-panel__title { margin: 0; font-size: 15px; color: var(--text-bright); }
.lst-panel__sub { margin: 0; font-size: 12px; color: var(--text-muted); }
.lst-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.lst-input { padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-raised); color: inherit; font-size: 13px; min-width: 100px; flex: 1 1 100px; }
.lst-check { font-size: 12px; color: var(--text-dim); display: inline-flex; gap: 4px; align-items: center; }
.lst-chks { display: flex; gap: 12px; flex-wrap: wrap; }
.lst-result { display: grid; gap: 8px; padding-top: 6px; border-top: 1px solid var(--line); }
.lst-result__row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 13px; align-items: center; }
.lst-result__block { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; font-size: 13px; }
.lst-result__block strong { color: var(--text-bright); }
.lst-note { font-size: 11px; color: var(--text-muted); }
.lst-disclaimer { font-size: 11px; color: #cbd5e1; margin: 0; line-height: 1.45; }

.lst-criteria { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.lst-field { display: grid; gap: 4px; font-size: 12px; color: var(--text-dim); }
.lst-field > span { font-size: 11px; color: var(--text-muted); }
.lst-field--chks { grid-column: 1 / -1; display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }

.lst-foot-disclaimer { font-size: 11px; color: #64748b; text-align: center; margin: 8px 0 0; }

@media (max-width: 640px) {
  .lst { padding: 12px; }
  .lst-tools { grid-template-columns: 1fr; }
}
  • Step 5: Wire the routesrc/routes.jsx:

(a) 상단 lazy import 블록에 (다른 const X = lazy(...) 근처, 예: Subscription import 뒤) 추가:

const Listings = lazy(() => import('./pages/listings/Listings'));

(b) appRoutes 배열의 { path: 'realestate', element: <Subscription /> }, 바로 뒤에 추가:

    {
        path: 'realestate/listings',
        element: <Listings />,
    },
  • Step 6: Add cross-link in Subscriptionsrc/pages/subscription/Subscription.jsx:

import { apiGet, apiPost, apiPut, apiDelete } from '../../api'; 뒤(파일 상단 import 블록)에 추가:

import { Link } from 'react-router-dom';

그리고 const TABS = ['대시보드', '공고 목록', '매칭 결과', '내 프로필']; 를 사용하는 최상위 탭바 렌더 지점에 "실매물" 링크를 넣기 어려우면(파일이 큼), 최소 침습으로: 메인 반환 JSX 최상단(페이지 컨테이너 여는 태그 직후)에 아래 배너 한 줄을 추가. 정확한 앵커는 return ( 이후 첫 번째 최상위 <div 자식 시작 지점. 구현자는 파일을 읽고 페이지 헤더 영역을 찾아 삽입:

<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
  <Link to="/realestate/listings" style={{ fontSize: 12, color: '#f43f5e', textDecoration: 'none' }}>
    실매물 · 안전마진 
  </Link>
</div>

앵커를 확실히 못 찾으면 이 링크 삽입은 생략하고 report에 남길 것(라우트는 Task 6 Step 5로 이미 접근 가능). Listings→청약 링크는 shell에 이미 있음.

  • Step 7: Run tests + lint + build

Run: npm run test:run Expected: 전체 통과 (신규 5개 테스트 파일 포함), 기존 회귀 없음. 통과 수 기록.

Run: npm run lint Expected: 신규 파일 관련 에러 0 (기존 사전 경고 무시).

Run: npm run build Expected: ✓ built 성공.

  • Step 8: Update web-ui/CLAUDE.md — 페이지 구조 표에 행 추가(/realestate/property 행 근처):
| `/realestate/listings` | `Listings` | 실매물 매매/전세 — 매물 목록+판정 tier / 안전마진 체커·예산 계산기 / 조건 편집 (3탭) |

API 엔드포인트 표에 realestate listings 8행 추가(부동산 섹션 근처):

| 부동산-매물 | GET | `/api/realestate/listings?dong=&deal_type=&tier=&matched_only=&page=&size=` — { listings: [...] } |
| 부동산-매물 | POST/GET | `/api/realestate/listings/collect`, `/collect/status` |
| 부동산-매물 | GET/PUT | `/api/realestate/listings/criteria` (부분 업데이트) |
| 부동산-매물 | GET | `/api/realestate/listings/matches` — { matches: [...] } |
| 부동산-안전마진 | POST | `/api/realestate/safety-check` — body { area, deal_type, amount(만원), dong? } → { median, ratio, tier, sample, is_toheo, disclaimer } |
| 부동산-예산 | POST | `/api/realestate/budget` — body { equity, annual_income, is_homeless, is_householder, is_first_home, target_dong? } → { jeonse, purchase, region, disclaimer } |
  • Step 9: Commit
git add src/pages/listings/Listings.jsx src/pages/listings/Listings.css src/pages/listings/Listings.test.jsx src/routes.jsx src/pages/subscription/Subscription.jsx CLAUDE.md
git commit -m "feat(listings): Listings shell+스타일+라우트 배선 + 청약 교차링크 + 문서"
  • Step 10: Manual verification (dev server)

npm run devhttp://localhost:3007/realestate/listings 접속. 3탭(매물/판정도구/조건) 전환, 조건 탭 로드(criteria 200), 안전마진·예산 폼 제출 → disclaimer 노출 확인. 매물은 수집 전이라 빈 상태 안내가 정상. (수집 실행 후 실데이터로 매물 카드 필드 확인 — 필요 시 후속 조정.)


Self-Review 결과

Spec coverage (설계 §1§10):

  • §2 계약 8종 → Task 2
  • §3 라우팅·교차링크 → Task 6
  • §4 컴포넌트 구조(utils+3탭+shell) → Task 1/3/4/5/6
  • §5 API 레이어 → Task 2
  • §6 안전장치(disclaimer·보류·배열 그대로·방어적 파싱) → Task 3(ListingCard)·Task 4(disclaimer)·Task 5
  • §7 스타일 lst-* → Task 6
  • §8 테스트 → Task 1(utils)·Task 3/4/5/6(스모크)
  • §9 완료 기준 → Task 6 Step 7·10
  • §10 리스크(매물 아이템 방어적 렌더) → Task 3 ListingCard 옵셔널 필드

Placeholder scan: 모든 코드/명령/기대출력 구체값. Task 6 Step 6의 Subscription 링크 앵커는 "못 찾으면 생략+report"로 명시(플레이스홀더 아님, 안전 폴백).

Type consistency: tierMeta(kind,tier)/formatMoney/formatRatio(Task1) ↔ Task3/4 사용 일치. api 헬퍼 8종(Task2) ↔ Task3/4/5 import 일치. ListingsTab/ToolsTab/CriteriaTab 기본 export ↔ Task6 shell import 일치.

참고 — routes.jsx/Subscription 라인: 근사치. 실제 편집은 앵커 문자열로 확인 후 삽입.