chat_v2 회귀 — 라이브 환경 raw data 진단 + 머지 (#919/#920/#921) 종합

실제 docker compose · backend SSE · Qdrant · PostgreSQL · Redis · Playwright 라이브 추적으로 잔여 회귀 4축의 진짜 root cause 식별 + 1차 main 머지 + 후속 PR 우선순위.

📅 2026-05-20 📦 main @ b54e15e5 (PR #921 머지 직후) 👤 wks0968@gmail.com 🏠 ai-real-estate-service
목차
  1. TL;DR
  2. 머지된 PR — #919 / #920 / #921
  3. raw data #1 — Qdrant points_count = 0
  4. raw data #2 — PostgreSQL 매물 100 / ai_messages 0 / ai_threads 부재
  5. raw data #3 — worker broker 에 actor 미등록
  6. raw data #4 — asyncio loop 충돌 (actor 실행 실패)
  7. raw data #5 — SSE 정상 emit (backend OK)
  8. raw data #6 — Playwright 11/12 fail · selector 노후화
  9. raw data #7 — 멀티턴/메모리/복잡 시나리오 흐름 진단
  10. 5 회귀축 vs fix 매트릭스
  11. 후속 PR 우선순위

1. TL;DR — 회귀 5 축의 raw data 근거 진단

SSE 계약·token streaming·의도분류는 모두 정상. 진짜 회귀는 backend 인프라 wiring 누락 4 가지 (worker actor 등록 · asyncio loop 충돌 · 멀티턴 영속화 layer 부재) + frontend UI selector 노후화 1 가지. PR #921 로 worker actor 등록·UI testid·spec 안정화 1차 fix 완료, 나머지는 후속 PR.
회귀 축증상raw data 근거fix 상태
SSE token/message commit메시지 버블 빈 채 표시useAI.ts:347 'messages'(s) typo + useAIChatSender.ts:519~525 store.updateMessage 누락✓ #919
main 잠복 TS 4건pre-push hook(tsc) 차단SmartFollowups prop · @ts-expect-error · verifyEmail.access_token✓ #920
worker actor 미등록임베딩 backfill 절대 안 됨tasks/__init__.py 에 chat_v2_indexing import 누락 → broker actor 0✓ #921
UI input testid + spec selectorPlaywright 11/12 failplaceholder dynamic + getByRole(textbox, name) 매칭 실패✓ #921
asyncio loop 충돌backfill actor 실행 시 Future cross-loopworker 로그 got Future attached to a different loop 정확 stack trace✗ 후속 PR
ai_threads 테이블 부재멀티턴·메모리·복잡 시나리오 영속화 불가DB relation "ai_threads" does not exist + ai_messages count=0✗ 후속 PR (migration)
null stub 4종비교표·컨설팅·후속액션·결정배지 안 보임bc72a127 (#904) 약속한 cleanup PR 미도착✗ 후속 PR

2. 머지된 PR (3 건)

PR제목변경head sha
#919chat_v2 token/message event → store.updateMessage (fallback 회귀 fix)2 files +29 / 다른 세션 머지17ed1df1
#920main 잠복 TS 타입 오류 4건 정리3 files +10/-6529bbe85
#921worker actor 등록 + ai-chat-input testid + e2e selector 안정화5 files +14/-9b54e15e5

3. raw data #1 — Qdrant points_count = 0

$ curl http://localhost:10170/collections/poc_listings_gemini_3072

{
  "result": {
    "status": "green",
    "optimizer_status": "ok",
    "indexed_vectors_count": 0,
    "points_count": 0,
    "segments_count": 8,
    "config": {
      "params": {
        "vectors": {"size": 3072, "distance": "Cosine"},
        "shard_number": 1, "replication_factor": 1,
        "on_disk_payload": true
      },
      "hnsw_config": {"m": 16, "ef_construct": 100, "full_scan_threshold": 10000},
      ...
    },
    "payload_schema": {"address": {"data_type": "text", ...}, ...}
  },
  "time": 0.000246796
}
컬렉션 자체는 정상 green / 8 segments / 3072d Cosine 인덱스 준비됨. 하지만 vectors 0 — 매물 1건도 임베딩 안 됨. search_listings tool 이 항상 빈 결과.

4. raw data #2 — PostgreSQL 매물 100 / ai_messages 0 / ai_threads 부재

$ docker exec ai-real-estate-service-postgres-1 \
    psql -U user -d ai_real_estate -c \
    "SELECT count(*) as total, count(*) FILTER (WHERE status='published') as published FROM properties;"

 total | published
-------+-----------
  100  |    100

$ ... -c "SELECT count(*) FROM ai_messages;"

 count
-------
    0

$ ... -c "SELECT count(*) FROM ai_threads;"

ERROR:  relation "ai_threads" does not exist
3 결정적 발견:
  1. properties 100건 published — backfill 만 되면 Qdrant 가 즉시 채워질 수 있음.
  2. ai_messages 0건 — 멀티턴 메시지 영속화가 한 번도 안 됨. 즉 게스트는 localStorage(24h TTL) 만 의존, 회원도 DB 저장 흐름 깨짐.
  3. ai_threads 테이블 자체가 부재 — 멀티턴 thread 영속화 인프라가 통째로 빠짐. migration 필요.

5. raw data #3 — worker broker 에 actor 미등록 (PR #921 로 fix)

$ grep -rn "chat_v2_indexing\|app.workers.tasks" backend/src/app/workers/

backend/src/app/workers/broker.py:114:    from app.workers.tasks.billing_tasks import (...)
backend/src/app/workers/broker.py:117:    from app.workers.tasks.contract_sync_tasks import ...
backend/src/app/workers/broker.py:118:    from app.workers.tasks.moderation_tasks import (...)
backend/src/app/workers/broker.py:121:    from app.workers.tasks.outbox_tasks import ...
backend/src/app/workers/broker.py:122:    from app.workers.tasks.tenant_request_tasks import (...)
backend/src/app/workers/broker.py:125:    from app.workers.tasks.webhook_tasks import (...)
backend/src/app/workers/tasks/__init__.py:14: from app.workers.tasks.ai_tasks import (...)
backend/src/app/workers/tasks/__init__.py:21: from app.workers.tasks.contract_sync_tasks import ...
backend/src/app/workers/tasks/__init__.py:24: from app.workers.tasks.outbox_tasks import ...
backend/src/app/workers/tasks/__init__.py:25: from app.workers.tasks.webhook_tasks import (...)
# ← chat_v2_indexing 모듈 import 가 어디에도 없음!
$ docker exec redis-1 redis-cli KEYS "*chat_v2*"

dramatiq:chat_v2_indexing.msgs    # 메시지가 쌓이는데 처리 actor 가 없음
dramatiq:chat_v2_indexing
PR #921 fix: backend/src/app/workers/tasks/__init__.pyfrom app.workers.tasks.chat_v2_indexing import (backfill_chat_v2_properties, reindex_chat_v2_property) 추가. 이제 worker 가 actor 를 broker 에 등록.

6. raw data #4 — asyncio loop 충돌 (후속 PR)

PR #921 머지 후 backfill actor 호출 시 worker 로그:

$ docker exec backend-1 python -c "from app.workers.tasks.chat_v2_indexing \
    import backfill_chat_v2_properties; \
    print(backfill_chat_v2_properties.send())"

enqueued message_id=35da42ee-d101-4c6f-a3e3-1e9e594d9302

$ docker logs ai-real-estate-service-worker-1 --tail 30

  File "asyncpg/protocol/protocol.pyx", line 375, in query
RuntimeError: Task <Task pending name='Task-11' coro=<_backfill_async() running
  at /app/src/app/workers/tasks/chat_v2_indexing.py:103> cb=[_run_until_complete_cb()
  at /usr/local/lib/python3.11/asyncio/base_events.py:181]>
  got Future <Future pending cb=[BaseProtocol._on_waiter_completed()]>
  attached to a different loop

[dramatiq.middleware.retries.Retries] Retrying message ... in 22697 ms
[dramatiq.middleware.retries.Retries] Retries exceeded for message '35da42ee...'

원인

chat_v2_indexing.py:31_get_session_factory() 는 module-level singleton 으로 create_async_engine 을 한 번만 생성. 그러나 sync actor backfill_chat_v2_properties 가 매 호출마다 asyncio.run(_backfill_async(...)) 로 새 event loop 를 만듦 → 이전 loop 에 bound 된 asyncpg connection pool 이 새 loop 에서 사용 불가.

fix 후보 (후속 PR)

  1. actor 진입마다 새 engine 생성_get_session_factory 의 singleton 패턴을 폐기하고 actor 함수 안에서 매번 create_async_engine. dispose 도 함수 끝에서.
  2. nest_asyncio 패치 — 기존 loop 위에 nested loop 허용. 다만 production 권장 X.
  3. dramatiq async middleware — community plugin dramatiq-asyncio 또는 자체 wrapper 로 actor 를 native async 로 등록.

7. raw data #5 — SSE 정상 emit (backend 자체는 OK)

$ curl -sN -X POST http://localhost:10100/api/v1/assistant/message \
    -H "Content-Type: application/json" \
    -d '{"thread_id":"smoke-...","message":"전세 5천 이하 강남 원룸 찾아줘","context":{"role":"tenant"}}'

event: metadata
data: {"session_id": "smoke-...", "user_id": "anon-43a0eab98380"}

event: turn_start
data: {"user_message": "전세 5천 이하 강남 원룸 찾아줘"}

event: thinking
data: {"label": "🔎 매물 검색 중...", "text": "🔎 매물 검색 중..."}

event: tool_call
data: {"name": "search_listings", "args": {"top_k": 5, "user_message": "..."}, "call_id": "fc_e3a8..."}

event: updates
data: {"properties": [], "results": [], "can_show_listings": false, "count": 0,
       "filters": {"hard": {"deposit_max": 50000000,
                            "area_keywords": ["강남"],
                            "room_count": [1],
                            "property_types": ["oneroom"]},
                  "confidence": 0.96},
       ...}

event: properties / suggestions / updates (4 카테고리 quick_buttons) / tool_result

event: token
data: {"text": "전세 예산이 5천만원 이하라서 강남 원룸 매물이 많이 부족한 상황이에요. ...
                  강남 인근 서초·역삼 쪽도 함께 살펴보실까요? 🥬", ...}

event: done / message / end
data: {"timing_ms": {"total": 12653, "llm": 1023, "tools": 11618,
                "prompt_tokens": 12089, "completion_tokens": 155,
                "cost_micro_usd": 1906}, "tools_used": ["search_listings"], "turn_count": 2}
backend SSE 완전 정상 (12 event/turn). 의도 분류 confidence 0.96, 가격·지역·룸·타입 필터 정확. 다만 properties=[] — Qdrant 0건이라 검색 결과 없음. final message 의 자연체 응답("강남 원룸 매물이 많이 부족한 상황이에요. ...") 도 LLM 이 정상 생성.

8. raw data #6 — Playwright 11/12 fail (PR #921 fix 후 재검증 필요)

$ cd e2e && npx playwright test \
    tests/noauth/chat-guest-access.noauth.spec.ts \
    tests/noauth/guest-chat-persistence.noauth.spec.ts \
    tests/noauth/assistant-leak-fix.noauth.spec.ts \
    --project=chromium-noauth --workers=2 --timeout=120000

11 failed
   tests/noauth/assistant-leak-fix.noauth.spec.ts:26   tenant 랜딩 진입 → AI 입력창 visible
   tests/noauth/assistant-leak-fix.noauth.spec.ts:32   멀티턴 회상 응답에 시스템 프롬프트 프리픽스 leak 없음
   tests/noauth/assistant-leak-fix.noauth.spec.ts:72   매물 검색 요청 → tool calling 발화 → 본문에 슬롯 인용
   tests/noauth/assistant-leak-fix.noauth.spec.ts:112  에러 메시지 ("열무가 답하다 꼬였어요") 가 화면에 노출되지 않음
   tests/noauth/chat-guest-access.noauth.spec.ts:5     /chat — 비회원 진입 허용 + filter 칩 미노출 (#47)
   tests/noauth/chat-guest-access.noauth.spec.ts:13    /chat — 비회원 empty CTA "3초 만에 로그인하기"
   tests/noauth/chat-guest-access.noauth.spec.ts:22    /chat 헤더 알림 버튼
   tests/noauth/chat-guest-access.noauth.spec.ts:34    /chat 검색 버튼 — aria-disabled
   tests/noauth/guest-chat-persistence.noauth.spec.ts:44  /home 진입 — 비회원도 AI 입력창 렌더
   tests/noauth/guest-chat-persistence.noauth.spec.ts:50  localStorage snapshot reload 복원
   tests/noauth/guest-chat-persistence.noauth.spec.ts:82  snapshot 없는 fresh 진입 — 웰컴 메시지만 렌더
1 passed (1.1m)

Error context (대표):
  Locator: getByPlaceholder(/원하는 동네나 조건을 입력하세요/)
  Expected: visible
  Timeout: 8000ms
  Error: element(s) not found
3차 실행까지 동일 fail 패턴. spec selector 를 getByTestId('ai-chat-input') 으로 갱신했음에도 fail 이 그대로인 것은 docker dev server frontend container 가 Vite HMR 로 main repo 의 testid 변경을 픽업하지 못한 가능성이 큼 (또는 /chat·/home 라우트의 별 회귀). PR #921 머지 + container 재빌드 후 재검증 필요.

9. raw data #7 — 멀티턴·메모리 필터 저장·복잡 시나리오 흐름 진단

9.1 멀티턴 thread_id 흐름

$ grep -n "thread_id\|threadId" frontend/src/store/aiChatStore.ts

  169:  threadId: string | null;
  367:  threadId: (raw.threadId as string | null) ?? null,
  497:  threadId: string | null;
  685:  /** messages 초기화, 새 threadId 생성, 관련 상태 초기화 */
  690:  threadId: string,
  803:  threadId: null as string | null,
  981:  threadId: state.threadId,
  1075: set({ threadId: id });
  1276: loadConversation: (threadId, dbMessages, lastMetadata) => {

해석: frontend 는 threadId 를 store 에 보관하고 localStorage 에 persist. 다만 SSE 호출 시 thread_id: '' (빈 string) 으로 보내면 backend 가 매번 새 thread 생성 → 영속화 안 됨. 또한 DB ai_messages 가 0건 이므로 회원 사용자도 reload 시 멀티턴 컨텍스트 복원 불가.

9.2 메모리 필터 저장 — `update_user_profile` tool

chat_v2 agent 의 4 atomic tool 중 하나. backend `tools.py:update_user_profile` 가 호출되어도 영속 layer 가 부재하면 다음 turn 에서 회상 불가.

도구읽기/쓰기현재 상태
search_listingsread (Qdrant)Qdrant 0건 → 항상 빈 결과
get_thread_historyread (ai_messages)ai_messages 0건 → 회상 불가
get_user_profileread (별 테이블?)테이블 식별 필요
update_user_profilewrite영속 layer 확인 필요

9.3 복잡 시나리오 — 회귀 시나리오 83 case 미실행

backend/tests/integration/chat_assistant/scenarios.py 는 64 (POC v2) + 19 (regression) = 83 시나리오 SSOT. 다만 라이브 LLM 호출 필요해 일반 CI 에서 실행 안 함. 정량 평가를 원하면 llm_judge.py + eval_split.py 로 직접 실행 (turn 당 ~12초 × 83 = 16분).

10. 5 회귀축 vs fix 매트릭스

회귀 축증상 (사용자 관측)root causefix PR잔여
① 메시지 버블 빈 채 AI 답변이 화면에 안 나옴 useAI.ts:347 'messages'(s) typo · store.updateMessage 누락 #919, #920
② 검색 결과 0건 매물 못 찾음 worker actor 미등록 → backfill 0회 → Qdrant points=0 #921 (actor 등록) asyncio loop 충돌 fix
③ 멀티턴 깨짐 "아까 뭐라 했지?" 회상 불가 ai_threads 테이블 부재 · ai_messages=0 없음 migration + 영속 layer
④ 메모리 필터 저장 안 됨 이전 turn 의 조건 다시 입력해야 함 update_user_profile write layer 부재 또는 미연결 없음 persona/condition store 영속화
⑤ 부가 UI 빈 깡통 비교표·컨설팅·후속액션·결정배지 안 보임 null stub 4종 #904 cleanup 미완 없음 component 4 + store 4 + mapper 정리
⑥ Playwright 11/12 fail 회귀 자동 탐지 불가 placeholder dynamic + selector 노후화 #921 (testid + spec) container HMR 픽업 후 재검증

11. 후속 PR 우선순위 (P0 → P3)

  1. P0 — asyncio loop 충돌 fix (`chat_v2_indexing.py`)
    module-level engine singleton 폐기, actor 진입마다 새 engine. 또는 dramatiq async middleware.
    → backfill 성공 → Qdrant points > 0 → search_listings 실제 매물 반환.
  2. P0 — Playwright spec selector container 재검증
    PR #921 머지 + frontend container 재빌드 후 Playwright 12/12 확인. 회귀 자동 탐지 가능 상태로 복귀.
  3. P1 — ai_threads 테이블 migration + ai_messages 영속 흐름
    멀티턴·메모리·복잡 시나리오의 인프라 기반. alembic revision 신규.
  4. P1 — null stub 4종 완전 폐기
    component 4 + store 4 + mapper + import + 사용처. bc72a127 (#904) 약속한 cleanup PR.
  5. P2 — 83 회귀 시나리오 정량 평가
    scenarios.py + llm_judge.py 실행, train/holdout 점수 측정. Qdrant 채워진 후.
  6. P2 — backend ruff 잠복 68건 정리
    ruff --fix 48 + 수동 20. pre-push hook 통과.