goodchuck 블로그

Next.js 블로그에서 GA4 · 애드센스를 운영에서만 켜기 — 로컬 · 미리보기 · 운영 나누는 법

10분 읽기

이 블로그는 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_ENVVERCEL_ENVAPP_ENV
next dev (내 컴퓨터)development—local
Vercel 미리보기 배포productionpreviewpreview
Vercel 운영 배포productionproductionproduction

주의할 점은 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 · 개인정보처리방침 같은 기술 세팅은 다 맞아도, 결국 심사는 글을 본다는 뜻이었어요. 그래서 지금은 이런 정보형 글을 꾸준히 쌓는 중이에요.