Files
web-page-backend/docs/superpowers/specs/2026-04-28-realestate-targeting-enhancement-design.md
gahusb eef2e3967e docs(spec): 청약 타겟팅 고도화 설계
- 수집 사전 좁힘(30일 윈도우) + 완료 공고 90일 grace 자동 정리
- 자치구 5티어 가중치 매칭 (S/A/B/C/D)
- realestate-lab → agent-office push 기반 즉시 텔레그램 알림
- 데일리 리포트 cron 폐기, 임계값 통과 신규 매칭만 푸시

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

17 KiB
Raw Blame History

청약 서비스 타겟팅 고도화 설계

대상: 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컬럼, announcementsdistrict, match_resultsnotified_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컬럼 추가

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티어 분류.
    {"S": ["강남구", "서초구"], "A": ["송파구", "마포구"], "B": [], "C": [], "D": []}
    
    모든 티어 비어있으면 자치구 기준 미설정으로 간주 (기존 호환 동작).
  • min_match_score: 알림 트리거 임계값(0~100). 기본 70.
  • notify_enabled: 텔레그램 푸시 ON/OFF. 0이면 알림 전체 차단.

3.2 announcementsdistrict 컬럼 추가

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_resultsnotified_at 컬럼 추가

ALTER TABLE match_results ADD COLUMN notified_at TEXT;
  • NULL이면 미알림. 알림 송신 후 strftime('%Y-%m-%dT%H:%M:%fZ','now') 기록.
  • 기존 is_new(사용자가 UI에서 봤는지)와 의미 분리.

3.4 신규 함수

def delete_old_completed_announcements(grace_days: int = 90) -> int:
    """winner_date + grace_days 경과한 status='완료' 공고를 삭제.
    winner_date가 NULL인 행은 안전하게 보존(수동 검토 대상).
    match_results는 FK CASCADE로 자동 삭제. 삭제된 건수 반환.
    """
def get_unnotified_matches(min_score: int) -> list[dict]:
    """notified_at IS NULL AND match_score >= min_score 인 매칭 + 공고 정보 조인 반환."""
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 모집공고일 윈도우 사전 좁힘

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

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 자치구 추출

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 정리 + 매칭 + 알림 트리거

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점)

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점)

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

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 측 — 신규 엔드포인트

# 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
# 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 동작 정리

# 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 모델 확장

# 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)
  • 자치구별 매칭 분포 대시보드 위젯