헤드리스 Playwright 메모리 최적화 및 누수 방지
업데이트: 2026-10-02
Playwright를 사용해 1회성 스크립트를 실행할 때는 메모리 문제를 체감하기 어렵습니다. 하지만 수백 개 쇼핑몰 상품 페이지를 매 5분마다 24시간 내내 순회하는 크롤러를 운영하다 보면, 처음에는 150MB 수준이던 메모리가 서서히 우상향하여 반나절 만에 1GB를 돌파하고 결국 메모리 초과로 프로세스가 강제 종료되는 현상을 마주하게 됩니다.
Chromium 브라우저 엔진의 내부 캐시 축적 원리를 이해하고, 24시간 가동해도 메모리 사용량이 일정하게 유지되도록 통제하는 4대 엔지니어링 기법을 상세히 다룹니다.
Chromium이 장시간 실행 시 메모리를 누수시키는 원인
- V8 자바스크립트 엔진의 분리된 DOM 노드 보관: 페이지 이동 시 이전 페이지의 복잡한 자바스크립트 이벤트 리스너와 DOM 트리 잔여물이 가비지 컬렉션되지 않고 힙에 남아 누적됩니다.
- 내부 이미지 디코딩 및 렌더링 캐시: 브라우저는 빠른 재렌더링을 위해 내려받은 그래픽 리소스와 폰트 글리프를 내부 메모리에 캐싱하며, 이 캐시는 일반적인 방법으로 비워지지 않습니다.
- GPU 프로세스 및 IPC 통신 파편화: 헤드리스 모드라 하더라도 내부적으로 소프트웨어 GL 렌더러가 작동하며 프로세스 간 통신 파이프에 버퍼가 쌓입니다.
최적화 기법 1: 브라우저 재사용 + 컨텍스트 수집 주기별 격리
브라우저 자체(browser = launch())를 띄우는 작업은 1~2초의 CPU 오버헤드가 발생하므로 매번 띄우기엔 부담스럽습니다.
가장 이상적인 프로덕션 패턴은 브라우저는 단 1회 띄워두고, 매 수집 루프마다 새로운 컨텍스트(browser.new_context())를 생성한 뒤 작업 직후 finally 블록에서 즉시 닫는 것입니다.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
# 1. 브라우저 인스턴스는 1개만 실행
browser = p.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage"]
)
while True:
# 2. [핵심] 수집 작업마다 독립된 가상 브라우저 컨텍스트 생성
context = browser.new_context()
page = context.new_page()
try:
page.goto("https://target-site.com", timeout=30000)
data = extract_data(page)
save_to_database(data)
except Exception as e:
logger.error(f"수집 실패: {e}")
finally:
# 3. [핵심] 컨텍스트를 닫는 순간 해당 세션의 모든 DOM 힙과 캐시가 100% 즉시 증발
context.close()page = context.new_page()를 루프 바깥에 하나만 만들어두고 page.goto()를 수백 번 반복하는 코드는 최악의 안티패턴입니다. 수집 페이지가 바뀔 때마다 메모리가 수십 MB씩 영구 누적되어 몇 시간 안에 서버가 다운됩니다.
최적화 기법 2: 네트워크 레벨 리소스 차단 (대역폭 80% 절감)
크롤러가 필요로 하는 것은 웹페이지의 텍스트 데이터(가격, 제목, 재고 수량)이지 화려한 배너 이미지나 동영상, 광고 트래커가 아닙니다.
Playwright의 page.route() 기능을 활용해 불필요한 리소스 요청을 네트워크 진입 단계에서 강제로 차단(route.abort())합니다.
def block_unnecessary_resources(page):
"""이미지, 미디어, 웹폰트 다운로드를 네트워크 단에서 즉각 거절"""
def route_filter(route):
# 차단할 리소스 타입 정의
if route.request.resource_type in ["image", "media", "font"]:
route.abort()
# 구글 애널리틱스 등 불필요한 외부 추적기 차단
elif any(domain in route.request.url for domain in ["google-analytics.com", "doubleclick.net", "facebook.net"]):
route.abort()
else:
route.continue_()
page.route("**/*", route_filter)- 메모리 절감 효과: 이미지 렌더링을 위한 버퍼 메모리가 생성되지 않아 1개 페이지당 메모리 점유율이 60~70% 감소합니다.
- 수집 속도 향상: 대용량 이미지 다운로드 대기 시간이 사라져
page.goto()완료 시간이 2.5초에서 0.4초로 단축됩니다.
최적화 기법 3: 프로세스 주기적 자동 재활용
Chromium 자체의 C++ 엔진 레벨 메모리 파편화는 파이썬 코드 레벨에서 100% 완벽히 제거하기 어렵습니다.
실전 프로덕션 크롤러에서 가장 안정적인 설계는 수집 작업을 500회 완료했거나 가동 후 12시간이 경과하면 프로세스가 스스로 정상 종료(os._exit(0))하고, DashCloud의 무중단 데몬 감시자가 즉시 깨끗한 새 프로세스로 재기동하도록 맡기는 것입니다.
import os
import time
import psutil
import logging
logger = logging.getLogger("RecycleWorker")
MAX_TASKS_PER_PROCESS = 500 # 500회 작업 후 자율 재시작
MAX_UPTIME_SECONDS = 12 * 3600 # 12시간 가동 후 자율 재시작
MAX_MEMORY_MB = 400 # 메모리가 400MB를 넘으면 자율 재시작
def get_current_memory_mb():
"""현재 프로세스의 실제 물리 메모리 RSS 점유량 측정"""
process = psutil.Process(os.getpid())
return process.memory_info().rss / (1024 * 1024)
def check_lifecycle_and_recycle(task_count, start_time):
current_mem = get_current_memory_mb()
uptime = time.time() - start_time
if task_count >= MAX_TASKS_PER_PROCESS or uptime >= MAX_UPTIME_SECONDS or current_mem >= MAX_MEMORY_MB:
logger.info(
f"자율 재활용 임계치 도달 (작업: {task_count}회, 가동: {int(uptime)}초, 메모리: {current_mem:.1f}MB). "
"프로세스를 정상 종료하여 메모리를 100% 초기화합니다."
)
# DashCloud 런타임이 프로세스 종료를 감지하면 깨끗한 컨테이너로 자동 재기동함
os._exit(0)최적화 기법 4: 완성형 무중단 크롤러 보일러플레이트
위의 3가지 최적화 기법이 모두 결합된 프로덕션급 파이썬 크롤러 템플릿입니다.
import os
import time
import logging
from playwright.sync_api import sync_playwright
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("CrawlerEngine")
def run_crawler_pipeline():
start_time = time.time()
task_count = 0
with sync_playwright() as p:
logger.info("Chromium 엔진 기동...")
browser = p.chromium.launch(
headless=True,
args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-extensions",
]
)
while True:
# 1. 독립 컨텍스트 생성
context = browser.new_context(viewport={"width": 1280, "height": 720})
page = context.new_page()
# 2. 이미지/미디어 네트워크 차단
page.route(
"**/*",
lambda route: route.abort()
if route.request.resource_type in ["image", "media", "font"]
else route.continue_()
)
try:
# 3. 데이터 수집 실행
page.goto("https://news.ycombinator.com", timeout=20000, wait_until="domcontentloaded")
title = page.title()
task_count += 1
logger.info(f"[{task_count}회차] 수집 성공: {title}")
except Exception as e:
logger.error(f"수집 예외 발생: {e}")
finally:
# 4. 컨텍스트 및 페이지 메모리 즉시 회수
context.close()
# 5. 자율 재시작 체크 (12시간 경과 또는 500회 수집)
if task_count >= 500 or (time.time() - start_time) > 43200:
logger.info("메모리 완전 초기화를 위해 프로세스를 정상 종료하고 재기동합니다.")
browser.close()
os._exit(0)
# 다음 수집 주기까지 대기 (예: 30초)
time.sleep(30)
if __name__ == "__main__":
run_crawler_pipeline()