블로그를 서브도메인에서 하위 경로로 옮긴 이유, 그리고 CSP가 애드센스를 막고 있었습니다

회사 사이트와 기술 블로그를 따로 운영하는 구성은 흔합니다. 회사는 duolabs.co.kr, 블로그는 blog.duolabs.co.kr.
저희도 그렇게 시작했고, 만들 때는 이쪽이 단순해서 그렇게 골랐습니다.

그런데 어느 날 숫자를 나란히 놓고 보니 이상했습니다.

| 회사 도메인 | 블로그 서브도메인
색인 대상 URL | 8종 × 4개 언어 ≈ 26개 | 발행 글 169편 + 태그·카테고리·피드
갱신 빈도 | 사실상 정지 | 상시
검색 유입 | 거의 없음 | 대부분

글은 원래부터 회사 쪽 데이터베이스에 있었고, 발행도 회사 관리자 화면에서 했습니다.
갈라져 있던 것은 콘텐츠가 아니라 주소 하나였습니다.
그런데 그 주소 하나 때문에, 169편이 쌓아 올린 신뢰도가 정작 영업에 쓰는 도메인에는
거의 적립되지 않고 있었습니다.

같은 고민을 하고 계신 분이 많을 것 같아, 옮기면서 확인한 것들을 정리했습니다.

왜 이 주제가 중요한가

서브도메인이냐 하위 경로냐는 취향 문제로 보이기 쉽습니다.
하지만 검색엔진은 서브도메인을 사실상 별개 사이트로 취급합니다.
서치 콘솔에서도 별도 속성으로 잡히고, 사이트 단위로 매기는 신호도 따로 갑니다.

그래서 블로그가 아무리 잘 되어도 그 힘이 회사 도메인의 서비스 소개 페이지나
가격 페이지로 흘러가지 않습니다. 두 사이트를 각자 키우는 셈이 됩니다.

같은 콘텐츠를 하위 경로(duolabs.co.kr/blog/...)에 두면 이야기가 달라집니다.
한 사이트가 되고, 글이 쌓일수록 도메인 전체가 같이 올라갑니다.

콘텐츠 마케팅으로 문의를 받는 것이 목적이라면, 이 차이는 생각보다 큽니다.
정작 전환이 일어나야 하는 페이지가 그 혜택을 하나도 못 받고 있었으니까요.

광고도 같은 뿌리의 문제였습니다

애드센스의 ads.txt는 도메인 루트가 기준입니다.
서브도메인에 광고를 붙여 두고 ads.txt는 루트에 두는 구성이 인정은 되지만,
굳이 애매하게 유지할 이유가 없었습니다.

그런데 코드를 열어 보니 문제는 그게 아니었습니다.

진짜 원인: CSP가 애드센스 로더를 차단하고 있었습니다

블로그 레이아웃에는 애드센스 로더가 멀쩡히 들어 있었습니다.



그런데 응답 헤더를 확인해 보니 이랬습니다.

content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline'; ...

script-src가 'self'뿐입니다. 구글 도메인에서 오는 스크립트는 브라우저가 로드조차 하지 않습니다.

보안을 조인다고 CSP를 'self' 기준으로 좁혀 놓은 것이 원인이었습니다.
폰트를 외부 CDN에서 자체 호스팅으로 옮기면서 "이제 외부 오리진이 없으니 전부 'self'로 좁히자"고
정리했는데, 그때 애드센스 로더가 같이 죽었습니다.

이것이 고약한 이유는 어디에도 에러가 뜨지 않는다는 점입니다.

서버 로그: 정상입니다. HTML은 200으로 잘 나갑니다

애드센스 대시보드: "노출 0". 그냥 트래픽이 없는 것처럼 보입니다

유일한 단서: 방문자 브라우저 콘솔의 CSP violation 한 줄

광고가 안 나올 때 보통은 승인 상태나 정책 위반을 먼저 의심합니다.
CSP는 확인 목록의 한참 아래에 있거나, 아예 목록에 없습니다.

그래서 이번에는 배포 스모크 테스트에 회귀 가드를 넣었습니다.

curl -sI https://duolabs.co.kr/blog/ | grep -o "pagead2.googlesyndication.com" \
|| { echo "ERROR: CSP blocks AdSense loader"; exit 1; }

CSP를 다시 좁히는 순간 배포가 실패합니다. 사람 기억에 맡기지 않습니다.

