Files
web-page-backend/docs/superpowers/specs/2026-04-28-realestate-frontend-targeting-design.md
gahusb 756f280bbc docs(spec): 청약 타겟팅 프론트엔드 설계
- DistrictTierEditor: 데스크톱 드래그&드롭 + 모바일 read-only
- NotificationSettings: 임계값 슬라이더 + 알림 토글
- AnnouncementCard/MatchesTab: district + 5티어 뱃지
- AnnouncementDetail: 매칭 분석 섹션 (점수 + reasons + 자격)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:40:36 +09:00

16 KiB
Raw Blame History

청약 타겟팅 프론트엔드 설계 — 자치구 5티어 + 알림 설정

대상: web-ui/src/pages/subscription/ 백엔드 의존: 2026-04-28-realestate-targeting-enhancement-design.md (이미 배포됨) 후속 별도 스펙: Subscription.jsx 분할 리팩토링, 5축 progress bar, 추가 알림 채널


1. 목표

백엔드 청약 타겟팅 고도화로 추가된 3 프로필 필드(preferred_districts, min_match_score, notify_enabled)를 프론트 UI에 노출한다. 매칭 결과·공고 카드에는 자치구 + 5티어 뱃지를, 상세 모달에는 매칭 사유 텍스트를 추가해 사용자가 점수의 근거를 즉시 이해할 수 있게 한다.

핵심 변경

  • ProfileTab: 자치구 5티어 분류(드래그&드롭, PC 전용) + 임계값 슬라이더 + 알림 토글
  • 모바일: 자치구 분류는 read-only — "PC에서 편집해주세요" 안내
  • 카드 표시: AnnouncementCard / 매칭 카드에 district 뱃지 + 5티어 뱃지(reasons에서 derive)
  • 상세 모달: AnnouncementDetail에 "매칭 분석" 섹션 (점수 + reasons 텍스트 + 자격)

변경하지 않는 것

  • Subscription.jsx 자체 분할 — 본 스코프 외(별도 리팩토링)
  • 백엔드 응답 형태 — 모든 필요 데이터는 이미 응답에 포함됨
  • 5축 점수 분해 시각화 — 백엔드 응답 변경 필요(별도)
  • 알림 채널 추가 — 텔레그램 외 이메일/Slack은 별도

2. 컴포넌트 분할

2.1 신규 컴포넌트 2개

파일 책임 추정 크기
web-ui/src/pages/subscription/components/DistrictTierEditor.jsx 자치구 5티어 드래그&드롭 + 모바일 read-only ~180줄
web-ui/src/pages/subscription/components/NotificationSettings.jsx 임계값 슬라이더 + 알림 토글 + 미리보기 ~80줄

ProfileTab(현재 343줄)에 그대로 추가하면 단일 함수가 거대화되어 가독성·유지보수가 떨어진다. 의미 단위로 분할.

2.2 변경 받는 기존 컴포넌트

컴포넌트 (파일: Subscription.jsx) 변경
ProfileTab (956~1299줄) 신규 컴포넌트 2개 import + 자치구 섹션 / 알림 설정 섹션 렌더 + handleSave에서 신규 3필드 송신
AnnouncementCard (315~389줄) district 뱃지 + 5티어 뱃지(extractTier(reasons))
AnnouncementDetail (390~595줄) "매칭 분석" 섹션 추가 (점수 + reasons + eligible_types)
MatchesTab (763~955줄) 매치 카드에 district + 5티어 뱃지 + reasons 표시
모듈 상단 DEFAULT_PROFILE에 신규 3필드 기본값 추가, extractTier 헬퍼 함수

2.3 스타일

  • Subscription.css: 5티어 뱃지 5 클래스(.sub-chip--tier-S~D), 드래그&드롭 hover/dragover, 슬라이더, 토글, district 뱃지

2.4 기각된 대안

대안 기각 사유
단일 파일에 모든 신규 UI ProfileTab이 500줄+ 거대화, 디버깅 어려움
Subscription.jsx 자체 분할 본 작업 스코프 외, 별도 리팩토링이 적절
react-dnd 도입 의존성 +50KB, 모바일 어차피 사용 안 함. YAGNI
5칼럼 체크박스 그리드 모바일/데스크톱 둘 다 무난하지만 드래그&드롭이 더 직관적이라 채택 안 함

3. DistrictTierEditor 컴포넌트

3.1 인터페이스

