Vercel API를 활용한 맞춤 도메인 관리 구축: 장점, 단점, 그리고 DNS 전파
Source: Dev.to
WikiBeem을 만들기 시작했을 때, 커스텀 도메인은 간단해 보였습니다. Vercel에 도메인을 추가하고, 사용자에게 DNS 레코드를 보여주면—끝. 맞나요?
아니요. 엣지 케이스, 타이밍 문제, 그리고 DNS 전파 지연 때문에 새벽 2시에 인생 선택을 의심하게 됩니다.
아래는 제가 실제로 구축한 방법, 깨진 부분, 그리고 배운 점입니다.

왜 Vercel의 API인가?
저는 WikiBeem을 Vercel에 호스팅하고 있기 때문에, 그들의 도메인 관리 API를 사용하는 것이 합리적이었습니다. 이 API는:
- SSL 인증서를 자동으로 처리합니다
- DNS 라우팅을 관리합니다
- Vercel 인프라와 직접 통합됩니다
대안으로는 AWS Route 53이나 Cloudflare를 사용해 모든 것을 처음부터 구축해야 했을 텐데, 기본적으로 같은 결과를 얻기 위해 훨씬 더 많은 작업이 필요했습니다.
Vercel은 API를 감싸는 공식 SDK(@vercel/sdk)를 제공하여 통합을 더 깔끔하게 만들었습니다. 하지만 문서는 실제 프로덕션 환경에서 무엇이 작동하는지 파악하기 위해 많은 시행착오가 필요했습니다.
기본 흐름
사용자가 사이트에 커스텀 도메인을 추가하려고 할 때, 다음 단계가 필요합니다:
- 사용자가 도메인을 입력 (예:
docs.yourcompany.com) - API를 통해 도메인을 Vercel에 추가
- Vercel이 구성해야 할 DNS 레코드를 반환
- 사용자가 등록기관에서 DNS를 구성
- DNS 전파 여부를 확인하기 위해 Vercel을 폴링
- 검증이 성공하면 SSL 인증서가 발급
- 사이트가 커스텀 도메인에서 작동
이론적으로는 간단합니다. 실제로는? 그렇지 않습니다.
Vercel 클라이언트 설정
Vercel SDK를 감싸는 얇은 래퍼를 만들었습니다. 이를 통해 오류를 한 곳에서 처리하고, 자격 증명이 올바르게 구성되었는지 확인할 수 있습니다.
import { Vercel } from '@vercel/sdk'
export class VercelClient {
private vercel: Vercel
private projectId: string
private teamId?: string
constructor() {
const token = process.env.VERCEL_TOKEN || ''
this.projectId = process.env.VERCEL_PROJECT_ID || ''
this.teamId = process.env.VERCEL_TEAM_ID
if (!token || !this.projectId) {
console.warn(
'Vercel credentials not configured. Custom domain features will not work.'
)
}
this.vercel = new Vercel({ bearerToken: token })
}
}
팁: 사용하기 전에 반드시 필요한 환경 변수가 존재하는지 확인하세요. 자격 증명이 없으면 디버깅하기 어려운 난해한 오류가 발생합니다.
도메인 추가
첫 번째 API 호출은 간단합니다—Vercel에 도메인을 추가하고 싶다고 알려주기만 하면 됩니다:
async addDomain(domain: string) {
const response = await this.vercel.projects.addProjectDomain({
idOrName: this.projectId,
requestBody: { name: domain },
...(this.teamId && { teamId: this.teamId })
})
return response
}
일관되지 않은 검증 레코드 처리
Vercel은 검증 레코드를 반환하지만, 항상 같은 위치에 있는 것은 아닙니다. 때로는 응답 객체에 직접 포함되어 있고, 다른 경우에는 도메인 구성을 별도로 가져와야 합니다. 저는 두 경우를 모두 확인했습니다:
// Try to get verification records from domain config first
let verificationRecords: { type: string; name: string; value: string }[] = []
try {
const domainConfig = await vercelClient.getDomainConfig(domain)
if (domainConfig?.verification) {
verificationRecords = domainConfig.verification.map(v => ({
type: v.type,
name: v.domain || domain,
value: v.value
}))
}
} catch (e) {
// Fallback to response verification if config fails
if (vercelDomain.verification) {
verificationRecords = vercelDomain.verification.map(v => ({
type: v.type,
name: v.domain || domain,
value: v.value
}))
}
}
Lesson: API 응답은 일관되지 않을 수 있습니다. 대체 방안을 마련해 두면 알 수 없는 오류를 방지할 수 있습니다.
DNS 검증: 기다리는 게임
사용자들이 좌절감을 느끼는 부분입니다. 레지스트라에서 DNS 레코드를 설정하고 Verify 버튼을 클릭해도… 바로 결과가 나타나지 않습니다.
- DNS 전파는 몇 분에서 48시간까지 걸릴 수 있습니다.
- 서브도메인은 보통 더 빠릅니다 (5‑30분).
- Apex 도메인은 훨씬 오래 걸릴 수 있습니다.
저는 몇 초마다 검증 상태를 확인하는 폴링 메커니즘을 만들었지만, Vercel API를 과도하게 호출하지 않도록 주의했습니다.
// Front‑end polling every 5 seconds
const pollDomainStatus = async () => {
const response = await fetch(`/api/domain?siteId=${siteId}`)
const data = await response.json()
if (data.domain?.isVerified) {
setPolling(false) // Stop polling when verified
return
}
setTimeout(pollDomainStatus, 5000) // Check again in 5 seconds
}
또한 사용자가 수동으로 Check Status 버튼을 눌러 상태를 확인할 수 있도록 추가했습니다. 사용자가 수동으로 제어할 수 있게 하면 경험이 개선됩니다.
SSL 인증서 레이스
DNS가 확인되면 Vercel이 자동으로 SSL 인증서를 프로비저닝하지만, 또 다른 지연이 있습니다—인증이 성공한 후에도 인증서가 발급되는 데 몇 분 정도 걸릴 수 있습니다.
저는 인증 상태와 별도로 SSL 상태를 추적합니다:
const sslStatus = vercelDomain.verified ? 'issued' : 'pending'
실제로 API는 한 단계 뒤처질 수 있습니다: 인증서가 아직 발급 중일 때 verified: true를 보고할 수 있습니다. 따라서 UI는 인증서가 활성 상태로 확인될 때까지 “pending” 상태를 표시해야 합니다.
요약
| 분야 | 배운 점 |
|---|---|
| Vercel SDK | 빠른 통합에 좋지만, 문서의 빈틈으로 인해 실험이 필요합니다. |
| Credential handling | 환경 변수가 누락되는 경우를 항상 방지하고, 명확한 경고와 함께 빠르게 실패하도록 합니다. |
| API inconsistencies | 여러 엔드포인트의 데이터를 검증하고 대체 방안을 제공합니다. |
| Polling | 사용자에게 정보를 제공하면서도 속도 제한을 피하도록 빈도를 조절합니다. |
| User experience | 수동 “상태 확인” 작업을 추가하고 “검증 중”, “SSL 대기 중”, “준비 완료”에 대한 명확한 UI 상태를 제공합니다. |
| Edge cases | DNS 전파 시간은 크게 차이날 수 있으므로, 긴 대기 시간을 견딜 수 있도록 흐름을 설계합니다. |
견고한 커스텀 도메인 시스템을 구축하는 것은 ‘행복한 경로’ 코드를 작성하는 것보다 알 수 없는 상황을 처리하는 것이 더 중요합니다. 위의 패턴을 활용하면 인터넷이 느려지더라도 사용자에게 원활한 경험을 제공할 수 있습니다.
SSL 프로비저닝 버퍼
SSL 인증서가 아직 실제로 준비되지 않았기 때문에, 약간의 버퍼 시간을 추가하고 사용자에게 “SSL 프로비저닝” 상태를 표시합니다.
멀티‑테넌트 라우팅: 실제 도전 과제
가장 까다로운 부분이었습니다. 누군가가 docs.yourcompany.com에 접속하면, 어떤 사이트를 보여줄지 어떻게 판단할까요?
Vercel이 DNS 라우팅과 SSL을 처리하지만, 실제 요청 라우팅은 우리에게 달려 있습니다. Next.js 미들웨어에서 host 헤더를 확인하여 커스텀 도메인인지 판단합니다:
// middleware.ts
import { NextResponse, NextRequest } from 'next/server';
export default function middleware(request: NextRequest) {
const host = request.headers.get('host') || '';
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const mainDomain = new URL(appUrl).hostname;
// Is this a custom domain?
const isCustomDomain =
host !== mainDomain &&
!host.startsWith('localhost') &&
!host.startsWith('127.0.0.1');
// Pass host to pages so they can look up the site
const response = NextResponse.next();
response.headers.set('X-Host', host);
return response;
}
페이지 컴포넌트에서 사이트 조회하기
// page.tsx (or any server component)
import { headers } from 'next/headers';
import prisma from '@/lib/prisma';
export default async function Page() {
// Get host from header
const host = headers().get('x-host') || '';
// Look up domain in database
const domain = await prisma.domain.findUnique({
where: { domain: host },
select: { siteId: true },
});
// Get the site
const site = await prisma.site.findUnique({
where: { id: domain?.siteId },
});
// …render the page using `site`
}
엣지 케이스
- DB에 도메인이 없을 경우 – 404를 반환합니다.
- DNS는 설정됐지만 도메인이 인증되지 않은 경우 – “도메인이 구성되지 않음” 메시지를 표시합니다.
Source: …
URL 구조 차이
맞춤 도메인은 기본 라우팅과 다른 URL 구조를 가집니다.
| 라우트 유형 | 예시 |
|---|---|
| Default | wikibeem.com/yoursite/docs/getting-started |
| Custom | docs.yourcompany.com/docs/getting-started |
- 기본 도메인에서는 첫 번째 세그먼트(
yoursite)가 사이트 슬러그입니다. - 맞춤 도메인에서는 경로에 사이트 슬러그가 없으며, 도메인 자체가 사이트를 식별하므로 문서 경로가 바로 시작됩니다.
라우팅 로직을 두 경우를 모두 처리하도록 리팩터링했습니다:
if (isCustomDomain) {
// Custom domain: domain identifies the site, no site slug in path
const domain = await prisma.domain.findUnique({
where: { domain: host },
});
// Document slug is everything after the domain
fullDocSlug = [siteSlug, ...docSlugArray].join('/');
} else {
// Default route: siteSlug is first segment, rest is document path
fullDocSlug = docSlugArray.join('/');
}
라우팅 엣지 케이스는 교묘하게 숨겨져 있어, 이를 정확히 구현하는 데 생각보다 오래 걸렸습니다.
오류 처리: 모든 것이 깨질 것으로 예상하세요
일반적인 프로덕션 오류
| 오류 | 처리 방법 |
|---|---|
| 도메인이 이미 존재합니다 | 도메인이 이미 사용 중임을 사용자에게 알리고, 일반적인 500 오류는 피합니다. |
| DNS가 구성되지 않음 | 필요한 DNS 레코드를 표시하고 사용자가 추가하도록 안내합니다. |
| 전파 시간 초과 | 몇 분간 폴링 후 다음 메시지를 표시합니다: “DNS 전파는 최대 48 시간이 걸릴 수 있습니다. 나중에 다시 확인하거나 DNS 설정을 검증하세요.” |
| SSL 인증서 실패 | SSL 상태를 별도로 확인하고 명확한 오류 메시지를 표시합니다. |
| 경쟁 상태 | 사용자가 검증 진행 중에 도메인을 제거하면 적절히 정리하고, 동시 작업을 방지합니다. |
내가 다르게 할 점
- 웹훅 추가 – Vercel은 도메인 이벤트를 위한 웹훅을 지원합니다. 검증 이벤트를 수신하는 것이 폴링보다 깔끔합니다.
- 더 나은 상태 메시지 – 구체적으로: “DNS 확인 중… → DNS 확인 완료, SSL 프로비저닝 중… → SSL 인증서 발급, 1‑2 분 내 준비 완료.”
- API 호출 전에 검증 – 도메인 형식을 확인하고, 기존 사용 여부를 체크하며, 가능하면 Vercel API를 호출하기 전에 소유권을 확인합니다.
- 재시도 로직 – 불안정한 API 호출에 대해 지수 백오프를 구현합니다.
- 테스트 – 스테이징 도메인을 사용해 전체 흐름을 엔드‑투‑엔드로 테스트합니다. DNS 전파가 번거롭지만 그만한 가치가 있습니다.
결과
모든 작업을 마친 후, 커스텀 도메인은 안정적으로 작동합니다. 사용자는 다음을 할 수 있습니다.
- 도메인을 추가합니다.
- DNS를 구성합니다.
- 몇 분 안에 SSL이 적용된 커스텀 도메인에서 사이트가 실시간으로 표시됩니다.
사용자 경험은 아직 개선될 수 있습니다—웹훅 지원 및 보다 풍부한 상태 메시지가 로드맵에 포함되어 있지만—핵심 흐름은 견고하고 사용자는 만족합니다.
커스텀 도메인이 실제로 어떻게 동작하는지 보고 싶다면, WikiBeem을 확인해 보세요. 몇 번의 클릭만으로 ClickUp 문서를 자체 도메인에 게시할 수 있습니다.