perf(infra): NAS CPU 중기 2건 + 1건 보류 (CHECK_POINT 🟡)

#6 insta-lab Chromium Browser Pool — Playwright/Chromium 인스턴스를
모듈 레벨에서 보관하고 매 슬레이트마다 reuse. 카드 10장 렌더의
launch 비용 (~3초/회)이 사라짐. startup/shutdown lifecycle hook 추가.
crashed/disconnected 시 lazy 재초기화.

#8 realestate-lab 수집 병렬화 — collect_all과 delete_old_completed가
서로 다른 데이터 영역이라 ThreadPoolExecutor(2)로 병렬. asyncio.gather
대신 thread executor를 쓴 이유는 BackgroundScheduler+동기 함수 환경
에서 자연스럽고 추가 의존성 없기 때문. 매칭은 일관성 유지로 순차.

#7 stock async — 보류. 재진단 결과 stock은 BackgroundScheduler 사용
중이라 main loop 블로킹 없음. fetch 4회는 network I/O wait가
대부분이라 to_thread도 의미 없음. 진짜 효과를 보려면 AsyncIOScheduler
전환 + aiohttp 병렬이라 큰 리팩토링. 박재오 판단 대기.

CHECK_POINT.md 갱신.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 10:42:43 +09:00
parent 7a470aad44
commit 20514193e8
4 changed files with 101 additions and 40 deletions

View File

@@ -113,23 +113,26 @@ scheduler.add_job(_run_simulation_job, "cron", hour="0,4,8,12,16,20", minute=30)
## 🟡 중기 (1~2주) ## 🟡 중기 (1~2주)
### 6. Chromium Browser Pool 재설계 (insta-lab) ### 6. Chromium Browser Pool 재설계 (insta-lab) ✅ 2026-05-18
- 매번 launch X → 1개 인스턴스 재사용 - 매번 launch X → 1개 인스턴스 재사용
- 카드 10장 렌더 시간 30% 단축 기대 - 카드 10장 렌더 시간 30% 단축 기대
- [ ] `insta-lab/app/browser_pool.py` 신규 모듈 - [x] `card_renderer.py` 내부에 모듈 레벨 `_PLAYWRIGHT`/`_BROWSER` + `init_browser`/`shutdown_browser` 함수 (별도 모듈 분리 안 함, 같은 파일에 인접 배치)
- [ ] card_renderer에서 풀 사용 - [x] `_render_slate_locked` 본체에서 `_get_browser()` 재사용 (crashed 시 lazy 재초기화)
- [x] `main.py` startup hook에서 `init_browser()`, shutdown hook에서 `shutdown_browser()`
### 7. stock 뉴스 스크랩 비동기화 ### 7. stock 뉴스 스크랩 비동기화 — ⚠️ 보류 2026-05-18
- **파일**: `stock/app/main.py:104-149` - **재진단**: stock`BackgroundScheduler` 사용 중 → main loop 블로킹 없음 (이미 별도 thread)
- `run_scraping_job()` 동기 → BackgroundTask 또는 asyncio - `fetch_market_news`의 4개 동기 `requests.get`은 network I/O wait라 CPU 거의 사용 안 함
- 08:00 APScheduler 블로킹 해소 - `to_thread`로 wrap해도 BackgroundScheduler 환경에서 사실상 의미 없음
- [ ] async/await 변환 - 진짜 효과를 보려면 AsyncIOScheduler 전환 + scraper.py 4개 fetch를 `aiohttp` 병렬로 — **큰 리팩토링 vs 효과 불명확**
- [ ] 박재오 판단: 큰 리팩토링 진행 여부
### 8. realestate 수집 병렬화 ### 8. realestate 수집 병렬화 ✅ 2026-05-18
- **파일**: `realestate-lab/app/main.py:28-37` - **파일**: `realestate-lab/app/main.py:scheduled_collect`
- `collect_all()` + `delete_old()` 병렬`asyncio.gather` - `collect_all()` + `delete_old_completed_announcements()` 병렬
- BackgroundScheduler 환경이라 `asyncio.gather` 대신 `ThreadPoolExecutor(max_workers=2)` 사용 (효과 동일)
- 매칭은 순차 유지 (DB 일관성) - 매칭은 순차 유지 (DB 일관성)
- [ ] async 함수 변환 + gather 적용 - [x] ThreadPoolExecutor 적용
### 9. lotto Monte Carlo 시뮬레이션 빈도 검토 ### 9. lotto Monte Carlo 시뮬레이션 빈도 검토
- 현재 6회/일 (00·04·08·12·16·20) - 현재 6회/일 (00·04·08·12·16·20)
@@ -173,6 +176,7 @@ services:
- 2026-05-17: insta-lab minimal theme + design_importer 추가 - 2026-05-17: insta-lab minimal theme + design_importer 추가
- 2026-05-17: blog-lab 트랙 완전 폐기 (docker-compose에 없음, 위키 정정 완료) - 2026-05-17: blog-lab 트랙 완전 폐기 (docker-compose에 없음, 위키 정정 완료)
- 2026-05-18: 🔴 즉시 5건 일괄 적용 — 09:00 cron 스태거링(insta/lotto/youtube/realestate), lotto Monte Carlo 08:30, insta-lab Semaphore(1), healthcheck 60s, uvicorn --workers 1 명시 (사용자 push + NAS deployer 재기동 대기) - 2026-05-18: 🔴 즉시 5건 일괄 적용 — 09:00 cron 스태거링(insta/lotto/youtube/realestate), lotto Monte Carlo 08:30, insta-lab Semaphore(1), healthcheck 60s, uvicorn --workers 1 명시 (사용자 push + NAS deployer 재기동 대기)
- 2026-05-18: 🟡 중기 2건 적용 — #6 insta-lab Chromium Browser Pool (lifecycle hook), #8 realestate ThreadPoolExecutor 병렬 (collect/delete). #7 stock async는 BackgroundScheduler 사용 중이라 재진단 후 보류 (효과 미미). #9 Monte Carlo 빈도는 박재오 결정 대기.
--- ---

