Vercel 계정 정지로 블로그가 내려갔다 — 다른 계정으로 재배포하고 도메인 옮기기

도메인 만료로 블로그를 한 번 잃고 새 도메인으로 옮긴 지 한 달 만에, 이번에는 호스팅 계정이 정지됐다. 사이트 주소를 열면 Vercel이 402 Payment Required 와 DEPLOYMENT_DISABLED 라는 에러 코드만 돌려줬다. 코드도, DNS도, 도메인도 멀쩡했지만 배포를 실행하는 계정이 막히니 모든 것이 한 번에 멈췄다.

이 글은 정지된 계정을 되살리는 대신 같은 코드를 다른 Vercel 팀으로 옮겨 재배포하고, 도메인을 새 프로젝트로 넘긴 과정을 순서대로 정리한 기록이다. 중간에 두 번 막혔던 지점(팀 기본값으로 켜진 접근 보호, 도메인 검증 대기 상태)도 원인과 함께 남긴다. Gatsby 정적 사이트 기준이지만 Next.js 등 다른 프레임워크도 절차는 같다.

증상: 402 응답과 DEPLOYMENT_DISABLED

브라우저에서는 그냥 흰 화면에 짧은 안내만 보였다. 원인을 좁힐 때는 응답 헤더를 봐야 한다. Vercel은 플랫폼 수준의 에러를 x-vercel-error 헤더에 적어 준다.

curl -sS -D - -o /dev/null https://www.bottlehs.dev/
# HTTP/2 402
# server: Vercel
# x-vercel-error: DEPLOYMENT_DISABLED

Vercel 에러 코드는 범위가 분명하다.

에러 코드 상태 뜻
DEPLOYMENT_DISABLED 402 계정·팀 문제로 배포가 비활성화됨
DEPLOYMENT_NOT_FOUND 404 요청한 호스트에 연결된 배포가 없음
DEPLOYMENT_BLOCKED 403 정책 위반 등으로 배포가 차단됨
NOT_FOUND 404 배포는 있지만 해당 경로가 없음

DEPLOYMENT_DISABLED는 코드나 DNS로 고칠 수 있는 문제가 아니다. 계정이 풀릴 때까지 기다리거나, 다른 계정(팀)으로 옮겨야 한다. 정적 블로그는 서버 상태가 없으므로 옮기는 쪽이 빠르다.

옮기기 전에 챙길 것

정지된 팀의 대시보드와 API는 읽기조차 안 되는 경우가 많다. 실제로 CLI 토큰은 Not authorized 를 돌려줬다. 그러니 옮기기 전에 “옛 프로젝트에만 있던 설정”이 무엇인지 떠올려야 한다.

  1. 환경 변수: 옛 프로젝트에서 꺼낼 수 없다. 코드에서 process.env 를 읽는 곳을 모두 찾아 어떤 값이 필요한지 목록을 만든다.
  2. 빌드 설정: 프레임워크, 빌드 명령, 출력 폴더, Node 버전. vercel.json 과 package.json 의 engines 에 적혀 있으면 새 프로젝트에서도 자동으로 적용된다.
  3. 도메인과 리다이렉트: 루트 → www 리다이렉트처럼 대시보드에서만 설정한 것이 있는지 확인한다. 코드(vercel.json)에 있으면 그대로 따라온다.
  4. DNS 레코드 현재 값: 전환 중 비교하기 위해 dig 로 지금 값을 기록해 둔다.
# 코드가 요구하는 환경 변수 목록
grep -rn "process.env" gatsby-config.js gatsby-node.js src | grep -v node_modules

# 현재 DNS
dig +short www.bottlehs.dev CNAME
dig +short bottlehs.dev A

이 블로그는 지난 도메인 이전 때 설정을 코드 기본값으로 모아 두고, 환경 변수는 덮어쓰기 용도로만 쓰는 구조로 바꿔 두었다. 그래서 새 프로젝트에 등록해야 할 변수는 애드센스 광고 단위를 끄는 GATSBY_ADSENSE_UNITS=off 하나뿐이었다. 이 결정이 이번 이전을 30분짜리 작업으로 만들었다.

1단계: CLI 로그인을 새 팀으로 바꾼다

