FastAPI app.on_event -> lifespan 변경

This commit is contained in:
Wind
2026-02-14 09:05:16 +09:00
parent d4f1ca87ab
commit fc1462a3f3
3 changed files with 289 additions and 132 deletions

View File

@@ -7,7 +7,7 @@ from typing import Dict, Optional
from sqlalchemy.orm import Session
from sqlalchemy import update
# 프로젝트 구조에 따라 .models 또는 models에서 Asset 가져옵니다.
# [중요] 순환 참조 방지를 위해 상단에서 Asset 가져옵니다.
try:
from .models import Asset
except ImportError:
@@ -15,7 +15,7 @@ except ImportError:
class DataFetcher:
def __init__(self):
# 인베스팅닷컴은 헤더가 없으면 403 에러를 뱉습니다. 브라우저와 동일하게 설정합니다.
# 인베스팅닷컴 등 외부 사이트 차단 방지용 헤더
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
@@ -26,18 +26,15 @@ class DataFetcher:
self.daily_closing_prices = {}
async def fetch_google_finance(self, client: httpx.AsyncClient, asset_code: str) -> Optional[float]:
"""인베스팅보다 빠르고 야후보다 안정적인 구글 파이낸스 우회"""
"""구글 파이낸스 환율/지수 수집"""
try:
# 구글 파이낸스 환율/지수 URL
symbol = "USD-KRW" if asset_code == "USD/KRW" else "INDEXDXY:CURRENCY" if asset_code == "USD/DXY" else None
if not symbol: return None
url = f"https://www.google.com/finance/quote/{symbol}"
# 구글은 헤더만 있으면 응답 속도가 정말 빠릅니다.
res = await client.get(url, timeout=5)
if res.status_code != 200: return None
# 구글 특유의 가격 클래스 추출 (정규식)
m = re.search(r'data-last-price="([\d,.]+)"', res.text)
if m:
return float(m.group(1).replace(",", ""))
@@ -46,38 +43,30 @@ class DataFetcher:
return None
async def fetch_investing_com(self, client: httpx.AsyncClient, asset_code: str) -> Optional[float]:
"""인베스팅닷컴 비동기 수집 (USD/KRW 포함 전용)"""
"""인베스팅닷컴 비동기 수집 (기존 패턴들 모두 포함)"""
try:
# 자산별 URL 매핑
if asset_code == "USD/DXY":
url = "https://www.investing.com/indices/usdollar"
# elif asset_code == "USD/KRW":
# url = "https://kr.investing.com/currencies/usd-krw"
else:
url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}"
# 인베스팅은 쿠키와 리다이렉트가 중요하므로 follow_redirects 사용
response = await client.get(url, timeout=15, follow_redirects=True)
if response.status_code != 200:
print(f"⚠️ Investing 응답 에러 ({asset_code}): {response.status_code}")
return None
html = response.text
# 인베스팅닷컴의 다양한 HTML 구조에 대응하는 정규식 (우선순위 순)
patterns = [
r'data-test="instrument-price-last">([\d,.]+)<', # 최신 메인 패턴
r'last_last">([\d,.]+)<', # 구형/세부 페이지 패턴
r'instrument-price-last">([\d,.]+)<', # 클래식 패턴
r'class="[^"]*text-2xl[^"]*">([\d,.]+)<' # 비상용 패턴
r'data-test="instrument-price-last">([\d,.]+)<',
r'last_last">([\d,.]+)<',
r'instrument-price-last">([\d,.]+)<',
r'class="[^"]*text-2xl[^"]*">([\d,.]+)<'
]
for pattern in patterns:
p = re.search(pattern, html)
if p:
val_str = p.group(1).replace(',', '')
return float(val_str)
return float(p.group(1).replace(',', ''))
print(f"⚠️ Investing 패턴 매칭 실패 ({asset_code})")
except Exception as e:
@@ -85,7 +74,7 @@ class DataFetcher:
return None
async def fetch_binance(self, client: httpx.AsyncClient) -> Optional[float]:
"""바이낸스 BTC/USDT"""
"""바이낸스 BTC/USD"""
try:
url = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSD"
res = await client.get(url, timeout=5)
@@ -111,7 +100,7 @@ class DataFetcher:
except: return None
async def update_closing_prices(self, db: Session):
"""매일 아침 기준가 스냅샷 찍어 메모리에 저장"""
"""매일 정해진 시간에 기준가 스냅샷 업데이트"""
results = await self.update_realtime_prices(db)
for symbol, price in results.items():
if price:
@@ -119,11 +108,11 @@ class DataFetcher:
print(f"📌 [기준가 업데이트] 완료: {self.daily_closing_prices}")
async def update_realtime_prices(self, db: Session) -> Dict:
"""[핵심] 비동기 수집 후 DB 즉시 업데이트"""
"""[핵심] 비동기 수집 후 DB 즉시 업데이트 및 특수 로직 처리"""
start_time = time.time()
async with httpx.AsyncClient(headers=self.headers, follow_redirects=True) as client:
# 1. 병렬 수집 태스크 정의 (USD/KRW도 인베스팅닷컴 함수 사용)
# 1. 병렬 수집
tasks = {
"XAU/USD": self.fetch_investing_com(client, "XAU/USD"),
"XAU/CNY": self.fetch_investing_com(client, "XAU/CNY"),
@@ -138,63 +127,55 @@ class DataFetcher:
keys = list(tasks.keys())
values = await asyncio.gather(*tasks.values(), return_exceptions=True)
# 결과 가공
raw_results = {}
for i, val in enumerate(values):
symbol = keys[i]
if isinstance(val, (int, float)):
raw_results[symbol] = val
else:
raw_results[symbol] = None
print(f"{symbol} 수집 실패: {val}")
raw_results = {keys[i]: (v if isinstance(v, (int, float)) else None) for i, v in enumerate(values)}
# [수정 구간] 2. XAU/KRW 계산 및 프리미엄용 전일종가 매핑
# 2. XAU/KRW 실시간 계산 (국제 금 시세 * 환율)
if raw_results.get("XAU/USD") and raw_results.get("USD/KRW"):
# (현재가) 국제 금 시세 실시간 계산
raw_results["XAU/KRW"] = round((raw_results["XAU/USD"] / 31.1034768) * raw_results["USD/KRW"], 0)
# [추가] XAU/KRW의 '전일종가' 필드에 'KRX/GLD 현재가'를 강제로 주입
# 이렇게 하면 화면에서 (국제계산가 - 국내현물가)가 실시간 변동액으로 표시됨
if raw_results.get("KRX/GLD"):
from app.models import UserAsset, Asset
asset_xau = db.query(Asset).filter(Asset.symbol == "XAU/KRW").first()
if asset_xau:
db.execute(
update(UserAsset)
.where(UserAsset.asset_id == asset_xau.id)
.values(previous_close=raw_results["KRX/GLD"])
)
# 2) [핵심 추가] 메모리에 저장된 기준가도 실시간 KRX 가격으로 갱신!
# 그래야 밑에 있는 '3. DB 업데이트 수행' 루프에서 state(up/down)가 실시간으로 계산됩니다.
self.daily_closing_prices["XAU/KRW"] = raw_results["KRX/GLD"]
else:
raw_results["XAU/KRW"] = None
# 3. DB 업데이트 수행
for symbol, price in raw_results.items():
if price is not None:
state = "stable"
if symbol in self.daily_closing_prices:
closing = self.daily_closing_prices[symbol]
if price > closing: state = "up"
elif price < closing: state = "down"
try:
# [A] 기본 Asset 테이블 업데이트
for symbol, price in raw_results.items():
if price is not None:
state = "stable"
if symbol in self.daily_closing_prices:
closing = self.daily_closing_prices[symbol]
if price > closing: state = "up"
elif price < closing: state = "down"
# SQL 실행
db.execute(
update(Asset)
.where(Asset.symbol == symbol)
.values(
current_price=price,
price_state=state,
last_updated=datetime.now()
db.execute(
update(Asset)
.where(Asset.symbol == symbol)
.values(current_price=price, price_state=state, last_updated=datetime.now())
)
)
db.commit()
# [B] XAU/KRW 프리미엄용 특수 로직 (KRX 가격을 전일종가로 세팅)
if raw_results.get("KRX/GLD"):
try:
from .models import UserAsset, Asset as ModelAsset # 로컬 임포트 유지
except ImportError:
from models import UserAsset, Asset as ModelAsset
target_asset = db.query(ModelAsset).filter(ModelAsset.symbol == "XAU/KRW").first()
if target_asset:
db.execute(
update(UserAsset)
.where(UserAsset.asset_id == target_asset.id)
.values(previous_close=raw_results["KRX/GLD"])
)
# 메모리 기준가도 함께 갱신하여 UI 상태 화살표 유지
self.daily_closing_prices["XAU/KRW"] = raw_results["KRX/GLD"]
db.commit()
except Exception as e:
db.rollback()
print(f"❌ DB 저장 중 오류 발생: {e}")
print(f"✅ [{datetime.now().strftime('%H:%M:%S')}] 수집 및 DB 저장 완료 ({time.time()-start_time:.2f}s)")
return raw_results
# 싱글톤 인스턴스 생성
# 싱글톤 인스턴스
fetcher = DataFetcher()

View File

@@ -4,13 +4,15 @@ import asyncio
import httpx
from datetime import datetime, timedelta
from typing import Dict
from contextlib import asynccontextmanager # lifespan용 추가
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
# [변경] 비동기용 스케줄러로 교체
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy.orm import Session
from sqlalchemy import update, or_
from pydantic import BaseModel
from dotenv import load_dotenv
@@ -24,36 +26,6 @@ load_dotenv()
# 데이터베이스 테이블 생성
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Asset Pilot - Orange Pi Edition", version="1.2.0")
# 1. 현재 main.py 파일의 절대 경로를 가져옵니다.
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 2. Static 파일 경로 설정 (절대 경로 사용)
static_path = os.path.join(BASE_DIR, "static")
if os.path.exists(static_path):
app.mount("/static", StaticFiles(directory=static_path), name="static")
print(f"✅ Static 마운트 성공: {static_path}")
else:
print(f"❌ Static 폴더를 찾을 수 없습니다: {static_path}")
# 3. 템플릿 설정 (절대 경로 사용)
templates_path = os.path.join(BASE_DIR, "templates")
templates = Jinja2Templates(directory=templates_path)
# [변경] 비동기 스케줄러 설정
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
# 전역 상태 관리
connected_clients = 0
clients_lock = asyncio.Lock()
last_alert_time = {}
# [신규] 시스템 상태 모니터링 변수 (Heartbeat)
system_status = {
"last_fetch_time": None,
"status": "initializing"
}
# ==================== Pydantic 모델 ====================
class UserAssetUpdate(BaseModel):
symbol: str
@@ -64,6 +36,19 @@ class UserAssetUpdate(BaseModel):
class AlertSettingUpdate(BaseModel):
settings: Dict
# ==================== 전역 변수 및 설정 ====================
# [변경] 비동기 스케줄러 설정
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
connected_clients = 0
clients_lock = asyncio.Lock()
last_alert_time = {}
system_status = {
"last_fetch_time": None,
"status": "initializing"
}
# ==================== 유틸리티 함수 ====================
async def send_telegram_msg_async(text: str):
"""비동기 방식으로 텔레그램 메시지 전송"""
@@ -80,7 +65,6 @@ async def send_telegram_msg_async(text: str):
if resp.status_code != 200: print(f"❌ 텔레그램 실패: {resp.text}")
except Exception as e: print(f"❌ 텔레그램 오류: {e}")
# ==================== DB 초기화 ====================
def init_db_data():
db = SessionLocal()
try:
@@ -113,7 +97,7 @@ def init_db_data():
finally:
db.close()
# ==================== 백그라운드 태스크 (Watchdog & 알림 통합) ====================
# ==================== 백그라운드 태스크 ====================
async def background_fetch():
"""비동기 수집 루프: DB 업데이트 + Heartbeat + 알림"""
while True:
@@ -123,14 +107,10 @@ async def background_fetch():
db = SessionLocal()
try:
# 1. 수집 및 DB 업데이트
current_data = await fetcher.update_realtime_prices(db)
# [성공] Heartbeat 기록
system_status["last_fetch_time"] = datetime.now()
system_status["status"] = "healthy"
# 2. 알림 로직
settings_raw = db.query(AlertSetting).all()
sets = {s.setting_key: json.loads(s.setting_value) for s in settings_raw}
user_assets = db.query(Asset, UserAsset).join(UserAsset).all()
@@ -145,7 +125,6 @@ async def background_fetch():
prev_c = float(ua.previous_close) if ua.previous_close else 0
avg_p = float(ua.average_price) if ua.average_price else 0
# 급등락 체크
if sets.get("급등락_감지") and prev_c > 0:
change = ((price - prev_c) / prev_c) * 100
if abs(change) >= float(sets.get("급등락_임계값", 3.0)):
@@ -154,7 +133,6 @@ async def background_fetch():
await send_telegram_msg_async(f"<b>[{icon}] {symbol}</b>\n현재가: {price:,.2f}\n변동률: {change:+.2f}%")
last_alert_time[f"{symbol}_vol"] = now_ts
# 수익률 체크
if sets.get("목표수익률_감지") and avg_p > 0:
profit = ((price - avg_p) / avg_p) * 100
if profit >= float(sets.get("목표수익률", 10.0)):
@@ -162,7 +140,6 @@ async def background_fetch():
await send_telegram_msg_async(f"<b>💰 수익 목표달성! ({symbol})</b>\n수익률: {profit:+.2f}%\n현재가: {price:,.2f}")
last_alert_time[f"{symbol}_profit"] = now_ts
# 특정가격 감지
if sets.get("특정가격_감지"):
if symbol == "KRX/GLD" and price >= float(sets.get("금_목표가격", 0)):
if now_ts - last_alert_time.get("gold_hit", 0) > 43200:
@@ -172,7 +149,6 @@ async def background_fetch():
if now_ts - last_alert_time.get("btc_hit", 0) > 43200:
await send_telegram_msg_async(f"<b>₿ BTC 목표가 돌파!</b>\n현재가: {price:,.0f}")
last_alert_time["btc_hit"] = now_ts
finally:
db.close()
except Exception as e:
@@ -181,28 +157,73 @@ async def background_fetch():
await asyncio.sleep(interval)
# ==================== 앱 생명주기 (AsyncIOScheduler 적용) ====================
@app.on_event("startup")
async def startup_event():
# ==================== [신규] Lifespan 핸들러 ====================
@asynccontextmanager
async def lifespan(app: FastAPI):
# [Startup] 앱 시작 시 실행
init_db_data()
# [변경] 7시 10분 비동기 전용 스케줄러 작업
async def daily_job():
print(f"🌅 [기준가 업데이트 시작] {datetime.now()}")
# 서버 재시작 시 DB의 종가를 메모리로 복구 (fetcher에 해당 함수가 있다고 가정)
db = SessionLocal()
try:
# DB에서 기존 종가를 불러와 메모리 fetcher.daily_closing_prices 채우기
assets = db.query(Asset, UserAsset).join(UserAsset).all()
for a, ua in assets:
if ua.previous_close:
fetcher.daily_closing_prices[a.symbol] = float(ua.previous_close)
finally:
db.close()
# 1. 비트코인 전용 자정 스케줄러 (00:00:05)
async def btc_daily_job():
print(f"🕛 [BTC 자정 스냅샷] {datetime.now()}")
db = SessionLocal()
try:
await fetcher.update_closing_prices(db)
finally:
db.close()
assets = db.query(Asset).filter(Asset.symbol.like("BTC/%")).all()
for asset in assets:
if asset.current_price:
db.execute(update(UserAsset).where(UserAsset.asset_id == asset.id).values(previous_close=asset.current_price))
fetcher.daily_closing_prices[asset.symbol] = asset.current_price
db.commit()
finally: db.close()
scheduler.add_job(daily_job, 'cron', hour=7, minute=10, id='daily_snapshot')
# 2. 전통 자산용 아침 스케줄러 (07:10:00)
async def legacy_daily_job():
print(f"🌅 [전통자산 아침 스냅샷] {datetime.now()}")
db = SessionLocal()
try:
assets = db.query(Asset).filter(~Asset.symbol.like("BTC/%")).all()
for asset in assets:
if asset.current_price:
db.execute(update(UserAsset).where(UserAsset.asset_id == asset.id).values(previous_close=asset.current_price))
fetcher.daily_closing_prices[asset.symbol] = asset.current_price
db.commit()
finally: db.close()
scheduler.add_job(btc_daily_job, 'cron', hour=0, minute=0, second=5, id='btc_snapshot')
scheduler.add_job(legacy_daily_job, 'cron', hour=7, minute=10, id='legacy_snapshot')
scheduler.start()
asyncio.create_task(background_fetch())
fetch_task = asyncio.create_task(background_fetch())
yield # 서버 운영 중
@app.on_event("shutdown")
def stop_scheduler():
# [Shutdown] 서버 종료 시 실행
scheduler.shutdown()
fetch_task.cancel()
print("👋 서버가 종료되었습니다.")
# ==================== FastAPI 앱 선언 ====================
app = FastAPI(title="Asset Pilot - Orange Pi Edition", version="1.2.0", lifespan=lifespan)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
static_path = os.path.join(BASE_DIR, "static")
if os.path.exists(static_path):
app.mount("/static", StaticFiles(directory=static_path), name="static")
print(f"✅ Static 마운트 성공: {static_path}")
templates_path = os.path.join(BASE_DIR, "templates")
templates = Jinja2Templates(directory=templates_path)
# ==================== API 엔드포인트 ====================
@app.get("/", response_class=HTMLResponse)
@@ -211,10 +232,7 @@ async def read_root(request: Request):
@app.get("/api/prices")
async def get_prices(db: Session = Depends(get_db)):
"""[개선] 데이터 신선도 상태와 서버 시각을 포함하여 반환"""
assets = db.query(Asset).all()
# 지연 판별 (마지막 성공 후 60초 경과 시 stale)
is_stale = False
if system_status["last_fetch_time"]:
if datetime.now() - system_status["last_fetch_time"] > timedelta(seconds=60):
@@ -300,8 +318,4 @@ async def update_alert_settings(data: AlertSettingUpdate, db: Session = Depends(
@app.get("/health")
async def health_check():
return {"status": "healthy", "last_fetch": system_status["last_fetch_time"]}
# if __name__ == "__main__":
# import uvicorn
# uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
return {"status": "healthy", "last_fetch": system_status["last_fetch_time"]}

View File

@@ -0,0 +1,162 @@
2026-02-14 (08:46)
선임님, VSCode에서 멤버명(변수나 함수 이름)에 **가운데 줄(취소선, Strikethrough)**이 그어져 있는 건 **"Deprecated(사용 권장되지 않음)"**라는 뜻입니다.
쉽게 말해 **"이 기능은 아직 작동은 하지만, 곧 없어질 예정이거나 더 좋은 다른 기능으로 대체되었으니 웬만하면 쓰지 마라"**고 경고를 보내는 겁니다.
🧐 왜 가운데 줄이 생겼을까요?
라이브러리 업데이트: 선임님이 쓰시는 라이브러리(예: SQLAlchemy, FastAPI, Pydantic 등)가 버전업 되면서, 기존 방식을 버리고 새로운 방식을 권장할 때 발생합니다.
보안/성능 이슈: 해당 멤버를 계속 쓰면 보안에 취약하거나 성능이 떨어질 수 있다고 판단될 때 개발자가 표시를 해둔 겁니다.
🛠️ 어떻게 해결하나요?
가장 쉬운 확인 방법은 그 가운데 줄이 쳐진 이름 위에 마우스 커서를 가만히 올려보는 겁니다.
그러면 "@deprecated: Use 'NewFunctionName' instead" 같은 가이드 팝업이 뜰 거예요.
거기서 추천하는 새로운 멤버명으로 이름을 바꿔주기만 하면 줄이 사라지고 코드도 최신 트렌드에 맞게 정리가 됩니다.
💡 우리 코드에서 주의할 점
혹시 아까 제가 짜드린 코드나 선임님이 작성하신 코드 중에 on_event("startup") 부분에 줄이 가 있나요?
최신 FastAPI에서는 @app.on_event("startup") 대신 **lifespan**이라는 방식을 권장하기 때문에 취소선이 생길 수 있습니다.
ython
from contextlib import asynccontextmanager
# [최신 방식] 앱 실행 전후를 한 번에 관리하는 lifespan 함수
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- Startup (앱 시작 시 실행) ---
init_db_data()
# 이원화 스케줄러 등록
async def btc_daily_job():
# ... (비트코인 자정 스냅샷 로직 동일) ...
pass
async def legacy_daily_job():
# ... (전통자산 아침 스냅샷 로직 동일) ...
pass
scheduler.add_job(btc_daily_job, 'cron', hour=0, minute=0, second=5, id='btc_snapshot')
scheduler.add_job(legacy_daily_job, 'cron', hour=7, minute=10, id='legacy_snapshot')
scheduler.start()
fetch_task = asyncio.create_task(background_fetch())
yield # 앱이 실행되는 동안 여기서 대기합니다.
# --- Shutdown (앱 종료 시 실행) ---
scheduler.shutdown()
fetch_task.cancel()
print("👋 서버가 안전하게 종료되었습니다.")
# FastAPI 선언 시 lifespan을 넣어줍니다.
app = FastAPI(title="Asset Pilot", lifespan=lifespan)
🧐 왜 이걸로 갈아타야 할까요? (취소선의 이유)
자원 관리: lifespan 방식은 yield를 기준으로 시작과 끝이 명확하게 한 곳에 모여 있어, 나중에 DB 커넥션이나 소켓을 닫을 때 실수를 안 하게 해줍니다.
비동기 최적화: 이전 방식보다 비동기 태스크들의 생명주기를 더 정확하게 제어할 수 있습니다.
VSCode 클린업: 이제 그 지긋지긋한 **가운데 줄(취소선)**이 사라지고, 선임님의 VSCode가 다시 깨끗해질 겁니다.
Gemini said
선임님, fetcher.py 소스 코드 잘 봤습니다. 코드를 뜯어보니 데이터 수집은 되는데 왜 현재가가 안 변했는지 확실한 범인이 잡혔습니다.
범인은 바로 db.commit()의 위치와 XAU/KRW의 특수 로직 때문입니다.
🕵️ fetcher.py의 문제점 분석
1. XAU/KRW 프리미엄 로직의 '월권' (핵심 원인)
코드 중간에 보면 이런 로직이 있습니다:
Python
# [수정 구간] 2. XAU/KRW 계산 및 프리미엄용 전일종가 매핑
if raw_results.get("KRX/GLD"):
# ... 중략 ...
db.execute(update(UserAsset).where(UserAsset.asset_id == asset_xau.id).values(previous_close=raw_results["KRX/GLD"]))
여기서 db.execute를 실행했는데, 이 작업이 완료되기도 전에 밑에서 다른 update(Asset) 루프가 돌고 있습니다. SQLAlchemy 세션 하나에서 여러 update를 비동기 루프와 섞어 쓸 때, 가끔 먼저 실행된 execute가 세션을 점유하거나 락(Lock)이 걸려 뒤의 업데이트가 씹히는 경우가 발생합니다.
2. db.commit()이 너무 뒤에 있음
현재 모든 자산의 가격을 루프로 돌면서 db.execute를 예약만 해두고, **맨 마지막에 한 번만 db.commit()**을 합니다. 만약 루프 도중 단 하나의 자산이라도 DB 제약 조건에 걸리거나 에러가 나면, 그전까지 업데이트한 모든 가격이 롤백되어 반영되지 않습니다. #### 3. fetch_google_finance의 정규식 오류 가능성
현재 구글 파이낸스 정규식이 data-last-price="([\d,.]+)"로 되어 있는데, 최근 구글 파이낸스 HTML 구조가 바뀌어서 이 속성이 안 나타날 때가 있습니다. 만약 환율(USD/KRW) 값이 None이 되면, 이를 기반으로 계산하는 XAU/KRW도 줄줄이 사탕으로 업데이트가 안 됩니다.
🛠️ 수정한 fetcher.py (핵심 구간)
선임님, 아래 부분을 특히 주의해서 봐주세요. 안정성을 위해 업데이트 방식을 더 직관적으로 고쳤습니다.
Python
async def update_realtime_prices(self, db: Session) -> Dict:
start_time = time.time()
async with httpx.AsyncClient(headers=self.headers, follow_redirects=True) as client:
tasks = {
"XAU/USD": self.fetch_investing_com(client, "XAU/USD"),
"XAU/CNY": self.fetch_investing_com(client, "XAU/CNY"),
"XAU/GBP": self.fetch_investing_com(client, "XAU/GBP"),
"USD/DXY": self.fetch_investing_com(client, "USD/DXY"),
"USD/KRW": self.fetch_google_finance(client, "USD/KRW"), # 구글 실패 시 대비 필요
"BTC/USD": self.fetch_binance(client),
"BTC/KRW": self.fetch_upbit(client),
"KRX/GLD": self.fetch_krx_gold(client),
}
keys = list(tasks.keys())
values = await asyncio.gather(*tasks.values(), return_exceptions=True)
raw_results = {keys[i]: (v if isinstance(v, (int, float)) else None) for i, v in enumerate(values)}
# [개선] XAU/KRW 계산
if raw_results.get("XAU/USD") and raw_results.get("USD/KRW"):
raw_results["XAU/KRW"] = round((raw_results["XAU/USD"] / 31.1034768) * raw_results["USD/KRW"], 0)
# [개선] DB 업데이트 로직 분리 및 안정화
try:
for symbol, price in raw_results.items():
if price is not None:
# 1. 상태(Up/Down) 계산
state = "stable"
closing = self.daily_closing_prices.get(symbol)
if closing:
if price > closing: state = "up"
elif price < closing: state = "down"
# 2. Asset 테이블 업데이트 (현재가)
db.query(Asset).filter(Asset.symbol == symbol).update({
"current_price": price,
"price_state": state,
"last_updated": datetime.now()
})
# 3. 특수 로직 (XAU/KRW 프리미엄 계산용 기준가 변경)
if raw_results.get("KRX/GLD"):
asset_xau = db.query(Asset).filter(Asset.symbol == "XAU/KRW").first()
if asset_xau:
# UserAsset 테이블의 previous_close를 KRX 현물가로 강제 동기화
db.query(UserAsset).filter(UserAsset.asset_id == asset_xau.id).update({
"previous_close": raw_results["KRX/GLD"]
})
self.daily_closing_prices["XAU/KRW"] = raw_results["KRX/GLD"]
db.commit() # 모든 업데이트 완료 후 커밋
except Exception as e:
db.rollback()
print(f"❌ DB 업데이트 중 롤백 발생: {e}")
print(f"✅ [{datetime.now().strftime('%H:%M:%S')}] 수집 및 DB 저장 완료 ({time.time()-start_time:.2f}s)")
return raw_results
💡 선임님을 위한 조언
XAU/KRW 프리미엄 수식: 지금 선임님 코드는 국제 시세와 국내 시세의 차이를 보여주기 위해 previous_close 값을 실시간으로 계속 덮어쓰고 계시네요. 이 방식은 등락률 계산 로직을 우회하는 아주 영리한 방법이지만, DB에 계속 쓰기 작업을 발생시킵니다. (오렌지파이 eMMC 수명에는 조금 가혹할 수 있지만, 지금 데이터 양으로는 큰 문제 없습니다!)
구글 환율 체크: 만약 로그에 USD/KRW가 None으로 찍힌다면, 구글 파이낸스 정규식을 r'class="fxKb7e".*?>([\d,.]+)<' 같은 클래스 기반으로 바꿔야 할 수도 있습니다.
🔍 빠진 게 없는지 대조 결과
패턴 매칭: patterns 리스트에 4가지 정규식을 모두 유지했습니다.
병렬 수집: asyncio.gather를 통해 모든 자산(8종)을 동시에 긁어옵니다.
XAU/KRW 로직: 환율과 국제 금값을 곱해 원화 금값을 계산하고, 국내 현물가(KRX/GLD)와 비교할 수 있도록 previous_close에 강제로 주입하는 핵심 로직 살아있습니다.
안전 장치: try-except-rollback 구조를 넣어 데이터 정합성을 지켰습니다.