View File

@@ -30,6 +30,47 @@ def _render_semaphore() -> asyncio.Semaphore:
return _RENDER_SEMAPHORE return _RENDER_SEMAPHORE
# Chromium 브라우저 풀 — 매 슬레이트마다 launch 하지 않고 1개를 살려둠.
# (CHECK_POINT 중기-6) 카드 10장 렌더 시간 ~30% 단축 기대.
_PLAYWRIGHT = None
_BROWSER = None
async def init_browser() -> None:
"""앱 startup hook에서 1회 호출. 이미 살아있으면 no-op."""
global _PLAYWRIGHT, _BROWSER
if _BROWSER is not None and _BROWSER.is_connected():
return
_PLAYWRIGHT = await async_playwright().start()
_BROWSER = await _PLAYWRIGHT.chromium.launch()
logger.info("Chromium browser pool 초기화 완료")
async def shutdown_browser() -> None:
"""앱 shutdown hook에서 1회 호출."""
global _PLAYWRIGHT, _BROWSER
if _BROWSER is not None:
try:
await _BROWSER.close()
except Exception:
logger.exception("browser close 중 예외 (무시)")
_BROWSER = None
if _PLAYWRIGHT is not None:
try:
await _PLAYWRIGHT.stop()
except Exception:
logger.exception("playwright stop 중 예외 (무시)")
_PLAYWRIGHT = None
async def _get_browser():
"""현재 브라우저 핸들 반환. crashed/None이면 재초기화 후 반환."""
global _BROWSER
if _BROWSER is None or not _BROWSER.is_connected():
await init_browser()
return _BROWSER
def _resolve_template_dir() -> str: def _resolve_template_dir() -> str:
"""Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/.""" """Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/."""
if os.path.isdir(CARD_TEMPLATE_DIR): if os.path.isdir(CARD_TEMPLATE_DIR):
@@ -97,10 +138,9 @@ async def _render_slate_locked(slate_id: int, template: str) -> List[str]:
out_dir = _slate_dir(slate_id) out_dir = _slate_dir(slate_id)
paths: List[str] = [] paths: List[str] = []
async with async_playwright() as p: browser = await _get_browser()
browser = await p.chromium.launch()
try:
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350}) ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
try:
page = await ctx.new_page() page = await ctx.new_page()
for spec in pages: for spec in pages:
html_str = tmpl.render(**spec) html_str = tmpl.render(**spec)
@@ -121,5 +161,5 @@ async def _render_slate_locked(slate_id: int, template: str) -> List[str]:
except OSError: except OSError:
pass pass
finally: finally:
await browser.close() await ctx.close()
return paths return paths

View File

@@ -31,9 +31,16 @@ app.add_middleware(
@app.on_event("startup") @app.on_event("startup")
def on_startup(): async def on_startup():
os.makedirs(INSTA_DATA_PATH, exist_ok=True) os.makedirs(INSTA_DATA_PATH, exist_ok=True)
db.init_db() db.init_db()
# Chromium browser pool 초기화 (CHECK_POINT 중기-6)
await card_renderer.init_browser()
@app.on_event("shutdown")
async def on_shutdown():
await card_renderer.shutdown_browser()
@app.get("/health") @app.get("/health")

View File

@@ -1,6 +1,7 @@
import os import os
import logging import logging
import threading import threading
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, FastAPI, Query, HTTPException from fastapi import BackgroundTasks, FastAPI, Query, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -26,10 +27,19 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
def scheduled_collect(): def scheduled_collect():
"""매일 09:00 — 수집 + 정리 + 매칭 + 알림 push""" """매일 09:15 — 수집 + 정리 (병렬) → 매칭 알림 push.
collect_all과 delete_old_completed_announcements는 서로 다른 데이터
영역을 건드리므로 thread 둘로 병렬화. 매칭은 두 작업 완료 후 순차
실행 (DB 일관성). CHECK_POINT 중기-8 — env이 BackgroundScheduler+
동기 함수 조합이라 asyncio.gather 대신 ThreadPoolExecutor 사용.
"""
logger.info("스케줄 수집 시작") logger.info("스케줄 수집 시작")
collect_all() with ThreadPoolExecutor(max_workers=2) as ex:
deleted = delete_old_completed_announcements(grace_days=90) collect_future = ex.submit(collect_all)
delete_future = ex.submit(delete_old_completed_announcements, 90)
collect_future.result()
deleted = delete_future.result()
if deleted: if deleted:
logger.info("정리: %d건 삭제", deleted) logger.info("정리: %d건 삭제", deleted)
run_matching() run_matching()