어디살지 백엔드 종합 진단

4축 · DB · 임베딩 · 리랭커 · 아키텍처 · 리포지터리 분석 · 2026-05-19
TL;DR — 4축 핵심 결함
7
Alembic head 분기
160 / 0
Index / JSONB GIN
1536d
단일 임베딩 차원
0
Cross-encoder 리랭커
6
Hex 경계 위반
0
관측성 라이브러리
5,348
models.py 라인
7
중복 cron (APScheduler)

축 1 · DB 구조 5 결함

#항목 · 위치리스크권장
1 Alembic head 7개 분기 미머지 backend/migrations/versions/ (90 파일) · heads: c0d1e2f3a4b5, d6e7f8a9b0c1, a1t2u3v4w5x6, c4d5e6f7h8i9, b3v4w5x6y7z8, f2a3b4c5d6e7, e5f6g7h8i9j0 CRIT alembic upgrade head 시 multiple heads 에러 단일 merge 리비전 추가 + CI 가드 (alembic heads | wc -l == 1)
2 동일 revision ID 충돌 a1t2u3v4w5x6_*.py — contract_documents / notification_settings / oauth_tokens 3 파일이 같은 down_revision="z0s1t2u3v4w5" HIGH suffix _cd/_ns만으로 구분, head 추가 분기 위험 리비전 ID 네이밍 정책 (YYYYMMDD_HHMM_slug)
3 PostGIS 미활용 (검색 경로) src/app/adapters/database/property_repository.py:1187-1217 (find_candidate_ids) — func.acos Haversine 풀스캔 HIGH geom + GIST 만들었으나 핵심 후보 추출 미사용 → 매물 늘면 선형 저하 ST_DWithin(geom, ST_MakePoint(..)::geography, radius_m) 교체
4 JSONB GIN 인덱스 0개 models.py:1346 property_details / :1411 vision_summary 모두 JSONB 인덱스 없음. property_details["area_m2"].astext.cast(Numeric) 풀스캔 MED 면적·방수 필터마다 전체 cast 비용 자주 쓰는 키는 generated column + B-tree, 또는 GIN (property_details jsonb_path_ops)
5 ORM relationship eager loading 0회 models.py relationship 12회 — selectinload/joinedload 전 코드베이스 0회. lazy 기본값 MED raw query 위주라 현재 미표출. relationship 접근 시 자동 N+1 relationship 정의에 lazy="raise" → lazy access 시 즉시 에러로 N+1 차단

축 2 · 임베딩 모델 4 결함

#항목 · 위치리스크권장
1 단일 모델 의존 src/app/adapters/ai/openai_embedding_adapter.py:34 MODEL=text-embedding-3-small · DIMENSIONS=1536 · qdrant/bootstrap.py:46 VECTOR_SIZE=1536 HIGH OpenAI 영어 중심. 한국어 부동산 토큰(반지하·풀옵션·역세권) 검증 X A/B: text-embedding-3-large(3072) vs bge-m3(1024) vs Gemini embedding-001(768) — POC 어댑터 골격 이미 존재
2 도메인 동의어 보강 부재 search_document.py:526-625 _build_embedding_text — 단순 [유형][주소][설명][태그] concat MED 한국어 약어/동의어("강남"="강남구·강남역") 정규화 없이 임베딩 → recall 저하 도메인 사전 기반 query/document 양쪽 동의어 확장
3 Qdrant 단일 dense vector qdrant/bootstrap.py:155-161 VectorParams(size=1536, distance=COSINE) MED dense-only 라 "강남역 도보 3분" 같은 키워드 정확 매칭은 BM25 보다 약함 properties_v2 dense+sparse(BM25/SPLADE) hybrid + alias zero-downtime 전환 (alias_manager.py 존재)
4 description 1,000자 truncate search_document.py:577 desc_text = description.strip()[:1000] LOW text-embedding-3-small 8,191 토큰 한도. 후반부(주변 인프라·인테리어) 손실 8,000자(≈3,000토큰) 또는 청크별 임베딩 후 mean pooling

축 3 · 리랭커 3 결함

