Files
auto-stock/docs/multi_tenant_phase1_review.md
shuim b4a1fc79ab feat: 멀티테넌트 1단계 — 사용자별 KIS 계좌·포트폴리오·수동매매 (MULTI_TENANT_ENABLED 기본 OFF)
설계: docs/multi_tenant_design.md / 검수: docs/multi_tenant_phase1_review.md (High 0, MT=False 배포 가능)

데이터·인프라
- user_kis_credentials(Fernet 암호화, PBKDF2 키 파생, key_version), stock_bans_v2 신설,
  portfolio/trade_history/active_trades/trade_journals/trading_sessions 등 user_id nullable 추가
- alembic 0009~0011(idempotent, legacy owner 백필). main.py 기동 시 portfolio.user_id 스키마 검사(H1)
- Jenkinsfile: DB Migration 을 Deploy 앞으로, alembic 이력 없으면 stamp 0008 후 upgrade(N1)
- bootstrap: users 비어 있으면 BOOTSTRAP_ADMIN_* 로 관리자 시드 + .env KIS 자격증명 이관 + 백필(SAVEPOINT)

백엔드
- core/crypto.py, core/tenant_auth.py(get_current_user — 비활성 사용자 401, API 키→legacy owner,
  deny_non_owner_session_when_mt), core/dt_utils.py
