← cd ..
JobRadarPlaywrightVercel서버리스

JobRadar 3편: Vercel에 Playwright 올렸더니 터졌다 — @sparticuz/chromium 삽질기

April 22, 20261 min read

2편에서 Playwright 스크래퍼 코드가 완성됐다. 로컬에서 실행해보니 Seek 공고가 잘 긁혔다. 이제 Vercel에 올리기만 하면 된다.

그렇게 생각했다.

"Vercel에만 올리면 터진다."

이 한 문장이 그날 하루를 통째로 설명한다.


전체 삽질 타임라인

playwright 패키지 사용
  → 빌드 에러 (dynamic import 필요)
  → 모듈 누락 (outputFileTracingIncludes)
  → playwright-extra transitive deps 지옥
  → ETXTBSY (동시 실행 시 바이너리 충돌)
  → playwright-extra 전면 제거
  → @sparticuz/chromium + playwright-core 조합으로 교체
  → 봇 감지 우회 (수동 stealth)
  → 60초 타임아웃 대응 (SCRAPE_TARGET_LIMIT)
  → Seek 38개 공고 저장 성공

사전 준비

  • Next.js 14 App Router 프로젝트 (1편에서 세팅 완료)
  • Playwright 스크래퍼 코드 (2편에서 구현 완료)
  • Vercel 배포 환경 (Hobby 플랜)

최종 패키지 구성:

npm install playwright-core @sparticuz/chromium
npm uninstall playwright playwright-extra puppeteer-extra-plugin-stealth

Step 1 — 처음엔 그냥 playwright 올렸다가 빌드 에러

로컬에서 잘 되니까 그냥 playwright를 그대로 배포했다.

에러 1: 최상단 import 실패

Next.js App Router에서 서버에서만 동작하는 무거운 모듈을 최상단에서 import하면 빌드 단계에서 터진다.

// 이렇게 하면 안 됨
import { scrapeSeek } from '@/lib/scrapers/seek'

// 이렇게 해야 함
const { scrapeSeek } = await import('@/lib/scrapers/seek')

에러 2: serverExternalPackages 설정 필요

// next.config.ts
const nextConfig: NextConfig = {
  serverExternalPackages: ['playwright', 'playwright-core'],
}

이 설정이 없으면 Next.js가 playwright를 번들링하려다 실패한다.


Step 2 — 모듈 누락 지옥

빌드는 됐는데 런타임에서 모듈을 못 찾는다는 에러가 쏟아졌다.

Error: Cannot find module 'lazy-cache'
Error: Cannot find module 'is-plain-object'

Vercel은 배포할 때 실제로 사용되는 파일만 Lambda 패키지에 포함시킨다 (File Tracing). 동적으로 로드되는 모듈은 이 과정에서 빠져버린다.

outputFileTracingIncludes로 수동 명시를 시도했다:

outputFileTracingIncludes: {
  '/api/scrape': [
    './node_modules/is-plain-object/**',
    './node_modules/clone-deep/**',
    './node_modules/merge-deep/**',
    './node_modules/lazy-cache/**',
    // ...
  ],
},

여기까지 와도 새로운 모듈 누락 에러가 계속 나왔다. playwright-extra의 플러그인 시스템이 런타임에 동적으로 로드하는 의존성이 끝이 없었다.

이쯤에서 판단했다. playwright-extra 자체를 버리자.


Step 3 — @sparticuz/chromium + playwright-core로 교체

Vercel Lambda에서 Playwright를 쓰는 표준 방법이 있었다. @sparticuz/chromium — Lambda 환경에서 실행 가능하도록 최적화된 Chromium 바이너리를 제공한다.

npm install playwright-core @sparticuz/chromium
npm uninstall playwright playwright-extra puppeteer-extra-plugin-stealth

next.config.ts가 대폭 간소화됐다:

const nextConfig: NextConfig = {
  serverExternalPackages: ['@sparticuz/chromium', 'playwright-core'],
  outputFileTracingIncludes: {
    '/api/scrape': [
      './node_modules/@sparticuz/chromium/**',
      './node_modules/playwright-core/**',
    ],
  },
}

모듈 목록이 7개에서 2개로 줄었다.

스크래퍼 코드에서 환경에 따라 Chromium 실행 방식을 분기한다:

import { chromium } from 'playwright-core'
import chromiumBin from '@sparticuz/chromium'

const isVercel = !!process.env.VERCEL

