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

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

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

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

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

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

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

A11y Check의 검사 실행 구조 다이어그램 - 검사 요청이 DB에 queued로 저장되고, 드레이너가 전역 동시 실행 상한을 확인한 뒤 여유가 있으면 검사 실행 엔드포인트를 별도의 HTTP 인보케이션으로 호출하며, 그 함수가 검사를 수행해 done 또는 failed로 마감하는 흐름을 보여줍니다. 서버리스에는 상시 워커가 없어 함수가 같은 앱의 다른 엔드포인트를 HTTP로 불러 무거운 작업을 넘긴다는 설명이 붙어 있습니다
A11y Check의 검사 실행 구조 다이어그램 - 검사 요청이 DB에 queued로 저장되고, 드레이너가 전역 동시 실행 상한을 확인한 뒤 여유가 있으면 검사 실행 엔드포인트를 별도의 HTTP 인보케이션으로 호출하며, 그 함수가 검사를 수행해 done 또는 failed로 마감하는 흐름을 보여줍니다. 서버리스에는 상시 워커가 없어 함수가 같은 앱의 다른 엔드포인트를 HTTP로 불러 무거운 작업을 넘긴다는 설명이 붙어 있습니다
서버리스에는 상시 워커가 없어, 함수가 같은 앱의 다른 엔드포인트를 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에 영원히 대기, 로그는 깨끗"이라는 완벽한 좀비 조건이 완성됐습니다.

Deployment Protection이 내부 호출을 막는 과정 다이어그램 - 드레이너가 VERCEL_URL 즉 배포별 vercel.app 주소로 검사 실행 엔드포인트를 호출하지만, 그 앞을 Deployment Protection 인증 벽이 가로막아 401 인증 페이지를 돌려줍니다. 그 결과 검사 실행 함수는 시작조차 못 하고 작업은 queued에 영원히 남으며, fire-and-forget이라 401 실패는 로그에도 남지 않아 좀비가 된다는 흐름입니다
Deployment Protection이 내부 호출을 막는 과정 다이어그램 - 드레이너가 VERCEL_URL 즉 배포별 vercel.app 주소로 검사 실행 엔드포인트를 호출하지만, 그 앞을 Deployment Protection 인증 벽이 가로막아 401 인증 페이지를 돌려줍니다. 그 결과 검사 실행 함수는 시작조차 못 하고 작업은 queued에 영원히 남으며, fire-and-forget이라 401 실패는 로그에도 남지 않아 좀비가 된다는 흐름입니다
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에서 볼 수 있어요.