이 블로그는 Next.js 로 만들고 Vercel 에 올려요. 화면은 세 군데에서 떠요 — 내 컴퓨터(로컬), 배포 전에 확인하는 Vercel 미리보기, 그리고 운영. 그런데 분석 스크립트와 광고를 그냥 붙이면 세 군데 모두에서 돌아요.
- 로컬에서 새로고침한 것까지 GA4 방문으로 잡힌다
- 미리보기 주소가 검색에 잡혀 운영 글과 중복 페이지가 된다
- 미리보기 화면에 애드센스 광고가 뜬다
그래서 환경을 셋으로 나누고, GA4 · 애드센스 · 검색 노출은 운영에서만 켜게 했어요. 코드는 이 블로그에 실제로 들어 있는 그대로예요.
환경을 어떻게 나누나 — NODE_ENV 와 VERCEL_ENV 두 개면 된다
next dev 면 로컬, Vercel 이 미리보기로 빌드했으면 미리보기, 나머지는 운영이에요. 변수 두 개로 한 번만 판단하고, 앱 전체가 이 값 하나(APP_ENV)만 보게 했어요.
// src/shared/config/app-env.ts
export type AppEnv = 'local' | 'preview' | 'production'
export const APP_ENV: AppEnv =
process.env.NODE_ENV === 'development'
? 'local'
: process.env.VERCEL_ENV === 'preview'
? 'preview'
: 'production'
| 어디서 | NODE_ENV | VERCEL_ENV | APP_ENV |
|---|---|---|---|
next dev (내 컴퓨터) | development | — | local |
| Vercel 미리보기 배포 | production | preview | preview |
| Vercel 운영 배포 | production | production | production |
주의할 점은 next build 의 NODE_ENV 는 미리보기에서도 production 이라는 거예요. NODE_ENV 만 보면 미리보기를 운영으로 착각해요. 그래서 VERCEL_ENV 를 같이 봐야 해요.
또 VERCEL_ENV 는 NEXT_PUBLIC_ 이 아니라서 브라우저 코드에선 안 보여요. 이 블로그는 정적으로 굽는 서버 컴포넌트(레이아웃 · 메타데이터 · robots · sitemap)에서만 읽어서 문제가 없었어요. 클라이언트 컴포넌트에서 환경을 알아야 한다면 서버에서 값을 내려 주거나 NEXT_PUBLIC_ 변수로 따로 넘겨야 해요.
GA4 를 운영에서만 — 컴포넌트가 null 을 돌려주게 한다
GA4 스크립트를 넣는 컴포넌트가 운영이 아니면 아무것도 그리지 않게 했어요. 스크립트 자체가 HTML 에 안 들어가니 로컬 · 미리보기 방문은 GA4 에 아예 안 잡혀요.
function Analytics() {
if (!GA_MEASUREMENT_ID || APP_ENV !== 'production') return null
return (
<>
<Script src={`https://www.googletagmanager.com/gtag/js?id=${GA_MEASUREMENT_ID}`} strategy="afterInteractive" />
<Script id="ga4" strategy="afterInteractive">
{`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${GA_MEASUREMENT_ID}');`}
</Script>
</>
)
}
strategy="afterInteractive"— 화면이 뜬 뒤에 받아서 첫 화면을 막지 않아요.- 측정 ID 는 비밀이 아니라 코드에 상수로 뒀어요. 어차피 HTML 에 그대로 나가는 공개 값이라 환경변수로 뺄 이유가 없었어요. 비워 두면 스크립트를 안 넣고요.
애드센스는 왜 next/script 가 아닌가 — 확인 크롤러가 HTML 에서 읽어야 해서
애드센스도 같은 방식으로 운영에서만 넣는데, next/script 대신 서버 HTML 의 <head> 에 <script> 를 그대로 넣었어요.
function AdSense() {
if (!ADSENSE_CLIENT || APP_ENV !== 'production') return null
return (
<script
async
src={`https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${ADSENSE_CLIENT}`}
crossOrigin="anonymous"
/>
)
}
export default function RootLayout({ children }) {
return (
<html lang="ko">
<head>
<AdSense />
</head>
{/* … */}
next/script 는 페이지가 뜬 뒤(hydration) 스크립트를 끼워 넣어요. 그러면 JS 를 돌리지 않고 HTML 만 읽는 애드센스 사이트 확인 크롤러가 코드를 못 볼 수 있어요. 애드센스가 안내하는 대로 <head> 에 직접 두면 정적 HTML 에 그대로 박혀요. async 라 첫 화면을 막지도 않고요.
애드센스 쪽은 세 가지가 같이 움직여요. 게시자 ID 하나만 넣으면 셋이 같이 켜지게 했어요.
| 무엇 | 언제 |
|---|---|
광고 스크립트 (<head>) | 운영에서만 |
소유 확인 <meta name="google-adsense-account"> | 운영에서만 (메타데이터 other) |
/ads.txt | 게시자 ID 가 있으면 (없으면 404) |
// app/ads.txt/route.ts
export const dynamic = 'force-static'
export function GET() {
if (!ADSENSE_CLIENT) return new Response('Not Found', { status: 404 })
const pub = ADSENSE_CLIENT.replace(/^ca-/, '')
// f08c47fec0942fa0 = Google 의 고정 인증 기관 ID
return new Response(`google.com, ${pub}, DIRECT, f08c47fec0942fa0\n`, {
headers: { 'content-type': 'text/plain; charset=utf-8' },
})
}
미리보기가 검색에 잡히지 않게 — robots.txt 와 noindex 를 둘 다 건다
미리보기 배포는 robots.txt 로 전부 막고, 페이지마다 noindex 도 붙여요. 하나만 걸면 빈틈이 생겨서 둘 다 걸었어요.
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
if (APP_ENV !== 'production') return { rules: { userAgent: '*', disallow: '/' } }
return { rules: { userAgent: '*', allow: '/' }, sitemap: `${SITE_URL}/sitemap.xml` }
}
// app/layout.tsx 의 metadata
export const metadata: Metadata = {
// …
...(APP_ENV === 'production' ? {} : { robots: { index: false, follow: false } }),
}
robots.txt만 걸면: 크롤러가 페이지를 못 읽지만, 다른 곳에 링크가 걸려 있으면 주소만 색인될 수 있어요.noindex만 걸면: 크롤러가 미리보기 페이지를 다 읽고 다녀요.
사이트맵도 운영이 아니면 비워요. 검색에서 빼겠다고 해 놓고 사이트맵엔 넣으면 앞뒤가 안 맞으니까요.
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
if (APP_ENV !== 'production') return []
// …
}
지금 어느 화면인지 헷갈리지 않게 — 배지 · 탭 제목 · 파비콘
로컬과 운영을 같은 브라우저에 띄워 두면 지금 보는 탭이 어느 쪽인지 헷갈려요. 그래서 운영이 아니면 세 군데에 표시해요.
export const ENV_BADGE: Record<AppEnv, { label: string; prefix: string; color: string } | null> = {
local: { label: 'LOCAL', prefix: '[로컬] ', color: '#f59e0b' },
preview: { label: 'PREVIEW', prefix: '[미리보기] ', color: '#38bdf8' },
production: null,
}
- 헤더 배지 — 블로그 이름 옆에
LOCAL/PREVIEW - 탭 제목 — 메타데이터 title template 앞에
[로컬]/[미리보기] - 파비콘 색 —
app/icon.tsx가 배지 색으로 아이콘을 그린다(운영은 원래 초록)
탭이 여러 개 열려 있어도 파비콘 색만 보고 운영 탭을 찾을 수 있어요.
걸렸던 함정 두 가지
운영 주소를 Vercel 변수로 만들었다가 vercel.app 이 박혔다
canonical · 사이트맵 · 공유 카드에 쓸 절대 주소를 처음엔 Vercel 이 주는 VERCEL_PROJECT_PRODUCTION_URL 로 만들려 했어요. 그런데 이 값은 빌드할 때 정해져서, 도메인을 붙이기 전에 한 배포는 *.vercel.app 주소를 그대로 박았어요. 그래서 운영 주소는 코드에 상수로 두고, 로컬만 localhost 로 바꿨어요.
export const PRODUCTION_URL = 'https://www.yangtinomad.com'
export const SITE_URL = (
process.env.SITE_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:3140' : PRODUCTION_URL)
).replace(/\/$/, '')
미리보기도 운영 주소를 가리키는데, 미리보기는 어차피 noindex 라 canonical 이 운영을 가리키는 게 오히려 맞아요.
직접 빌드하면 VERCEL_ENV 를 누가 정하나
이 블로그는 Vercel 이 빌드하지 않고 GitHub Actions 에서 빌드해 결과물만 올려요(vercel build → vercel deploy --prebuilt). 이때 VERCEL_ENV 는 vercel pull --environment=… 과 vercel build --prod 플래그로 정해져요. 운영 태그면 --prod 를 붙이고, 스테이징 태그면 안 붙여서 미리보기로 빌드돼요. 이 플래그를 빠뜨리면 운영 배포인데 미리보기로 빌드돼서 GA4 · 애드센스가 통째로 꺼진 채 나가니, 직접 빌드한다면 꼭 확인해야 해요.
정리
| 무엇 | 로컬 | 미리보기 | 운영 |
|---|---|---|---|
| GA4 | 꺼짐 | 꺼짐 | 켜짐 |
| 애드센스 스크립트 · meta | 꺼짐 | 꺼짐 | 켜짐 |
| robots.txt | 전부 막음 | 전부 막음 | 허용 + 사이트맵 |
| 페이지 noindex | 붙음 | 붙음 | 없음 |
| 사이트맵 | 비움 | 비움 | 공개 글 |
| 표시 | LOCAL · [로컬] · 주황 | PREVIEW · [미리보기] · 하늘 | 없음 |
이렇게 세팅을 마치고 애드센스를 신청했는데, 1차 심사는 "콘텐츠 가치" 를 이유로 거절됐어요. 스크립트 · ads.txt · 개인정보처리방침 같은 기술 세팅은 다 맞아도, 결국 심사는 글을 본다는 뜻이었어요. 그래서 지금은 이런 정보형 글을 꾸준히 쌓는 중이에요.