- 수집 사전 좁힘(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>
17 KiB
청약 서비스 타겟팅 고도화 설계
대상:
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컬럼 추가
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 announcements — district 컬럼 추가
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 컬럼 추가
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_fromskip하는 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) - ❌ 자치구별 매칭 분포 대시보드 위젯