웹스크래핑fetchNext.jsVercelcheerio

로컬에선 되는데 서버에선 403 — 채용공고 스크래핑 봇 차단과 싸우기

June 23, 20261 min read🌐 Only Korean available

들어가며: "내 컴퓨터에선 되는데요?"

JobRadar는 채용공고 URL을 붙여넣으면 제목·회사·설명을 자동으로 긁어온다(스크래핑). 그런데 어느 날 목록에 이런 카드가 떠 있었다.

스크래핑 실패
회사명 없음
오류: Error: Fetch failed: 403

403 Forbidden. "넌 접근 금지야"라는 뜻이다. 문제의 URL은 독일 회사 trivago의 채용 페이지(careers.trivago.com)였다. 이상한 건, 내 노트북에서 똑같이 긁으면 잘 됐다는 것이다. 개발하다 보면 가장 골치 아픈 유형의 버그다 — "로컬에선 되는데 서버에선 안 되는" 버그.

이 글은 그 403의 정체를 추적하고, 코드로 대응한 과정을 정리한 것이다. 결론부터 말하면 원인은 봇 차단(Akamai) 이었고, 추적 과정에서 진짜 데이터를 망가뜨리던 별개의 버그도 하나 발견했다.

Step 1. 진짜 원인부터 좁히기

"403이니까 헤더를 바꿔보자"는 건 너무 성급한 접근이다. 먼저 403이 나는지를 봐야 한다. 똑같은 요청을 헤더만 바꿔가며 curl로 던져봤다.

URL="https://careers.trivago.com/job/r8424124002/?gh_src=..."

# A) 기존 스크래퍼와 동일한 단순 헤더
curl -s -o /dev/null -w "status=%{http_code}\n" \
  -H 'User-Agent: Mozilla/5.0 ... Chrome/120 ...' \
  -H 'Accept: text/html,...' "$URL"
# → status=200

# 응답 헤더에 뭐가 들어있나?
curl -s -D - -o /dev/null "$URL" | grep -iE "server:|set-cookie"

여기서 결정적인 단서가 나왔다. 응답에 이런 쿠키가 들려 있었다.

set-cookie: ak_bmsc=...
set-cookie: bm_mi=...

ak_bmsc, bm_mi — 이건 Akamai Bot Manager가 심는 쿠키다. 즉 이 사이트는 Akamai라는 봇 차단 솔루션 뒤에 있다. 그리고 Akamai 같은 봇 차단은 흔히 이렇게 동작한다.

  • 주거용 IP(내 집 인터넷)에서 온 요청 → 사람일 가능성 높음 → 통과(200)
  • 데이터센터 IP(Vercel, AWS 등 서버)에서 온 요청 → 봇일 가능성 높음 → 차단(403)

JobRadar는 Vercel에 배포돼 있다. 즉 스크래핑 요청이 Vercel의 데이터센터 IP에서 나가니까, Akamai가 "어 너 봇이지?" 하고 403을 때린 것이다. 내 노트북(주거용 IP)에선 통과했고. "로컬에선 되는데 서버에선 안 되는" 미스터리의 정답이었다.

교훈: 403이 떴을 때 응답 쿠키/헤더를 먼저 봐라. ak_(Akamai), __cf_(Cloudflare) 같은 접두사가 보이면 단순 권한 문제가 아니라 봇 차단이다. 대응 방법이 완전히 달라진다.

Step 2. 핵심 깨달음 — 이건 "간헐적" 문제다

더 파보니 흥미로운 사실이 있었다. trivago 공고가 이미 한 번은 정상적으로 수집된 적이 있었다. DB에 멀쩡한 설명이 들어 있었다. 같은 URL인데 어떤 날은 200, 어떤 날은 403.

이게 봇 차단의 특성이다. IP 평판, 요청 타이밍, 트래픽 패턴 등을 종합해서 확률적으로 막는다. 항상 막는 게 아니라 간헐적으로 막는다. 실제로 내가 디버깅하는 동안 "실패한 공고"를 다시 시도하니 곧바로 성공했다.

