← cd ..
JobRadarcheerio스크래핑Vercel

JobRadar 4편: Playwright 버리고 cheerio로 갈아탄 이유 — 4일차 방향 전환기

April 23, 20261 min read

3편까지의 이야기를 한 줄로 요약하면 이렇다.

Playwright + Vercel = 3일 동안 에러만 봤다.

@sparticuz/chromium으로 겨우 배포에 성공했는데, 막상 긁어온 공고를 확인하니 봇 감지를 완전히 피하지 못했다. 그리고 더 근본적인 문제가 있었다.

매일 Cron으로 대량 수집해봤자, 내가 관심 없는 공고가 대부분이었다.

4일차에 접어들면서 생각을 바꿨다.


왜 바꿨나

봇 차단 — 지쳐가는 싸움

Playwright로 headless Chrome을 띄워도 Cloudflare나 자체 봇 감지에 걸리는 경우가 잦았다. User-Agent 바꾸고, stealth 수동 설정하고, 딜레이 넣고... 이걸 반복하다 보면 이걸 왜 하고 있나 싶어진다.

Vercel Lambda — Playwright가 살기 너무 힘든 환경

3편에서 겪은 문제들만 정리해도 이렇다:

문제내용
번들 크기Playwright + Chromium은 수백 MB
실행 시간브라우저 기동 + 스크래핑이 60초 초과하기 쉬움
ETXTBSYLambda에서 Chromium 바이너리 동시 실행 충돌
봇 감지stealth 제거 후 완전한 대응 어려움

결론

"자동 수집"의 낭만을 버리기로 했다.

생각해보면 — 내가 실제로 지원하고 싶은 공고는 내가 직접 보고 판단한다. 랜덤으로 수집된 100개보다 내가 고른 10개가 훨씬 낫다.

기존: Cron → Playwright → 대량 수집 → DB
변경: 사용자 URL 입력 → fetch + cheerio → 단건 스크래핑 → DB

on-demand 방식으로 전환. 서버 부하도 없고, Lambda 제약도 없다.


구현 개요

[URL 입력창] → addJobByUrl() → jobs 테이블에 저장
                                     ↓
                           /api/scrape-url 자동 호출
                                     ↓
                    플랫폼 감지 → 전용 스크래퍼 실행
                                     ↓
                           title, company, description 등 업데이트

Step 1 — 플랫폼 자동 감지 유틸

URL만 보고 어느 사이트인지 알아야 스크래퍼를 분기할 수 있다.

// src/lib/detect-platform.ts
export type Platform = 'seek' | 'indeed' | 'linkedin' | 'other'

const PLATFORM_PATTERNS: { platform: Platform; pattern: RegExp }[] = [
  { platform: 'seek',     pattern: /seek\.com\.au/i },
  { platform: 'indeed',  pattern: /indeed\.com/i },
  { platform: 'linkedin', pattern: /linkedin\.com\/jobs/i },
]

export function detectPlatform(url: string): Platform {
  for (const { platform, pattern } of PLATFORM_PATTERNS) {
    if (pattern.test(url)) return platform
  }
  return 'other'
}

export const PLATFORM_STYLE: Record<Platform, { label: string; className: string }> = {
  seek:     { label: 'Seek',     className: 'bg-blue-100 text-blue-700' },
  indeed:   { label: 'Indeed',   className: 'bg-orange-100 text-orange-700' },
  linkedin: { label: 'LinkedIn', className: 'bg-sky-100 text-sky-700' },
  other:    { label: 'Other',    className: 'bg-zinc-100 text-zinc-500' },
}

플랫폼별 뱃지 색상까지 같이 정의해 뒀다. UI에서 PLATFORM_STYLE[source].className만 꺼내 쓰면 된다.


Step 2 — URL 입력 폼 (AddJobForm)

잡 목록 상단에 URL 입력창을 붙였다. 붙여넣고 추가 누르면 끝.

'use client'

