크롤러가 필터 링크를 긁으면 왜 Vercel Hobby 팀 전체가 멈추나

동딩
2026.11.06·3분 읽기·
필터 주소 요청함수 호출
막지 않음55만104만
방화벽이 robots.txt까지 막음17만 재시도거의 없음
robots.txt에서 막음0건거의 없음
막는 방법별 ClaudeBot 하루 요청과 함수 호출

필터 주소는 robots.txt에서 막는다

필터·정렬·더 보기가 만든 주소는 robots.txt의 Disallow: /explore? 한 줄로 막습니다.

robots 규칙은 적어 둔 값으로 시작하는 주소를 모두 막습니다. 제 앱 voopo라면 둘러보기 첫 화면 /explore는 열리고, /explore?cat=게임,툴&sort=likes처럼 물음표 뒤에 필터가 붙은 주소만 막힙니다. 쿼리 링크를 단 목록마다 원래 있던 disallow에 한 줄씩 더합니다.

ts
// app/robots.ts
export default function robots() {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: [
"/explore?",
"/community?",
],
},
};
}

필터 링크에는 <Link rel="nofollow">도 답니다. nofollow를 따르는 크롤러만 건너뛰는 힌트라서, 막는 것은 robots.txt 한 줄입니다.

voopo를 긁은 Anthropic의 크롤러 ClaudeBot은 robots.txt를 따릅니다. 방화벽에서 봇을 막을 때도 /robots.txt는 규칙에서 뺍니다.

robots.txt까지 403이면 필터 주소 요청이 하루 17만 번 다시 온다

방화벽이 robots.txt까지 막음/robots.txt403, 규칙이 없다고 봄/explore?cat=게임,툴방화벽 규칙17만하루 필터 주소 요청모두 403, 함수 호출 0
robots.txt는 규칙에서 뺌/robots.txtDisallow: /explore?/explore?cat=게임,툴방화벽 규칙0건하루 필터 주소 요청
403통과
voopo에 온 ClaudeBot 요청 하루치

사건 뒤에 걸어 둔 차단 규칙에서 robots.txt를 빼자, 2주 동안 이어지던 필터 주소 요청이 한 시간쯤 뒤 끊겼습니다.

바꾼 뒤에도 봇이 계속 온다면

크롤러는 robots.txt를 24시간까지 캐시할 수 있어서, 새 규칙이 먹히기까지 하루가 걸릴 수 있습니다. 그동안 한도가 급하면 방화벽 규칙으로 막되 robots.txt 요청은 지나가게 둡니다. robots.txt가 403이면 크롤러는 규칙이 없는 사이트로 봅니다.

bash
vercel firewall rules add "block ClaudeBot" --project voopo \
--condition '{"type":"user_agent","op":"sub","value":"ClaudeBot"}' \
--condition '{"type":"path","op":"eq","neg":true,"value":"/robots.txt"}' \
--action deny --yes
vercel firewall publish --project voopo --confirm-name voopo

두 조건이 함께 맞아야 막혀서 ClaudeBot의 robots.txt 요청은 지나갑니다. 규칙은 publish해야 적용되고, Hobby는 사용자 규칙을 3개까지 둘 수 있습니다.

robots.txt가 먹힌 뒤에는 rules remove로 규칙을 지우고 다시 publish합니다. 남겨 두면 ClaudeBot은 /sitemap.xml과 다른 페이지에서도 계속 403을 받습니다.

막은 요청은 CDN 요청 요금에 잡히지 않고 /v2/usage의 요청 수에만 남습니다. 요청은 많은데 함수 호출이 적으면 막힌 트래픽입니다.

필터를 누를수록 주소가 번진다

필터 링크마다 지금 고른 필터가 그대로 붙어 있어서, 켜는 조합과 순서마다 다른 주소가 생깁니다.

켠 순서만 달라도 다른 주소라, 세 번 누르면 새 주소가 2,752개 생긴다

/explore?cat=게임?cat=툴툴 켬게임 켬cat=게임,툴cat=툴,게임같은 목록한 번 누르면두 번세 번21개270개2,752개
따로 그리는 주소
분류 6개·AI 10개, 링크를 모두 따라갈 때

켠 순서에 정렬·더 보기까지 곱하면 이 페이지의 주소는 2조 개가 넘습니다. 링크를 만들 때 값을 정렬해 켠 순서를 하나로 모아도 786만 개라, robots.txt 한 줄은 그대로 필요합니다.