여기서 대응 전략이 정해졌다.

  1. 간헐적 403은 재시도로 흡수한다. 한 번 막혀도 잠깐 뒤 다시 하면 통과할 확률이 높으니까.
  2. 헤더를 더 진짜 브라우저처럼 보이게 한다. 차단당할 확률 자체를 조금이라도 낮춘다.
  3. (그리고 곧 발견할) 실패가 기존 데이터를 망가뜨리지 않게 한다.

Step 3. 재시도 + 헤더 보강 헬퍼 만들기

기존 스크래퍼들은 각자 fetch를 직접 호출하고, 단순한 헤더에, 실패하면 그냥 한 번에 throw했다.

// 기존: 한 방에 실패
const res = await fetch(url, {
  headers: {
    'User-Agent': 'Mozilla/5.0 ... Chrome/120 ...',
    'Accept': 'text/html,...',
  },
})
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`)

이걸 공통 헬퍼 fetchHtml로 묶고, 브라우저 유사 헤더 + 재시도를 넣었다.

const BROWSER_HEADERS: Record<string, string> = {
  'User-Agent': 'Mozilla/5.0 (Macintosh; ...) Chrome/126.0.0.0 Safari/537.36',
  'Accept': 'text/html,application/xhtml+xml,...,image/avif,image/webp,*/*;q=0.8',
  'Accept-Language': 'en-US,en;q=0.9',
  'Sec-Fetch-Dest': 'document',
  'Sec-Fetch-Mode': 'navigate',
  'Sec-Fetch-Site': 'none',
  'Sec-Fetch-User': '?1',
  'Upgrade-Insecure-Requests': '1',
}

// 재시도 대상이 되는 '일시적' 상태 코드
const RETRYABLE = new Set([403, 429, 500, 502, 503, 504])
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))

export async function fetchHtml(url: string, opts = {}): Promise<string> {
  const { label = 'Fetch', acceptLanguage, retries = 2 } = opts
  const headers = acceptLanguage
    ? { ...BROWSER_HEADERS, 'Accept-Language': acceptLanguage }
    : BROWSER_HEADERS

  let lastError = `${label} failed`

  for (let attempt = 0; attempt <= retries; attempt++) {
    if (attempt > 0) await sleep(400 * 2 ** (attempt - 1)) // 400ms → 800ms ...

    let res: Response
    try {
      res = await fetch(url, { headers })
    } catch (e) {
      lastError = `${label} fetch failed: ${String(e)}`
      continue // 네트워크 오류 → 재시도
    }

    if (res.ok) return res.text()

    lastError = `Fetch failed: ${res.status}`
    // 404 같은 '영구적' 오류는 즉시 포기, 일시적 차단만 재시도
    if (!RETRYABLE.has(res.status)) break
  }

  throw new Error(lastError)
}

설계할 때 신경 쓴 포인트 세 가지.

포인트이유
지수 백오프(400ms → 800ms)곧바로 재시도하면 똑같이 차단된다. 잠깐 텀을 둬야 통과 확률이 오른다
재시도할 코드 구분403/429/5xx만 재시도. 404(없는 페이지)는 몇 번을 해도 없으니 즉시 포기
Sec-Fetch-*, Accept-Language실제 브라우저가 항상 보내는 헤더. 이게 없으면 봇 티가 난다

이제 각 스크래퍼는 이렇게 단순해졌다.

// generic-url.ts
const html = await fetchHtml(url)

// seek-url.ts (사이트별 언어/라벨만 다르게)
const html = await fetchHtml(url, { label: 'Seek', acceptLanguage: 'en-AU,en;q=0.9' })

Step 4. 추적하다 발견한 진짜 버그

원인을 쫓다가, 정작 더 위험한 버그를 발견했다. 스크래핑이 실패했을 때 처리하는 코드가 이랬다.

// 실패 시: 제목과 설명을 에러로 '덮어쓴다'
catch (e) {
  await supabaseAdmin.from('jobs').update({
    title: '스크래핑 실패',
    description: String(e),
  }).eq('id', job.id)
}

문제가 보이는가? 이미 잘 수집된 공고가 있는데, 어떤 이유로 재스크래핑을 돌렸다가 간헐적 403을 만나면 — 멀쩡하던 제목과 설명이 '스크래핑 실패'와 에러 메시지로 덮어써진다. 봇 차단의 간헐적 특성과 만나면 최악이다. 잘 되던 데이터가 한순간에 날아간다.

그래서 "한 번도 성공한 적 없는 공고만 실패로 표시"하도록 고쳤다.

catch (e) {
  const errMsg = String(e)
  // 이전에 정상 수집된 적이 있으면(= 진짜 제목 보유) 기존 데이터를 보존
  const neverScraped =
    job.title === '스크래핑 대기 중...' || job.title === '스크래핑 실패'

  if (neverScraped) {
    await supabaseAdmin.from('jobs')
      .update({ title: '스크래핑 실패', description: errMsg })
      .eq('id', job.id)
  }
  // 이미 데이터가 있으면? → 아무것도 덮어쓰지 않고 그냥 둔다
  return NextResponse.json({ error: errMsg }, { status: 500 })
}

"성공 여부"를 어떻게 아느냐가 관건이었는데, 공고 생성 시 제목이 '스크래핑 대기 중...'으로 들어가고 성공하면 진짜 제목으로 바뀐다는 점을 이용했다. 제목이 '스크래핑 대기 중...''스크래핑 실패'도 아니라면 = 한 번은 성공했다는 뜻이니, 그 데이터는 건드리지 않는다.

이게 사실 이번 작업에서 제일 중요한 수정이었다. 403 자체는 외부 사이트 사정이라 100% 막을 수 없지만, 실패가 멀쩡한 데이터를 파괴하지 않게 하는 것은 전적으로 내 코드의 책임이니까.

트러블슈팅: 자주 만나는 차단 신호 읽기

스크래핑하다 막혔을 때, 응답에서 이런 신호를 보면 원인을 빠르게 좁힐 수 있다.

신호의미대응
set-cookie: ak_bmscAkamai 봇 차단재시도 + 헤더 보강, 그래도 안 되면 헤드리스 브라우저
set-cookie: __cf_bm, cf-ray 헤더Cloudflare 봇 차단위와 동일
로컬은 200, 서버는 403데이터센터 IP 차단재시도 / 프록시 검토
항상 404진짜 없는 페이지재시도 무의미, URL 확인
429요청 과다(rate limit)백오프 재시도

정리: 못 막는 것과 막을 수 있는 것을 나누기

이번 작업의 흐름을 한눈에 보면:

  1. 원인 규명 — 응답 쿠키(ak_bmsc)로 Akamai 봇 차단 확인, "로컬 200 / 서버 403 = 데이터센터 IP 차단"으로 좁힘
  2. 간헐성 파악 — 항상 막는 게 아니라 확률적 → 재시도 전략으로 결정
  3. 재시도 헬퍼 — 브라우저 유사 헤더 + 지수 백오프, 재시도할 코드(403/429/5xx)와 포기할 코드(404) 구분
  4. 데이터 보존 버그 수정 — 실패가 기존에 잘 수집된 공고를 덮어쓰지 않도록

마지막으로 솔직히 적어두면, 이건 완벽한 해결이 아니다. Akamai가 데이터센터 IP를 강하게 막기로 마음먹으면 재시도와 헤더로는 한계가 있다. 근본적으로 뚫으려면 헤드리스 브라우저(Playwright) 렌더링이나 프록시가 필요한데, 비용과 복잡도가 확 올라간다. 그래서 이번엔 "대부분의 간헐적 차단은 재시도로 흡수하고, 그래도 실패하면 적어도 기존 데이터는 지킨다"는 선에서 멈췄다.

엔지니어링은 종종 "완벽히 막기"가 아니라 "못 막는 것과 막을 수 있는 것을 구분하고, 막을 수 있는 쪽을 확실히 책임지기"인 것 같다. 외부 사이트의 차단은 내 통제 밖이지만, 그 실패가 내 데이터를 망가뜨리는 건 내 통제 안의 일이니까.

PM

backtodev

A 40-something PM returns to code. Learning, failing, and growing.