export default function AddJobForm() {
  const [loading, setLoading] = useState(false)
  const [url, setUrl] = useState('')

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setLoading(true)
    const fd = new FormData()
    fd.append('url', url)
    const result = await addJobByUrl(fd)

    if (result.error) {
      setLoading(false)
      return
    }

    // DB 저장 후 즉시 스크래핑 트리거
    if (result.jobId) {
      await fetch('/api/scrape-url', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ jobId: result.jobId }),
      })
    }

    setLoading(false)
    setUrl('')
  }

  return (
    <form onSubmit={handleSubmit} className="flex gap-2 mb-6">
      <input
        type="url"
        value={url}
        onChange={e => setUrl(e.target.value)}
        placeholder="채용공고 URL 붙여넣기 (Seek, Indeed, LinkedIn...)"
        disabled={loading}
      />
      <button type="submit" disabled={loading || !url}>
        {loading ? '추가 중...' : '추가'}
      </button>
    </form>
  )
}

저장(addJobByUrl)과 스크래핑(/api/scrape-url)을 분리한 이유: 저장은 빠르게 하고, 스크래핑은 실패해도 나중에 재시도할 수 있게.


Step 3 — Seek 스크래퍼: __NEXT_DATA__ 파싱

Seek은 Next.js로 만들어져 있어서 페이지 HTML 안에 <script id="__NEXT_DATA__"> 태그가 있다. 서버사이드 props가 JSON으로 통째로 들어있어서, 이걸 파싱하면 DOM 셀렉터 없이 깔끔하게 데이터를 꺼낼 수 있다.

export async function scrapeSeekUrl(url: string): Promise<ScrapedJob> {
  const res = await fetch(url, {
    headers: {
      'User-Agent': 'Mozilla/5.0 ... Chrome/120.0.0.0 Safari/537.36',
      'Accept-Language': 'en-AU,en;q=0.9',
    },
  })
  const html = await res.text()
  const $ = cheerio.load(html)

  // 1순위: __NEXT_DATA__ JSON 파싱
  const nextDataRaw = $('#__NEXT_DATA__').text()
  if (nextDataRaw) {
    const nextData = JSON.parse(nextDataRaw)
    const job = nextData?.props?.pageProps?.jobDetails?.job
      ?? nextData?.props?.pageProps?.job
      ?? nextData?.props?.pageProps?.jobViewDetails

    if (job) {
      return {
        title:       job.title ?? job.header?.jobTitle ?? '',
        company:     job.advertiser?.description ?? job.companyName ?? '',
        location:    job.location ?? job.locationLabel ?? '',
        description: job.content ?? job.description ?? '',
        posted_at:   job.listingDate ?? null,
      }
    }
  }

  // 2순위: cheerio fallback
  const title = $('h1[data-automation="job-detail-title"]').text() || $('h1').first().text()
  // ...
}

?? 체이닝이 많은 이유: Seek의 JSON 구조가 공고 유형에 따라 다르기 때문이다. 어느 경로에 데이터가 있는지 공고마다 달라서 다 체크해야 한다.


Step 4 — Indeed 스크래퍼: JSON-LD 구조화 데이터

Indeed는 <script type="application/ld+json"> 태그에 JobPosting 스키마로 데이터를 넣어둔다. Google SEO 목적으로 넣어둔 걸 그대로 활용하는 거다.

export async function scrapeIndeedUrl(url: string): Promise<ScrapedJob> {
  const $ = cheerio.load(html)

  let ldJob: ScrapedJob | null = null
  $('script[type="application/ld+json"]').each((_, el) => {
    if (ldJob) return
    try {
      const data = JSON.parse($(el).text())
      if (data['@type'] === 'JobPosting') {
        ldJob = {
          title:       data.title ?? '',
          company:     data.hiringOrganization?.name ?? '',
          location:    data.jobLocation?.address?.addressLocality ?? '',
          description: data.description ?? '',
          posted_at:   data.datePosted ?? null,
        }
      }
    } catch { /* JSON 파싱 실패 시 다음으로 */ }
  })
  if (ldJob) return ldJob

  // 2순위: cheerio fallback
}

사이트 UI가 바뀌어도 구조화 데이터는 유지되는 경우가 많아서 DOM 파싱보다 훨씬 안정적이다.


Step 5 — Generic 스크래퍼: 단계적 fallback

LinkedIn이나 그 외 사이트는 하나의 Generic 스크래퍼로 처리한다. JSON-LD → Open Graph → meta 태그 순서로 시도한다.

