본문으로 건너뛰기

Next.js App Router에서 "Build Once, Deploy Anywhere" 구현하기

· 약 6분
방경민
방경민
Frontend Developer

프로젝트를 개발하다 보면 "Build Once, Deploy Anywhere" 개념을 한 번쯤은 접하게 됩니다. 한 번의 빌드로 여러 환경에 배포하거나, 환경변수만 수정해 다양한 variant로 배포하고 싶은 니즈를 표현하는 문구입니다.

하지만 Next.js는 빌드 타임에 환경변수를 결정하는 철학을 가지고 있어, 이 개념과 본질적으로 충돌합니다.

누군가는 어쩔 수 없이 Docker 이미지 하나로 여러 환경에 배포해야 하는 상황에 마주할 수 있습니다

이 글에서는 그런 분들을 위해 Next.js app router에서 런타임 환경변수를 사용하기 위한 다양한 방법을 살펴봅니다.


Next.js의 캐싱 철학​

Next.js는 빌드 타임에 최대한 많은 것을 미리 생성해두고, 이를 통해 런타임 성능을 향상시키는 철학을 가지고 있습니다.

Pages Router에서는 getStaticProps를 사용해 SSG를 선택할 수 있었지만, App Router에서는 이 방향이 더욱 강화되었습니다. cookies(), headers(), fetch의 cache: 'no-store' 같은 동적 API를 명시적으로 사용하지 않으면, Next.js는 해당 라우트를 자동으로 정적 생성 대상으로 판단합니다. 즉, 동적 렌더링은 opt-in이고, 정적 생성이 기본값입니다.


Next.js와 Deploy Anywhere의 충돌​

Next.js의 철학에 따르면, 캐싱을 위해 빌드 타임에 모든 것이 결정되어야 합니다. 서버 측 코드는 런타임에 주입된 환경변수를 사용할 수 있지만, 클라이언트 측 코드는 빌드 타임에 환경변수가 값으로 치환되어 정적 파일에 포함됩니다. NEXT_PUBLIC_ 접두사가 붙은 환경변수들이 대표적인 예입니다.

NEXT_PUBLIC 환경변수의 인라인 동작​

NEXT_PUBLIC_ 환경변수는 빌드(Compile) 시점에 문자열 리터럴로 치환됩니다:

// 소스 코드
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

// 빌드 후 번들
const apiUrl = "https://api.production.example.com";

이 치환은 서버 컴포넌트와 클라이언트 컴포넌트 모두에 적용됩니다. 빌드된 번들에는 환경변수 참조가 아닌 실제 문자열 값이 하드코딩되어 있습니다.

NEXT_PUBLIC 환경변수 인라인 동작


해결 방법​

전제 조건: 동적 환경변수 접근 패턴​

어떤 방법을 사용하든, 런타임 환경변수를 사용하려면 먼저 환경변수 접근 방식을 변경해야 합니다. 그렇지 않으면 compile 시점에 값이 인라인되어 런타임 주입이 불가능합니다.

핵심 발견: process.env.NEXT_PUBLIC_*와 process.env["NEXT_PUBLIC_*"]는 다르게 동작합니다.

// ❌ 정적 접근 - Compile 시점에 인라인됨
const url = process.env.NEXT_PUBLIC_API_URL;
// 빌드 후: const url = "https://staging.api.com";

// ✅ 동적 접근 - 런타임에 읽힘
const url = process.env["NEXT_PUBLIC_API_URL"];
// 빌드 후: const url = process.env["NEXT_PUBLIC_API_URL"]; (유지됨!)

이를 활용한 유틸리티 함수:

// src/lib/env.ts
export function getEnv(key: string): string {
return process.env[key] ?? "(not set)";
}

export function getEnvs<T extends string>(keys: T[]): Record<T, string> {
return keys.reduce((acc, key) => {
acc[key] = process.env[key] ?? "(not set)";
return acc;
}, {} as Record<T, string>);
}

사용 예시:

// app/page.tsx
import { getEnv } from "@/lib/env";

export default function Page() {
const apiUrl = getEnv("NEXT_PUBLIC_API_URL");
const appName = getEnv("NEXT_PUBLIC_APP_NAME");

return (
<div>
<p>API URL: {apiUrl}</p>
<p>App Name: {appName}</p>
</div>
);
}

이제 위 패턴을 적용했다면, 아래 방법들 중 하나를 선택해 런타임 환경변수를 주입할 수 있습니다.

왜 이렇게 동작할까?​

Next.js는 빌드 시 Webpack(또는 SWC)을 통해 process.env.NEXT_PUBLIC_* 패턴을 정적 분석으로 찾아 문자열로 치환합니다. 이 분석은 코드를 구문 분석한 트리 구조에서 이루어지며, process.env. 뒤에 오는 리터럴 식별자만 감지합니다.

반면 process.env[KEY]는 동적 속성 접근이므로, 정적 분석 단계에서 어떤 키에 접근하는지 확정할 수 없습니다. 빌드 도구는 이를 무시하고, 원본 코드를 그대로 유지합니다.

정적 분석과 동적 접근의 차이

// 정적 분석으로 감지됨 → 치환
process.env.NEXT_PUBLIC_API_URL;

// 동적 속성 접근 → 감지 불가 → 유지
function getEnv(key: string) {
return process.env[key];
}

getEnv("NEXT_PUBLIC_API_URL");

방법 1. 모든 페이지를 Dynamic Rendering으로 전환​

페이지를 강제로 Dynamic Rendering하면, 요청 시점의 환경변수를 사용할 수 있습니다.

