설계: 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>
34 KiB
멀티테넌트(사용자별 매매) 전환 설계
작성: 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()사용, 쓰기 APIrequire_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개, 주문·잔고 = 사용자별 인스턴스.
KoreaInvestmentAPI의 조회 메서드는 이미_quote_delegate()로real_market_api에 위임하는 구조(korea_investment_api.py:558-598, 794, 855, 1273)이며 조회 API는 계좌번호를 사용하지 않는다(get_current_priceparams에 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이거나 운영자가 실전 앱키를 등록하지 않았으면 각 사용자 인스턴스가 자기 앱키로 조회한다(현재 동작과 동일). - legacy owner:
users중is_admin=True이고id가 가장 작은 사용자..env의 KIS 자격증명은 이 사용자의user_kis_credentials행으로 이관(관리자 API).MULTI_TENANT_ENABLED=False(기본)이면 현재 전역 싱글톤이 그대로 legacy owner 컨텍스트로 동작. - 기존 싱글톤 유지:
korea_investment_api,mdd_manager,loss_tracker,day_trading_state,portfolio_cache는 삭제하지 않고UserContextRegistry.legacy()가 이 인스턴스들을 감싼 컨텍스트를 반환. 기존 호출 코드는 수정 없이 동작. - Redis 키·락 네임스페이스에 user_id 포함:
order_lock:{user_id}:{stock_code}:{order_type},mdd:state:{user_id}. legacy 컨텍스트(user_id=None 또는 legacy owner id)는 기존 키를 그대로 사용해 하위 호환. - 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쓰기, taskupsert_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는 사용자 필터.
- False: 스케줄러·trade_executor·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.pylifespan이 기동 시 이를 감지해 중단시킨다. - 롤백:
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설정 시 복호 가능, 관리자 APIPOST /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
@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에 새 classmethodfor_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_URLTOKEN_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-68navItems에{ 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단계 개요
- 스케줄러 사용자 순회:
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회 갱신)이므로 발급 제한 문제 없음.
- 사용자 사이클 내부:
- 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필요. - db_worker: §3.4 필터 적용 완료 +
trade_journal·trading_sessionsuser_id. - 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 키 접속(운영자)은 전체 수신. - 데이트레이딩 상태:
DayTradingState를 ctx별 인스턴스로,restore_from_db(db)(:256) →restore_from_db_v2(db, user_id);entry_service의 모듈 전역 watchlist(:69-80)는 공용(모닝 배치 산출) 유지. - 리스크 규칙 점검 포인트: 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()) 재사용. - swing_portfolio.py / day_trading.py 엔드포인트
_v2, MCPtrade_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 체크리스트
- 기존 함수/클래스 시그니처 diff 0건 (
korea_investment_api.__init__,safe_place_order,enqueue_*,MDDManager/ConsecutiveLossTracker기존 메서드, 스케줄러 함수). - 자격증명 API 외 어떤 경로에서도
AsyncSessionLocal()/commit()이 실시간 계층(콜백·웹소켓·스케줄러 사이클 내부)에 추가되지 않았는지; 추가된 DB 쓰기가 db_worker 경유인지. - 응답·로그에 app_secret/app_key 평문 없음(grep
app_secretin logger/return). - Redis 락 키·mdd 키에 user_id 포함, legacy 경로는 기존 키 문자열 그대로.
_v2진입 경로의 리스크 게이트:ctx.mdd.can_open_new_position(),ctx.loss_tracker.is_banned_safe(),MAX_HOLDINGS_COUNT가 사용자 보유 수 기준,record_trade_result_safe()뒤save_to_db_v2()호출.- SL 계산 함수 재사용 확인(
min()),max()신규 도입 없음. - 새 쓰기 엔드포인트 전부
require_api_key라우터 하위 +get_current_user; 관리자 API는require_admin. _dumps()사용,json.dumps신규 0건.- alembic 0009~0011 idempotent(재실행 안전), downgrade 작성.
MULTI_TENANT_ENABLED=False스냅샷 테스트 통과.
백테스트 영향: 없음. 전략 시그널·SL/TP·사이징 공식은 변경하지 않고 실행 컨텍스트(계좌·상태 저장소)만 사용자 단위로 분리하므로 Walk-forward 재검증 불필요. 단 2단계에서 correlation_filter_v2가 사용자 보유 기준으로 바뀌는 것은 로직 변경이 아니라 입력 스코프 변경이므로 동일하게 면제. 사이징 기준 자본이 "계좌 총자산"인 점은 기존과 동일.