Playwright를 서버에서 오래 돌리면 메모리가 계속 느는 이유

Playwright를 서버에서 오래

Playwright를 서버에 올려서 오래 돌리다 보면 “이 라이브러리는 원래 메모리가 새는구나”라고 오해하기 쉽습니다. 하지만 실제로는 라이브러리 자체의 결함보다 브라우저·컨텍스트·페이지라는 세 계층의 수명을 어떻게 관리하느냐가 메모리 증가 폭을 결정합니다. Playwright를 서버에서 오래 돌리면 메모리가 계속 늘어나는 대부분의 사례는 browser.close()를 안 해서가 아니라, context를 재사용하거나 방치하는 패턴에서 비롯됩니다.

이 글에서는 어떤 계층에서 메모리가 쌓이는지, 왜 close()를 호출해도 즉시 줄지 않는지, 그리고 실제 서버 코드에서 컨텍스트 수명을 짧게 끊는 구체적인 방법을 다룹니다.

브라우저 · 컨텍스트 · 페이지, 메모리를 쥐고 있는 세 계층

Playwright는 chromium.launch()로 브라우저 프로세스 하나를 띄우고, 그 안에서 browser.newContext()로 격리된 브라우징 세션(쿠키, 캐시, 로컬스토리지가 독립된 공간)을 만듭니다. 각 컨텍스트 안에서 context.newPage()로 실제 탭에 해당하는 페이지를 엽니다.

문제는 이 세 계층이 서로 다른 메모리 영역을 씁니다. 브라우저 프로세스는 크로미움 자체의 네이티브 메모리를, 컨텍스트는 세션별 캐시와 쿠키 저장소를, 페이지는 렌더링된 DOM과 JS 힙을 각각 물고 있습니다. 서버 요청마다 컨텍스트만 계속 새로 만들고 오래된 컨텍스트를 안 닫으면, 브라우저 프로세스 하나에 컨텍스트가 수백 개씩 쌓이는 구조가 됩니다.

context.close()를 호출해도 왜 메모리가 안 줄어들까요?

context.close()를 호출하면 Node.js 쪽에서 해당 컨텍스트 객체와 그 안의 페이지 객체는 참조가 끊기고 V8 가비지 컬렉터 대상이 됩니다. 하지만 이건 어디까지나 Playwright를 구동하는 Node.js 프로세스의 힙 이야기입니다.

실제 렌더링을 담당하는 크로미움은 별도의 OS 프로세스이고, 이 프로세스의 메모리는 리눅스 glibc의 malloc 아레나가 관리합니다. glibc는 해제된 메모리를 즉시 OS에 반환하지 않고 재사용을 위해 프로세스 안에 들고 있는 경우가 흔합니다. 그래서 ps나 docker stats로 확인하는 RSS(Resident Set Size)는 컨텍스트를 다 닫아도 눈에 띄게 줄지 않고 완만하게 우상향하는 그래프를 그리는 경우가 많습니다.

서버 메모리 사용량 모니터링 대시보드 그래프

여기에 더해, 서버 코드에서 세션 추적용으로 activeContexts 같은 배열에 컨텍스트나 페이지 객체를 담아두고 요청이 끝난 뒤 splice로 제거하지 않는 패턴이 실무에서 자주 보입니다. 이러면 close()를 호출해도 배열이 참조를 계속 쥐고 있어서 Node.js 힙조차 정리되지 않습니다.

V8 힙과 크로미움 프로세스 메모리는 따로 놉니다

이 둘을 구분하지 않으면 엉뚱한 곳을 디버깅하게 됩니다. Node.js 쪽 누수인지 확인하려면 process.memoryUsage()를 주기적으로 찍어보는 게 가장 빠릅니다.

setInterval(() => {
  const mem = process.memoryUsage();
  console.log({
    rss: (mem.rss / 1024 / 1024).toFixed(1) + 'MB',
    heapUsed: (mem.heapUsed / 1024 / 1024).toFixed(1) + 'MB',
    external: (mem.external / 1024 / 1024).toFixed(1) + 'MB',
  });
}, 30000);

heapUsed가 요청량과 무관하게 계속 오른다면 Node.js 쪽 객체 참조 누수(배열에 쌓인 컨텍스트, 해제 안 된 이벤트 리스너)를 의심해야 합니다. 반면 heapUsed는 안정적인데 rss나 시스템 전체 메모리(크로미움 프로세스 자체)만 오른다면 브라우저 프로세스 수명 관리 쪽 문제입니다. Playwright 공식 문서의 BrowserContext.close() API 설명에도 컨텍스트를 닫으면 연결된 페이지가 함께 닫힌다고 명시돼 있을 뿐, 브라우저 프로세스 메모리 자체를 즉시 반환한다고는 보장하지 않습니다. 정확한 동작은 Playwright 공식 문서에서 확인할 수 있습니다.

컨텍스트 수명을 요청 단위로 짧게 끊어 보세요

가장 현실적인 해법은 브라우저 프로세스는 오래 유지하되, 컨텍스트는 요청 하나당 하나씩 만들고 반드시 닫은 뒤, 일정 횟수마다 브라우저 자체를 재시작하는 구조입니다. Express 기반 렌더링 서버를 예로 들면 다음과 같습니다.

const { chromium } = require('playwright');

