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

@@ -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"] == "안전"

View File

@@ -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() == []

View File

@@ -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