- DistrictTierEditor: 데스크톱 드래그&드롭 + 모바일 read-only - NotificationSettings: 임계값 슬라이더 + 알림 토글 - AnnouncementCard/MatchesTab: district + 5티어 뱃지 - AnnouncementDetail: 매칭 분석 섹션 (점수 + reasons + 자격) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
16 KiB
청약 타겟팅 프론트엔드 설계 — 자치구 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 필요 시)