본문으로 건너뛰기

Next.js app router에서 runtime env 적용하는 방법

· 약 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(), fetchcache: '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의 조합을 검토해보시기 바랍니다.