- services/tenant: context, kis_pool, registry(UserContextRegistry, 락 최소화·복호화 to_thread), risk_factory
- KoreaInvestmentAPI.for_user(사용자별 인스턴스, 토큰캐시 users/{id}/, 시세는 공용 위임)
- trade_executor.safe_place_order_v2(사용자별 락/세마포어, legacy ctx 는 기존 키)
- mdd_manager/loss_tracker _v2 + ban_until tz-aware/naive 경계 정규화(레거시 경로 버그 포함 수정)
- db_worker: payload user_id 옵션 필터, upsert_stock_ban_v2, legacy ActiveTrade 조회 LIMIT 1
- API: /account/credentials(등록·마스킹조회·삭제·연결테스트·자동매매), /auth/admin/tenant/*,
  /portfolio/v2(조회·요약·sync·buy·sell), /trades/v2(+pending). PortfolioResponse Optional 수정

프론트
- /account 내 계좌 설정, useTenantMode(세션=v2 / API키=legacy, 미확정 시 쿼리·버튼 비활성),
  계좌 미등록 인라인 안내, v2 매수/매도/거래이력 분기

테스트: tests/integration(격리 Postgres, KIS 전면 mock) 20 passed / 1 xfailed(0012 이전 제약)
2단계(스케줄러 사용자화·legacy 라우터 격리) 전까지 MULTI_TENANT_ENABLED=True 전환 금지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:26:51 +09:00

149 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 멀티테넌트 1단계 검증 보고서 (2026-09-21, 브랜치 `dev` 미커밋 변경분)
검수 범위: `git status` 의 백엔드/프론트 변경 23파일 + 신규 17파일, `docs/multi_tenant_design.md`, CLAUDE.md. 읽기 전용(코드 수정·컨테이너 조작 없음). `ast.parse` 29파일 전부 통과, `npx tsc --noEmit` exit 0. 로컬에 fastapi 미설치로 import 실행 검사 불가 → 정적 순환 임포트 분석으로 대체(순환 없음: `auth.py↔bootstrap.py` 는 양쪽 모두 함수 내 지연 import).
---
## High (배포 차단)
### H1. 배포 순서 결함 — `alembic upgrade head` 없이 새 이미지를 올리면 레거시 경로 전체 500
- `backend/app/models/models.py:47,130,165,213,228,278,332` 에 `user_id` 컬럼 추가. `main.py:159` 주석대로 스키마는 `create_all` 만 실행되고 alembic 은 CLI 전용인데, `create_all` 은 기존 테이블에 컬럼을 추가하지 않음. Dockerfile CMD 는 `uvicorn` 직행, compose 에도 alembic 단계 없음.
- 영향: SQLAlchemy 는 매핑된 모든 컬럼을 명시 SELECT/INSERT 하므로 `portfolio/trade_history/active_trades/trade_journals/trading_sessions` 관련 모든 기존 엔드포인트와 db_worker 태스크(insert_trade_history, upsert_portfolio 등)가 `UndefinedColumnError` 로 실패. MT 기능과 무관하게 자동매매 기록이 전부 유실됨.
- 권장: 배포 절차에 `alembic current` 확인 → `alembic upgrade head` → `docker compose up -d --build` 명문화. `main.py` lifespan 에 `information_schema` 로 `portfolio.user_id` 존재 확인 후 없으면 명확한 에러 로그+기동 중단(또는 경고). 담당: dev(mt-a-dev) + 배포 문서.
### H2. `get_current_user` 가 비활성/삭제 사용자를 legacy owner 로 폴백 → 회수된 계정이 owner 계좌로 매매 가능
- `backend/app/core/tenant_auth.py:87-99`: `_resolve_session_user` 결과가 `None` 또는 `is_active=False` 면 예외 대신 `CurrentUser(id=legacy_owner_id, ...)` 반환. `security.require_api_key`(`security.py:72-91`)는 JWT 서명/exp/aud 만 검증하고 `is_active` 를 보지 않으므로 이 경로는 "비정상 경합"이 아니라 revoke 직후 토큰 만료(24h)까지 항상 도달함.
- 영향: MT=True 에서 `registry.resolve()`(`registry.py:160-166`)가 `current_user.id == legacy_ctx.user_id` 로 legacy 컨텍스트를 반환 → 비활성 사용자가 `/portfolio/v2/buy|sell` 로 legacy owner 의 KIS 계좌에 주문, `/portfolio/v2`·`/trades/v2` 로 owner 데이터 열람. MT=False 에서도 owner 데이터 열람.
- 권장: 두 경우 모두 `HTTPException(401)` 로 거부. 담당: dev(mt-b-dev).
### H3. 자격증명 변경/삭제/자동매매 OFF 가 캐시된 `UserContext` 에 반영되지 않음
- `backend/app/api/endpoints/kis_credentials.py:183,200` 은 `kis_client_pool.invalidate()` 만 호출. `user_context_registry.invalidate()` 호출처는 코드베이스에 0건(grep 확인). `registry.get()`(`registry.py:59-61`)은 `_contexts` 캐시 히트 시 DB 를 보지 않음.
- 영향: PUT(키 교체, `is_enabled=False` 로 리셋)·DELETE 후에도 캐시된 ctx 가 구 app_key 로 만든 KIS 인스턴스를 계속 보유(httpx 는 `is_closed` 체크로 자동 재생성) → 삭제·교체된 자격증명으로 재시작 전까지 주문 계속 가능. `PATCH /auto-trade enabled=false` 도 `ctx.auto_trade_enabled` 에 반영 안 됨 → `buy_stock_v2:270` 가드 무력화.
- 권장: 세 엔드포인트에서 `await user_context_registry.invalidate(user_id)` 호출(내부에서 kis_pool 도 정리, 순환 임포트 없음). 담당: dev(mt-b-dev).
### H4. `tests/integration/test_kis_credentials.py` 가 운영 DB 를 직접 대상으로 함
- 파일 docstring(`:4-13`) 자체가 "실제 `AsyncSessionLocal`(운영 DB)에 대해 검증", 실행 예시 `docker exec autostock-backend python -m pytest ...`. 라이브 DB 가드 없음, 테스트 사용자 생성/삭제를 운영 DB 에서 수행. 중간 실패 시 잔여 행.
- `test_multi_tenant.py:38-43` 가드는 `os.environ["DATABASE_URL"]` 만 검사 → env 미설정 시 `settings.DATABASE_URL` 기본값(`...:5432/autostock_db`, 컨테이너 내부면 라이브 postgres)으로 떨어지는데 가드를 통과함. `test_h_bootstrap_admin_seed_and_idempotent` 는 `_backfill_legacy_owner_ids` 로 실제 UPDATE 를 수행.
- 권장: 두 파일 모두 `settings.DATABASE_URL` 로 가드하고, 임시 DB 마커(예: DB 명 `_test` 접미사) 필수화. 담당: mt-c-dev(테스트).
---
## Medium
### M1. `CurrentUser.effective_user_id`(None) vs `UserContextRegistry.legacy().user_id`(0) 불일치
- 위치: `tenant_auth.py:47,85`(None) vs `registry.py:128`(`owner_id = 0`).
- 통일 방향: `None`. `users.id=0` 은 존재하지 않으므로 0 은 FK 위반 값이고, `Optional[int]` 타입(`context.py:36`)과도 맞음.
- 실제 오동작 경로(관리자 미존재 + API 키 호출, 즉 초기 배포/MCP 서버 호출):
- `portfolio_v2.py:268,406` `ctx.user_id is None` 가드를 `0` 이 통과 → 주문 실행 후 `enqueue_trade_history({"user_id": 0})`(`:355,443`) → `users` FK 위반 → db_worker 3회 재시도 후 폐기 → 주문은 나갔는데 거래 기록 유실. `enqueue_portfolio_upsert:373`, `enqueue_close_active_trade_v2:465` 동일.
- MT=True 시 `_user_scope_filter(Portfolio.user_id, 0)` → 빈 결과 → 매도 항상 404(fail-closed), 매수는 보유수 0 으로 계산돼 `MAX_HOLDINGS_COUNT` 가드 무의미.
- `_resolve_target_user_id` 는 None 이면 400 → GET 계열은 fail-closed(정상).
- 권장: `registry.legacy()` 에서 `owner_id = owner.id if owner else None`, owner 미확정 상태면 캐시하지 않기(현재 60초 TTL 동안 0 고정). 담당: mt-a-trading.
### M2. legacy 컨텍스트가 `safe_place_order_v2` 를 타면 락 키가 기존 경로와 달라 이중 매도 방지가 깨짐
- `trade_executor.py` `safe_place_order_v2` 는 항상 `order_lock:{user_id}:{code}:{type}` + 세마포어 키 `{user_id}:{code}` 사용. 자체 docstring 과 설계문서 §2-4/§10-4("legacy 경로는 기존 키 그대로") 위반. MT=False(기본)에서 세션 사용자가 v2 매도 → `order_lock:{owner}:005930:SELL`, 동시에 swing/daytrading exit_service 는 `order_lock:005930:SELL` → 같은 계좌 같은 종목 동시 매도 가능.
- 기존 `/portfolio/sell` 도 락이 없었으므로 회귀는 아니지만 설계 의도 미달. 권장: `ctx.is_legacy` 면 `_acquire_order_lock`/`_get_order_semaphore(stock_code)` 로 분기. 담당: mt-a-trading.
### M3. MT=True 에서 legacy 스케줄러가 쓰는 행(`user_id=NULL`)이 owner 의 v2 화면에서 사라지고 ActiveTrade 종료가 누락
- swing/daytrading/trade_executor/portfolio.py/manual_buy.py 어느 곳도 payload 에 `user_id` 를 넣지 않음(grep 0건; 설계 §4 "DB 쓰기 payload에 user_id를 넣되" 미구현). 부트스트랩 백필은 기동 시 1회.
- 영향: MT=True 시 `_user_scope_filter` 가 `== owner` 로 엄격해져 기동 이후 자동매매로 생긴 보유/이력이 `/portfolio/v2`, `/trades/v2` 에서 비표시, `sell_stock_v2` 404. MT=False 에서도 `sell_stock_v2:465` 의 `enqueue_close_active_trade_v2(owner, ...)` 가 `ActiveTrade.user_id == owner` 필터(`db_worker.py:587`)로 NULL 행을 못 찾아 전량 수동매도 후 ActiveTrade 가 active 로 잔존 → exit_service 가 유령 포지션 추적.
- 권장: 1단계에서는 `enqueue_close_active_trade_v2` 를 legacy ctx 에서 `user_id=None` 으로 호출(기존 동작), 또는 `_user_scope_filter` 를 close 에도 적용. MT=True 전환 전제조건으로 legacy 쓰기 경로 user_id 주입 필요. 담당: mt-a-trading.
### M4. `registry.get()` 이 `asyncio.Lock` 안에서 DB I/O + Redis + PBKDF2 CPU 작업 수행
- `registry.py:63-111`: 락 내부에서 `db.execute`, `decrypt`×3(PBKDF2 200k 회 × 3 ≈ 0.3~0.6초 동기 CPU, `crypto.py:28-32` 파생키 캐시 없음), `load_user_risk_state`(Redis+DB). CLAUDE.md §3-4 위반이며 모든 사용자 컨텍스트 조립을 직렬화 + 이벤트 루프 정지(실시간 스케줄러 지연).
- `kis_credentials._to_out:103` 도 GET 마다 `decrypt` 실행 → 요청당 ~0.2초 루프 블로킹.
- 권장: `_derive_key` 에 `functools.lru_cache`, 복호화는 `asyncio.to_thread`, 락은 dict 갱신 구간만. 담당: mt-b-dev / mt-a-trading.
### M5. 프론트 `useTenantMode` 로딩 중 `legacy` 반환 → 세션 사용자에게 owner 데이터 노출/오주문 창
- `frontend/src/lib/tenant.ts:37`. `usePortfolioData` 는 `['portfolio-combined','legacy']` 키로 `/portfolio/combined` 를 즉시 호출해 결과를 캐시. MT=True 에서 이는 타 사용자(owner) 보유 정보 노출이며, 그 창에 매수/매도 클릭 시 `useTradeActions` 가 legacy `/portfolio/sell` 로 owner 계좌에 주문.
- 근본 원인은 백엔드: legacy `/portfolio*`, `/trades*` 가 MT=True 에서도 모든 세션 사용자에게 열려 있음(설계 §5.7 "기존 /portfolio* 는 legacy 컨텍스트로 유지"). 프론트 가드는 완화책일 뿐.
- 권장: (프론트) `mode: 'v2' | 'legacy' | undefined`, 로딩 중 쿼리 `enabled: false`. (백엔드, MT=True 전환 전제) legacy 라우터에 "MT=True 이고 세션 사용자가 owner 아니면 403" 의존성. 담당: mt-d-dev / mt-b-dev.
### M6. `resetPendingTrades` → `DELETE /trades/pending` 가 사용자 무관 전체 삭제
- `useTradeActions.ts:97-99` v2 전환 없이 legacy 유지, `trades.py:192-209` 는 `user_id` 조건 없음 → MT=True 에서 일반 사용자가 타 사용자의 PENDING/FAILED 이력 삭제 가능. 권장: `/trades/v2/pending` 추가 또는 v2 모드에서 버튼 숨김. 담당: mt-d-dev.
### M7. db_worker `scalar_one_or_none()` 이 사용자 다중화로 `MultipleResultsFound` 가능
- `db_worker.py:543,589`(`upsert_active_trade`/`close_active_trade`) legacy 호출(`user_id=None`) 시 필터 미적용인데 MT 로 두 사용자가 같은 종목 active 면 예외 → 태스크 3회 실패 후 폐기. `user_id=None` 경로 자체는 기존과 동일(`is not None` 체크 정확 — `== None` 실수 없음). 권장: `.limit(1)` 또는 `scalars().first()`. 담당: mt-e-risk-fix.
### M8. 부트스트랩/이관 API 의 백필이 첫 실패 후 조용히 전부 무효화
- `bootstrap.py:122-146`, `auth.py:337-349`: PostgreSQL 은 트랜잭션 내 한 문장 실패 시 이후 문장이 모두 `InFailedSqlTransaction` → 개별 try/except 가 삼켜 `backfilled` 카운트가 0 으로 오보되고 실제 UPDATE 미실행. 권장: 문장별 `SAVEPOINT`(`session.begin_nested()`) 또는 사전 `information_schema` 체크. 담당: mt-b-dev.
---
## Low
- L1. `mdd_manager.py:159` `json.dumps` 신규 사용(§4 `_dumps()` 규칙). 기존 `:44` 도 동일 패턴이라 일관성 차원.
- L2. `crypto.decrypt` `ValueError` → `registry.get:80` 은 "계좌 미등록" 400 으로 안내(원인 은폐), `kis_credentials._to_out:103` 은 미처리 → 500. 키 로테이션 실패 시 진단 어려움.
- L3. Alembic `0009` downgrade `drop_index("ix_user_kis_credentials_app_key_hash")` — 테이블이 `create_all` 로 생성된 경우 unique 는 constraint(`..._app_key_hash_key`)라 인덱스가 없어 downgrade 실패. `0010` idempotent OK, FK `ondelete` 모델과 일치 OK(SET NULL / CASCADE). 사용자 삭제 엔드포인트가 없어 CASCADE↔SET NULL 정책 충돌은 현재 비발생.
- L4. `registry.legacy()` 의 `auto_trade_enabled=settings.AUTO_TRADE_ENABLED` 스냅샷은 60초 TTL 동안 고정 — 런타임 토글 지연.
- L5. `kis_credentials.verify_credentials:235` `detail=f"...{e}"` 로 KIS 예외 원문 노출 — 현재 KIS 오류 본문에 키는 없으나 방어적으로 예외 타입만 노출 권장. 평문 키/시크릿/비밀번호는 응답·로그 어디에도 없음 확인(hint 6자만, `BOOTSTRAP_ADMIN_PASSWORD` 미로깅).
---
## 확인 통과 항목
- 시그니처 변경: `git diff` `-` 라인에 def/class 변경 0건. 신규는 전부 `_v2`/`for_user`/신규 파일.
- 실시간 계층 `AsyncSessionLocal()` 신규 호출 없음(bootstrap 은 lifespan 1회, 허용).
- 직접 commit 신규 경로: `kis_credentials`(설계 §5.3 허용), `sync_portfolio_v2`(sync 예외), `sell_stock_v2:469`(기존 `/portfolio/sell:1365` 와 동일 패턴). 정책 내.
- `require_api_key`: `portfolio_v2`/`trades_v2`/`kis_credentials` 라우터 등록 시 의존성 부여(`api/__init__.py:30-52`). `/auth/admin/tenant/*` 는 `require_admin`(내부적으로 `require_api_key` 의존) 강제.
- 관리자 `?user_id=` 오버라이드: `_resolve_target_user_id:69`/`_resolve_ctx:80` 비관리자 403, GET 2개 + trades_v2 모두 경유.
- `app_key_hash` 중복 체크 `user_id != user_id` 로 자기 갱신 오탐 없음(`:151`).
- JWT: `aud`/`exp` 검증(`security.py:58`).
- `.env` 미추적(`.env.example` 만), `git diff --cached` 비어 있음.
- `sync_portfolio_v2` SELECT/INSERT/DELETE 모두 `Portfolio.user_id == ctx.user_id`(`:193,219,230-232`).
- `execute_reduction_v2` 큐 payload 3곳 모두 `user_id` 포함.
- `registry.get()` `is_enabled=False` 거부(`:72`), `invalidate` 시 `_active_cache=None`(`:214`).
- `for_user` 속성 집합 = `for_real_market_data` ⊇ + `mode`,`user_id`; `_last_token_request_time` 은 `hasattr` 가드.
- SL 관련 `max()` 신규 도입 없음.
## CLAUDE.md §8.1 체크리스트
1. DB 쓰기 db_worker 경유 — ✅ (허용 예외만 직접 commit)
2. §5 리스크 규칙 — ✅ (buy_v2 에 가격필터/ban/MDD/최대보유 적용) — 단 M1 로 legacy·무관리자 시 보유수 가드 무의미
3. SIDEWAYS 진입 제안 금지 — 해당 없음 ✅
4. 백테스트 없이 실전 투입 금지 — 전략 변경 없음 ✅
5. SL `min()` 만 — ✅
6. `record_trade_result` 후 `save_to_db` — v2 경로에 record 호출 없음(해당 없음) ✅
7. 쓰기 API `require_api_key` — ✅
8. `_dumps()`/새 파일/시그니처 불변 — ❌ (L1 `json.dumps` 1건; 나머지 ✅)
## 배포 가능 여부
**불가.** High 4건(H1 배포 순서, H2 비활성 사용자 폴백, H3 컨텍스트 캐시 미무효화, H4 운영 DB 테스트). H1~H3 는 각 1~5줄 수정으로 해소 가능. MULTI_TENANT_ENABLED=True 전환은 추가로 M1·M3·M5(백엔드 legacy 라우터 격리) 해소가 전제.
---
# 재검증 (2026-09-21, 4팀 수정 반영 후 `dev` 워킹트리)
읽기 전용 재검수. `ast.parse` 30파일 OK, `npx tsc --noEmit` exit 0. `git diff` `-` 라인 재점검: 함수/클래스 시그니처 변경 없음(`portfolio.py` `PortfolioResponse` 필드 `Optional[...]` 화는 기존 알려진 pydantic v2 버그 수정, 별도 항목). 순환 임포트 신규 없음(`api/__init__.py → core/tenant_auth`, `kis_credentials → services/tenant/registry` 모두 endpoints 로 되돌아오지 않음).
## 항목별 판정
| 항목 | 판정 | 근거 |
|---|---|---|
| H1 alembic 배포 순서 | ✅ 해소 | `main.py:90-109` `information_schema` 검사 — `create_all`(`:87`) 직후, 부트스트랩(`:184`) 이전, `try` 블록 내 `raise RuntimeError` → 기동 중단. `Jenkinsfile:81-88` DB Migration 스테이지가 Deploy(`:90`) 앞으로 이동, `\|\| echo WARNING` 제거로 실패 시 파이프라인 중단. `docker-compose run --rm --no-deps backend alembic upgrade head`: compose `backend` 서비스 `env_file: .env`(DEPLOY_DIR 기준) + `autostock-network` 부착 + 컨테이너 내 `/.dockerenv` 존재로 `config._apply_docker_db_override` 가 `localhost→postgres` 치환(`config.py:369-372`), `WORKDIR /app` 에 `alembic.ini` 존재 → NAS 환경 동작 가능. 단 전제 2건은 아래 N1 참조 |
| H2 비활성 사용자 폴백 | ✅ 해소 | `tenant_auth.py:97-105` `user is None or not is_active` → 401. API 키 경로(`:76-85`) 변경 없음 — legacy owner 매핑 유지, owner 미존재 시 `id=None, is_api_key=True` |
| H3 컨텍스트 캐시 미무효화 | ✅ 해소 | `kis_credentials.py:201,218,312` PUT/DELETE/PATCH 모두 `user_context_registry.invalidate(user_id)` (내부에서 `kis_client_pool.invalidate` 호출, `registry.py:226-231`) |
| H4 운영 DB 테스트 | ✅ 해소 | `tests/integration/conftest.py:44-52` `settings.DATABASE_URL` DB명 `_test` 접미사 강제, 미충족 시 `pytest.exit`. 두 테스트 파일이 공용 conftest 경유. 부작용 N3 참조 |
| M1 effective_user_id vs legacy 0 | ✅ 해소 | `registry.py:147-172` `owner_id: int \| None = None`, `_legacy_owner_confirmed` 로 owner 미확정 시 TTL 캐시 미적용. `portfolio_v2.py:59-73` `_user_scope_filter` MT=True & None → `sql_false()`(fail-closed), `column == None` 의 `IS NULL` 컴파일 함정 회피. buy/sell 가드 `ctx.user_id is None and not ctx.is_legacy`(`:282`) |
| M2 legacy 락 키 | ✅ 해소 | `trade_executor.py` `safe_place_order_v2` `ctx.is_legacy` 분기 → `_acquire_order_lock`/`_release_order_lock`/`_get_order_semaphore(stock_code)` (v1 과 동일 키) |
| M3 legacy 행 user_id 정합 | 🟡 부분 | (1단계) `portfolio_v2.py:386-407,485-496` legacy ctx 는 trade_history/portfolio payload 에 `user_id` 미포함 + `enqueue_close_active_trade`(v1) 사용 → 기존 v1 과 동일 ✅. (MT=True 전제) 스윙/데이트레이딩 스케줄러 쓰기 경로에 `user_id` 주입은 여전히 없음 → MT=True 에서 `_user_scope_filter == owner` 가 기동 이후 자동매매 행(NULL)을 owner 의 v2 화면에서 제외. 설계 §4 미구현 그대로 — MT=True 전환 전 필수 |
| M4 락 내 I/O·PBKDF2 | ✅ 해소 | `registry.py:57-116` 락은 dict 갱신(더블체크)만, `decrypt` 는 `asyncio.to_thread`, `crypto.py:36` `_derive_key` `lru_cache`. 동일 user_id 동시 요청: `kis_client_pool.get_or_create` 가 자체 락+캐시로 KIS 인스턴스 단일 보장 → 이중 생성/누수 없음(중복 조립되는 MDD/loss_tracker 객체는 패자 측이 버려짐, 외부 자원 없음). `kis_credentials.py:100-119` `_to_out` 도 to_thread |
| M5 프론트 legacy 플래시 / 백엔드 legacy 라우터 격리 | 🟡 부분 | 프론트: `tenant.ts:30-55` `mode: 'v2'\|'legacy'\|undefined`, `usePortfolioData.ts:100` `enabled: mode !== undefined`, `useTradeActions.ts:48` `isReady`, `Dashboard.tsx:797,838,844` 버튼 disabled, `TradeHistory.tsx:352` 동일 ✅. `/auth/me`(`auth.py:250-258`) API 키 → `auth_type: "api_key"` → legacy 정상 ✅. 백엔드: `api/__init__.py:44,48,100` `portfolio`/`manual_buy`/`trades` 라우터에 `deny_non_owner_session_when_mt`(`tenant_auth.py:108-128`: MT=False 통과, `is_api_key` 통과 → MCP 서버 영향 없음, 스케줄러는 HTTP 를 타지 않아 무관) ✅. 잔여: `swing_portfolio`/`day_trading`/`daytrading_v2`/`us_trading`/`journals`/`risk_management`/`recommendations` 등 나머지 legacy 라우터는 MT=True 에서 여전히 모든 세션에 노출(설계 §7-3 2단계 이관 대상) — MT=True 전환 전제 |
| M6 DELETE /trades/pending 전체 삭제 | ✅ 해소 | `trades_v2.py:96-128` `/trades/v2/pending` `_user_scope_filter` 적용, `useTradeActions.ts:114-119` v2 분기 |
| M7 scalar_one_or_none MultipleResultsFound | ✅ 해소 | `db_worker.py:540-550,592-602` legacy(user_id None) 경로 `order_by(id.desc()).limit(1)` + `scalars().first()` |
| M8 백필 InFailedSqlTransaction | ✅ 해소 | `bootstrap.py:128-146` 문장별 `begin_nested()`, `auth.py:336-345` 동일 |
| L1 json.dumps | ✅ 해소 | `mdd_manager.py:13,46,161` `_dumps` |
| L2 decrypt 오류 진단 | ✅ 해소 | `crypto.py:29` `CredentialDecryptError`, `registry.py:83-87` 400 "복호화 실패" 명시, `_to_out` `decrypt_error` 플래그 |
| L3 0009 downgrade | ✅ 해소 | `0009:50-66` `pg_indexes` 존재 확인 후 drop |
| L4 AUTO_TRADE_ENABLED 스냅샷 | ✅ 해소 | `context.py:48-` `effective_auto_trade_enabled` 프로퍼티, `portfolio_v2.py:286` 사용 |
| L5 KIS 예외 원문 노출 | ✅ 해소 | `kis_credentials.py:260` `type(e).__name__` 만 노출 |
## 신규 발견 (수정 과정 부작용 / 전제조건)
- **N1 (Medium, 배포 전제조건)** `alembic upgrade head` 의 `alembic_version` 선행 상태. 이전 파이프라인의 `docker-compose exec ... \|\| echo WARNING` 이 실제 성공했는지 불명. 운영 DB 가 `create_all` 로만 만들어져 `alembic_version` 이 비어 있으면 `upgrade head` 가 0001 부터 실행되고, `0003_add_strategy_type.py:23-53` `add_column`/`create_index` 는 존재 가드가 없어 `DuplicateColumn` 으로 실패 → 이제는 파이프라인이 중단(안전하지만 배포 불가). 첫 배포 전 `alembic current` 확인 후 비어 있으면 `alembic stamp 0008` 필요 — Jenkinsfile 또는 배포 문서에 명시 권장. 또한 `--no-deps` 는 postgres 컨테이너가 이미 기동 중임을 전제(최초 배포 환경에선 Deploy 이전이라 실패).
- **N2 (Low)** `get_current_user` 가 `deny_non_owner_session_when_mt` 경유로 모든 `/portfolio*`·`/trades*` 요청에 붙어 요청당 DB 쿼리 2회(`get_legacy_owner`, 사용자 조회) 추가 — MT=False 에서도 실행됨(플래그 검사가 의존성 해석 뒤). 30초 폴링 수준에선 무시 가능하나 `MULTI_TENANT_ENABLED=False` 면 `get_current_user` 를 건너뛰는 조기 반환으로 줄일 수 있음.
- **N3 (Low)** `tests/integration/conftest.py:47` `pytest.exit` 는 `backend/` 에서 인자 없이 `pytest` 실행(단위 테스트 전체 수집) 시에도 발동 → 라이브 DB 를 안 건드리는 기존 단위 테스트 실행까지 중단. `pytest.ini` 에 `testpaths = tests` + `norecursedirs = tests/integration`(또는 `addopts = --ignore=tests/integration`) 로 기본 수집에서 제외하고 통합 테스트는 명시 경로로만 실행하도록 권장. Jenkins 는 pytest 를 실행하지 않아 CI 영향 없음.
- **N4 (Low, UX)** `useTenantMode` 가 `/auth/me` 실패(네트워크 오류 등) 시 `undefined` 를 유지하고 `staleTime` 5분·`refetchOnWindowFocus:false` 라 재시도 소진 후 대시보드가 빈 상태·버튼 비활성으로 고정될 수 있음. `retry`/수동 재시도 버튼 고려.
## 최종 판정
**배포 가능(MULTI_TENANT_ENABLED=False 기본값 기준).** High 4건 모두 해소, Medium 8건 중 6건 해소·2건(M3, M5) 은 1단계 범위 내 부분 해소이며 잔여분은 MT=True 전환 전제조건. 배포 시 N1(`alembic current` 확인/필요 시 `stamp 0008`) 을 먼저 수행할 것.
**MULTI_TENANT_ENABLED=True 전환은 아직 불가** — M3 잔여(legacy 스케줄러 쓰기 경로 `user_id` 주입 또는 owner 에 대해 NULL 행 포함 필터), M5 잔여(나머지 legacy 라우터 격리) 해소 후.