const browser = await chromium.launch({
  args: isVercel ? chromiumBin.args : [],
  executablePath: isVercel ? await chromiumBin.executablePath() : undefined,
  headless: true,
})
  • 로컬: executablePath 없이 → playwright-core가 알아서 찾음
  • Vercel: @sparticuz/chromium의 바이너리 경로와 최적화 args 사용

Step 4 — ETXTBSY 해결

두 스크래퍼(Indeed + Seek)를 Promise.allSettled로 동시 실행했더니:

ETXTBSY: text file busy, open '/tmp/chromium'

Lambda 환경의 /tmp에 Chromium 바이너리가 압축 해제되는데, 두 프로세스가 동시에 같은 파일을 쓰려다 충돌한 것이다.

// 이전: 동시 실행 (ETXTBSY 발생)
const [indeed, seek] = await Promise.allSettled([scrapeIndeed(), scrapeSeek()])

// 이후: 순차 실행
const indeedResult = await scrapeIndeed().catch((e: unknown) => ({ error: String(e) }))
const seekResult = await scrapeSeek().catch((e: unknown) => ({ error: String(e) }))

성능은 약간 손해지만, Lambda 환경에서는 선택의 여지가 없다.


Step 5 — 봇 감지 우회 (수동 stealth)

playwright-extra를 버렸으니 stealth 플러그인도 없어졌다. 수동으로 처리한다.

args: [
  ...(isVercel ? chromiumBin.args : []),
  '--disable-blink-features=AutomationControlled',
],
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'

await page.setExtraHTTPHeaders({ 'User-Agent': UA })

// navigator.webdriver = true 이면 봇으로 인식됨
await page.addInitScript(() => {
  Object.defineProperty(navigator, 'webdriver', { get: () => false })
})

Step 6 — 60초 타임아웃 대응

Vercel Hobby 플랜의 서버리스 함수 실행 시간 한도는 60초다. 스크래핑 타겟이 많으면 그냥 타임아웃으로 잘린다.

환경변수로 타겟 수를 제한할 수 있게 만들었다:

const limit = parseInt(process.env.SCRAPE_TARGET_LIMIT ?? '2')
return targets.slice(0, limit)
환경변수설명
SCRAPE_TARGET_LIMIT2기본값, 60초 안전권
SCRAPE_TARGET_LIMIT5여유 있게 실행하고 싶을 때

Step 7 — Vercel 리전 시드니로 변경

호주 잡보드를 긁는 거라 미국 리전에서 요청하면 차단될 가능성이 있다.

// vercel.json
{
  "regions": ["syd1"],
  "crons": [...]
}

트러블슈팅 요약

에러원인해결
빌드 에러최상단 static importawait import() 동적 import
Cannot find moduleFile Tracing 누락playwright-extra 제거
ETXTBSY/tmp 바이너리 동시 쓰기순차 실행
봇 감지 차단stealth 플러그인 없음수동 UA + webdriver override
60초 타임아웃타겟 수 과다SCRAPE_TARGET_LIMIT 환경변수

정리 — 최종 구성 한눈에

패키지:
  playwright-core + @sparticuz/chromium

next.config.ts:
  serverExternalPackages: ['@sparticuz/chromium', 'playwright-core']
  outputFileTracingIncludes: { '/api/scrape': [...] }

스크래퍼 실행 흐름:
  1. VERCEL 환경변수 체크
  2. @sparticuz/chromium의 executablePath + args 사용
  3. 수동 stealth (UA 헤더, webdriver override)
  4. Indeed → Seek 순차 실행 (ETXTBSY 방지)
  5. SCRAPE_TARGET_LIMIT으로 타임아웃 제어

vercel.json:
  regions: ["syd1"]

@sparticuz/chromium으로 교체하고 나서는 구성이 오히려 더 단순해졌다. playwright-extra가 편리하긴 하지만, Lambda 환경에서는 의존성 복잡도가 발목을 잡는다.

결과적으로 Seek에서 38개 공고를 Supabase에 저장하는 데 성공했다.

근데 기쁨도 잠깐이었다. inserted: 38은 됐는데, 실제로 확인해보니 공고 내용이 엉망이었다. 봇 감지를 완전히 피하지 못한 거다. 그리고 매일 Cron으로 긁어봤자 관심 없는 공고가 대부분이라는 것도 느꼈다.

이걸 어떻게 해결했는지는 4편에서 다룬다.

PM

backtodev

40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.

JobRadar 3편: Vercel에 Playwright 올렸더니 터졌다 — @sparticuz/chromium 삽질기 | backtodev