브로커 401
거래소(Binance / Bybit / OKX / Alpaca / KIS) API가 401 Unauthorized를 반환하는 상황입니다. 거래 자체가 멈출 수 있는 P1 장애 — 즉시 진단 후 사용자 영향 범위를 결정합니다.
사전 요구 사항
broker_order_events테이블에 최근 5xx/401 이벤트가 있는지 조회 가능- 영향 사용자 ID 또는 자산군이 알려져 있어야 우선순위 부여 가능
1. 원인 가설 (빈도순)
| 원인 | 영향 범위 | 검증 |
|---|---|---|
placeholder 값 (your_api_key_here) | 단일 사용자 (env 폴백 사용 시 전체) | .env 또는 exchange_keys 행 확인 |
| API 키 만료 / 무효화 (사용자가 거래소 측에서 revoke) | 단일 사용자 | 거래소 콘솔 |
| 권한 부족 (read-only / IP 제한) | 단일 사용자 | 거래소 키 권한 확인 |
ENCRYPTION_SALT 회전 후 미재등록 | 전체 사용자 | DB 복호화 실패 로그 |
ALPACA_BASE_URL 라이브/페이퍼 미스매치 | Alpaca 단독 | URL 점검 |
| 시계 드리프트 (Binance HMAC) | 단일 사용자 | ntpq 또는 timedatectl |
2. 진단
단일 사용자 단위
# 1) 가장 최근 401/403 이벤트 추출
docker compose exec timescaledb psql -U quant -d quantai -c "
SELECT received_at, user_id, asset_class,
event_type, payload->>'http_status' AS code,
payload->>'broker' AS broker,
LEFT(payload->>'error', 120) AS err
FROM broker_order_events
WHERE event_type IN ('error_4xx','broker_4xx','http_4xx','auth_failed')
AND received_at > NOW() - INTERVAL '6 hours'
ORDER BY received_at DESC LIMIT 20;
"
# 2) 해당 사용자의 활성 키 상태
docker compose exec timescaledb psql -U quant -d quantai -c "
SELECT id, exchange, asset_class, is_active, created_at, last_used_at,
(api_key_encrypted IS NOT NULL) AS has_key
FROM exchange_keys
WHERE user_id = <USER_ID>;
"
# 3) Fernet 복호화가 정상인지 (placeholder 점검 포함)
docker compose exec api python -c "
from server.auth.crypto import decrypt
from server.db.session import SessionLocal
from server.models.exchange_keys import ExchangeKey
with SessionLocal() as s:
for k in s.query(ExchangeKey).filter_by(user_id=<USER_ID>):
try:
api = decrypt(k.api_key_encrypted)
bad = api in {'', 'your_api_key_here'} or api.startswith('your_')
print(k.id, k.exchange, 'BAD' if bad else 'ok', f'len={len(api)}')
except Exception as e:
print(k.id, 'DECRYPT_FAIL', e)
"
전체 사용자 광역 (SALT 회전 의심)
# 모든 키가 동시에 401이라면 SALT 회전을 의심
docker compose exec timescaledb psql -U quant -d quantai -c "
SELECT COUNT(DISTINCT user_id) AS users_failing
FROM broker_order_events
WHERE event_type = 'auth_failed'
AND received_at > NOW() - INTERVAL '15 minutes';
"
20명 이상 동시 실패면 거의 확실히 SALT 회전이 원인입니다 → 시크릿 회전 — ENCRYPTION_SALT 참고.
3. 조치
Case A: placeholder 값
# .env에서 더미 값 발견
grep -E "(your_api_key_here|your_secret|changeme)" .env
# 발견 시 진짜 값으로 교체 또는 삭제
docker compose restart api
사용자가 UI에서 등록한 키가 placeholder인 경우, 사용자에게 재등록 안내.
Case B: 만료된 키
사용자에게 거래소 콘솔에서 새 키 발급 안내. 운영자가 직접 새 키를 등록할 필요는 없음 (거래소 키는 사용자 소유).
# 사용자 키 비활성 처리 (로그인 시 재등록 유도)
docker compose exec timescaledb psql -U quant -d quantai -c "
UPDATE exchange_keys SET is_active = false
WHERE user_id = <USER_ID> AND exchange = '<binance|alpaca|kis>';
"
Case C: 권한 부족
거래소 콘솔에서 다음 권한 ON 필요:
| 거래소 | 필수 권한 |
|---|---|
| Binance / Bybit / OKX | Spot Trade (또는 Futures Trade), Read |
| Alpaca | Trading + Market Data |
| KIS | 거래용 + 조회용 (모의투자는 별도 발급) |
IP 제한이 켜져 있다면 운영 VM의 outbound IP를 화이트리스트에 추가.
Case D: SALT 회전 후 미재등록
시크릿 회전 — ENCRYPTION_SALT의 재등록 절차 따르기. 단일 페이지로 처리 — 본 페이지에서 중복하지 않음.
Case E: Alpaca URL 미스매치
# 페이퍼 키로 라이브 URL을 호출하면 401
grep ALPACA_BASE_URL .env
# 페이퍼: https://paper-api.alpaca.markets
# 라이브: https://api.alpaca.markets
라이브 승급 시 사용자가 페이퍼 키를 그대로 둔 케이스가 흔함. UI에서 "라이브 키 등록" 강제.
Case F: 시계 드리프트 (Binance)
# VM 시계 동기화
ssh azureuser@<vm-ip> 'sudo timedatectl set-ntp true; timedatectl status'
5초 이상 어긋나면 Binance가 401 / Timestamp for this request was 1000ms ahead.
4. 검증
# 조치 후 다음 사이클에서 reconciler audit가 정상으로
docker compose exec timescaledb psql -U quant -d quantai -c "
SELECT received_at, user_id, payload->>'severity' AS sev
FROM broker_order_events
WHERE user_id = <USER_ID>
AND event_type = 'reconcile_pass'
ORDER BY received_at DESC LIMIT 3;
"
# 가장 최근 row의 severity가 'info' 또는 'warning' (이전 'critical'에서 회복)
트러블슈팅
| 증상 | 원인 / 조치 |
|---|---|
| 사용자가 키 재등록 후에도 401 | 캐시된 토큰 (KIS) 또는 BotManager 메모리 캐시. compose restart api |
| 401 → 200 후 다시 401 | 거래소 측 IP 제한 / 일시 rate limit. broker 콘솔 / 상태 페이지 확인 |
| 401인데 단일 사용자 / 단일 자산군만 | 거의 확실히 사용자 측 키 문제. 다른 사용자가 같은 거래소에서 정상이면 광역 원인 배제 |
| 401인데 거래는 들어감 (간헐적) | 다른 사용자 keys로 우연히 동작? 권한 분리 사고 의심 → 멀티유저 격리 사고 |