From 4097c952865a76b4a4ee2af17d21d4629b1054aa Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 10 Jul 2026 13:56:45 +0900 Subject: [PATCH] =?UTF-8?q?perf(realestate):=20run=5Flisting=5Fmatching=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=20conn+=EB=B0=B0=EC=B9=98=20+=20POST=20/list?= =?UTF-8?q?ings/rematch(MOLIT=EC=97=86=EC=9D=B4=20=EC=A6=89=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=AC=ED=8C=90=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Claude-Session: https://claude.ai/code/session_01EqCYBhvTcdeCTUDX3RhWx9 --- realestate-lab/app/db.py | 48 ++++++++++++-- realestate-lab/app/listing_matcher.py | 68 +++++++++++--------- realestate-lab/app/main.py | 12 ++++ realestate-lab/tests/test_listing_api.py | 25 +++++++ realestate-lab/tests/test_listing_db.py | 60 +++++++++++++++++ realestate-lab/tests/test_listing_matcher.py | 50 ++++++++++++++ 6 files changed, 230 insertions(+), 33 deletions(-) diff --git a/realestate-lab/app/db.py b/realestate-lab/app/db.py index 74766ed..4a82629 100644 --- a/realestate-lab/app/db.py +++ b/realestate-lab/app/db.py @@ -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") diff --git a/realestate-lab/app/listing_matcher.py b/realestate-lab/app/listing_matcher.py index b7d4816..d9c7644 100644 --- a/realestate-lab/app/listing_matcher.py +++ b/realestate-lab/app/listing_matcher.py @@ -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: diff --git a/realestate-lab/app/main.py b/realestate-lab/app/main.py index 6490804..3d733bc 100644 --- a/realestate-lab/app/main.py +++ b/realestate-lab/app/main.py @@ -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, diff --git a/realestate-lab/tests/test_listing_api.py b/realestate-lab/tests/test_listing_api.py index f87e27d..473f355 100644 --- a/realestate-lab/tests/test_listing_api.py +++ b/realestate-lab/tests/test_listing_api.py @@ -31,3 +31,28 @@ def test_safety_check_rent(): def test_listings_collect_status(): assert _client().get("/api/realestate/listings/collect/status").status_code == 200 + + +def test_listings_rematch_endpoint(): + """MOLIT 재수집 없이 기존 listings+market_deals로 즉시 재판정 — 200 + 요약 카운트.""" + from app import db + for i, v in enumerate((40000, 42000, 44000)): + db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동", + "complex_name": "OO", "area": 42.0, "deal_type": "전세", + "deposit": v, "sale_amount": None, + "deal_ym": f"2026{i+1:02d}", "floor": "5", "source": "molit"}) + db.upsert_listing({"article_no": "RM1", "source": "naver", "deal_type": "전세", + "deposit": 28000, "area_exclusive": 42.0, "complex_name": "OO", + "dong": "신대방동", "dong_code": "11590"}) + db.update_listing_criteria({"dongs": ["신대방동"], "deal_types": ["전세"], "max_deposit": 32000, + "min_area": 40.0, "house_types": ["아파트"]}) + + r = _client().post("/api/realestate/listings/rematch") + assert r.status_code == 200 + body = r.json() + assert body["total"] == 1 + assert body["passed"] == 1 + assert body["judged"] == 1 + + matches = db.get_listing_matches() + assert matches[0]["safety_tier"] == "안전" diff --git a/realestate-lab/tests/test_listing_db.py b/realestate-lab/tests/test_listing_db.py index 1fed16c..32c5252 100644 --- a/realestate-lab/tests/test_listing_db.py +++ b/realestate-lab/tests/test_listing_db.py @@ -104,3 +104,63 @@ def test_get_listing_matches_json_parsed(): assert matches[0]["regulation_flags"] == ["토지거래허가구역"] assert isinstance(matches[0]["reasons"], list) assert matches[0]["reasons"] == ["ok"] + + +# ── Task 2: run_listing_matching 단일 conn 성능개선 ────────────────────────── + +def test_get_market_deals_for_reuses_passed_conn(): + """conn 인자를 넘기면 그 connection을 재사용하고, 넘기지 않았을 때와 동일한 + 결과를 반환해야 한다(1차 complex 스코프 → 표본 부족 시 광역 폴백 로직 불변).""" + from app import db + base = {"house_type": "아파트", "dong_code": "11590", "dong": "신대방동", + "complex_name": "OO", "area": 42.0, "deal_type": "전세", + "sale_amount": None, "floor": "5", "source": "molit"} + db.upsert_market_deal({**base, "deposit": 40000, "deal_ym": "202601"}) + db.upsert_market_deal({**base, "deposit": 41000, "deal_ym": "202602"}) + db.upsert_market_deal({**base, "deposit": 42000, "deal_ym": "202603"}) + + without_conn = db.get_market_deals_for("11590", "OO", 42.0, "전세", months=120) + with db._conn() as shared: + with_conn = db.get_market_deals_for("11590", "OO", 42.0, "전세", months=120, conn=shared) + # conn을 넘겼으니 함수가 스스로 닫지 않아야 함 — 같은 conn으로 후속 쿼리 가능해야 한다. + still_open = shared.execute("SELECT COUNT(*) FROM market_deals").fetchone()[0] + assert len(with_conn) == len(without_conn) == 3 + assert still_open == 3 + + +def test_bulk_upsert_listing_matches_multi_insert_and_update(): + """bulk_upsert_listing_matches가 여러 건을 한 번에 upsert하고, 재호출 시 + 기존 upsert_listing_match와 동일하게 갱신(notified_at 보존)해야 한다.""" + from app import db + l1, _ = db.upsert_listing({"article_no": "B1", "source": "naver", "deal_type": "전세", + "deposit": 29000, "area_exclusive": 42.0, "dong": "신대방동"}) + l2, _ = db.upsert_listing({"article_no": "B2", "source": "naver", "deal_type": "매매", + "sale_price": 85000, "area_exclusive": 59.0, "dong": "상도동"}) + db.bulk_upsert_listing_matches([ + {"listing_id": l1["id"], "category": "임차", "passed": 1, "match_score": 100, + "safety_tier": "안전", "sample_size": 5, "reasons": ["ok"], "is_new": 1}, + {"listing_id": l2["id"], "category": "매매", "passed": 0, "match_score": 0, + "valuation_tier": "고가", "sample_size": 4, "reasons": ["매수가능 상한 초과"], "is_new": 1}, + ]) + matches = {m["listing_id"]: m for m in db.get_listing_matches()} + assert len(matches) == 2 + assert matches[l1["id"]]["safety_tier"] == "안전" and matches[l1["id"]]["passed"] == 1 + assert matches[l2["id"]]["valuation_tier"] == "고가" and matches[l2["id"]]["passed"] == 0 + + # notified_at 마킹 후 재호출해도 보존돼야 함(재알림 방지) + match_id = next(m["id"] for m in db.get_listing_matches() if m["listing_id"] == l1["id"]) + db.mark_listings_notified([match_id]) + db.bulk_upsert_listing_matches([ + {"listing_id": l1["id"], "category": "임차", "passed": 1, "match_score": 95, + "safety_tier": "주의", "sample_size": 6, "reasons": ["ok"], "is_new": 1}, + ]) + updated = {m["listing_id"]: m for m in db.get_listing_matches()} + assert updated[l1["id"]]["safety_tier"] == "주의" # 갱신됨 + assert updated[l1["id"]]["notified_at"] is not None # 보존됨 + + +def test_bulk_upsert_listing_matches_empty_noop(): + """빈 리스트는 아무 것도 하지 않는다(가드).""" + from app import db + db.bulk_upsert_listing_matches([]) + assert db.get_listing_matches() == [] diff --git a/realestate-lab/tests/test_listing_matcher.py b/realestate-lab/tests/test_listing_matcher.py index e07782a..5cdf517 100644 --- a/realestate-lab/tests/test_listing_matcher.py +++ b/realestate-lab/tests/test_listing_matcher.py @@ -27,3 +27,53 @@ def test_match_listing_rent_deposit_cap(): over = match_listing(crit, {"deal_type": "전세", "deposit": 40000, "area_exclusive": 42.0, "dong": "신대방동"}, budget={"max_deposit": 45000}) assert over["passed"] is False + + +# ── Task 2: run_listing_matching 단일 conn 통합 테스트 ─────────────────────── + +def test_run_listing_matching_end_to_end_safe_rent_and_overpriced_sale(): + """listings+market_deals+criteria를 시드하고 run_listing_matching()을 돌려 + listing_matches가 실제로 생성되고 tier/passed가 기대대로인지 확인한다 + (단일 connection 리팩터 후에도 판정 로직이 보존됐는지 검증하는 회귀 테스트).""" + from app import db + from app.listing_matcher import run_listing_matching + + # criteria: 신대방동 전세/매매, 보증금 상한 32000, 최소면적 40 + db.update_listing_criteria({ + "dongs": ["신대방동"], "deal_types": ["전세", "매매"], + "max_deposit": 32000, "min_area": 40.0, + "house_types": ["아파트"], "equity": 20000, "annual_income": 6000, + }) + + # 전세 실거래 시세(median ~42000) — 안전 매물(28000, ratio 0.67) + for i, v in enumerate((40000, 42000, 44000)): + db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동", + "complex_name": "OO", "area": 42.0, "deal_type": "전세", + "deposit": v, "sale_amount": None, + "deal_ym": f"2026{i+1:02d}", "floor": "5", "source": "molit"}) + l_rent, _ = db.upsert_listing({"article_no": "R1", "source": "naver", "deal_type": "전세", + "deposit": 28000, "area_exclusive": 42.0, + "complex_name": "OO", "dong": "신대방동", "dong_code": "11590"}) + + # 매매 실거래 시세(median ~87500) — 고가 매물(95000, ratio 1.09) + for i, v in enumerate((80000, 88000, 90000, 87000)): + db.upsert_market_deal({"house_type": "아파트", "dong_code": "11590", "dong": "신대방동", + "complex_name": "PP", "area": 59.0, "deal_type": "매매", + "deposit": None, "sale_amount": v, + "deal_ym": f"2026{i+1:02d}", "floor": "10", "source": "molit"}) + l_sale, _ = db.upsert_listing({"article_no": "R2", "source": "naver", "deal_type": "매매", + "sale_price": 95000, "area_exclusive": 59.0, + "complex_name": "PP", "dong": "신대방동", "dong_code": "11590"}) + + run_listing_matching() + + matches = {m["listing_id"]: m for m in db.get_listing_matches()} + assert len(matches) == 2 + rent_m = matches[l_rent["id"]] + assert rent_m["passed"] == 1 + assert rent_m["safety_tier"] == "안전" + assert rent_m["sample_size"] == 3 + + sale_m = matches[l_sale["id"]] + assert sale_m["valuation_tier"] == "고가" + assert sale_m["sample_size"] == 4