export async function scrapeGenericUrl(url: string): Promise<ScrapedJob> {
  const $ = cheerio.load(html)

  // 1순위: JSON-LD JobPosting
  // ...

  // 2순위: Open Graph 메타 태그
  const ogTitle = $('meta[property="og:title"]').attr('content') ?? ''
  const ogDesc  = $('meta[property="og:description"]').attr('content')
    ?? $('meta[name="description"]').attr('content') ?? ''

  // description은 페이지에서 가장 긴 텍스트 블록으로
  let description = ogDesc
  if (!description) {
    let maxLen = 0
    $('div, article, section').each((_, el) => {
      const text = $(el).text().trim()
      if (text.length > maxLen) { maxLen = text.length; description = text }
    })
  }

  return {
    title: ogTitle.trim(),
    description: description.slice(0, 5000),
    // ...
  }
}

Step 6 — /api/scrape-url API Route

export const maxDuration = 30  // Playwright 기반 300초에서 30초로 줄었다

export async function POST(request: Request) {
  const { jobId } = await request.json()
  const { data: job } = await supabaseAdmin
    .from('jobs').select('id, url, source').eq('id', jobId).single()

  try {
    const source = job.source as Platform
    const scraped =
      source === 'seek'   ? await scrapeSeekUrl(job.url) :
      source === 'indeed' ? await scrapeIndeedUrl(job.url) :
                            await scrapeGenericUrl(job.url)

    await supabaseAdmin.from('jobs').update({
      title:       scraped.title,
      company:     scraped.company,
      description: scraped.description,
      scraped_at:  new Date().toISOString(),
    }).eq('id', job.id)

    return NextResponse.json({ ok: true, title: scraped.title })
  } catch (e) {
    await supabaseAdmin.from('jobs').update({ title: '스크래핑 실패' }).eq('id', job.id)
    return NextResponse.json({ error: String(e) }, { status: 500 })
  }
}

maxDuration = 30으로 설정했는데, fetch + cheerio는 대부분 3~5초 안에 끝난다. 3편에서 300초를 써도 불안하던 것과 비교하면 완전히 딴 세상이다.


플랫폼별 전략 요약

플랫폼1순위 전략2순위 fallback
Seek__NEXT_DATA__ JSON 파싱cheerio DOM 셀렉터
IndeedJSON-LD (JobPosting 스키마)cheerio DOM 셀렉터
LinkedIn / OtherJSON-LD (JobPosting 스키마)Open Graph / meta 태그

트러블슈팅

fetch가 403을 반환하는 경우

User-Agent를 실제 브라우저처럼 설정하는 것만으로 상당수 해결된다.

headers: {
  'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...',
  'Accept-Language': 'en-AU,en;q=0.9',
}

__NEXT_DATA__ 경로가 달라지는 경우

Seek의 JSON 구조는 공고 유형에 따라 달라진다. ?? 체이닝으로 여러 경로를 커버하는 것 외에 방법이 없다.

JSON-LD 파싱 실패

JSON.parsetry-catch로 감싸고, 실패하면 다음 전략으로 넘어가게 했다. JSON-LD가 있어도 불완전한 경우가 있어서 파싱 성공 + 필수 필드 체크까지 같이 해줘야 한다.


정리 — 핵심 흐름 한눈에

사용자 URL 붙여넣기
    ↓
detectPlatform(url) → 'seek' | 'indeed' | 'other'
    ↓
DB에 저장 (jobId 반환)
    ↓
POST /api/scrape-url { jobId }
    ↓
플랫폼별 스크래퍼 실행
  Seek:    __NEXT_DATA__ → cheerio
  Indeed:  JSON-LD → cheerio
  Generic: JSON-LD → Open Graph → meta
    ↓
DB 업데이트 (title, company, description ...)

3일 동안 Playwright와 씨름하다가 방향을 바꿨는데, 결과적으로 코드가 더 단순해지고 Vercel에서도 아무 문제 없이 돌아간다.

자동 수집이 더 멋있어 보이지만, 실용성은 반대인 경우도 있다. 내가 직접 고른 공고 10개가 자동 수집된 100개보다 훨씬 유용하다는 걸 4일차에 깨달았다.

다음 단계는 이렇게 쌓인 공고들을 Claude API로 매칭하는 것이다. 5편에서 계속.

PM

backtodev

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

JobRadar 4편: Playwright 버리고 cheerio로 갈아탄 이유 — 4일차 방향 전환기 | backtodev