옮기기: basePath가 붙는 것과 안 붙는 것

Next.js는 basePath 한 줄로 앱 전체를 하위 경로로 옮겨 줍니다.

// next.config.ts
const nextConfig = {
basePath: "/blog",
};

canonical, 사이트맵, RSS, OG 이미지, 구조화 데이터가 전부 하나의 SITE_URL 상수에서
파생되게 해 두었다면 그 상수 한 줄만 바꾸면 나머지는 따라옵니다.

단, basePath가 자동으로 붙는 대상은 정해져 있습니다.

자동으로 붙습니다 | 직접 붙여야 합니다
next/link | 생짜
next/image | (스타일시트·폰트)
/_next/* 번들 | 매니페스트의 start_url, icons[].src
라우트 핸들러 경로 | 구조화 데이터에 손으로 쓴 경로

그래서 헬퍼 하나를 두고 그쪽에만 씁니다.

export const BASE_PATH = "/blog";
export function withBasePath(path: string): string {
return `${BASE_PATH}${path.startsWith("/") ? path : `/${path}`}`;
}

놓치면 배포 후에 폰트만 안 나오거나, PWA 매니페스트가 404가 되는 식으로 조용히 어긋납니다.
페이지 자체는 멀쩡해 보이기 때문에 늦게 발견됩니다.

robots.txt는 루트 하나만 유효합니다

이것이 옮기고 나서 제일 헷갈리는 지점입니다. 두 파일의 규칙이 서로 반대입니다.

robots.txt: 크롤러는 도메인 루트의 것 하나만 읽습니다.
duolabs.co.kr/blog/robots.txt는 아무도 보지 않습니다.
하위 경로 앱이 각자 만들던 robots는 의미가 없어지고,
루트 robots가 모든 구획의 규칙과 사이트맵을 대신 선언해야 합니다.

사이트맵: 반대로 자기 경로 이하만 담을 수 있습니다.
/blog/sitemap.xml은 /blog/**만 신고할 수 있습니다.

결과적으로 사이트맵은 여러 벌로 쪼개지고, 루트 robots.txt가 그것을 전부 안내합니다.

Sitemap: https://duolabs.co.kr/sitemap.xml
Sitemap: https://duolabs.co.kr/blog/sitemap.xml
Sitemap: https://duolabs.co.kr/docs/sitemap.xml

하나라도 빠뜨리면 그 구획이 통째로 발견되지 않습니다.

옮기면서 같이 발견한 것들

nginx: location 안의 add_header 하나가 보안 헤더를 전부 날립니다

nginx의 add_header는 상속 규칙이 직관과 반대입니다.

하위 블록에 add_header가 하나라도 있으면, 상위 블록의 add_header는 전부 무시됩니다.
일부가 아니라 전부입니다.

server {
add_header Content-Security-Policy "..." always;
add_header Strict-Transport-Security "..." always;

location /internal {
add_header Cache-Control "private, no-store" always;
# CSP도 HSTS도 이 경로에는 붙지 않습니다
}
}

캐시 헤더 하나를 추가하려다 그 경로의 CSP와 HSTS를 통째로 날린 상태였고,
한동안 아무도 몰랐습니다. 헤더가 사라진 것은 화면에 보이지 않으니까요.

해결은 경로별로 달라지는 값을 map으로 뽑아 server 단에서 한 번만 붙이는 것입니다.

map $uri $cache_control {
default "public, max-age=0, s-maxage=300, stale-while-revalidate=86400";
"~^/blog/_next/static/" "public, max-age=31536000, immutable";
"~^/internal" "private, no-store";
}

server {
add_header Content-Security-Policy "..." always;
add_header Cache-Control $cache_control always;
# location 안에는 add_header를 두지 않습니다
}

값이 빈 문자열이면 nginx가 해당 add_header를 건너뛴다는 점도 같이 쓸 수 있습니다.
경로에 따라 붙거나 붙지 않는 헤더를 이것으로 표현합니다.

force-dynamic이 엣지 캐시를 0으로 만들고 있었습니다

블로그의 모든 페이지가 force-dynamic이었습니다. 빌드 시점에는 데이터베이스에 접속할 수 없으니
그렇게 둔 것이고, 판단 자체는 맞습니다.

문제는 Next가 dynamic 라우트에 붙이는 기본 헤더입니다.

cache-control: private, no-cache, no-store, max-age=0, must-revalidate

no-store가 붙어 있으면 CDN은 HTML을 절대 담지 않습니다. 확인해 보니 전부 DYNAMIC,
즉 크롤러가 한 번 올 때마다 매번 서버에서 렌더하고 있었습니다.

로그인도 개인화도 없는 읽기 전용 블로그라면 엣지 캐시가 안전합니다.

cache-control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400

브라우저에는 캐시하지 않고(항상 최신 확인), CDN에는 5분 담고,
그 뒤 하루 동안은 낡은 응답을 먼저 주면서 뒤에서 갱신합니다.
발행 반영이 최대 5분 늦어지는 대신 크롤 예산과 응답 속도를 벌었습니다.

글 페이지가 크롤러 입장에서 막다른 길이었습니다

글 하단에 목록으로 돌아가는 링크 하나만 있었습니다.
크롤러는 글에 들어왔다가 나갈 곳이 없어 되돌아 나갑니다.

같은 카테고리와 겹치는 태그로 점수를 매겨 관련 글을 붙였습니다.
계산은 이미 캐시된 목록으로 하니 데이터베이스 조회가 늘지 않습니다.

const sharedTags = p.tags.filter((t) => tags.has(t)).length;
const sameFolder = post.folder && p.folder === post.folder ? 1 : 0;
const score = sameFolder * 3 + sharedTags;

같은 김에 저자를 조직이 아니라 사람으로 명시했습니다.
경험과 전문성 신호를 보는 요즘 기준에서 Organization만 적혀 있는 것은 약합니다.

서브도메인은 어떻게 했나

지웠느냐 하면, 아닙니다. 경로를 보존한 301 리다이렉트로 남겼습니다.

blog.duolabs.co.kr/어떤-글 → duolabs.co.kr/blog/어떤-글

이렇게 하면 기존 링크, 북마크, 외부 인용, RSS 구독이 전부 살아 있고,
쌓아 둔 신뢰도가 새 주소로 넘어갑니다.

여기서 중요한 것이 하나 있습니다.
301을 한 번 내보낸 뒤에 되돌리면 색인이 두 번 흔들립니다.

이전 기간에는 순위가 출렁이는 것이 정상입니다. 보통 3~6주 걸립니다.
그 출렁임을 보고 "역시 아니었나" 하며 되돌리는 것이 최악입니다.
되돌리는 판단은 순위가 아니라 장애를 근거로 해야 합니다.

주의할 점

이 작업을 그대로 따라 하실 분들을 위해, 순서에서 걸리기 쉬운 부분을 정리합니다.

경로 라우팅을 먼저, 301을 나중에. 반대로 하면 옛 주소가 아직 존재하지 않는 새 주소로 보내집니다

CDN 캐시 규칙에서 쿠키를 무시하도록 설정합니다. 같은 도메인이 되면서 회사 사이트의
세션 쿠키가 블로그 요청에도 실려 옵니다. 쿠키가 있다고 캐시를 건너뛰면 설정이 사실상 꺼진 것과 같습니다

서치 콘솔의 주소 변경 도구를 사용하고 사이트맵을 다시 제출합니다. 국내 유입을 본다면
네이버 서치어드바이저에도 같이 등록합니다

광고를 회사 도메인에 노출해도 괜찮은지 먼저 판단합니다. 기술적으로는 자연스러운 결과지만
브랜드 결정입니다. 저희는 광고를 /blog 아래에서만 쓰고 회사 소개 페이지에는 붙이지 않습니다

듀오랩스가 보는 관점

두 문제가 별개로 보였는데, 파 보니 뿌리가 같았습니다.
"콘텐츠를 어느 주소가 소유하는가" 하나였습니다.
검색 유입도 광고 수익도 결국 그 주소에 붙습니다.

그리고 CSP처럼 조용히 실패하는 것들은 반드시 테스트로 묶어 두시길 권합니다.
사람은 몇 달 전에 자기가 좁힌 헤더를 기억하지 못합니다.
장애는 알림이 오지만, 이런 종류는 아무 일도 일어나지 않은 것처럼 보이면서
그냥 수익과 유입이 없을 뿐입니다. 발견이 늦어지는 이유가 여기에 있습니다.

만들어 두고 끝이 아니라, 만든 뒤에 무엇이 조용히 새고 있는지 확인하는 일까지가
운영이라고 생각합니다.