<DistrictTierEditor
  value={preferredDistricts}    // {"S":["강남구",...], "A":[...], "B":[...], "C":[...], "D":[...]}
  onChange={(next) => setProfile({...profile, preferred_districts: next})}
/>

value가 비어있거나 누락되면 빈 객체 fallback. onChange는 새 객체를 항상 한 번 호출(부모는 setState만 처리).

3.2 상수

const SEOUL_DISTRICTS = [
  "강남구","강동구","강북구","강서구","관악구",
  "광진구","구로구","금천구","노원구","도봉구",
  "동대문구","동작구","마포구","서대문구","서초구",
  "성동구","성북구","송파구","양천구","영등포구",
  "용산구","은평구","종로구","중구","중랑구",
];

const TIERS = [
  { key: "S", label: "S", weight: "100%" },
  { key: "A", label: "A", weight: "80%"  },
  { key: "B", label: "B", weight: "60%"  },
  { key: "C", label: "C", weight: "40%"  },
  { key: "D", label: "D", weight: "20%"  },
];

const EMPTY_TIERS = { S:[], A:[], B:[], C:[], D:[] };

3.3 데스크톱 레이아웃 (≥768px)

┌─ 자치구 우선순위 ─────────────────────────────────────────┐
│ 미할당 (드래그해서 분류)                                    │
│ [강서구] [노원구] [도봉구] [중랑구] [관악구] ...           │
│                                                            │
│ ┌─ S 100% ─┐ ┌─ A 80% ─┐ ┌─ B 60% ─┐ ┌─ C 40% ─┐ ┌─ D 20% ─┐│
│ │[강남구]× │ │[송파구]× │ │          │ │          │ │          ││
│ │[서초구]× │ │[마포구]× │ │          │ │          │ │          ││
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘│
└────────────────────────────────────────────────────────────┘
  • 5티어는 가로 5칼럼 그리드(grid-template-columns: repeat(5, 1fr))
  • 미할당 풀은 그리드 위, 가로 wrap
  • 자치구 칩은 <span draggable="true"> + <button>×</button> (× 클릭 시 미할당으로 복귀)
  • 각 티어 슬롯은 dropzone(onDragOver + onDrop)
  • 미할당 풀도 dropzone(드래그해서 떨어뜨리면 해당 티어에서 제거)

3.4 모바일 레이아웃 (<768px) — read-only

┌─ 자치구 우선순위 ──────────────┐
│ S 100%   강남구, 서초구         │
│ A 80%    송파구, 마포구          │
│ B 60%    (없음)                  │
│ C 40%    (없음)                  │
│ D 20%    (없음)                  │
│                                  │
│ ✏️ 자치구 분류는 PC에서 편집     │
└──────────────────────────────────┘

분기 로직:

const [isDesktop, setIsDesktop] = useState(
  typeof window !== "undefined" && window.matchMedia("(min-width: 768px)").matches
);
useEffect(() => {
  const mq = window.matchMedia("(min-width: 768px)");
  const handler = (e) => setIsDesktop(e.matches);
  mq.addEventListener("change", handler);
  return () => mq.removeEventListener("change", handler);
}, []);

isDesktop=false면 read-only 뷰만 렌더, 드래그 핸들러는 등록하지 않음.

3.5 핵심 로직

const handleDrop = (district, targetTier /* null = 미할당 */) => {
  const current = value || EMPTY_TIERS;
  const next = { ...EMPTY_TIERS };
  for (const t of Object.keys(EMPTY_TIERS)) {
    next[t] = (current[t] || []).filter(d => d !== district);
  }
  if (targetTier) {
    next[targetTier] = [...next[targetTier], district];
  }
  onChange(next);
};

const unassigned = SEOUL_DISTRICTS.filter(d =>
  !TIERS.some(t => (value?.[t.key] || []).includes(d))
);

onChange는 새 객체를 통째로 전달(immutable update).

3.6 드래그&드롭 이벤트 (HTML5 native)

이벤트 핸들러
onDragStart (chip) e.dataTransfer.setData("district", districtName)
onDragOver (zone) e.preventDefault() (drop 허용)
onDrop (zone) e.preventDefault() + handleDrop(e.dataTransfer.getData("district"), tierKey)

외부 라이브러리 없음.


4. NotificationSettings 컴포넌트

4.1 인터페이스