Vercel CLI는 한 번에 한 계정으로만 로그인된다. 정지된 계정 토큰이 남아 있으면 모든 명령이 Not authorized 로 실패하므로 먼저 교체한다.

vercel logout
vercel login            # 새 계정(팀)으로 로그인
vercel teams ls         # ✔ 표시가 새 팀에 있는지 확인

이후 명령에는 --scope <팀 슬러그> 를 붙여 어느 팀에서 실행하는지 명시하는 편이 안전하다. 기본 팀 설정에 의존하면 엉뚱한 팀에 프로젝트가 생길 수 있다.

2단계: 옛 프로젝트 링크를 끊고 새 프로젝트를 만든다

레포의 .vercel/project.json 에는 옛 팀 ID와 프로젝트 ID가 들어 있다. 이 상태로 배포하면 CLI가 정지된 프로젝트에 올리려다 실패한다. 폴더를 치우고 새로 연결한다.

mv .vercel /tmp/old-vercel-link        # 복구용으로 보관
vercel link --scope bluebutton --project blog --yes
cat .vercel/project.json
# {"projectId":"prj_새ID","orgId":"team_새ID","projectName":"blog"}

--yes 를 주면 같은 이름의 프로젝트가 없을 때 바로 생성한다. .vercel 은 .gitignore 에 있어야 하며, 커밋할 내용은 생기지 않는다.

3단계: 환경 변수를 다시 등록한다

여기서 처음 막혔다. GATSBY_ 처럼 프레임워크가 브라우저에 노출하는 접두사가 붙은 변수는 민감 정보(secret)로 등록할 수 없다.

나쁜 예시: 기본값으로 등록

printf 'off' | vercel env add GATSBY_ADSENSE_UNITS production
# "reason": "invalid_visibility",
# "message": "Environment variables with a public framework prefix (GATSBY)
#  cannot use secret visibility on Production or Preview."

좋은 예시: 일반 변수로 명시

printf 'off' | vercel env add GATSBY_ADSENSE_UNITS production \
  --visibility config --no-sensitive
vercel env ls
# GATSBY_ADSENSE_UNITS   Non-sensitive   Production

브라우저 코드에 포함되는 값은 어차피 공개되므로 일반 변수가 맞다. 비밀값(API 키 등)은 접두사 없이 서버 쪽에서만 읽도록 설계해야 한다.

4단계: 프로덕션 배포

이 블로그는 Git 연동 없이 CLI로 수동 배포한다. CLI는 .vercelignore 로 거른 소스만 올리고 빌드는 Vercel에서 한다.

vercel deploy --prod --scope bluebutton --yes
# Production  https://blog-xxxxxxxx-bluebutton.vercel.app
# Building…
# ...
# status  ● Ready

.vercelignore 에는 node_modules, 빌드 산출물(public, .cache), .env* 를 반드시 넣는다. 업로드가 200KB 남짓으로 끝났고, Node 24는 package.json 의 engines 로 자동 선택됐다.

5단계: 팀 기본값으로 켜진 Deployment Protection 끄기

배포는 Ready인데 임시 주소를 열면 302로 Vercel 로그인 페이지로 튕겼다.

curl -sS -D - -o /dev/null https://blog-bluebutton.vercel.app/
# HTTP/2 302
# location: https://vercel.com/sso-api?url=...

새 팀에 Vercel Authentication(Deployment Protection) 이 기본으로 켜져 있었다. 설정값은 all_except_custom_domains, 즉 *.vercel.app 주소는 로그인한 팀원만 볼 수 있고 커스텀 도메인은 공개된다는 뜻이다. 커스텀 도메인만 쓴다면 그대로 둬도 되지만, 공개 블로그는 임시 주소로도 점검해야 하므로 껐다. 대시보드의 Settings → Deployment Protection에서 끄거나, API로 바꿀 수 있다.

curl -X PATCH "https://api.vercel.com/v9/projects/blog?teamId=team_xxx" \
  -H "Authorization: Bearer $VERCEL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ssoProtection": null}'

끄고 나서 임시 주소로 홈, 글 한 편, sitemap.xml, rss.xml, robots.txt, ads.txt 가 모두 200인지 확인했다. canonical과 사이트맵 URL은 코드 기본값 덕분에 처음부터 www.bottlehs.dev 로 나왔다.

