# 청약 서비스 타겟팅 고도화 설계 > 대상: `web-backend/realestate-lab/` + `web-backend/agent-office/` > 후속 별도 스펙: 프론트 자치구 입력 UI(`web-ui`), 청약 가점 vs 커트라인 비교, 서울 외 광역 자치구 파싱 --- ## 1. 목표 현재 청약 서비스가 1) 완료된 공고까지 무차별 수집하고, 2) 매칭이 binary라 단지별 의미 있는 점수 차이가 없으며, 3) 데일리 리포트라 "발견 즉시"의 가치를 못 살리는 문제를 해결한다. ### 핵심 변경 - **수집**: 모집공고 30일 이전 + 이미 `완료` 상태인 공고는 저장하지 않음. 90일 경과 완료 공고 자동 정리. - **단일 SoT**: `user_profile.preferred_regions`를 수집·조회·매칭의 단일 기준점으로 사용 (서울 default). - **매칭**: 자치구 5티어 가중치(S=100% / A=80% / B=60% / C=40% / D=20%) 도입. 자격 점수 미세 조정. - **알림**: 데일리 리포트 폐기. "신규 매칭 + 임계값 통과" 즉시 텔레그램 푸시. realestate-lab → agent-office HTTP push. ### 변경하지 않는 것 - 공공데이터 API 엔드포인트 5종 구성 - 매칭 총점 100점 체계 - 텔레그램 봇 토큰·formatter는 agent-office에 단일 보관 - realestate-lab의 09:00 / 00:00 cron 스케줄(기존 그대로 유지, 트리거 로직만 변경) --- ## 2. 아키텍처 변경 개요 ### 2.1 변경 포인트 | # | 위치 | 변경 | |---|------|------| | 1 | `realestate-lab/collector.py` | API 호출 시 모집공고일 윈도우 사전 적용. 응답 시 `완료` 상태 skip. 자치구 파싱. 90일 경과 완료 공고 정리. | | 2 | `realestate-lab/db.py` | `user_profile`에 3컬럼, `announcements`에 `district`, `match_results`에 `notified_at` 추가. `delete_old_completed_announcements()` 신규. | | 3 | `realestate-lab/matcher.py` | 자치구 5티어 가중치 + 자격 점수 재배분. binary → 자치구 그라디언트. | | 4 | `realestate-lab` 신규 모듈 | `notifier.py`: 임계값 통과 신규 매칭 추출 + agent-office push. `notified_at` 멱등 마킹. | | 5 | `agent-office/agents/realestate.py` | 데일리 cron 폐기. `on_new_matches(matches)` 신규. 메시지 fmt + 인라인 키보드. | | 6 | `agent-office/main.py` | `POST /api/agent-office/realestate/notify` 신규 엔드포인트. | ### 2.2 데이터 흐름 ``` [09:00 cron] realestate-lab.scheduled_collect() ├─ collect_all() │ ├─ API 호출 (RCRIT_PBLANC_DE_FROM = today − 30일) │ ├─ 응답 파싱 + district 추출 │ ├─ status='완료' skip → upsert │ └─ delete_old_completed_announcements(grace_days=90) ├─ run_matching() // 5티어 가중치 적용 └─ notify_new_matches() ├─ SELECT match_results WHERE notified_at IS NULL │ AND match_score >= profile.min_match_score │ AND profile.notify_enabled = 1 ├─ POST agent-office /api/agent-office/realestate/notify └─ 성공 → UPDATE notified_at = now() [agent-office] POST /api/agent-office/realestate/notify └─ RealestateAgent.on_new_matches(matches) ├─ formatter로 텔레그램 텍스트 + 인라인 키보드 빌드 └─ telegram_bot.send_message() ``` ### 2.3 기각된 대안 | 대안 | 기각 사유 | |------|-----------| | 매칭 로직을 agent-office에 이식 | 두 서비스에 매칭 코드 복제 → 동기화 부담 | | 완료 공고 즉시 삭제 | 사용자가 회고 못 함. 90일 grace 채택 | | agent-office가 realestate-lab을 폴링 | 트래픽 + 지연 | | realestate-lab이 직접 텔레그램 호출 | 토큰·formatter 분산. 봇 단일 책임 위반 | | 가격·면적 그라디언트 곡선 | 점수 해석 어려움. binary 유지 (자치구 1축에만 곡선 적용) | --- ## 3. DB 스키마 변경 ### 3.1 `user_profile` — 3컬럼 추가 ```sql ALTER TABLE user_profile ADD COLUMN preferred_districts TEXT NOT NULL DEFAULT '{}'; ALTER TABLE user_profile ADD COLUMN min_match_score INTEGER NOT NULL DEFAULT 70; ALTER TABLE user_profile ADD COLUMN notify_enabled INTEGER NOT NULL DEFAULT 1; ``` - **`preferred_districts`**: JSON. 5티어 분류. ```json {"S": ["강남구", "서초구"], "A": ["송파구", "마포구"], "B": [], "C": [], "D": []} ``` 모든 티어 비어있으면 자치구 기준 미설정으로 간주 (기존 호환 동작). - **`min_match_score`**: 알림 트리거 임계값(0~100). 기본 70. - **`notify_enabled`**: 텔레그램 푸시 ON/OFF. 0이면 알림 전체 차단. ### 3.2 `announcements` — `district` 컬럼 추가 ```sql ALTER TABLE announcements ADD COLUMN district TEXT; CREATE INDEX IF NOT EXISTS idx_ann_district ON announcements(district); ``` - collector가 응답의 `HSSPLY_ADRES` / `region_name`을 정규식 파싱하여 채움. - 서울 외 지역, 파싱 실패 → NULL. ### 3.3 `match_results` — `notified_at` 컬럼 추가 ```sql ALTER TABLE match_results ADD COLUMN notified_at TEXT; ``` - NULL이면 미알림. 알림 송신 후 `strftime('%Y-%m-%dT%H:%M:%fZ','now')` 기록. - 기존 `is_new`(사용자가 UI에서 봤는지)와 의미 분리. ### 3.4 신규 함수 ```python def delete_old_completed_announcements(grace_days: int = 90) -> int: """winner_date + grace_days 경과한 status='완료' 공고를 삭제. winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상). match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환. """ ``` ```python def get_unnotified_matches(min_score: int) -> list[dict]: """notified_at IS NULL AND match_score >= min_score 인 매칭 + 공고 정보 조인 반환.""" ``` ```python def mark_matches_notified(match_ids: list[int]) -> None: """notified_at = now() 일괄 업데이트.""" ``` ### 3.5 마이그레이션 패턴 기존 db.py의 `init_db()` 안에서 try/except로 컬럼 존재 여부 검사 후 ALTER (운영 DB 무중단). --- ## 4. collector 변경 ### 4.1 모집공고일 윈도우 사전 좁힘 ```python def collect_all() -> dict: today = date.today() date_from = (today - timedelta(days=30)).strftime("%Y%m%d") for detail_ep, model_ep in DETAIL_ENDPOINTS: rows = _api_call(detail_ep, params={ # 공공데이터 API 파라미터명은 엔드포인트별로 다를 수 있음. # 구현 시 한국부동산원 API 스펙 확인 후 정확한 키 적용. "RCRIT_PBLANC_DE_FROM": date_from, }) # ... ``` > ⚠️ **구현 시 검증 필요**: `ApplyhomeInfoDetailSvc`의 5개 엔드포인트가 모두 모집공고일 필터 파라미터를 지원하지 않을 수 있음. 미지원 시 응답 수신 후 클라이언트 측에서 `parsed["rcrit_date"] < date_from` skip하는 fallback을 적용. ### 4.2 `완료` 상태 skip ```python parsed = _parse_apt_detail(raw) parsed["district"] = _extract_district(parsed) status = compute_status( parsed.get("receipt_start", ""), parsed.get("receipt_end", ""), parsed.get("winner_date", ""), ) if status == "완료": continue # DB 자원 절감 # 일정 정보 없는 공고 skip (기존 로직 유지) has_dates = any(parsed.get(f) for f in (...)) if not has_dates: continue upsert_announcement(parsed) ``` ### 4.3 자치구 추출 ```python DISTRICT_PATTERN = re.compile(r"(?:서울특별시|서울시|서울)\s+(\S+?(?:구|군))") def _extract_district(parsed: dict) -> str | None: for src in (parsed.get("address"), parsed.get("region_name")): if not src: continue m = DISTRICT_PATTERN.search(src) if m: return m.group(1) return None ``` ### 4.4 정리 + 매칭 + 알림 트리거 ```python def collect_all() -> dict: # ... 위 수집 로직 save_collect_log(new_count, total_count) return {"new_count": new_count, "total_count": total_count} def scheduled_collect(): """09:00 cron — 수집 + 정리 + 매칭 + 알림""" collect_all() deleted = delete_old_completed_announcements(grace_days=90) logger.info("정리: %d건 삭제", deleted) run_matching() notify_new_matches() # NEW ``` --- ## 5. matcher 변경 ### 5.1 가중치 재배분 (총 100점 유지) | 축 | 기존 | 신규 | |----|------|------| | 지역 | 30 | **35** (광역 10 + 자치구 가중 0~25) | | 주택유형 | 10 | 10 | | 면적 | 15 | 15 | | 가격 | 15 | 15 | | 자격 | 30 | **25** | ### 5.2 지역 점수 (35점) ```python TIER_WEIGHTS = {"S": 1.00, "A": 0.80, "B": 0.60, "C": 0.40, "D": 0.20} def _region_score(profile: dict, ann: dict) -> tuple[int, list[str]]: region_name = ann.get("region_name") or "" district = ann.get("district") or "" preferred_regions = profile.get("preferred_regions") or [] preferred_districts = profile.get("preferred_districts") or {} region_match = bool(region_name and any(r in region_name for r in preferred_regions)) if not region_match: return 0, [] # 자치구 기준 미설정 → 광역만으로 풀 점수 (기존 호환) has_districts = any(preferred_districts.get(t) for t in TIER_WEIGHTS) if not has_districts: return 35, [f"선호 지역 일치: {region_name}"] score = 10 reasons = [f"광역 일치: {region_name}"] for tier, weight in TIER_WEIGHTS.items(): if district in (preferred_districts.get(tier) or []): tier_score = round(25 * weight) score += tier_score reasons.append(f"자치구 {tier}티어: {district} (+{tier_score})") break return score, reasons ``` ### 5.3 자격 점수 (25점) ```python def _eligibility_score(eligible_types: list[str]) -> int: if not eligible_types: return 0 score = 15 # 첫 자격 score += min((len(eligible_types) - 1) * 5, 10) # 추가 자격당 +5, 최대 +10 return score ``` 다른 축(주택유형 10, 면적 15, 가격 15)은 기존 binary 로직 유지. ### 5.4 매칭 결과 저장 `run_matching()`은 기존 흐름 유지. `match_results.notified_at`은 손대지 않음 (notifier가 관리). --- ## 6. 알림 흐름 ### 6.1 realestate-lab 측 — `notifier.py` ```python import os import requests from .db import get_unnotified_matches, mark_matches_notified, get_profile AGENT_OFFICE_URL = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000") def notify_new_matches() -> dict: profile = get_profile() if not profile or not profile.get("notify_enabled"): return {"sent": 0, "skipped": "notify_disabled"} threshold = profile.get("min_match_score", 70) matches = get_unnotified_matches(threshold) if not matches: return {"sent": 0} try: resp = requests.post( f"{AGENT_OFFICE_URL}/api/agent-office/realestate/notify", json={"matches": matches}, timeout=15, ) resp.raise_for_status() body = resp.json() sent_ids = body.get("sent_ids", []) if sent_ids: mark_matches_notified(sent_ids) return body except requests.RequestException as e: logger.error("알림 push 실패: %s", e) return {"sent": 0, "error": str(e)} ``` 알림 push 실패 시 `notified_at`을 채우지 않아 다음 사이클에서 재시도된다. ### 6.2 agent-office 측 — 신규 엔드포인트 ```python # agent-office/main.py @app.post("/api/agent-office/realestate/notify") async def realestate_notify(body: dict): matches = body.get("matches", []) agent = registry.get("realestate") result = await agent.on_new_matches(matches) return result ``` ```python # agents/realestate.py async def on_new_matches(self, matches: list[dict]) -> dict: if not matches: return {"sent": 0, "sent_ids": []} text = telegram_formatter.format_realestate_matches(matches) keyboard = telegram_formatter.build_match_keyboard(matches) tg = await telegram_bot.send_message(text, reply_markup=keyboard) if not tg.get("ok"): return {"sent": 0, "sent_ids": [], "error": tg.get("error")} sent_ids = [m["id"] for m in matches] return {"sent": len(matches), "sent_ids": sent_ids, "message_id": tg.get("message_id")} ``` ### 6.3 텔레그램 메시지 포맷 **3건 이상 — 묶음 카드** ``` 🏢 새 청약 매칭 3건 ⭐ 92점 — 디에이치 강남 [S] 📍 서울 강남구 (분양가상한제) · 32~45㎡ · 6.2~9.8억 📅 청약 05/15(수) ~ 05/19(일) ⭐ 78점 — 마포 푸르지오 [A] 📍 서울 마포구 · 59~84㎡ · 8.0~11.5억 📅 청약 05/22(수) ~ 05/26(일) ⭐ 72점 — 송파 데시앙 [A] 📍 서울 송파구 · 39~59㎡ · 5.8~7.9억 📅 청약 05/27(월) ~ 05/30(목) [전체 보기] ``` **1~2건 — 풀 카드** ``` ⭐ 90점 — 디에이치 강남 [S] 📍 서울 강남구 (분양가상한제) 🏠 32~45㎡ · 6.2~9.8억 📅 청약 05/15(수) ~ 05/19(일) ✓ 자격: 일반1순위, 특별-신혼부부 💡 광역 일치 / 자치구 S티어 / 예산 범위 / 자격 2개 [🔖 북마크] [📄 공고 보기] ``` ### 6.4 인라인 키보드 콜백 | 버튼 | 콜백 동작 | |------|-----------| | `[🔖 북마크]` | `PATCH /api/realestate/announcements/{id}/bookmark` (기존 endpoint) | | `[📄 공고 보기]` | `pblanc_url` (텔레그램 URL 버튼) | | `[전체 보기]` | 대시보드 deep link (`/realestate?tab=matches`) | agent-office의 텔레그램 webhook(`/api/agent-office/telegram/webhook`)이 callback_query를 받아 service_proxy로 realestate-lab API 호출. ### 6.5 기존 RealestateAgent 동작 정리 ```python # agent-office/scheduler.py — 09:15 데일리 cron 제거 # scheduler.add_job(realestate_agent.on_schedule, ...) ← REMOVE ``` `RealestateAgent.on_schedule()`은 호출 지점이 사라지므로 제거. `on_command("fetch_matches")`는 수동 트리거(텔레그램 슬래시 명령)용으로 보존하되 `on_new_matches()`를 직접 호출하도록 단순화. ### 6.6 환경변수 | 변수 | 위치 | 기본값 | |------|------|--------| | `AGENT_OFFICE_URL` | realestate-lab `.env` | `http://agent-office:8000` | | `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | agent-office (기존) | (기존) | docker-compose의 사내 네트워크로 호출되므로 외부 노출 없음. --- ## 7. API 변경 요약 ### 7.1 realestate-lab | 메서드 | 경로 | 변경 | |--------|------|------| | PUT | `/api/realestate/profile` | body에 `preferred_districts`, `min_match_score`, `notify_enabled` 수용 | | GET | `/api/realestate/profile` | 응답에 위 3필드 포함 | | GET | `/api/realestate/announcements` | 응답 item에 `district` 포함 | | GET | `/api/realestate/announcements/{id}` | 응답에 `district` 포함 | | GET | `/api/realestate/matches` | 응답 item에 `notified_at` 포함 (디버깅용) | ### 7.2 agent-office | 메서드 | 경로 | 변경 | |--------|------|------| | POST | `/api/agent-office/realestate/notify` | **신규** — realestate-lab 전용 push 수신 | ### 7.3 Pydantic 모델 확장 ```python # realestate-lab/app/models.py class ProfileUpdate(BaseModel): # ... 기존 필드 preferred_districts: Optional[Dict[str, List[str]]] = None min_match_score: Optional[int] = Field(default=None, ge=0, le=100) notify_enabled: Optional[bool] = None ``` --- ## 8. 테스트 전략 | 영역 | 테스트 항목 | |------|-------------| | `_extract_district` | "서울특별시 강남구 도곡동" → `"강남구"`, "서울 송파구" → `"송파구"`, "부산 해운대구" → NULL, "" → NULL | | `compute_status` | 변경 없음. 기존 테스트 유지 | | `_region_score` | 광역 미매칭 / 광역만 매칭 + 자치구 미설정 / S~D 티어별 / 광역 매칭 + 비선호 자치구 — 5케이스 | | `_eligibility_score` | 자격 0개 / 1개 / 3개 / 5개 — 점수 단조 증가 + 25 상한 | | `delete_old_completed_announcements` | winner_date 91일 전 → 삭제, 89일 전 → 보존, status≠'완료' → 보존 | | collector 사전 좁힘 | mock API 응답으로 30일 윈도우 외 데이터 skip 확인. `완료` skip 확인 | | `notify_new_matches` 멱등성 | `notified_at` 채워진 매치는 push 후보 제외, push 실패 시 `notified_at` 미기록 → 다음 사이클 재시도 | | agent-office push endpoint | mock telegram client로 `format_realestate_matches` 호출 + send 검증 | | 알림 임계값 필터 | min_match_score=70, score=69 → push 대상 외 / score=70 → 포함 | | `notify_enabled=0` | push 자체 skip | NAS Docker는 git push 자동 배포이므로 별도 절차 없음. ALTER TABLE은 init_db에서 try/except 패턴으로 운영 DB 무중단 적용. --- ## 9. 스코프 ### 본 스펙 범위 - ✅ realestate-lab: collector, matcher, db 변경, notifier 신규 - ✅ agent-office: `/realestate/notify` 엔드포인트, `on_new_matches` 메소드, 메시지 formatter - ✅ 기존 데일리 RealestateAgent cron 폐기 ### 후속 별도 스펙 - ❌ 프론트(`web-ui`) 자치구 5티어 입력 UI (별도 frontend 스펙) - ❌ 청약 가점 vs 공고별 예상 커트라인 비교 (외부 데이터 의존성, 별도 연구) - ❌ 서울 외 광역(부산 해운대구 등) 자치구 파싱 확장 - ❌ 매칭 임계값 변경 후 재발송 트리거 (`POST /notifications/resend`) - ❌ 자치구별 매칭 분포 대시보드 위젯