// src/lib/runtime-env.ts
import { unstable_noStore as noStore } from "next/cache";

export function getRuntimeEnv(key: string): string {
noStore(); // 캐싱 비활성화 → Dynamic Rendering 강제
return process.env[key] ?? "";
}

이 함수를 호출하는 서버 컴포넌트는 자동으로 Dynamic Rendering 대상이 됩니다. 클라이언트 컴포넌트에서 사용하려면 서버 컴포넌트에서 props로 전달하세요.

장점​

  1. 완전한 런타임 환경변수: 서버 시작 후 언제든 환경변수 변경 가능
  2. 심플한 구현:Next.js 제공 기능 내에서 구현
  3. Standalone 빌드 호환: output: 'standalone' 옵션과 함께 사용 가능

한계​

  1. 성능 저하: 모든 요청마다 서버에서 페이지를 생성해야 함
  2. 서버 부하 증가: 대용량 트래픽 상황에서 병목 발생 가능
  3. Next.js 철학과 상충: 정적 생성의 이점을 완전히 포기

방법 2. 내부 API를 통한 환경변수 주입​

현재 환경의 환경변수를 API로 클라이언트에 전달하고, 클라이언트가 최초 렌더링 전에 이 값을 받아 사용하는 방식입니다. Next.js의 Route Handlers로 구현할 수 있습니다.

네트워크 요청은 기록이 남으므로, 민감한 값은 암/복호화를 통해 보호하는 것이 좋습니다. 다만 서버에서 런타임 시점의 환경변수를 가져오려면 별도 응답 서버를 구성해야 합니다.

장점​

  1. 정적 생성 유지: 페이지 자체는 정적으로 생성 가능
  2. 클라이언트 컴포넌트 지원: 서버 컴포넌트 없이도 환경변수 사용 가능
  3. Standalone 빌드 호환: output: 'standalone' 옵션과 함께 사용 가능

한계​

  1. 추가 네트워크 요청: 페이지 로드 시 API 호출 필요
  2. 초기 렌더링 지연: 환경변수를 받기 전까지 관련 UI 렌더링 불가 또는 깜빡임 발생
  3. 구현 복잡성: 암/복호화, 환경변수 동기화 등 추가 작업 필요

방법 3. 컴파일과 빌드 분리 (experimental-build-mode)​

Next.js 14.1부터 빌드 과정을 컴파일과 정적 페이지 생성으로 분리할 수 있는 실험적 옵션이 추가되었습니다.

# 1단계: 컴파일만 수행 (정적 페이지 생성 건너뜀)
next build --experimental-build-mode compile

# 2단계: 정적 페이지 생성
next build --experimental-build-mode generate

각 모드의 동작​

compile 모드는 TypeScript/JavaScript 컴파일, 번들링, 최적화만 수행하고 정적 페이지 생성(prerendering)을 건너뜁니다. 이 결과물만으로 서버를 실행하면 모든 페이지가 Dynamic Rendering으로 동작합니다.

실제로 .next 폴더를 비교해보면 차이가 명확합니다:

항목일반 빌드Compile 모드
총 파일 수104개59개
HTML 파일✅ 생성됨❌ 없음
RSC 파일✅ 생성됨❌ 없음
page.js✅ 생성됨✅ 생성됨

generate 모드는 이미 컴파일된 결과물을 기반으로 정적 페이지를 생성합니다. 이 시점의 환경변수가 페이지에 반영되므로, 배포 환경에 맞는 정적 페이지를 생성할 수 있습니다.

검증 결과​

동적 접근 패턴과 experimental-build-mode를 함께 사용한 테스트 결과:

# 1. Compile (기본 환경변수)
NEXT_PUBLIC_API_URL=https://compile-time.api.com npm run build:compile

# 2. Generate (다른 환경변수 주입)
NEXT_PUBLIC_API_URL=https://generate-time.api.com npm run build:generate
접근 방식정적 페이지에 반영된 값
process.env.NEXT_PUBLIC_API_URLcompile-time.api.com ❌
getEnv("NEXT_PUBLIC_API_URL")generate-time.api.com ✅

장점​

  1. 정적 생성 유지: 페이지가 정적으로 생성되어 성능 이점 유지
  2. 환경별 유연성: generate 시점에 환경변수 주입 가능
  3. 단순한 구현: 복잡한 라이브러리 없이 유틸리티 함수만으로 해결

한계​

  1. 실험적 기능: experimental-build-mode 옵션은 향후 변경 가능
  2. 소스 코드 필요: generate 단계에서 소스 코드가 필요하므로 Docker 이미지 크기 증가
  3. Standalone 빌드 불가: output: 'standalone' 옵션과 함께 사용할 수 없음

마치며​

Next.js의 강력한 캐싱 시스템은 성능 최적화에 큰 도움이 되지만, "Build Once, Deploy Anywhere" 패러다임과는 본질적으로 충돌합니다.

각 방법에는 트레이드오프가 있습니다:

방법장점단점
Dynamic Rendering 전환완전한 런타임 환경변수성능 저하, 서버 부하
API 환경변수 주입클라이언트에서도 동작추가 네트워크 요청, 복잡성
experimental-build-mode정적 생성 유지실험적 기능, 이미지 크기 증가

성능이 최우선이라면 환경별로 빌드하는 것이 여전히 최선입니다. 배포 유연성이 중요하다면 동적 환경변수 접근 패턴과 experimental-build-mode의 조합을 검토해보시기 바랍니다.