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>
This commit is contained in:
269
docs/multi_tenant_design.md
Normal file
269
docs/multi_tenant_design.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# 멀티테넌트(사용자별 매매) 전환 설계
|
||||
|
||||
작성: 2026-09-20 / 상태: 설계(코드 미수정) / 대상 브랜치: `fix/trading-adx-warmup-mlgate` 이후 신규 브랜치 `feat/multi-tenant-phase1`
|
||||
|
||||
---
|
||||
|
||||
## 1. 현황 요약 & 제약
|
||||
|
||||
### 1.1 이미 있는 것
|
||||
| 항목 | 위치 | 비고 |
|
||||
|---|---|---|
|
||||
| `User` 모델 (id, username, password_hash, is_active, is_admin) | `backend/app/models/models.py:390-405`, alembic `0008_add_users_table.py` | 첫 가입자 = 관리자 |
|
||||
| 로그인/세션 토큰 | `backend/app/api/endpoints/auth.py` (register/login/me/users), `app/core/security.py:49-62` (`create_session_token`, `verify_session_token` → username) | JWT HS256, `sub=username`, aud=`autostock-dashboard`, 24h |
|
||||
| 인증 의존성 | `security.py:74` `require_api_key` — X-API-Key 헤더에 **API 키(SECRET_KEY/API_KEY) 또는 세션 토큰** 모두 수용 | 어느 쪽인지 구분은 `auth.py:147 _resolve_user`, `auth.py:155 require_admin`에서만 수행 |
|
||||
| 라우터 등록 | `backend/app/api/__init__.py:23-95` — 라우터 단위 `dependencies=[Depends(require_api_key)]` | 엔드포인트 함수는 사용자를 모름 |
|
||||
|
||||
### 1.2 단일 계좌 전제인 지점 (근거)
|
||||
| 구성요소 | 근거 | 단일 전제 내용 |
|
||||
|---|---|---|
|
||||
| KIS 클라이언트 | `korea_investment_api.py:219-261 __init__`, `:1315 korea_investment_api = KoreaInvestmentAPI()`, `:1320 real_market_api` | `settings.KOREA_INVESTMENT_APP_KEY/SECRET/ACCOUNT_NUMBER`(config.py:389-392에서 KIS_MODE로 결정)만 사용. 모듈 import 시 즉시 인스턴스화 |
|
||||
| 토큰 캐시 | `:178 TOKEN_CACHE_FILE = ~/.cache/auto-stock/kis_token_cache.json`, `:201 kis_token_cache_real.json` | 파일명 고정 → 사용자별 인스턴스가 서로 덮어씀 |
|
||||
| 레이트리미터 | `:229 _throttle_lock`, `:232 min_request_interval=settings.API_MIN_REQUEST_INTERVAL(0.15)`, `:239 semaphore(API_MAX_CONCURRENT_REQUESTS=8)`, `:406 _get_access_token`(_token_lock, `_handle_rate_limit` 1분 제한) | 인스턴스 단위 상태 → 인스턴스 = 앱키 단위로 만들면 자연히 앱키별 제한이 됨 |
|
||||
| 시세 위임 | `:558 _quote_delegate` → `globals().get("real_market_api")`; `KIS_QUOTES_VIA_REAL`(config.py:78) | 조회 API는 계좌와 무관(앱키만 필요). 공용 1개로 공유 가능 |
|
||||
| 테이블 | `models.py` Portfolio(`:158 stock_code unique`), TradeHistory, ActiveTrade, StockBan(`:336 PK=stock_code`), TradeJournal, TradingSession(`:45 session_date unique`), WatchlistStock/History | user_id 컬럼 없음 |
|
||||
| db_worker | `db_worker.py:264 db_writer_worker` — `upsert_portfolio`(:416 stock_code로 SELECT), `upsert_active_trade`(:441 stock_code+status+strategy_name), `close_active_trade`(:483), `upsert_stock_ban`(:351 stock_code) | 조회 키에 user_id 없음 |
|
||||
| trade_executor | `:21 from ... import korea_investment_api`(모듈 전역), `:45 _order_semaphores{stock_code}`, `:95 order_lock:{stock_code}:{order_type}`(Redis), `:169 safe_place_order(stock_code, order_type, quantity, price, is_domestic, exchange_code)`, `:554 get_balance()`, `:1438 mdd_manager.can_open_new_position()`, `:1442 loss_tracker.is_banned_safe()` | 전부 전역 싱글톤 참조 |
|
||||
| mdd_manager | `mdd_manager.py:21-28` peak_balance/is_paused/… + `_lock`, `:15 _MDD_REDIS_KEY="mdd:state"`, `:325 mdd_manager = MDDManager()`, `:200 execute_reduction`은 `korea_investment_api.get_balance()` 직접 호출 | 단일 상태·단일 Redis 키 |
|
||||
| loss_tracker | `loss_tracker.py:27 _tracker{stock_code}`, `:53 save_to_db(db=None, stock_code)` → `enqueue_stock_ban_upsert(stock_code, …)`, `:79 load_from_db(db)` 전체 StockBan 로드, `:171 loss_tracker = _create_loss_tracker()` | 단일 dict |
|
||||
| 스케줄러 | `swing/scheduler.py:79 swing_scheduler()` 60s 루프(`:119 korea_investment_api.get_balance()`, `:107/167/243/249 AsyncSessionLocal()`), `daytrading/scheduler.py:46 daytrading_scheduler()` 30s(`:32 day_trading_state` 싱글톤), `main.py:239-240 create_task` | 프로세스당 루프 1개 |
|
||||
| 데이트레이딩 상태 | `daytrading/position_manager.py:48 DayTradingState` 싱글톤(positions, orb_cache, daily_pnl, loss_streak_today), `daytrading/entry_service.py:69-80 set/get_watchlist` 모듈 전역 | 단일 |
|
||||
| 포트폴리오 캐시 | `portfolio.py:199 PortfolioCache`(holdings, summary, sync_lock) 프로세스 단일 인스턴스, `/portfolio/sync`(:453)가 Portfolio 테이블 직접 커밋(:734-738, CLAUDE.md 허용 예외) | 단일 |
|
||||
| WebSocket | `websocket_manager.py:93 broadcast`/`:105 broadcast_json` → 모든 연결; `main.py:614-618` 토큰 검증만(`is_valid_credential`) 후 `client_id` 등록. 호출지: `trade_executor.py:916,1157`, `portfolio.py:1265` | 사용자 개념 없음 |
|
||||
| MCP/외부 호출자 | `mcp-server/trade_monitor.py:34-35` `X-API-Key = API_KEY or SECRET_KEY`, `:58 /api/v1/portfolio/active-trades/{code}/status` | API 키 호출 = 사용자 미지정 |
|
||||
| 프론트 | `frontend/src/lib/api.ts:65-67` `localStorage.api_key` → X-API-Key; `useWebSocket.ts:83` `?token=`; `App.tsx:109-116` 라우트 6개; `Layout.tsx:62-68` navItems | 계좌 설정 화면 없음 |
|
||||
|
||||
### 1.3 지켜야 할 규칙 (CLAUDE.md)
|
||||
- 기존 함수/클래스 시그니처 변경 금지 → 새 함수는 `_v2`, 새 모듈은 새 파일. 기존 전역 싱글톤(`korea_investment_api`, `mdd_manager`, `loss_tracker`, `db_task_queue`)은 그대로 두고 **legacy owner 사용자의 컨텍스트 별칭**으로 유지.
|
||||
- DB 쓰기는 `db_worker` 경유(예외: `sync_portfolio` 류 단일 트랜잭션 필요 엔드포인트). 실시간 계층 DB I/O 금지.
|
||||
- 리스크 규칙(§5)은 **사용자별 자본 기준**으로 동일 적용. SL 선택 `min()`. 전략 로직 자체는 변경하지 않음(백테스트 불필요 — §8 참고).
|
||||
- `_dumps()` 사용, 쓰기 API `require_api_key`.
|
||||
|
||||
---
|
||||
|
||||
## 2. 목표 아키텍처
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
HTTP/WS 요청 ───▶ │ get_current_user (세션토큰→User / API키→legacy owner)│
|
||||
└──────────────┬───────────────────────────┘
|
||||
▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ UserContextRegistry (app/services/tenant/registry.py)│
|
||||
│ get(user_id) -> UserContext (캐시, TTL/무효화) │
|
||||
│ iter_active() -> 자동매매 활성 사용자 컨텍스트 목록 │
|
||||
└──────────────┬───────────────────────────┘
|
||||
▼
|
||||
UserContext(user_id, username, kis: KoreaInvestmentAPI(주문·잔고 전용),
|
||||
mode: virtual|real, mdd: MDDManager, loss_tracker: ConsecutiveLossTracker,
|
||||
dt_state: DayTradingState, portfolio_cache: PortfolioCache, settings_override)
|
||||
│
|
||||
┌──────────────────────────┼─────────────────────────────┐
|
||||
▼ ▼ ▼
|
||||
trade_executor_v2(ctx, …) swing/daytrading 스케줄러 db_worker task(data.user_id)
|
||||
safe_place_order_v2(ctx,…) 사용자별 asyncio.Task upsert_* 핸들러 user 필터
|
||||
│
|
||||
▼
|
||||
공용 시세 인스턴스 real_market_api (앱키 1개, 20/s) ← 모든 사용자 공유 (조회 전용)
|
||||
```
|
||||
|
||||
### 2.1 핵심 결정
|
||||
1. **시세(조회) = 공용 1개, 주문·잔고 = 사용자별 인스턴스.** `KoreaInvestmentAPI`의 조회 메서드는 이미 `_quote_delegate()`로 `real_market_api`에 위임하는 구조(`korea_investment_api.py:558-598, 794, 855, 1273`)이며 조회 API는 계좌번호를 사용하지 않는다(`get_current_price` params에 CANO 없음). 따라서 사용자별 인스턴스에는 `_quote_delegate`가 공용 `real_market_api`를 가리키도록 하고, 사용자 앱키는 `place_order`/`get_balance`/`get_order_execution`/`cancel_order`/해외 주문·잔고에만 소비된다. → **사용자 수가 늘어도 시세 트래픽은 앱키 1개(20/s)에 고정**, 사용자별 앱키는 주문·잔고(모의 2/s)만 감당. 단 `KIS_QUOTES_VIA_REAL=False`이거나 운영자가 실전 앱키를 등록하지 않았으면 각 사용자 인스턴스가 자기 앱키로 조회한다(현재 동작과 동일).
|
||||
2. **legacy owner**: `users` 중 `is_admin=True`이고 `id`가 가장 작은 사용자. `.env`의 KIS 자격증명은 이 사용자의 `user_kis_credentials` 행으로 이관(관리자 API). `MULTI_TENANT_ENABLED=False`(기본)이면 현재 전역 싱글톤이 그대로 legacy owner 컨텍스트로 동작.
|
||||
3. **기존 싱글톤 유지**: `korea_investment_api`, `mdd_manager`, `loss_tracker`, `day_trading_state`, `portfolio_cache`는 삭제하지 않고 `UserContextRegistry.legacy()`가 이 인스턴스들을 감싼 컨텍스트를 반환. 기존 호출 코드는 수정 없이 동작.
|
||||
4. **Redis 키·락 네임스페이스에 user_id 포함**: `order_lock:{user_id}:{stock_code}:{order_type}`, `mdd:state:{user_id}`. legacy 컨텍스트(user_id=None 또는 legacy owner id)는 기존 키를 그대로 사용해 하위 호환.
|
||||
5. **db_worker는 새 task 타입을 추가하지 않고 payload `data["user_id"]` 옵션으로 확장**(§3.4 근거).
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터 모델 변경
|
||||
|
||||
### 3.1 신규 테이블 `user_kis_credentials` (models.py에 `UserKisCredential` 추가)
|
||||
| 컬럼 | 타입 | 비고 |
|
||||
|---|---|---|
|
||||
| id | Integer PK | |
|
||||
| user_id | Integer FK users.id ON DELETE CASCADE, **unique** | 사용자당 1세트(모의/실전 동시 등록은 2단계에서 `UniqueConstraint(user_id, mode)`로 완화 가능 — 1단계는 1세트) |
|
||||
| mode | String(10) NOT NULL | `virtual` \| `real` |
|
||||
| app_key_enc | Text NOT NULL | Fernet 암호문 |
|
||||
| app_secret_enc | Text NOT NULL | Fernet 암호문 |
|
||||
| account_no_enc | Text NOT NULL | 계좌 8자리+상품코드 2자리 (`korea_investment_api.py:1006-1007`이 `[:8]`,`[8:]`로 분리하므로 하이픈 제거 후 10자리 저장) |
|
||||
| account_product_code | String(2) NOT NULL default '01' | 검색·표시용 (평문 허용) |
|
||||
| app_key_hint | String(8) | 마스킹 표시용 앞 6자 (`korea_investment_api.py:257`와 동일 마스킹 관례) |
|
||||
| is_enabled | Boolean default False | 자동매매 대상 여부 (등록 직후 False → 연결 테스트 성공 시 True 허용) |
|
||||
| auto_trade_enabled | Boolean default False | 사용자 단위 AUTO_TRADE_ENABLED |
|
||||
| last_verified_at | DateTime | 연결 테스트 성공 시각 |
|
||||
| key_version | Integer default 1 | 암호화 키 로테이션용 (§9) |
|
||||
| created_at / updated_at | DateTime | |
|
||||
|
||||
### 3.2 user_id 추가 대상 (사용자 스코프)
|
||||
| 테이블 | 현 제약 | 변경 | 근거 |
|
||||
|---|---|---|---|
|
||||
| `portfolio` | `stock_code unique`(models.py:158) | `user_id` 추가; unique → `UniqueConstraint(user_id, stock_code, name=uq_portfolio_user_stock)` | 동일 종목을 여러 사용자가 보유 가능 |
|
||||
| `trade_history` | 없음 | `user_id` + `Index(user_id, created_at)` | 조회 필터 (`trades.py:51`) |
|
||||
| `active_trades` | idx stock_code/status(:267,:280) | `user_id` + `Index(user_id, stock_code, status)` | db_worker upsert 키(:445-450)에 user_id 추가 |
|
||||
| `stock_bans` | `PK=stock_code`(:336) | **PK 변경 불가피** → 새 테이블 `stock_bans_v2 (id PK, user_id, stock_code, consecutive_losses, ban_until, updated_at, UNIQUE(user_id, stock_code))`. 기존 `stock_bans`는 legacy owner용으로 남기고 백필 후 읽기 중단 | PK 변경은 기존 코드 시그니처 변경에 해당 → 새 테이블 |
|
||||
| `trade_journals` | 없음 | `user_id` | |
|
||||
| `trading_sessions` | `session_date unique`(:45) | `user_id` + `UniqueConstraint(user_id, session_date)`; 기존 unique 제거 | |
|
||||
| `watchlist_stocks` / `watchlist_history` | 없음 | **공용 유지**(모닝 배치 산출물, 사용자 무관). 사용자별 감시목록이 필요하면 2단계에서 `user_watchlist` 신규 | |
|
||||
| `mcp_tool_calls`, `claude_analysis_log` | 없음 | `user_id` nullable 추가(감사 추적용, 필수 아님) | |
|
||||
|
||||
**공용 유지(변경 없음)**: `stock_daily_prices`, `stock_analysis`, `market_data`, `news`, `exchange_rates`, `us_stock_universe`, `strategy_configs`, `trading_rules`, `strategy_optimization_logs`, `watchlist_*`, `users`.
|
||||
|
||||
### 3.3 Alembic 계획
|
||||
| rev | 내용 |
|
||||
|---|---|
|
||||
| `0009_user_kis_credentials` | `user_kis_credentials` 생성 (+ `ix_user_kis_credentials_user_id` unique) |
|
||||
| `0010_add_user_id_nullable` | portfolio/trade_history/active_trades/trade_journals/trading_sessions/mcp_tool_calls/claude_analysis_log 에 `user_id Integer NULL FK users.id`; 인덱스 추가; `stock_bans_v2` 생성 |
|
||||
| `0011_backfill_legacy_owner` | 데이터 마이그레이션: `legacy_owner_id = SELECT id FROM users WHERE is_admin ORDER BY id LIMIT 1`; 없으면 **스킵**(앱 최초 로그인 후 관리자 API `/auth/admin/tenant/migrate-legacy`가 동일 로직 수행). `UPDATE … SET user_id = :owner WHERE user_id IS NULL`; `INSERT INTO stock_bans_v2 SELECT … FROM stock_bans` |
|
||||
| `0012_user_id_not_null_and_uniques` | `MULTI_TENANT_ENABLED=True` 전환 시점에만 적용: user_id NOT NULL, `portfolio.stock_code` unique 드롭 → `uq_portfolio_user_stock`, `trading_sessions.session_date` unique 드롭 → `(user_id, session_date)` |
|
||||
|
||||
0008과 동일하게 `information_schema` 존재 확인 후 idempotent 작성(`0008_add_users_table.py:19-24` 패턴).
|
||||
|
||||
### 3.4 db_worker 확장 방식 — 결정: **payload에 `user_id` 옵션 추가** (새 task 타입 아님)
|
||||
- 근거: 핸들러가 `data.get(...)`로 키를 꺼내는 구조(`db_worker.py:354-356, 419, 444-445, 487-488`)라 `user_id = data.get("user_id")`가 None이면 기존 쿼리, 있으면 `.where(Model.user_id == user_id)` 추가로 **완전 하위 호환**. 새 task 타입(`upsert_portfolio_v2`)은 12개 핸들러 복제·DLQ(`_save_to_dlq`) 분기·`enqueue_*` 헬퍼 중복을 낳아 오히려 위험.
|
||||
- `enqueue_*` 헬퍼는 시그니처 불변. 사용자 스코프용 새 헬퍼만 추가: `enqueue_stock_ban_upsert_v2(user_id, stock_code, consecutive_losses, ban_until)` (→ `stock_bans_v2` 쓰기, task `upsert_stock_ban_v2` — 이것만 대상 테이블이 다르므로 신규 타입), `enqueue_close_active_trade_v2(user_id, stock_code, strategy_name)`.
|
||||
- `upsert_portfolio`/`upsert_active_trade`/`close_active_trade`/`insert_trade_history`/`insert_trade_journal`: `data["user_id"]` 있으면 필터·컬럼 세팅. `Portfolio(**data)`(:426)·`ActiveTrade(**create_data)`(:459)는 모델에 컬럼이 생기면 그대로 통과.
|
||||
|
||||
---
|
||||
|
||||
## 4. 하위 호환 / 이행 전략
|
||||
|
||||
- `config.py`에 `MULTI_TENANT_ENABLED: bool = False` 추가.
|
||||
- **False**: 스케줄러·trade_executor·API 모두 기존 경로. `get_current_user`는 세션 토큰이든 API 키든 **legacy 컨텍스트**를 반환(user_id는 legacy owner id 또는 None). DB 쓰기 payload에 user_id를 넣되(0010 이후 컬럼 존재) 조회 필터는 적용하지 않음 → 기존 동작 100% 유지.
|
||||
- **True**: `UserContextRegistry`가 `user_kis_credentials.is_enabled=True` 사용자 컨텍스트를 로드, 스케줄러 사용자 순회(2단계), API는 사용자 필터.
|
||||
- 이관 API(관리자): `POST /api/v1/auth/admin/tenant/migrate-legacy` — `.env`의 `KOREA_INVESTMENT_APP_KEY/SECRET/ACCOUNT_NUMBER` + `KIS_MODE`를 legacy owner의 `user_kis_credentials`로 암호화 저장(이미 있으면 409), 0011 백필 로직 재실행. `require_admin`(auth.py:155) 사용.
|
||||
- 전환 순서(H1): **`cd backend && alembic upgrade head`로 0009~0011 적용(반드시 새 이미지 기동 전)** → 관리자 로그인 → migrate-legacy → 다른 사용자 자격증명 등록·연결 테스트 → `MULTI_TENANT_ENABLED=True` + 0012 마이그레이션 적용 → 재기동(`docker compose up -d --build`). `create_all()`은 기존 테이블에 컬럼을 추가하지 않으므로 이 순서를 지키지 않으면(새 이미지 먼저 기동) `portfolio.user_id` 등 신규 컬럼 부재로 레거시 경로 전체가 `UndefinedColumnError`로 실패한다 — `main.py` lifespan이 기동 시 이를 감지해 중단시킨다.
|
||||
- 롤백: `MULTI_TENANT_ENABLED=False`로 되돌리면 legacy 경로. 0012 이전이면 스키마 롤백 불필요.
|
||||
|
||||
---
|
||||
|
||||
## 5. 1단계 상세 (구현 가능 수준)
|
||||
|
||||
### 5.1 자격증명 암복호화 — `backend/app/core/crypto.py` (신규) — **dev-team**
|
||||
- `cryptography` 패키지: `requirements.txt:15 python-jose[cryptography]`의 extras로 이미 설치됨. 명시적으로 `cryptography>=42` 한 줄 추가 권장(암묵 의존 제거).
|
||||
- 키 파생: `_derive_key(secret: str, version: int) -> bytes` = `base64.urlsafe_b64encode(hashlib.pbkdf2_hmac("sha256", secret.encode(), f"autostock-kis-cred-v{version}".encode(), 200_000))`. 소스는 `settings.CREDENTIALS_ENCRYPTION_KEY`(신규, 비어 있으면 `settings.SECRET_KEY` 폴백). SECRET_KEY가 기본값 `change-this-secret-key`(config.py:24)면 저장 API 거부.
|
||||
- `encrypt(plain: str) -> str`, `decrypt(token: str, key_version: int) -> str`. `MultiFernet([current, previous])`로 로테이션: `CREDENTIALS_ENCRYPTION_KEY_PREVIOUS` 설정 시 복호 가능, 관리자 API `POST /auth/admin/tenant/rotate-keys`가 전 행 재암호화 + `key_version` 증가.
|
||||
|
||||
### 5.2 모델·마이그레이션 — **dev-team**
|
||||
- `models.py`: `UserKisCredential`, `StockBanV2` 추가; 스코프 테이블에 `user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)`.
|
||||
- alembic 0009/0010/0011 (§3.3).
|
||||
|
||||
### 5.3 credentials API — `backend/app/api/endpoints/kis_credentials.py` (신규, prefix `/account`) — **dev-team**
|
||||
| 메서드 | 경로 | 동작 |
|
||||
|---|---|---|
|
||||
| GET | `/account/credentials` | 현 사용자 자격증명 마스킹 조회(`app_key_hint + "…"`, 계좌 `****` 뒤 2자리, mode, is_enabled, last_verified_at) |
|
||||
| PUT | `/account/credentials` | 등록/교체 `{mode, app_key, app_secret, account_no}` → 암호화 저장, `is_enabled=False`, 레지스트리 `invalidate(user_id)` |
|
||||
| DELETE | `/account/credentials` | 삭제 + invalidate |
|
||||
| POST | `/account/credentials/verify` | 임시 `KoreaInvestmentAPI` 사용자 인스턴스 생성 → `get_balance()` 호출 성공(rt_cd=0) 시 `last_verified_at` 갱신·`is_enabled=True` 허용. 토큰 발급은 앱키당 1분 1회 제한(`korea_investment_api.py:334 _handle_rate_limit`)이므로 verify는 사용자당 60초 쿨다운 |
|
||||
| PATCH | `/account/auto-trade` | `{enabled}` → `auto_trade_enabled` |
|
||||
- 모두 `Depends(get_current_user)`; API 키 호출자는 legacy owner에 매핑. 자격증명 쓰기는 **엔드포인트에서 단일 트랜잭션 직접 커밋 허용**(sync_portfolio와 같은 성격: 읽기→쓰기 원자성 + 즉시 응답 필요; 실시간 계층 아님). CLAUDE.md §3.2 예외 목록에 추가 기재 필요.
|
||||
|
||||
### 5.4 `get_current_user` 의존성 — `backend/app/core/tenant_auth.py` (신규) — **dev-team**
|
||||
```python
|
||||
@dataclass
|
||||
class CurrentUser: id: int | None; username: str; is_admin: bool; is_api_key: bool
|
||||
|
||||
async def get_current_user(credential: str = Depends(require_api_key), db=Depends(get_db)) -> CurrentUser
|
||||
```
|
||||
- 세션 토큰 → `verify_session_token` → `User` 조회(is_active 확인). API 키(`hmac.compare_digest(credential, _get_api_key())`, auth.py:159 패턴) → `get_legacy_owner(db)`(admin 최소 id; 없으면 `CurrentUser(id=None, username="api-key", is_admin=True, is_api_key=True)`).
|
||||
- 라우터 등록은 그대로 `require_api_key` 유지(`api/__init__.py`), 엔드포인트 함수에 `user: CurrentUser = Depends(get_current_user)` 파라미터를 **추가하는 것은 시그니처 변경**이므로, 사용자 스코프가 필요한 엔드포인트는 `_v2` 경로(§5.6) 또는 새 라우터 파일에 작성.
|
||||
|
||||
### 5.5 `KISClientPool` — `backend/app/services/tenant/kis_pool.py` (신규) — **trading-agent**
|
||||
- `KoreaInvestmentAPI`에 새 classmethod `for_user(user_id, app_key, app_secret, account_no, mode) -> KoreaInvestmentAPI` 추가(기존 `for_real_market_data`(:181-217) 패턴 복제, `__init__` 불변):
|
||||
- `base_url = settings.KIS_REAL_URL if mode=="real" else settings.KIS_VIRTUAL_URL`
|
||||
- `TOKEN_CACHE_FILE = ~/.cache/auto-stock/users/{user_id}/kis_token_{mode}.json` (chmod 600, `:305-316 _write_token_file` 재사용)
|
||||
- `min_request_interval = 0.5 if mode=="virtual" else settings.KIS_REAL_MIN_REQUEST_INTERVAL` (모의 2/s, 실전 20/s)
|
||||
- `_quote_delegate`는 현재 `globals()["real_market_api"]`를 보므로 사용자 인스턴스도 자동으로 공용 시세 인스턴스 사용. 단 `_real is self` 체크(:562)로 재귀 방지 유지.
|
||||
- `KISClientPool.get(user_id) -> KoreaInvestmentAPI`: 복호화 후 생성·캐시(dict + asyncio.Lock). `invalidate(user_id)`는 `await inst.close()`(:278) 후 제거. legacy owner이고 `MULTI_TENANT_ENABLED=False`면 전역 `korea_investment_api` 반환.
|
||||
- 실전 계좌 사용자 인스턴스는 `circuit_breaker`(:249)가 인스턴스별이므로 한 사용자의 연속 실패가 타 사용자를 차단하지 않음.
|
||||
|
||||
### 5.6 `UserContext` / `UserContextRegistry` — `backend/app/services/tenant/context.py`, `registry.py` (신규) — **trading-agent**(구조) + **risk-monitor-agent**(mdd/loss_tracker 팩토리)
|
||||
- `UserContext`: `user_id, username, mode, kis, mdd: MDDManager, loss_tracker: ConsecutiveLossTracker, dt_state: DayTradingState, portfolio_cache: PortfolioCache, auto_trade_enabled`.
|
||||
- `MDDManager`·`ConsecutiveLossTracker`·`DayTradingState`·`PortfolioCache`는 이미 클래스이므로 인스턴스만 새로 만든다(시그니처 불변). 사용자 스코프 영속화는 새 메서드로:
|
||||
- `MDDManager.save_to_redis_v2(user_id)` / `load_from_redis_v2(user_id)` → 키 `mdd:state:{user_id}`.
|
||||
- `ConsecutiveLossTracker.save_to_db_v2(user_id, stock_code)` → `enqueue_stock_ban_upsert_v2`; `load_from_db_v2(db, user_id)` → `StockBanV2` 필터.
|
||||
- `MDDManager.execute_reduction_v2(mdd_action, kis, user_id)` — 현재 `:200`에서 전역 `korea_investment_api.get_balance()`를 쓰므로 kis 주입 버전 필요.
|
||||
- `UserContextRegistry`: `get(user_id)`, `legacy()`(전역 싱글톤 래핑), `iter_active()`(`is_enabled and auto_trade_enabled` 사용자, 60초 TTL 재로드), `invalidate(user_id)`.
|
||||
|
||||
### 5.7 사용자 스코프 API (`_v2`) — **dev-team**
|
||||
| 파일 | 추가 |
|
||||
|---|---|
|
||||
| `portfolio.py` | `GET /portfolio/v2`, `GET /portfolio/v2/summary`, `GET /portfolio/v2/sync` — `user: CurrentUser = Depends(get_current_user)`; Portfolio 쿼리 `.where(Portfolio.user_id == user.id)`; sync는 `ctx.kis.get_balance()` + `ctx.portfolio_cache`. 기존 `/portfolio*`는 legacy 컨텍스트로 유지 |
|
||||
| `manual_buy.py` | `POST /portfolio/v2/buy` → `_buy_stock_impl_v2(request, stock_code, db, ctx)`; `enqueue_trade_history({... "user_id": ctx.user_id})`, `enqueue_portfolio_upsert({... "user_id"})` |
|
||||
| `portfolio.py` `/sell`(:1285) | `POST /portfolio/v2/sell` → `safe_place_order_v2(ctx, …)` |
|
||||
| `trades.py` | 기존 `GET /trades`(:51)에 user 필터 추가는 시그니처 변경 → `GET /trades/v2` |
|
||||
| `swing_portfolio.py`, `day_trading.py` | 2단계 (스케줄러 사용자화와 함께) |
|
||||
| `api/__init__.py` | `kis_credentials.router` 등록(`prefix="/account", dependencies=[Depends(require_api_key)]`) |
|
||||
|
||||
### 5.8 trade_executor 진입점 — **trading-agent**
|
||||
- `safe_place_order_v2(ctx: UserContext, stock_code, order_type, quantity, price=None, is_domestic=True, exchange_code="NASD")`: 락 키 `order_lock:{ctx.user_id}:{stock_code}:{order_type}`(`_acquire_order_lock_v2`), 세마포어 키 `f"{ctx.user_id}:{stock_code}"`(`_get_order_semaphore` 재사용 — 키 문자열만 바뀜, 시그니처 불변), 주문은 `ctx.kis.place_order(...)`. `_auto_cancel_if_pending`도 `ctx.kis.cancel_order`를 쓰는 `_v2`.
|
||||
- 1단계는 수동매수/매도 경로만 `_v2`로 연결. `execute_auto_trade`/`_execute_buy`/`_execute_sell`의 컨텍스트화는 2단계.
|
||||
|
||||
### 5.9 프론트 — **design-team**(UI) + **dev-team**(api.ts 함수)
|
||||
- `frontend/src/lib/api.ts`: `getKisCredentials()`, `putKisCredentials(body)`, `deleteKisCredentials()`, `verifyKisCredentials()`, `patchAutoTrade(enabled)`.
|
||||
- `frontend/src/pages/AccountSettings.tsx` (신규): 모드 라디오(모의/실전 — 실전은 확인 모달), 앱키/시크릿/계좌 입력(마스킹 표시), "연결 테스트" 버튼(60초 쿨다운 안내), 자동매매 토글, 삭제.
|
||||
- `App.tsx:109-116`에 `<Route path="/account" element={<AccountSettings />} />`, `Layout.tsx:62-68` navItems에 `{ path: '/account', icon: KeyRound, label: '내 계좌' }`.
|
||||
- 로그인이 API 키 방식(`localStorage.api_key`)이면 화면에 "운영자(legacy) 계좌" 배지 표시(`/auth/me`의 `auth_type`).
|
||||
|
||||
### 5.10 의존 순서 / 병렬 그룹
|
||||
| 그룹 | 항목 | 담당 | 선행 |
|
||||
|---|---|---|---|
|
||||
| A (병렬) | 5.1 crypto, 5.2 모델+alembic, 5.4 get_current_user, `MULTI_TENANT_ENABLED` config | dev-team | 없음 |
|
||||
| A (병렬) | `KoreaInvestmentAPI.for_user`, 5.5 KISClientPool | trading-agent | 없음 |
|
||||
| A (병렬) | 5.6 mdd/loss_tracker `_v2` 메서드, `StockBanV2` 쓰기 task | risk-monitor-agent | 5.2 모델 정의만 합의 |
|
||||
| A (병렬) | 5.9 AccountSettings UI 목업(api.ts 함수 시그니처 합의 후) | design-team | 없음 |
|
||||
| B | 5.3 credentials API, migrate-legacy 관리자 API | dev-team | A |
|
||||
| B | 5.6 UserContext/Registry 조립 | trading-agent | A |
|
||||
| C | 5.7 `_v2` 엔드포인트, 5.8 `safe_place_order_v2`, db_worker user_id 필터 | dev-team + trading-agent | B |
|
||||
| D | 프론트 연결·라우팅, 통합 테스트 | design-team + dev-team | C |
|
||||
| E | code-verifier 검수(§8) | code-verifier-agent | D |
|
||||
|
||||
---
|
||||
|
||||
## 6. 2단계 개요
|
||||
|
||||
1. **스케줄러 사용자 순회**: `swing_scheduler_v2()`·`daytrading_scheduler_v2()`는 "마스터 루프"로서 `registry.iter_active()`마다 `asyncio.create_task(_run_user_swing_cycle(ctx))`를 관리(사용자별 Task 1개, 종료·예외 시 재생성). 한 사용자의 예외는 자기 Task 안에서 `try/except` → 로그 + 백오프, 타인에 영향 없음. `main.py:239-240`은 `MULTI_TENANT_ENABLED`에 따라 `_v2`를 기동.
|
||||
- 사용자 사이클 내부: `check_and_execute_exit_v2(session)`(swing/exit_service.py:133) 등 기존 함수는 전역 싱글톤을 쓰므로 `ctx`를 받는 `_v3`(`check_and_execute_exit_v3(db, ctx)`, `execute_swing_entries_v2(rec, db, ctx)`, `scan_and_enter_v2(ctx)`, `check_and_exit_positions_v2(ctx)`)로 분리. 추천 데이터(`recommendations:latest`, `stock_analysis`)는 공용 — 사용자별로 재계산하지 않음.
|
||||
- **레이트리밋 스케일**: 사용자 사이클에서 사용자 앱키로 나가는 호출은 `get_balance`(사이클당 1) + 주문·체결확인만. 모의 2/s면 60초 사이클에 최대 120콜 — 20종목 청산체크+진입도 여유. 시세는 공용 20/s 인스턴스로 몰리므로 **사용자 N명 × 보유 종목 수만큼 시세 콜이 증가** → 종목 단위 `quote:{stock_code}` 캐시(이미 존재, TTL 확인) 공유로 중복 제거하고, 마스터 루프에서 사용자 사이클을 `asyncio.Semaphore(MULTI_TENANT_MAX_CONCURRENT_USERS=3)`로 순차화. 토큰은 사용자 앱키별 1개(하루 1회 갱신)이므로 발급 제한 문제 없음.
|
||||
2. **trade_executor 사용자 스코프**: `execute_auto_trade_v2(stock, db, ctx)` → `_execute_buy_v2/_execute_sell_v2(…, ctx)`. 내부의 `korea_investment_api`→`ctx.kis`, `mdd_manager`→`ctx.mdd`, `loss_tracker`→`ctx.loss_tracker`, `enqueue_*` payload에 `user_id`. `_calculate_kelly_position`·사이징은 `ctx.kis.get_balance()`의 총자산 기준 → §5.1 6%/20종목/레버리지 20%가 사용자 자본 기준으로 자동 적용. `correlation_filter`는 사용자 보유 목록을 인자로 받는 `_v2` 필요.
|
||||
3. **db_worker**: §3.4 필터 적용 완료 + `trade_journal`·`trading_sessions` user_id.
|
||||
4. **WebSocket 사용자 채널**: `websocket_manager.connect`(:26) 시 토큰에서 `username`을 뽑아 `self._client_user[client_id] = user_id` 저장(`main.py:614-621`에서 `verify_session_token` 결과 전달). `broadcast_json_to_user(user_id, data)` 신규; `trade_executor.py:916,1157`·`portfolio.py:1265`의 거래 알림은 사용자 채널로, 시세(`send_market_update`)는 기존 브로드캐스트 유지. API 키 접속(운영자)은 전체 수신.
|
||||
5. **데이트레이딩 상태**: `DayTradingState`를 ctx별 인스턴스로, `restore_from_db(db)`(:256) → `restore_from_db_v2(db, user_id)`; `entry_service`의 모듈 전역 watchlist(:69-80)는 공용(모닝 배치 산출) 유지.
|
||||
6. **리스크 규칙 점검 포인트**: MDD -10/-20% 트리거가 `ctx.mdd.update(사용자 총자산)`으로 호출되는지; `execute_reduction_v2`가 `ctx.kis`로 사용자 보유만 축소하는지; 3연패 ban이 `stock_bans_v2(user_id, stock_code)`로 분리되는지; 일일 -3% 손실 한도(`DAY_TRADE_DAILY_LOSS_LIMIT`)가 `ctx.dt_state.daily_pnl` 기준인지; SL은 기존 함수 그대로(`min()`) 재사용.
|
||||
7. **swing_portfolio.py / day_trading.py 엔드포인트 `_v2`**, MCP `trade_monitor.py`는 API 키 = legacy owner 매핑 유지(변경 없음).
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크 & 열린 질문
|
||||
|
||||
| 항목 | 내용 | 대응 |
|
||||
|---|---|---|
|
||||
| KIS 제한 스케일링 | 시세는 공용 앱키 20/s에 집중. 사용자 10명 × 20종목 = 200종목 시세/사이클 → 10초 소요. | 종목 캐시 공유 + 사용자 사이클 동시성 3 제한 + 사이클 60s 유지. 사용자 30명 이상이면 공용 시세 앱키 2개 라운드로빈(2단계 후속) |
|
||||
| 공용 시세 앱키 부재 | 운영자가 `KIS_REAL_APP_KEY` 미설정이면 `real_market_api=None` → 각 사용자 모의 앱키 2/s로 시세까지 부담 | 멀티테넌트 활성 조건에 `real_market_api is not None` 권고(경고 로그) |
|
||||
| 실전 계좌 오류 격리 | 한 사용자의 잘못된 자격증명이 스케줄러 전체를 막는 것 | 사용자별 Task + 인스턴스별 circuit_breaker; verify 실패 시 `is_enabled=False` 자동 전환(3회 연속 401/403) |
|
||||
| 토큰 캐시 충돌 | 동일 앱키를 두 사용자가 등록하면 KIS가 토큰을 재발급해 서로 무효화 | `user_kis_credentials`에 `app_key_hash(sha256)` 컬럼 + unique → 중복 등록 409 |
|
||||
| 기존 데이터 백필 | 관리자 사용자가 없는 상태에서 0011 실행 | 스킵 후 migrate-legacy API로 지연 백필; user_id NULL 행은 legacy 컨텍스트에서 계속 조회 가능(0012 전) |
|
||||
| 자격증명 유출 | DB 덤프 유출 시 SECRET_KEY만 있으면 복호 가능 | `CREDENTIALS_ENCRYPTION_KEY` 별도 설정 권고, 응답에 평문 절대 미포함, 로그 마스킹(`app_key[:6]`) |
|
||||
| PortfolioCache/`/portfolio/sync` | 사용자별 캐시·락 → 메모리 증가는 미미하나 `sync_lock` 30초 타임아웃(portfolio.py:463)이 사용자별로 병행 | ctx별 PortfolioCache 인스턴스 |
|
||||
| `korea_investment_api_adapter.py`/`app/trading/*` | 별도 클라이언트 계층(`kis_client`)이 존재하나 `app/services`에서는 미사용(`strategy/scanner.py`만 참조) | 본 설계 범위 외. 멀티테넌트에서는 `KoreaInvestmentAPI.for_user`만 사용 |
|
||||
| 열린 질문 | (1) 사용자당 모의+실전 2세트 동시 허용 여부 (1단계: 1세트) (2) 관리자가 타 사용자 포트폴리오 열람 가능해야 하는가(`?user_id=` 관리자 전용 파라미터) (3) 사용자별 전략 파라미터 오버라이드(`strategy_configs`는 공용) 필요 여부 | PM 결정 필요 |
|
||||
|
||||
**테스트 전략**: `backend/tests/test_api_endpoints.py`는 SQLite(:90)라 JSONB/FK CASCADE 검증 불가 → 컨테이너 Postgres(`docker compose run --rm backend pytest -m integration`)용 `tests/integration/test_multi_tenant.py` 신설: (a) 두 사용자 자격증명 등록·마스킹 조회, (b) `upsert_portfolio` user_id 필터로 동일 종목 2행 공존, (c) `stock_bans_v2` 사용자 분리, (d) `safe_place_order_v2` 락 키 분리(fakeredis), (e) `MULTI_TENANT_ENABLED=False`에서 기존 `/portfolio` 응답 불변 스냅샷.
|
||||
|
||||
---
|
||||
|
||||
## 8. 검증 계획
|
||||
|
||||
**code-verifier 체크리스트**
|
||||
1. 기존 함수/클래스 시그니처 diff 0건 (`korea_investment_api.__init__`, `safe_place_order`, `enqueue_*`, `MDDManager`/`ConsecutiveLossTracker` 기존 메서드, 스케줄러 함수).
|
||||
2. 자격증명 API 외 어떤 경로에서도 `AsyncSessionLocal()`/`commit()`이 실시간 계층(콜백·웹소켓·스케줄러 사이클 내부)에 추가되지 않았는지; 추가된 DB 쓰기가 db_worker 경유인지.
|
||||
3. 응답·로그에 app_secret/app_key 평문 없음(grep `app_secret` in logger/return).
|
||||
4. Redis 락 키·mdd 키에 user_id 포함, legacy 경로는 기존 키 문자열 그대로.
|
||||
5. `_v2` 진입 경로의 리스크 게이트: `ctx.mdd.can_open_new_position()`, `ctx.loss_tracker.is_banned_safe()`, `MAX_HOLDINGS_COUNT`가 사용자 보유 수 기준, `record_trade_result_safe()` 뒤 `save_to_db_v2()` 호출.
|
||||
6. SL 계산 함수 재사용 확인(`min()`), `max()` 신규 도입 없음.
|
||||
7. 새 쓰기 엔드포인트 전부 `require_api_key` 라우터 하위 + `get_current_user`; 관리자 API는 `require_admin`.
|
||||
8. `_dumps()` 사용, `json.dumps` 신규 0건.
|
||||
9. alembic 0009~0011 idempotent(재실행 안전), downgrade 작성.
|
||||
10. `MULTI_TENANT_ENABLED=False` 스냅샷 테스트 통과.
|
||||
|
||||
**백테스트 영향**: 없음. 전략 시그널·SL/TP·사이징 공식은 변경하지 않고 실행 컨텍스트(계좌·상태 저장소)만 사용자 단위로 분리하므로 Walk-forward 재검증 불필요. 단 2단계에서 `correlation_filter_v2`가 사용자 보유 기준으로 바뀌는 것은 로직 변경이 아니라 입력 스코프 변경이므로 동일하게 면제. 사이징 기준 자본이 "계좌 총자산"인 점은 기존과 동일.
|
||||
148
docs/multi_tenant_phase1_review.md
Normal file
148
docs/multi_tenant_phase1_review.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 멀티테넌트 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 라우터 격리) 해소 후.
|
||||
Reference in New Issue
Block a user