<NotificationSettings
  minScore={profile.min_match_score}      // number 0~100
  notifyEnabled={profile.notify_enabled}   // bool
  onChange={(patch) => setProfile({...profile, ...patch})}
/>

onChange 호출 예시: 슬라이더 변경 시 onChange({ min_match_score: 75 }), 토글 시 onChange({ notify_enabled: false }).

4.2 레이아웃

┌─ 🔔 알림 설정 ────────────────────────────────┐
│ 텔레그램 알림   ●━━━○ ON                       │
│ 매칭 임계값     ▬▬▬▬▬▬●▬▬▬ 70점               │
│                 0      50     100              │
│                                                │
│ 💡 70점 이상 매치 시 텔레그램에 자동 알림합니다│
└────────────────────────────────────────────────┘

4.3 컨트롤

  • 토글: <input type="checkbox" className="sub-toggle"> + 사용자 정의 CSS (Subscription.css에 .sub-toggle 신설)
  • 슬라이더: <input type="range" min="0" max="100" step="5"> + 우측 숫자 라벨
  • 미리보기: notify_enabled === false 일 때 경고 톤 메시지("알림 OFF — 메시지가 발송되지 않습니다")

4.4 저장 동작

각 컨트롤 변경 시 onChange로 부모 state만 업데이트. 실제 PUT 요청은 ProfileTab 기존 "저장" 버튼이 일괄 처리(다른 모든 필드와 동일 패턴).

4.5 카운트 미리보기 (스코프 외)

"현재 임계값 통과 매치 N건" 같은 카운트 미리보기는 본 스펙에서 다루지 않는다. dashboard.new_match_count는 "미확인 매칭"이라 임계값 통과와 의미가 다르고, 정확한 카운트를 위해서는 백엔드에 dashboard.pass_count 필드 신설이 필요하다. 후속 스펙으로 분리.


5. 카드 표시 변경

5.1 헬퍼 함수 (Subscription.jsx 모듈 상단)

function extractTier(reasons) {
  for (const r of reasons || []) {
    const m = r.match(/자치구 ([SABCD])티어/);
    if (m) return m[1];
  }
  return null;
}
  • 백엔드 응답 변경 없이 reasons 배열에서 티어 도출
  • reasons 형식 예시: "자치구 S티어: 강남구 (+25)" (백엔드 matcher.py의 fmt와 일치)
  • 광역만 매칭(legacy 모드)이면 티어 없음 → null

5.2 AnnouncementCard

기존 카드 제목/지역 라인 옆 또는 메타 라인에 추가:

{item.district && (
  <span className="sub-chip sub-chip--district">{item.district}</span>
)}
{(() => {
  const tier = extractTier(item.match_reasons);
  return tier ? (
    <span className={`sub-chip sub-chip--tier sub-chip--tier-${tier}`}>
      {tier}티어
    </span>
  ) : null;
})()}

item.match_reasons는 매칭 결과가 있는 경우만 존재. 없으면 뱃지 미표시(공고 목록 탭에서 매칭 결과 없는 카드).

5.3 AnnouncementDetail

상세 모달 하단에 새 섹션:

┌─ 매칭 분석 ─────────────────────────────────┐
│ ⭐ 점수: 90점 / 100점                         │
│                                              │
│ 💡 매칭 사유                                  │
│  • 광역 일치: 서울특별시                       │
│  • 자치구 S티어: 강남구 (+25)                  │
│  • 예산 범위 내 모델 존재 (최고가 7.2억원)     │
│  • 자격 유형 2개: 일반1순위, 특별-신혼부부      │
│                                              │
│ ✓ 신청 자격                                   │
│  [일반1순위] [특별-신혼부부]                  │
└──────────────────────────────────────────────┘

item.match_score, item.match_reasons, item.eligible_types는 이미 응답에 포함됨(get_unnotified_matches는 물론 get_matches/get_announcement도 enrich_items 거침). 매칭 결과가 없는 공고에는 이 섹션 자체를 렌더하지 않음(item.match_score 존재 여부로 분기).

5.4 MatchesTab

매치 카드는 이미 매칭 데이터를 받지만 district + 5티어 뱃지 표시가 부족할 가능성 높음. AnnouncementCard와 동일한 helper(extractTier)로 일관 표시. 카드 클릭 시 AnnouncementDetail 모달이 reasons 노출.

5.5 5티어 뱃지 색상 (Subscription.css 신설)

