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

34 KiB
Raw Blame History

멀티테넌트(사용자별 매매) 전환 설계

작성: 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

@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가 사용자 보유 기준으로 바뀌는 것은 로직 변경이 아니라 입력 스코프 변경이므로 동일하게 면제. 사이징 기준 자본이 "계좌 총자산"인 점은 기존과 동일.