let browser;
let contextCount = 0;
const MAX_CONTEXTS_BEFORE_RESTART = 500;

async function getBrowser() {
  if (!browser) {
    browser = await chromium.launch({ headless: true });
  }
  return browser;
}

app.get('/render', async (req, res) => {
  const b = await getBrowser();
  const context = await b.newContext();
  const page = await context.newPage();

  try {
    await page.goto(req.query.url, { waitUntil: 'networkidle', timeout: 15000 });
    const html = await page.content();
    res.send(html);
  } catch (err) {
    res.status(500).send('render failed');
  } finally {
    await page.close();
    await context.close();

    contextCount += 1;
    if (contextCount >= MAX_CONTEXTS_BEFORE_RESTART) {
      await browser.close();
      browser = null;
      contextCount = 0;
    }
  }
});

finally 블록에서 page.close()와 context.close()를 둘 다 호출하는 게 핵심입니다. 컨텍스트를 닫으면 하위 페이지가 자동으로 닫히긴 하지만, 예외 처리 경로에서 순서가 꼬이는 걸 막기 위해 명시적으로 둘 다 정리하는 편이 안전합니다. 500회라는 재시작 기준은 트래픽과 서버 사양에 따라 조정해야 하는 값이라, 위에서 소개한 process.memoryUsage() 로그로 실제 환경에서 RSS가 얼마나 오르는지 먼저 관찰한 뒤 숫자를 정하는 걸 권합니다.

컨텍스트를 매번 새로 만들면 생기는 비용도 있습니다

헤드리스 브라우저 자동화 프로그래밍 코드

이 방식이 공짜는 아닙니다. browser.newContext()는 완전히 격리된 세션을 새로 초기화하는 작업이라 페이지 하나 재사용하는 것보다 지연이 더해집니다. 트래픽이 초당 수십 건을 넘어가는 서버라면 이 오버헤드가 응답 시간에 그대로 반영됩니다.

브라우저 재시작 시점에 걸린 요청은 재시작이 끝날 때까지 대기하게 되는 점도 짚어야 합니다. 이 순간 지연이 튀는 걸 피하려면 generic-pool 같은 라이브러리로 브라우저 인스턴스를 2~3개 풀로 돌리면서 순번대로 재시작하는 방식이 더 낫습니다. 다만 이건 코드 복잡도가 늘어나는 트레이드오프라, 요청량이 많지 않은 서버라면 위의 단순한 카운터 방식으로 충분한 경우가 많습니다.

또한 이 접근이 안 통하는 상황도 있습니다. 이미 컨텍스트와 페이지를 전부 제대로 닫고 있는데도 RSS가 완만하게 오르다가 특정 수준에서 멈추고 더 이상 안 오른다면, 이는 앞서 설명한 glibc 메모리 아레나 특성일 뿐 실제 누수가 아닙니다. 이 경우엔 재시작 로직을 추가해도 체감 효과가 크지 않고, 오히려 불필요한 지연만 늘어날 수 있습니다.

브라우저를 재시작해도 RSS가 계속 오르는 경우엔 뭘 봐야 하나요?

재시작 로직을 넣었는데도 계속 오른다면 세 가지를 순서대로 확인하는 걸 권합니다.

첫 번째는 좀비 프로세스입니다. browser.close() 호출 중 타임아웃이나 예외로 크로미움 프로세스가 부모-자식 관계에서 끊긴 채 남는 경우가 있습니다. ps aux | grep chrome으로 close() 이후에도 프로세스가 남아 있는지 확인해 보세요.

두 번째는 Docker 환경의 /dev/shm 크기입니다. 컨테이너 기본값(64MB)이 작으면 크로미움이 공유 메모리 부족으로 크래시하거나 비정상 종료되는데, 이게 표면적으로는 메모리 문제처럼 보일 수 있습니다. --shm-size=1g 옵션으로 컨테이너를 띄우거나 launch 옵션에 args: ['--disable-dev-shm-usage']를 넣는 게 일반적인 대응입니다. 이 항목은 리눅스 커널의 공유 메모리 자체가 원인이라, 자세한 개념은 메모리 누수 문서에서 일반적인 정의를 참고할 수 있습니다.

세 번째는 page.on()으로 등록한 이벤트 리스너입니다. page.on('response', handler)처럼 콜백을 등록하고 page.close() 전에 page.off()나 removeAllListeners()로 정리하지 않으면, 클로저가 캡처한 큰 객체(요청 본문, 응답 버퍼 등)가 리스너와 함께 살아있을 수 있습니다. 페이지를 닫기 직전에 page.removeAllListeners()를 호출하는 습관을 들이면 이 경로로 인한 누수는 예방됩니다.

Playwright를 서버에서 오래 돌리면 메모리가 계속 느는 문제는 대부분 “안 닫아서”가 아니라 “무엇을, 언제, 얼마나 자주 닫을지”를 설계하지 않아서 생깁니다. 오늘 바로 할 수 있는 일은 두 가지입니다. 지금 서버 코드에서 컨텍스트를 요청 단위로 열고 닫는지 finally 블록을 확인하고, process.memoryUsage() 로그를 30초 간격으로 남겨서 실제 증가 패턴을 먼저 눈으로 확인해 보세요.

서브프로세스가 좀비로 남을 때, async generator를 안 닫으면 벌어지는 일

Leave a Comment