도메인 만료로 블로그를 한 번 잃고 새 도메인으로 옮긴 지 한 달 만에, 이번에는 호스팅 계정이 정지됐다. 사이트 주소를 열면 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_DISABLEDVercel 에러 코드는 범위가 분명하다.
| 에러 코드 | 상태 | 뜻 |
|---|---|---|
DEPLOYMENT_DISABLED |
402 | 계정·팀 문제로 배포가 비활성화됨 |
DEPLOYMENT_NOT_FOUND |
404 | 요청한 호스트에 연결된 배포가 없음 |
DEPLOYMENT_BLOCKED |
403 | 정책 위반 등으로 배포가 차단됨 |
NOT_FOUND |
404 | 배포는 있지만 해당 경로가 없음 |
DEPLOYMENT_DISABLED는 코드나 DNS로 고칠 수 있는 문제가 아니다. 계정이 풀릴 때까지 기다리거나, 다른 계정(팀)으로 옮겨야 한다. 정적 블로그는 서버 상태가 없으므로 옮기는 쪽이 빠르다.
옮기기 전에 챙길 것
정지된 팀의 대시보드와 API는 읽기조차 안 되는 경우가 많다. 실제로 CLI 토큰은 Not authorized 를 돌려줬다. 그러니 옮기기 전에 “옛 프로젝트에만 있던 설정”이 무엇인지 떠올려야 한다.
- 환경 변수: 옛 프로젝트에서 꺼낼 수 없다. 코드에서
process.env를 읽는 곳을 모두 찾아 어떤 값이 필요한지 목록을 만든다. - 빌드 설정: 프레임워크, 빌드 명령, 출력 폴더, Node 버전.
vercel.json과package.json의engines에 적혀 있으면 새 프로젝트에서도 자동으로 적용된다. - 도메인과 리다이렉트: 루트 → www 리다이렉트처럼 대시보드에서만 설정한 것이 있는지 확인한다. 코드(
vercel.json)에 있으면 그대로 따라온다. - 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 bluebuttonAPI로 상태를 보면 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라 그대로 두면 된다.