6단계: 도메인을 새 프로젝트에 추가하고 소유권 검증

두 번째로 막힌 지점이다. 도메인은 아직 정지된 팀의 프로젝트에 묶여 있다. 다른 팀이 같은 도메인을 추가하면 Vercel은 _vercel TXT 레코드로 소유권 검증을 요구한다.

vercel domains add www.bottlehs.dev blog --scope bluebutton
vercel domains add bottlehs.dev blog --scope bluebutton

API로 상태를 보면 verified: false 와 함께 넣어야 할 TXT 값이 나온다. 도메인마다 값이 다르고, 한 이름(_vercel)에 두 값을 함께 둘 수 있다.

이름: _vercel.bottlehs.dev   타입: TXT
값:   vc-domain-verify=www.bottlehs.dev,xxxxxxxxxxxxxxxxxxxx
값:   vc-domain-verify=bottlehs.dev,yyyyyyyyyyyyyyyyyyyy

검증이 “대기”에서 멈추면 404가 난다

TXT를 넣고 나흘 뒤에 사이트를 열어 보니 이번에는 404 DEPLOYMENT_NOT_FOUND 였다. DNS는 Vercel을 가리키는데, Vercel 입장에서는 그 호스트에 연결된 검증된 프로젝트가 없는 상태다. 확인해 보니 TXT 값은 정확히 들어가 있었지만 두 도메인 모두 pending_domain_verification 에 머물러 있었다. Vercel이 TXT를 알아서 재검사해 주지 않은 것이다.

이때는 검증을 직접 트리거해야 한다. 대시보드의 도메인 항목에서 Refresh를 누르거나, API의 verify 엔드포인트를 호출한다.

curl -X POST "https://api.vercel.com/v9/projects/blog/domains/www.bottlehs.dev/verify?teamId=team_xxx" \
  -H "Authorization: Bearer $VERCEL_TOKEN"
# {"name":"www.bottlehs.dev", ..., "verified":true}

호출 직후 verified: true 로 바뀌었고, 20초 안에 사이트가 200으로 돌아왔다. 대시보드에서 도메인을 다시 추가하면 검증 코드가 새로 발급되므로, DNS의 TXT 값은 항상 대시보드(또는 API)에 지금 표시되는 값과 맞춰야 한다.

7단계: DNS 전환

Vercel은 도메인마다 권장 레코드를 알려 준다. 루트는 A 레코드, 서브도메인은 CNAME이다.

레코드 이름 값 비고
A @ 216.150.1.1, 216.150.16.1 Vercel 공통 IP, 옛 값과 같으면 유지
CNAME www xxxxxxxx.vercel-dns-0xx.com 프로젝트별로 발급되는 값으로 교체
TXT _vercel vc-domain-verify=... 검증 후에도 삭제하지 않는다

흥미로운 점은 www의 CNAME이 옛 프로젝트 값을 가리키는 동안에도 사이트가 열렸다는 것이다. *.vercel-dns-*.com 이 결국 Vercel 공통 IP로 풀리고, 라우팅은 호스트 이름 기준으로 검증된 프로젝트를 찾기 때문이다. 그래도 옛 레코드는 언제 정리될지 모르니 새 값으로 바꿔 두어야 한다. Cloudflare를 쓴다면 세 레코드 모두 프록시를 끄고 DNS only로 두는 편이 인증서 발급과 디버깅에 안전하다.

정리: 이전 순서와 검증 명령

# 1. 로그인 전환
vercel logout && vercel login && vercel teams ls

# 2. 새 프로젝트 연결
rm -rf .vercel && vercel link --scope <팀> --project <이름> --yes

# 3. 환경 변수 (공개 접두사는 --no-sensitive)
printf '값' | vercel env add KEY production --visibility config --no-sensitive

# 4. 배포
vercel deploy --prod --scope <팀> --yes

# 5. 접근 보호 확인 (302 → sso-api 면 끈다)
curl -sS -D - -o /dev/null https://<프로젝트>.vercel.app/ | grep -i "^HTTP\|^location"

# 6. 도메인 추가 → TXT 등록 → 검증 트리거
vercel domains add www.example.com <프로젝트> --scope <팀>