서버에서 searchParams를 읽는 Next.js 페이지는 데이터 조회를 캐시해도 요청마다 Vercel 함수가 새로 그립니다. Hobby는 이 함수 호출을 30일에 100만 번까지 줍니다.

요청마다 미들웨어인 proxy.ts도 함께 돌아, 함수 호출은 필터 주소 요청의 두 배가 됐습니다. 로그인 상태를 쓰지 않는 목록이면 proxy의 matcher에서 그 경로를 빼 늘어난 절반을 없앱니다.

Hobby 팀은 계정마다 하나만 둘 수 있는 무료 작업 공간이고, 팀의 앱이 무료 사용량을 함께 씁니다. 크롤링 날 팀은 함수 호출·계산 시간·메모리 세 한도를 넘겨 앱 11개가 모두 멈췄습니다. 함수 호출의 88%는 voopo가, 메모리는 대부분 같은 팀의 MCP 서버가 썼습니다.

팀이 이미 멈췄다면

멈춘 날 팀이 쓴 양과 한도입니다.

  • 함수 호출 119만 건(한도 100만)
  • Active CPU 12시간 17분(한도 4시간). 함수가 실제로 계산한 시간입니다
  • Provisioned Memory 688 GB-hrs(한도 360). 함수가 떠 있는 동안 잡아 둔 메모리입니다

팀은 30일을 기다리거나 Pro로 올려야 풀리고, 올리면 앱이 바로 돌아옵니다. Pro는 월 $20에 같은 금액의 사용 크레딧이 들어 있고, 넘는 만큼은 종량제입니다. 크롤러가 계속 긁으면 그대로 요금이 되니 Spend Management에서 On-Demand Budget을 작게 잡고, 예산에 닿으면 프로덕션을 멈추는 Pause Production Deployments를 켭니다.

이렇게 멈춘 앱은 예산을 올려도 저절로 돌아오지 않아 프로젝트마다 다시 켭니다.

Hobby에선 방화벽 기록으로 범인을 찾는다

앱별 함수 호출은 /v2/usage로 보고, 그 요청을 보낸 봇과 경로는 vercel firewall traffic list로 좁힙니다.

bash
Q="teamId=$ORG&from=$FROM&to=$TO"
vercel api "/v2/usage?type=requests&granularity=day&$Q" | jq -c '.data[] |
{date, calls: .function_invocation_successful_count,
apps: [.breakdown.function_invocations[:3][] | "\(.name) \(.percent)%"]}'
vercel firewall traffic list --project voopo

$ORG에는 .vercel/project.json의 orgId를, $FROM·$TO에는 ISO 시각을 넣습니다. projectId 없이 부르면 날마다 앱별 몫이 붙어, 크롤링 날 voopo가 98%로 나옵니다. /v2/usage는 공식 문서에 없는 주소라 바뀔 수 있습니다.

Hobby에서는 vercel metrics가 막히고 런타임 로그는 1시간만 남지만, 방화벽 기록은 최근 24시간까지 남습니다. 사용량이 튄 날 바로 엽니다.

크롤링이 한창이던 하루의 voopo 방화벽 기록
MCP 서버나 스트리밍 응답을 둔다면

/v2/usage에서 function_invocation_timeout_count가 쌓이면 연결을 오래 여는 응답을 봅니다. 함수는 응답이 끝날 때까지 메모리를 잡고 있어서, 최대 실행 시간 300초를 채우고 끊기는 응답마다 메모리 한도를 먹습니다. 같은 팀의 블로그와 plateLog MCP 서버는 Claude Code가 보낸 subscriptions/listen 요청에 스트림을 열고 닫지 않아, 그 응답이 30일에 7천 번 넘게 300초에서 끊겼습니다.

mcp-handler로 만든 서버라면 서버 옵션에 capabilities: { tools: { listChanged: false } }를 넣습니다. listChanged만 꺼도 listen 요청이 오면 스트림이 열리니, 그 요청은 handler에 넘기기 전에 거절합니다.

ts
async function refuseListen(req: Request) {
if (req.method !== "POST") return null;
const msg = await req.clone().json().catch(() => null);
if (msg?.method !== "subscriptions/listen") return null;
return Response.json({
jsonrpc: "2.0", id: msg.id ?? null,
error: { code: -32601, message: "subscriptions/listen is not supported" },
});
}
export async function POST(req: Request) {
return (await refuseListen(req)) ?? handler(req);
}
Comments

댓글

powered by giscus ↗