# '검사중'에 멈춘 좀비 작업 — Vercel Deployment Protection이 범인이었다

> 서비스 오픈 직후, 검사 2건이 몇 시간째 '검사중'에 멈춰 있었습니다. 원인이라 확신한 수정이 두 번 빗나가고, 세 번째에야 진짜 범인 — Vercel Deployment Protection이 내부 호출을 401로 막고 있었다 — 을 찾은 서버리스 큐 디버깅 회고입니다.

**Published:** 2026-07-24 | **Updated:** 2026-07-24

---


운영 중인 서비스의 로그를 습관처럼 넘겨보다가 손이 멈추는 순간이 있습니다. 저는 그날, 웹 접근성 점검 서비스 [A11y Check](https://www.a11ychk.com)의 검사 로그에서 그런 순간을 만났어요. 검사 2건이 **몇 시간째 "검사중"** 상태로 멈춰 있었습니다.

진행 중도 아니고 실패도 아닌, 그냥 멈춘 상태. 이런 걸 흔히 **좀비 작업(zombie task)** 이라고 부릅니다 — 죽지도 살지도 않은 채 큐에 남아 있는 작업이요. 이 글은 그 좀비 2마리를 잡으면서 원인이라 **확신했던 수정이 두 번 빗나가고**, 세 번째에야 진짜 원인을 찾은 과정의 기록입니다. 결론부터 말하면 범인은 제 로직이 아니라 **Vercel의 Deployment Protection**이었어요. 정확히는, 그걸 고려하지 않은 제 내부 호출 코드였고요.

## 배경: 검사가 실행되는 구조

먼저 그림을 맞춰둘게요. A11y Check의 검사는 **서버리스 함수**에서 돕니다. 서버리스 함수는 요청이 올 때만 잠깐 떴다가 사라지는 함수인데, 하나 중요한 제약이 있어요 — **최대 실행 시간이 정해져 있습니다.** 그런데 검사 한 번은 여러 페이지를 크롤링하고 헤드리스 브라우저로 렌더링하느라 오래 걸립니다. 요청받은 함수가 검사를 끝까지 직접 하면 시간 제한에 걸려요.

그래서 작업을 큐로 나눠 놓았습니다. **큐(queue)** 는 작업을 순서대로 쌓아두고 하나씩 처리하는 대기열이고요.

```text
검사 요청 → DB에 queued로 저장
         → 드레이너(drainer)가 전역 동시 실행 상한을 확인
         → 여유가 있으면 검사 실행 엔드포인트를 "별도 인보케이션"으로 호출
         → 그 함수가 검사를 수행하고 done/failed로 마감
```

**드레이너(drainer)** 는 큐에 쌓인 작업을 꺼내 실행으로 넘기는 역할이에요. 그리고 여기서 핵심은 "별도 인보케이션으로 호출"입니다. 서버리스에는 계속 떠 있는 백그라운드 워커가 없으니, **함수가 같은 앱의 다른 엔드포인트를 HTTP로 호출**해서 무거운 작업을 넘깁니다. 인보케이션(invocation)은 그냥 "함수를 한 번 호출해 실행시키는 것"이라고 보면 돼요. 이 패턴 자체는 Vercel에서 흔합니다. 문제는 그 호출의 **주소**였습니다.

{{< img src="images/contents/zombie-queue-architecture.png" alt="A11y Check의 검사 실행 구조 다이어그램 - 검사 요청이 DB에 queued로 저장되고, 드레이너가 전역 동시 실행 상한을 확인한 뒤 여유가 있으면 검사 실행 엔드포인트를 별도의 HTTP 인보케이션으로 호출하며, 그 함수가 검사를 수행해 done 또는 failed로 마감하는 흐름을 보여줍니다. 서버리스에는 상시 워커가 없어 함수가 같은 앱의 다른 엔드포인트를 HTTP로 불러 무거운 작업을 넘긴다는 설명이 붙어 있습니다" caption="서버리스에는 상시 워커가 없어, 함수가 같은 앱의 다른 엔드포인트를 HTTP로 불러 작업을 넘깁니다." >}}

## 1차 시도: 회수 로직을 의심하다

멈춘 검사를 발견하고 처음 한 일은 당연히 복구였습니다. 그런데 복구를 위해 재실행을 걸어도 검사가 다시 멈췄어요. 이 시점의 제 가설은 두 가지였습니다.

**가설 1 — 좀비 회수가 안 돌고 있다.** 함수가 강제 종료되면 `running` 상태가 그대로 남습니다. 이를 대비한 **회수(reclaim)** 로직이 있었는데, 하필 *사용자가 새 검사를 시작할 때만* 트리거되는 구조였어요. 아무도 새 검사를 안 걸면 좀비가 영원히 남습니다. → 크론에서 **전역 회수**를 돌도록 보강했습니다(`running` 10분, `queued` 30분 경과 시 실패 처리 후 1회 자동 재큐잉).

**가설 2 — 페이지 수집이 너무 오래 걸린다.** 검사 대상 사이트가 느리면 페이지 수집 단계에서 함수 시간을 다 써버릴 수 있죠. → 수집 단계에 **시간 예산 75초**를 두고, 초과하면 대표 페이지만으로 폴백하도록 했습니다.

둘 다 그 자체로 필요한 개선이었어요. 배포도 확인했습니다. 그런데 **검사는 여전히 멈췄습니다.**

## 멈추고, 다시 생각하기

여기서 중요한 갈림길이 있었습니다. "한 번 더 재실행해 보자"를 반복할 수도 있었어요. 하지만 같은 수정으로 세 번째 재실행을 하는 건 디버깅이 아니라 **기도**입니다. 재실행을 멈추고 관찰로 돌아갔습니다.

다시 보니 이상한 점이 눈에 들어왔어요. 검사가 **`running`에서 죽는 게 아니라 `queued`에서 아예 시작을 못 하고 있었습니다.** 시간 예산이니 회수니 하는 제 수정은 전부 "실행 중에 죽는" 시나리오를 고치는 것이었어요. 증상을 처음부터 정확히 읽었다면 두 수정이 빗나갈 것도 미리 알 수 있었습니다. 증상 관찰을 대충 하고 가설로 뛰어든 대가였죠.

`queued`에서 멈춘다는 건, 드레이너가 검사 실행 엔드포인트를 **호출하는 그 지점**이 실패한다는 뜻입니다. 그런데 왜 에러 로그가 없지?

## 진짜 원인: VERCEL_URL + Deployment Protection + 무시된 실패

드레이너의 내부 호출 코드는 대략 이랬습니다.

```ts
// 내부 호출 기준 주소 — 이게 문제였다
const base = process.env.VERCEL_URL
  ? `https://${process.env.VERCEL_URL}`
  : "http://localhost:3000";

// 검사 실행을 별도 인보케이션으로 넘긴다 (fire-and-forget)
fetch(`${base}/api/.../run`, { ... }).catch(() => {
  // 실패해도 다음 크론이 처리하겠지... (안 한다)
});
```

문제가 세 개 겹쳐 있었습니다.

**첫째, `VERCEL_URL`은 공개 도메인이 아닙니다.** 이 환경변수에는 배포마다 새로 생성되는 `*.vercel.app` 고유 주소가 들어옵니다. 내가 쓰는 프로덕션 도메인이 따로 있어도 그래요.

**둘째, Deployment Protection이 그 주소를 막습니다.** **Deployment Protection**은 프리뷰 배포를 외부에서 함부로 못 보게 인증을 요구하는 Vercel의 보호 기능이에요. 이걸 켜두면 자동 생성 도메인(`*.vercel.app`)에 접근할 때 인증을 요구합니다. 서버리스 함수가 `VERCEL_URL`로 자기 앱을 호출하면, 그 요청은 **인증 없는 외부 요청과 똑같이 취급되어 401 인증 페이지**가 돌아옵니다. 검사 실행 함수는 아예 실행조차 되지 않죠.

**셋째, 그 실패가 조용히 무시됐습니다.** fire-and-forget — 호출만 하고 결과를 안 기다리는 패턴 — 이라 401이 와도 `.catch()`가 비어 있어 아무 데도 기록되지 않았어요. 그래서 "드레이너는 정상 종료, 검사는 `queued`에 영원히 대기, 로그는 깨끗"이라는 **완벽한 좀비 조건**이 완성됐습니다.

{{< img src="images/contents/deployment-protection-401.png" alt="Deployment Protection이 내부 호출을 막는 과정 다이어그램 - 드레이너가 VERCEL_URL 즉 배포별 vercel.app 주소로 검사 실행 엔드포인트를 호출하지만, 그 앞을 Deployment Protection 인증 벽이 가로막아 401 인증 페이지를 돌려줍니다. 그 결과 검사 실행 함수는 시작조차 못 하고 작업은 queued에 영원히 남으며, fire-and-forget이라 401 실패는 로그에도 남지 않아 좀비가 된다는 흐름입니다" caption="VERCEL_URL 호출이 인증 벽에 막혀 401 → 실행 함수는 시작도 못 하고, 실패는 로그에도 안 남습니다." >}}

로컬에서 재현이 안 됐던 이유도 이걸로 설명됩니다. 로컬에는 Deployment Protection이라는 게 아예 없으니까요.

여기서 개발자라면 누구나 아는 그 장면이 떠오릅니다. 로컬에서 수십 번 돌려보고, 테스트도 죄다 초록불, "이 정도면 버그 없지" 하고 배포한 순간 — 꼭 실제 사용자 환경에서 뭔가가 터지죠. 그 유명한 **"제 컴퓨터에선 되는데요(It works on my machine)"** 말입니다. 밈이 괜히 밈이 아니에요.

이번 건이 딱 그 교과서적 사례였습니다. 제 로컬엔 인증 벽이 없으니 내부 호출이 막힐 일도 없고, 검사는 매번 깔끔하게 통과했어요. 정작 그 벽은 **프로덕션에만** 서 있었죠. 테스트 환경이 실제 환경과 다른 딱 한 곳 — 그 한 곳이 전부를 멈춰 세웁니다. 아무리 테스트를 많이 해도 결국 사용자의 진짜 환경에서만 드러나는 버그가 있다는 걸, 저는 좀비 2마리한테 다시 배웠습니다.

## 수정: 주소 하나, 로그 한 줄

원인을 알고 나니 수정은 허무할 만큼 작았습니다.

```ts
// 1) 내부 호출은 공개 프로덕션 도메인을 우선한다
const base =
  process.env.NEXT_PUBLIC_SITE_URL          // 공개 도메인 (Protection 대상 아님)
  ?? (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : undefined)
  ?? "http://localhost:3000";

// 2) fire-and-forget이어도 실패는 반드시 기록한다
fetch(`${base}/api/.../run`, { ... }).catch((e) => logAppError("drain.invoke", e));
```

`NEXT_PUBLIC_SITE_URL`은 제가 직접 관리하는 공개 도메인이라 Deployment Protection의 대상이 아닙니다. 내부 호출이 이 주소로 가면 인증 벽에 막힐 일이 없어요. 배포하자 멈춰 있던 검사 2건이 그대로 완료됐습니다.

앞서 넣었던 전역 회수와 수집 시간 예산도 그대로 남겨뒀어요. 이번 사건의 원인은 아니었지만, *다른 종류의* 좀비를 막는 안전망으로는 여전히 유효하니까요. 헛발질이 꼭 손해만은 아니었던 셈입니다.

그리고 사용자에게는 검사 완료 안내와 함께 지연에 대한 사과 메일을 보냈습니다. 장애는 코드로 끝나지 않고 커뮤니케이션으로 끝난다고 생각해요.

## 배운 것

1. **`VERCEL_URL`로 자기 앱을 호출하지 마세요.** Deployment Protection이 켜져 있으면 내부 호출도 401로 막힙니다. 인증이 필요 없는 내부 호출은 직접 관리하는 공개 도메인(`NEXT_PUBLIC_SITE_URL` 같은 값)을 우선해야 해요. 보호 기능을 *끄는* 게 아니라 주소를 *바꾸는* 게 맞습니다.
2. **fire-and-forget에도 최소한 로그는 남기세요.** "실패하면 다음 주기가 처리하겠지"는 그 실패가 보일 때만 성립합니다. **조용한 실패는 없는 실패가 아니라 못 본 실패**예요.
3. **수정 전에 증상을 끝까지 읽으세요.** `running`에서 죽는 것과 `queued`에서 시작을 못 하는 것은 전혀 다른 문제입니다. 저는 증상 분류를 대충 하고 가설부터 세워서 두 번 빗나갔어요. 같은 수정으로 재시도를 반복하고 있다면, 그건 디버깅이 아니라는 신호입니다.
4. **큐 시스템에는 전역 회수 안전망이 필요합니다.** 트리거에 의존하는 회수는 트리거가 없으면 안 돕니다. 시간 기준 전역 회수를 크론에 두면 원인 불명의 좀비도 결국 정리돼요.

## 한 장 요약

- 증상: 검사가 `queued`에서 멈춘 **좀비 작업**. 로그는 깨끗, 로컬 재현 안 됨.
- 원인 3종 세트: ① 내부 호출을 `VERCEL_URL`(배포별 `*.vercel.app`)로 보냄 ② **Deployment Protection**이 그 주소를 401로 막음 ③ **fire-and-forget**이라 401이 조용히 사라짐.
- 수정: 내부 호출 기준 주소를 **공개 도메인**(`NEXT_PUBLIC_SITE_URL`)으로 + `.catch()`에 **에러 로깅** 한 줄.
- 디버깅 교훈: 증상(`running` 사망 vs `queued` 미시작)을 먼저 정확히 읽어라. 같은 수정 반복은 기도지 디버깅이 아니다.
- 안전망: 크론에 **시간 기준 전역 회수**를 두면 원인 불명의 좀비도 결국 정리된다.

같은 구조(서버리스 + DB 큐 + 내부 인보케이션)를 쓰는 분들이 이 글로 삽질 하나를 건너뛰면 좋겠습니다. A11y Check는 오픈소스라, 실제 수정 내역은 [GitHub](https://github.com/IsaacEryn/a11ychk)에서 볼 수 있어요.

