perf(realestate): run_listing_matching 단일 conn+배치 + POST /listings/rematch(MOLIT없이 즉시 재판정)

- get_market_deals_for에 optional conn 파라미터 추가(재사용 시 open/close 안 함, 기존 단건 호출부는 불변)
- bulk_upsert_listing_matches 추가(listing_matches 다건 upsert, 단일 connection)
- run_listing_matching이 listings 조회+매물별 market_deals 조회+최종 upsert를 단일 _conn()으로 처리
  (기존: 269건 매물마다 개별 _conn() 3회 → 병목)
- POST /api/realestate/listings/rematch: MOLIT 재수집 없이 기존 listings+market_deals로 즉시 재판정
  (알림 미발송, 즉시 피드백용)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9
This commit is contained in:
2026-07-10 13:56:45 +09:00
parent b471d2c455
commit 4097c95286
6 changed files with 230 additions and 33 deletions

View File

@@ -1019,10 +1019,14 @@ def bulk_upsert_market_deals(rows: List[Dict[str, Any]]) -> int:
return len(payload)
def get_market_deals_for(dong_code, complex_name, area, deal_type, months=6) -> List[Dict[str, Any]]:
with _conn() as conn:
def get_market_deals_for(dong_code, complex_name, area, deal_type, months=6, conn=None) -> List[Dict[str, Any]]:
"""conn을 넘기면 재사용(open/close 안 함, 호출부가 소유) — run_listing_matching 단일 conn 배치용.
conn 없으면 기존대로 자체 _conn() 열고 닫음(safety-check 등 단건 호출 경로 불변)."""
own = conn is None
c = conn or _conn()
try:
def _query(cn):
rows = conn.execute(
rows = c.execute(
"""SELECT * FROM market_deals
WHERE dong_code=? AND deal_type=?
AND (complex_name=? OR ? IS NULL OR complex_name IS NULL)
@@ -1038,7 +1042,10 @@ def get_market_deals_for(dong_code, complex_name, area, deal_type, months=6) ->
# 단지 표본이 부족하면 complex 제약을 풀고 같은 동/거래유형/면적±5/최근성으로
# 광역 재조회한다. 표본을 늘리기만 하므로 안전(잘못된 판정 유발 X).
deals = _query(None)
return deals
return deals
finally:
if own:
c.close()
def upsert_listing_match(data: Dict[str, Any]) -> None:
@@ -1061,6 +1068,39 @@ def upsert_listing_match(data: Dict[str, Any]) -> None:
""", d)
def bulk_upsert_listing_matches(recs: List[Dict[str, Any]], conn=None) -> None:
"""listing_matches 다건 upsert(단일 connection). conn 주면 재사용(호출부 with가 commit) —
run_listing_matching 269건×3conn 병목 개선(Task 2). ON CONFLICT 절은 upsert_listing_match와 동일
(notified_at 미포함=보존, 재알림 방지)."""
if not recs:
return
cols = ("listing_id", "category", "passed", "match_score", "jeonse_ratio", "safety_tier",
"price_ratio", "valuation_tier", "market_median", "sample_size", "budget_ok",
"regulation_flags", "reasons", "is_new")
own = conn is None
c = conn or _conn()
try:
for data in recs:
d = {col: data.get(col) for col in cols}
d["regulation_flags"] = json.dumps(data.get("regulation_flags") or [], ensure_ascii=False)
d["reasons"] = json.dumps(data.get("reasons") or [], ensure_ascii=False)
c.execute(f"""
INSERT INTO listing_matches ({','.join(cols)}) VALUES ({','.join(':'+col for col in cols)})
ON CONFLICT(listing_id) DO UPDATE SET
passed=excluded.passed, match_score=excluded.match_score,
jeonse_ratio=excluded.jeonse_ratio, safety_tier=excluded.safety_tier,
price_ratio=excluded.price_ratio, valuation_tier=excluded.valuation_tier,
market_median=excluded.market_median, sample_size=excluded.sample_size,
budget_ok=excluded.budget_ok, regulation_flags=excluded.regulation_flags,
reasons=excluded.reasons, category=excluded.category
""", d)
if own:
c.commit()
finally:
if own:
c.close()
def get_listings(dong=None, deal_type=None, tier=None, matched_only=False, limit=50, offset=0) -> List[Dict[str, Any]]:
sql = ("SELECT l.*, m.safety_tier, m.valuation_tier, m.category, m.match_score, m.passed "
"FROM listings l LEFT JOIN listing_matches m ON m.listing_id=l.id WHERE 1=1")

View File

@@ -2,8 +2,8 @@
import logging
import statistics
from .db import (get_listing_criteria, get_market_deals_for, upsert_listing_match,
get_listings)
from .db import (get_listing_criteria, get_market_deals_for, bulk_upsert_listing_matches,
_conn)
from . import finance_rules
logger = logging.getLogger("realestate-lab")
@@ -74,37 +74,47 @@ def match_listing(criteria, listing, budget) -> dict:
def run_listing_matching() -> None:
"""미평가 포함 전체 매물 재평가 → listing_matches upsert (청약 run_matching 대칭)."""
"""미평가 포함 전체 매물 재평가 → listing_matches upsert (청약 run_matching 대칭).
listings 조회 + 매물별 market_deals 조회 + 최종 upsert를 단일 connection으로 처리
(Task 2 성능개선: 이전엔 매물마다 get_market_deals_for/upsert_listing_match가 각각
자체 _conn()을 열어 269건×~3conn이 발생했다)."""
criteria = get_listing_criteria()
equity = criteria.get("equity") or 0
income = criteria.get("annual_income") or 0
jeonse_budget = finance_rules.estimate_jeonse_budget(equity, income)
for l in get_listings(limit=1000):
is_rent = l["deal_type"] != "매매"
reg = finance_rules.region_regulation(l.get("dong"))
if is_rent:
budget = jeonse_budget
else:
budget = finance_rules.estimate_purchase_budget(
equity, income, reg, criteria.get("is_first_home"), criteria.get("is_homeless"))
m = match_listing(criteria, l, budget)
deals = get_market_deals_for(l.get("dong_code"), l.get("complex_name"),
l.get("area_exclusive"), "전세" if is_rent else "매매")
rec = {"listing_id": l["id"], "category": "임차" if is_rent else "매매",
"passed": 1 if m["passed"] else 0, "match_score": m["score"],
"sample_size": 0, "reasons": m["reasons"], "is_new": 1,
"regulation_flags": reg_flags(reg)}
if is_rent:
s = compute_safety(l.get("deposit"), l["deal_type"], deals)
rec.update({"jeonse_ratio": s["jeonse_ratio"], "safety_tier": s["tier"],
"market_median": s["median"], "sample_size": s["sample"]})
else:
v = compute_valuation(l.get("sale_price"), deals)
rec.update({"price_ratio": v["price_ratio"], "valuation_tier": v["tier"],
"market_median": v["median"], "sample_size": v["sample"],
"budget_ok": 1 if (l.get("sale_price") or 0) <= (budget.get("max_price") or 0) else 0})
upsert_listing_match(rec)
logger.info("매물 매칭 완료")
with _conn() as conn:
listings = [dict(r) for r in conn.execute(
"SELECT * FROM listings ORDER BY created_at DESC LIMIT 1000"
).fetchall()]
recs = []
for l in listings:
is_rent = l["deal_type"] != "매매"
reg = finance_rules.region_regulation(l.get("dong"))
if is_rent:
budget = jeonse_budget
else:
budget = finance_rules.estimate_purchase_budget(
equity, income, reg, criteria.get("is_first_home"), criteria.get("is_homeless"))
m = match_listing(criteria, l, budget)
deals = get_market_deals_for(l.get("dong_code"), l.get("complex_name"),
l.get("area_exclusive"), "전세" if is_rent else "매매",
conn=conn)
rec = {"listing_id": l["id"], "category": "임차" if is_rent else "매매",
"passed": 1 if m["passed"] else 0, "match_score": m["score"],
"sample_size": 0, "reasons": m["reasons"], "is_new": 1,
"regulation_flags": reg_flags(reg)}
if is_rent:
s = compute_safety(l.get("deposit"), l["deal_type"], deals)
rec.update({"jeonse_ratio": s["jeonse_ratio"], "safety_tier": s["tier"],
"market_median": s["median"], "sample_size": s["sample"]})
else:
v = compute_valuation(l.get("sale_price"), deals)
rec.update({"price_ratio": v["price_ratio"], "valuation_tier": v["tier"],
"market_median": v["median"], "sample_size": v["sample"],
"budget_ok": 1 if (l.get("sale_price") or 0) <= (budget.get("max_price") or 0) else 0})
recs.append(rec)
bulk_upsert_listing_matches(recs, conn=conn)
logger.info("매물 매칭 완료(%d건)", len(recs))
def reg_flags(reg: dict) -> list:

View File

@@ -286,6 +286,18 @@ def api_listing_matches():
return {"matches": get_listing_matches()}
@app.post("/api/realestate/listings/rematch")
def api_listings_rematch():
"""MOLIT 재수집 없이 기존 listings+market_deals로 즉시 재판정(즉시 피드백용, 알림 미발송)."""
with match_notify_lock:
run_listing_matching()
matches = get_listing_matches()
passed = sum(1 for m in matches if m.get("passed"))
judged = sum(1 for m in matches if m.get("passed")
and (m.get("safety_tier") or m.get("valuation_tier")) not in (None, "보류"))
return {"total": len(matches), "passed": passed, "judged": judged}
@app.post("/api/realestate/safety-check")
def api_safety_check(req: SafetyCheckRequest):
deals = get_market_deals_for(req.dong_code, req.complex_name, req.area,