.sub-chip--tier-S { background:#fee2e2; color:#dc2626; border-color:#fca5a5; }
.sub-chip--tier-A { background:#fef3c7; color:#d97706; border-color:#fcd34d; }
.sub-chip--tier-B { background:#d1fae5; color:#059669; border-color:#6ee7b7; }
.sub-chip--tier-C { background:#dbeafe; color:#2563eb; border-color:#93c5fd; }
.sub-chip--tier-D { background:#ede9fe; color:#7c3aed; border-color:#c4b5fd; }
.sub-chip--district { background:#f3f4f6; color:#374151; border-color:#d1d5db; }

6. ProfileTab 통합

6.1 DEFAULT_PROFILE 갱신

Subscription.jsx 모듈 상단의 DEFAULT_PROFILE 상수에 3 필드 default 추가:

const DEFAULT_PROFILE = {
  // ... 기존 필드
  preferred_regions: '',
  preferred_types: '',
  min_area: '',
  max_area: '',
  max_price: '',
  // 신규
  preferred_districts: {},
  min_match_score: 70,
  notify_enabled: true,
};

6.2 ProfileTab 렌더 추가 위치

자치구 섹션은 기존 "선호 조건" 섹션 다음, 알림 설정 섹션은 그 다음에 배치(저장 버튼 위):

<DistrictTierEditor
  value={profile.preferred_districts}
  onChange={(next) => handleChange("preferred_districts", next)}
/>

<NotificationSettings
  minScore={profile.min_match_score ?? 70}
  notifyEnabled={profile.notify_enabled ?? true}
  onChange={(patch) => setProfile(prev => ({ ...prev, ...patch }))}
/>

6.3 handleSave 변경

신규 3 필드는 변환 없이 그대로 PUT body에 포함:

// 기존 변환 로직 다음에
payload.preferred_districts = profile.preferred_districts || {};
payload.min_match_score = profile.min_match_score ?? null;
payload.notify_enabled = profile.notify_enabled ?? null;

JSON 형태(객체)는 백엔드 ProfileUpdate 모델에서 Dict[str, List[str]]로 받음(이미 구현됨).


7. 테스트 전략

web-ui 레포는 단위 테스트 인프라가 빈약(컨벤션 확인 필요). 본 작업의 검증:

영역 검증 방식
빌드 npm run build warning/error 없음
데스크톱 자치구 편집 미할당 풀 → S 슬롯 드래그 → 저장 → 새로고침 → 유지 확인
자치구 티어 이동 S → A로 드래그 → S에서 사라지고 A에 등장
자치구 해제 × 버튼 또는 미할당 풀로 드래그 → 미할당 풀에 복귀
모바일 read-only 개발자 도구 < 768px → 편집 영역 숨김 + 안내 메시지 표시
임계값 슬라이더 0→100 조절, 즉시 미리보기 텍스트 갱신, 저장·새로고침 후 유지
알림 토글 OFF 시 경고 톤 안내 표시
매칭 카드 district 뱃지 + 5티어 뱃지 표시 (해당 데이터 있는 경우)
상세 모달 매칭 분석 섹션의 점수 + reasons + 자격 표시
회귀 기존 프로필 필드(나이/청약통장/특공 등) 입력·저장 정상

scripts/dev.bat 또는 cd web-ui && npm run dev로 dev server 실행 후 브라우저에서 수동 검증.

배포는 frontend 별도 절차: cd web-ui && npm run release:nas (NAS Z 드라이브에 robocopy).


8. 스코프

본 스펙 범위

  • DistrictTierEditor 신규 컴포넌트
  • NotificationSettings 신규 컴포넌트
  • ProfileTab 신규 3 필드 통합 + 저장
  • AnnouncementCard / MatchesTab district + 5티어 뱃지
  • AnnouncementDetail 매칭 분석 섹션
  • Subscription.css 5티어 뱃지 + 드래그 영역 + 토글 + 슬라이더 스타일
  • 모바일 read-only fallback

후속 별도 스펙

  • Subscription.jsx 자체 분할 (1354줄 → 별도 리팩토링)
  • 5축 점수 분해 progress bar (백엔드 응답에 5축 점수 추가 필요)
  • 임계값 통과 매치 카운트 미리보기 (dashboard.pass_count 백엔드 신설 필요)
  • 자치구 5티어 자동 추천 (사용자 가점·예산 기반)
  • 알림 채널 추가 (이메일/Slack)
  • 모바일 자치구 편집 지원 (touch backend 필요 시)