#항목 · 위치리스크권장
1 Cross-encoder 리랭커 미존재 pyproject.toml:43-58 cohere·sentence-transformers·bge 의존성 없음. cohere/cross_encoder/reranker_model 매치 0건 HIGH "rerank"는 search_properties.py:531 payload-feature 가중 합계(vector α=0.75 + prefer 0.25). 의미적 재정렬 X 2-stage: ANN top-50 → Cohere rerank-multilingual-v3 또는 BAAI/bge-reranker-v2-m3 top-10. POC 어댑터 존재
2 per-search 추가 count RT src/app/adapters/qdrant/qdrant_search_repository.py:242 MED 매 검색마다 Qdrant count() 한 번 더 — 리랭커 도입 시 latency 누적 total 필요한 path만 count, 무한 스크롤은 has_next
3 검색 결과 캐시 부재 services/embedding_cache.py Redis 24h TTL — query vector만 캐싱 LOW "강남역 원룸" 반복 쿼리도 Qdrant 매번 호출. 리랭커 추가 시 cost 폭증 (query_hash + filter_hash) → top-K id 짧은 TTL(5~10분) 캐시

축 4 · 아키텍처 5 결함

#항목 · 위치리스크권장
1 Hex Port/Adapter 경계 위반 6곳 search/ports/search_properties_port.py:21-22 / auth/use_cases/request_email_verification.py:24 / contract_documents/use_cases/generate_{hwpx,pdf}.py / admin/user_management/use_cases/admin_user_management_service.py HIGH import-linter는 core → adapters만 차단. domains → adapters 무방비 import-linter contract에 app.domainsapp.adapters 금지 추가 + adapter port 분리 리팩토
2 관측성 도구 0 pyproject.toml 전체에 sentry-sdk / opentelemetry / prometheus-client 없음. sentry 매치 0건 (conversation_memory.py:397 주석뿐) CRIT 프로덕션 에러·latency 추적 수단이 구조화 로그뿐. APM·alerting·trace 없음 sentry-sdk[fastapi] + OTel auto-instrument(asyncpg/httpx/redis/qdrant) + CF Workers trace export
3 boto3 sync 호출이 async loop block src/app/core/storage.py:67,86 boto3.client + put_object. 호출처 domains/messaging/use_cases/upload_chat_image.py:143,173 async def HIGH 동기 S3 I/O가 event loop block — 업로드 트래픽 동시성 저하 aioboto3 전환 또는 asyncio.to_thread 일괄 래핑 (email_notification_adapter.py:107이 선례)
4 Dramatiq + APScheduler 중복 트리거 src/app/workers/broker.py:100-204 BackgroundScheduler(in-process) 7개 cron · Redis 락 없음 HIGH worker N개면 동일 cron N회 trigger — webhook 재시도·outbox 중복 처리 위험 dramatiq-periodiq 또는 단일 leader 패턴 (Redis distributed lock + heartbeat)
5 거대 단일 파일 + eager 0회 models.py 5,348줄 / property_repository.py 1,540줄 / routers/properties.py 3,019줄. selectinload·joinedload 매치 0건 MED 변경 영향 추적 곤란 + relationship 사용 시 자동 lazy → N+1 위험 도메인별 model 분할 + lazy="raise" 기본 + 명시적 eager 전략

우선순위 매트릭스 (P0 → P3)

우선항목영향예상 작업량
P0 Alembic head 7개 → 단일 merge 리비전 배포 실패 회복 S (~40 LOC)
P0 Sentry + OTel 최소 도입 프로덕션 가시성 M (~150 LOC + config)
P1 PostGIS ST_DWithin 으로 검색 후보 추출 교체 지리 검색 지연 ↓ M (~80 LOC)
P1 import-linter contract 강화 (도메인 → 어댑터 금지) 아키텍처 회귀 차단 S + 6 파일 리팩토
P1 APScheduler → Redis-lock 또는 dramatiq-periodiq 중복 webhook/outbox 차단 M (~100 LOC)
P1 boto3 → aioboto3 또는 to_thread 일괄 래핑 업로드 동시성 ↑ M
P2 Cross-encoder 리랭커 도입 (Cohere rerank-multilingual-v3) 검색 정확도 MRR ↑ M (~120 LOC, POC 어댑터 재활용)
P2 임베딩 A/B (bge-m3 vs gemini vs text-embedding-3-large) 한국어 recall ↑ L (offline eval 셋 필요)
P2 JSONB jsonb_path_ops GIN 인덱스 면적·방수 필터 지연 ↓ S (alembic 1 리비전)
P3 거대 단일 파일 분할 + lazy="raise" 유지보수 ↑ L (단계적)

참고 — 이미 존재하지만 활용 안 된 자산