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

@@ -30,6 +30,47 @@ def _render_semaphore() -> asyncio.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:
"""Prefer config CARD_TEMPLATE_DIR if it exists; else fall back to in-repo templates/."""
if os.path.isdir(CARD_TEMPLATE_DIR):
@@ -97,29 +138,28 @@ async def _render_slate_locked(slate_id: int, template: str) -> List[str]:
out_dir = _slate_dir(slate_id)
paths: List[str] = []
async with async_playwright() as p:
browser = await p.chromium.launch()
try:
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
page = await ctx.new_page()
for spec in pages:
html_str = tmpl.render(**spec)
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
f.write(html_str)
html_path = f.name
browser = await _get_browser()
ctx = await browser.new_context(viewport={"width": 1080, "height": 1350})
try:
page = await ctx.new_page()
for spec in pages:
html_str = tmpl.render(**spec)
with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False, encoding="utf-8") as f:
f.write(html_str)
html_path = f.name
try:
await page.goto(f"file://{html_path}", wait_until="networkidle")
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
await page.screenshot(path=out_path, full_page=False, omit_background=False)
with open(out_path, "rb") as fp:
file_hash = hashlib.md5(fp.read()).hexdigest()
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
paths.append(out_path)
finally:
try:
await page.goto(f"file://{html_path}", wait_until="networkidle")
out_path = os.path.join(out_dir, f"{spec['page_no']:02d}.png")
await page.screenshot(path=out_path, full_page=False, omit_background=False)
with open(out_path, "rb") as fp:
file_hash = hashlib.md5(fp.read()).hexdigest()
db.add_card_asset(slate_id, spec["page_no"], out_path, file_hash)
paths.append(out_path)
finally:
try:
os.unlink(html_path)
except OSError:
pass
finally:
await browser.close()
os.unlink(html_path)
except OSError:
pass
finally:
await ctx.close()
return paths