로컬에선 되는데 서버에선 403 — 채용공고 스크래핑 봇 차단과 싸우기
들어가며: "내 컴퓨터에선 되는데요?"
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 평판, 요청 타이밍, 트래픽 패턴 등을 종합해서 확률적으로 막는다. 항상 막는 게 아니라 간헐적으로 막는다. 실제로 내가 디버깅하는 동안 "실패한 공고"를 다시 시도하니 곧바로 성공했다.
여기서 대응 전략이 정해졌다.
- 간헐적 403은 재시도로 흡수한다. 한 번 막혀도 잠깐 뒤 다시 하면 통과할 확률이 높으니까.
- 헤더를 더 진짜 브라우저처럼 보이게 한다. 차단당할 확률 자체를 조금이라도 낮춘다.
- (그리고 곧 발견할) 실패가 기존 데이터를 망가뜨리지 않게 한다.
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_bmsc | Akamai 봇 차단 | 재시도 + 헤더 보강, 그래도 안 되면 헤드리스 브라우저 |
set-cookie: __cf_bm, cf-ray 헤더 | Cloudflare 봇 차단 | 위와 동일 |
| 로컬은 200, 서버는 403 | 데이터센터 IP 차단 | 재시도 / 프록시 검토 |
| 항상 404 | 진짜 없는 페이지 | 재시도 무의미, URL 확인 |
| 429 | 요청 과다(rate limit) | 백오프 재시도 |
정리: 못 막는 것과 막을 수 있는 것을 나누기
이번 작업의 흐름을 한눈에 보면:
- 원인 규명 — 응답 쿠키(
ak_bmsc)로 Akamai 봇 차단 확인, "로컬 200 / 서버 403 = 데이터센터 IP 차단"으로 좁힘 - 간헐성 파악 — 항상 막는 게 아니라 확률적 → 재시도 전략으로 결정
- 재시도 헬퍼 — 브라우저 유사 헤더 + 지수 백오프, 재시도할 코드(403/429/5xx)와 포기할 코드(404) 구분
- 데이터 보존 버그 수정 — 실패가 기존에 잘 수집된 공고를 덮어쓰지 않도록
마지막으로 솔직히 적어두면, 이건 완벽한 해결이 아니다. Akamai가 데이터센터 IP를 강하게 막기로 마음먹으면 재시도와 헤더로는 한계가 있다. 근본적으로 뚫으려면 헤드리스 브라우저(Playwright) 렌더링이나 프록시가 필요한데, 비용과 복잡도가 확 올라간다. 그래서 이번엔 "대부분의 간헐적 차단은 재시도로 흡수하고, 그래도 실패하면 적어도 기존 데이터는 지킨다"는 선에서 멈췄다.
엔지니어링은 종종 "완벽히 막기"가 아니라 "못 막는 것과 막을 수 있는 것을 구분하고, 막을 수 있는 쪽을 확실히 책임지기"인 것 같다. 외부 사이트의 차단은 내 통제 밖이지만, 그 실패가 내 데이터를 망가뜨리는 건 내 통제 안의 일이니까.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.