글 · 시리즈 내 사이트 만들기 · 3번째 글 / 총 3편
Vercel에 배포한 사이트를 서브도메인으로 나누기
2026.09.26
사이트를 만들면서 /, /blog, /portfolio 경로를 각각 메인, 블로그, 포트폴리오로 사용하고 있었다. 기능이 어느 정도 정리되고 나니 주소도 나누고 싶어졌다.
앞 글에서 만든 메뉴에는 이미 도메인 모양의 이름을 표시하고 있었다. 이제 그 이름으로 접속했을 때 각 페이지를 보여주도록 연결할 차례였다.
| 역할 | 주소 |
|---|---|
| 메인 | minjun.me |
| 블로그 | blog.minjun.me |
| 포트폴리오 | portfolio.minjun.me |
경로를 도메인으로 바꾸는 일은 링크 몇 개를 수정하는 것으로 끝나지 않았다. Vercel 프로젝트에 도메인을 연결하고, DNS에 레코드를 추가하고, Next.js가 호스트 이름에 따라 요청을 처리하도록 만들어야 했다. 헤더의 사이트 전환 메뉴도 실제 도메인을 사용하도록 바꿔야 했다.
Vercel 사이트에서 배포하기
이번 배포는 CLI 명령어를 직접 입력하지 않고 Vercel 사이트에서 진행했다. 기존 minjun-me 프로젝트를 열어 웹 화면에서 프로덕션 배포를 시작했다.
배포 로그에는 Next.js 빌드와 타입 검사 결과가 함께 출력됐다. 배포가 완료된 뒤에는 Vercel이 발급한 주소와 minjun-me.vercel.app 별칭으로 먼저 사이트를 확인할 수 있었다.
이 단계에서 애플리케이션이 배포된 것과 커스텀 도메인이 연결된 것은 별개라는 점을 알게 됐다. Vercel 배포가 성공해도 minjun.me의 DNS가 Vercel을 가리키지 않으면 사용자가 도메인으로 접속할 수 없다.
가비아에 DNS 레코드 추가하기
도메인은 가비아에서 관리하고 있었기 때문에 가비아의 DNS 관리 화면에서 레코드를 추가했다. 당시에는 세 주소에 같은 IP를 사용하는 A 레코드를 설정했다. 아래 표는 그때 입력한 값이다. 새로 연결할 때는 이 값을 그대로 복사하기보다 Vercel 프로젝트의 Domains 화면에서 각 도메인에 안내하는 레코드를 확인해야 한다.
| 타입 | 호스트 | 값 |
|---|---|---|
| A | @ | 76.76.21.21 |
| A | blog | 76.76.21.21 |
| A | portfolio | 76.76.21.21 |
가비아 화면에서 호스트에 @를 입력하면 minjun.me를 의미한다. blog와 portfolio를 입력하면 각각 blog.minjun.me, portfolio.minjun.me가 된다. TTL은 기본값인 600초를 그대로 두었다.
DNS 레코드를 추가한 뒤에는 세 도메인을 Vercel 프로젝트에도 등록해야 했다. 배포를 AI에게 부탁했더니, 내가 CLI 명령어를 입력하지 않아도 AI가 아래 명령어를 실행해 minjun-me 프로젝트에 도메인을 등록했다.
vercel domains add minjun.me minjun-me
vercel domains add blog.minjun.me minjun-me
vercel domains add portfolio.minjun.me minjun-me
Vercel에 도메인을 등록하는 것과 DNS 레코드가 실제로 반영되는 것도 다른 단계였다. 등록 직후에는 Vercel에서 도메인이 아직 제대로 설정되지 않았다는 경고가 보였다. DNS 제공자의 네임서버를 Vercel로 바꾸는 방법도 있었지만, 가비아의 네임서버를 유지하고 A 레코드만 추가하는 방법을 선택했다.
홈 경로를 서브도메인으로 리다이렉트하기
DNS가 도메인을 Vercel 프로젝트까지 연결해 준다면, Next.js는 들어온 요청에 어떤 페이지를 보여줄지 결정한다. 먼저 minjun.me/blog와 minjun.me/portfolio에 접속했을 때 주소를 각각 서브도메인으로 바꾸도록 했다.
async redirects() {
return ['blog', 'portfolio'].map((section) => ({
source: `/${section}`,
has: [{ type: 'host' as const, value: 'minjun\\.me' }],
destination: `https://${section}.minjun.me`,
permanent: true,
}));
}
여기서 has의 host 조건은 현재 요청의 호스트 이름을 확인한다. 메인 도메인에서만 이 리다이렉트를 적용해야 하므로 minjun.me를 조건에 넣었다.
리다이렉트는 브라우저의 주소를 바꾼다. 그래서 minjun.me/blog를 요청하면 브라우저가 https://blog.minjun.me를 다시 요청하고 주소창도 바뀐다.
서브도메인의 루트 요청을 내부 경로에 연결하기
서브도메인으로 이동한 다음에는 / 요청이 들어온다. 하지만 실제 블로그 페이지는 애플리케이션 내부의 /blog 경로에 있다. 이 둘을 연결하기 위해 rewrites를 사용했다.
async rewrites() {
return {
beforeFiles: ['blog', 'portfolio'].map((section) => ({
source: '/',
has: [
{ type: 'host' as const, value: `${section}\\.minjun\\.me` },
],
destination: `/${section}`,
})),
};
}
blog.minjun.me/에 요청이 오면 Next.js 내부에서는 /blog 페이지를 렌더링한다. 이때 브라우저 주소는 그대로 blog.minjun.me/에 남는다.
리다이렉트와 리라이트의 차이를 직접 나눠서 생각하니 설정이 조금 더 명확해졌다.
redirects: 브라우저 주소를 다른 주소로 바꾼다.rewrites: 주소는 유지하고 서버 내부에서 다른 경로의 페이지를 보여준다.
메인 도메인에서 서브도메인으로 이동할 때는 redirects, 서브도메인의 루트 주소를 내부 페이지에 연결할 때는 rewrites를 사용했다.
헤더와 애플리케이션 라우팅을 나누기
도메인을 연결하고 나니 헤더의 사이트 전환 메뉴도 실제 주소를 사용해야 했다. 이때 SiteSwitcher가 어느 도메인으로 이동할지를 정하는 부분은 도메인 모양의 로고를 페이지 선택 메뉴로 만들기에서 다뤘다.
이 글에서는 메뉴가 만든 주소가 실제 페이지로 연결되도록 Vercel과 Next.js 설정을 확인하는 데 집중한다. 메인 도메인에서 서브도메인으로 주소를 바꾸는 redirects, 서브도메인의 루트 요청을 내부 경로에 연결하는 rewrites, 그리고 DNS가 서로 다른 역할을 맡는다.
헤더의 현재 사이트 표시까지 확인하는 과정에서 경로만 비교해서는 부족한 문제도 발견했다. 서브도메인의 / 요청은 내부적으로 /blog 또는 /portfolio 페이지를 렌더링할 수 있기 때문이다. 이 문제를 확인하려면 현재 주소의 hostname과 pathname을 나눠 봐야 했다.
경로만으로 현재 사이트를 판단할 수 없었다
헤더가 읽어야 할 값은 두 가지였다. 커스텀 도메인에서는 hostname으로 사이트를 구분하고, 로컬이나 Vercel 미리보기에서는 경로를 보조 기준으로 사용했다. 메뉴를 열고 닫는 상태와 선택지 미리보기, 키보드 포커스 처리는 앞 글에서 다뤘으므로 여기서는 현재 사이트를 판별하는 부분만 살펴본다.
usePathname()은 Next.js가 현재 경로를 읽어 주는 hook이다.
const pathname = usePathname();
const isPortfolio =
pathname === '/portfolio' || pathname.startsWith('/portfolio/');
커스텀 도메인을 사용하지 않는 로컬 환경이나 Vercel 미리보기에서는 hostname만으로 현재 사이트를 알 수 없다. 이때 pathname을 보조 기준으로 사용한다. 포트폴리오 상세 경로처럼 /portfolio로 시작하는 경로도 같은 사이트로 처리해야 해서 startsWith 조건을 함께 두었다.
useSyncExternalStore()는 브라우저의 hostname처럼 React 바깥에 있는 값을 읽는다. 세 번째 인자로 서버 snapshot을 전달할 수 있어서 서버 렌더링과 hydration 시점의 값을 맞출 수 있다.
const subscribe = () => () => {};
const getHostname = () => window.location.hostname;
const getServerHostname = () => '';
const hostname = useSyncExternalStore(
subscribe,
getHostname,
getServerHostname
);
getHostname은 브라우저에서 window.location.hostname을 읽고, getServerHostname은 서버에서 사용할 빈 값을 반환한다. 서버에는 window가 없기 때문에 렌더링 중에 브라우저 API를 직접 읽으면 안 된다. 서버와 hydration 초기 단계가 같은 빈 snapshot으로 시작한 뒤 브라우저 snapshot으로 갱신되도록 한 이유다.
여기서 subscribe가 빈 함수인 것이 처음에는 어색했다. 일반적인 외부 store라면 이벤트를 등록하고 정리 함수를 반환해야 하기 때문이다. 하지만 hostname은 같은 문서 안에서 실시간으로 바뀌지 않는다. 다른 도메인으로 이동할 때 브라우저가 새 문서를 요청하므로 현재 화면에서 hostname 변경을 감시할 이벤트가 없다. 그래서 subscribe는 이벤트를 등록하지 않고, 호출되면 빈 정리 함수를 반환한다.
처음에는 hostname만 useState와 useEffect로 읽는 단순한 방법도 생각했다.
const [hostname, setHostname] = useState('');
useEffect(() => {
setHostname(window.location.hostname);
}, []);
이 방식은 목적이 바로 드러난다. 다만 첫 렌더링에서는 hostname이 비어 있고 effect가 실행된 뒤에 실제 값이 들어온다. 구현에서는 브라우저 바깥의 값을 읽는 역할과 서버 snapshot을 함께 표현하려고 useSyncExternalStore를 사용했다.
이 선택이 초기 표시의 흔들림까지 없애는 것은 아니다. useSyncExternalStore도 서버 렌더링과 hydration에서는 빈 hostname으로 시작한다. 당시 코드에서는 hostname이 비어 있으면 홈으로 판단했으므로 실제 hostname을 읽기 전까지 홈 기준으로 계산될 여지가 있다. 확인한 수정 결과는 브라우저의 hostname을 읽은 뒤 현재 사이트를 알맞게 표시하고 도메인 사이를 이동한다는 범위다.
배포 후 경로 확인하기
설정을 배포한 뒤에는 메뉴의 시각적인 이동 흐름은 앞 글의 GIF로 확인하고, 이 글에서는 각 계층의 경로가 의도한 방식으로 연결되는지 따로 확인했다.
minjun.me/blog와minjun.me/portfolio가 각각 서브도메인으로 리다이렉트되는지 확인한다.blog.minjun.me/와portfolio.minjun.me/의 주소는 유지한 채 내부 페이지가 렌더링되는지 확인한다.- 메인, 블로그, 포트폴리오의 DNS 레코드가 Vercel 프로젝트를 가리키는지 확인한다.
헤더에서 실제 도메인 사이를 이동하고 현재 사이트 이름을 표시하는 흐름은 도메인 모양의 로고를 페이지 선택 메뉴로 만들기의 실제 도메인에서 메뉴 사용해 보기 절에서 다뤘다.
마무리
배포 후에는 DNS 연결, 주소 전환, 내부 페이지 선택을 나눠 확인해야 했다. 배포가 성공했다는 사실만으로 세 도메인의 동작까지 확인된 것은 아니었다.
Vercel은 프로젝트를 배포하고 도메인을 연결한다. 가비아는 도메인 요청이 Vercel로 향하도록 DNS 레코드를 관리한다. Next.js는 호스트 이름에 따라 리다이렉트와 리라이트를 적용한다. 헤더 컴포넌트는 사용자가 실제 도메인 사이를 이동하도록 절대 주소를 만든다.
이 네 부분을 한꺼번에 생각하면 어디에서 문제가 생겼는지 찾기 어렵다. 반대로 주소가 바뀌는 문제인지, DNS가 연결되지 않은 문제인지, 내부 페이지를 잘못 렌더링하는 문제인지 나누면 확인할 범위가 작아진다.
이제 minjun.me는 소개를 보여주고, blog.minjun.me는 기록을 모으고, portfolio.minjun.me는 작업을 소개하는 주소가 됐다. 각 사이트의 콘텐츠도 더 채워 나가려고 한다.