# 7. 최종 확인
for u in / /sitemap.xml /rss.xml /robots.txt; do
  curl -sS -o /dev/null -w "$u %{http_code}\n" https://www.example.com$u
done

재발 방지: 호스팅 계정에 묶이지 않는 구조

도메인 만료와 계정 정지를 연달아 겪고 나니 결론은 하나다. 배포 플랫폼의 특정 계정에만 존재하는 상태를 최소화해야 한다.

  • 설정은 코드에 둔다. 프레임워크, 빌드 명령, 리다이렉트, 헤더는 vercel.json, Node 버전은 engines, 사이트 주소는 코드 기본값으로.
  • 환경 변수는 “덮어쓰기”로만 쓰고, 어떤 키가 있는지 README나 글에 적어 둔다. 비밀값은 비밀 관리 도구에 사본을 둔다.
  • 모니터링 스크립트에 상태 코드뿐 아니라 x-vercel-error 헤더 검사를 넣는다. 402·403·404의 플랫폼 에러는 콘텐츠 404와 구분해야 한다.
  • 도메인 검증 TXT 레코드는 삭제하지 않는다. 다음 이전 때 다시 쓸 수 있다.

지난 글의 domain-check.sh 에 다음 한 줄을 추가했다.

ERR=$(curl -sS -D - -o /dev/null "$URL" | awk -F': ' 'tolower($1)=="x-vercel-error"{print $2}' | tr -d '\r')
[ -n "$ERR" ] && { echo "platform error: $ERR"; exit 1; }

FAQ

Q: 정지된 계정의 프로젝트 설정이나 환경 변수를 꺼낼 방법은 없나요?
A: 대시보드와 API, CLI 토큰이 모두 막히는 경우가 많아 사실상 없다. 코드에서 process.env 사용처를 찾아 목록을 복원하고, 값은 비밀 관리 도구나 문서에 남긴 사본에서 가져와야 한다. 사본이 없으면 발급처에서 재발급받는다.

Q: 다른 팀에 도메인을 추가했는데 404 DEPLOYMENTNOTFOUND가 납니다.
A: 도메인이 아직 소유권 검증을 통과하지 못한 상태다. _vercel TXT 값이 대시보드에 지금 표시되는 값과 같은지 확인하고, 대시보드의 Refresh 또는 API의 verify 엔드포인트로 검증을 직접 트리거한다. TXT가 맞아도 자동 재검사가 오래 걸릴 수 있다.

Q: 배포는 Ready인데 vercel.app 주소가 Vercel 로그인 화면으로 넘어갑니다.
A: 팀에 Deployment Protection이 기본으로 켜져 있어서다. 기본값 all_except_custom_domains 는 커스텀 도메인은 공개하고 vercel.app 주소만 보호한다. 공개 사이트라면 프로젝트 설정에서 끄거나 ssoProtection 을 null 로 바꾼다.

Q: GATSBY·NEXTPUBLIC_ 변수를 등록하려는데 secret으로 저장할 수 없다고 합니다.
A: 브라우저 번들에 포함되는 공개 접두사 변수는 민감 정보로 저장할 수 없다. --visibility config --no-sensitive 로 일반 변수로 등록한다. 진짜 비밀값이라면 접두사를 빼고 서버에서만 읽도록 코드를 바꿔야 한다.

Q: DNS를 바꾸기 전에 새 프로젝트를 미리 점검할 수 있나요?
A: 가능하다. 배포 후 <프로젝트>.vercel.app 임시 주소로 홈, 글, 사이트맵, RSS, robots.txt를 확인한 뒤 도메인을 추가하면 된다. 접근 보호가 켜져 있으면 먼저 끄거나 vercel curl 로 우회해 점검한다.

Q: 옛 CNAME 값을 그대로 둬도 사이트가 열리는데 꼭 바꿔야 하나요?
A: 바꿔야 한다. 프로젝트별 CNAME이 지금은 같은 공통 IP로 풀리지만 옛 레코드의 수명은 보장되지 않는다. Vercel이 새 프로젝트에 표시하는 값으로 교체하고, 루트 A 레코드는 공통 IP라 그대로 두면 된다.


Written by Jeon Byung Hun 개발을 즐기는 bottlehs - Engineer, MS, AI, FE, BE, OS, IOT, Blockchain, 설계, 테스트