Files
auto-stock/docs/multi_tenant_design.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

270 lines
34 KiB
Markdown
Raw Permalink 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.
# 멀티테넌트(사용자별 매매) 전환 설계
작성: 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`가 사용자 보유 기준으로 바뀌는 것은 로직 변경이 아니라 입력 스코프 변경이므로 동일하게 면제. 사이징 기준 자본이 "계좌 총자산"인 점은 기존과 동일.