Next.js 공개 값은 빌드에 굽지 말고 실행 환경에서 넘긴다
NEXT_PUBLIC_*은 번들에 문자열로 박혀 환경마다 이미지를 다시 굽게 만든다. 실행 env를 루트 레이아웃이 window로 넘기면 같은 이미지를 어디서나 쓴다. 빌드는 Turbopack으로.
Next.js의 NEXT_PUBLIC_* 값은 빌드할 때 번들에 문자열로 박힌다. 그래서 스테이징과 운영에 서로 다른 이미지를 구워야 하고, 설정 하나만 바뀌어도 이미지를 다시 굽는다. 빌드 머신에 값이 비어 있으면 빈 값이 그대로 HTML에 들어간다.
브라우저가 알아야 할 값(에러 수집 DSN, 결제 클라이언트 키, 기능 스위치)은 빌드 때가 아니라 실행 때 환경변수로 받는다. 매 요청 렌더되는 루트 레이아웃이 그 값을 <head>의 window.__PUBLIC__로 넘기고, 클라이언트 코드는 거기서 읽는다.
// app/layout.tsx
export const dynamic = 'force-dynamic';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const pub = { sentryDsn: process.env.APP_PUBLIC_SENTRY_DSN ?? '' };
const json = JSON.stringify(pub).replace(/</g, '\\u003c');
return (
<html lang="ko">
<head>
<script dangerouslySetInnerHTML={{ __html: `window.__PUBLIC__=${json}` }} />
</head>
<body>{children}</body>
</html>
);
}
이렇게 하면 이미지를 환경이 아니라 아키텍처로만 가른다. 같은 이미지를 스테이징과 운영에 그대로 쓰고, 설정만 바뀐 배포는 이미지를 다시 굽지 않는다.
빌드 자체는 next build --turbopack으로 줄인다. webpack으로 2~2.5분 걸리던 빌드에서 컴파일 단계가 17.5초에서 8.8초로 줄었다(추적 수집 단계 약 20초는 그대로 남는다). 주의할 점: Turbopack은 Sentry가 쓰는 require-in-the-middle·import-in-the-middle을 외부 패키지로 남기는데, pnpm에서는 앱 디렉터리에서 풀리지 않아 standalone 결과물에서 빠진다. 코드에서 import하지 않더라도 앱의 직접 의존성에 넣는다.