Recode Log

  • tech-blog
  • apps
  • device
  • features

Copyright © [WebKBS]. All rights reserved.

·이용 안내
  • tech-blog
  • apps
  • device
  • features
  1. Home
  2. tech-blog
  3. Next.js 환경 변수는 언제 사용해야 할까? 사용 기준과 모범 사례

Next.js 환경 변수는 언제 사용해야 할까? 사용 기준과 모범 사례

Next.js에서 상수와 환경 변수를 구분하는 기준부터 NEXT_PUBLIC의 주의사항, 환경별 API 주소, 로컬 스토리지 키, 공개 키와 비밀 키 관리 방법까지 실무 예제로 알아봅니다.

  • Next.js 16+ App Router
2026년 8월 11일

소개

Next.js 프로젝트를 만들다 보면 어떤 값을 환경 변수에 넣어야 할지 고민하게 됩니다.

API 키나 데이터베이스 접속 정보처럼 민감한 값은 환경 변수로 관리해야 한다는 기준이 명확합니다. 반면 API 주소, 로컬 스토리지 키, 페이지네이션 개수처럼 보안과 관계없는 값은 판단하기 애매합니다.

특히 NEXT_PUBLIC_이 붙은 환경 변수는 브라우저에 그대로 노출됩니다. 노출되는 값이라면 무조건 환경 변수로 관리하는 것이 좋은 방법은 아닙니다. 모든 값을 환경 변수로 만들면 배포 환경, .env 파일, CI/CD 설정 등 확인해야 할 관리 포인트만 늘어날 수 있습니다.

이 글에서는 환경 변수의 사용 방법보다 한 단계 더 나아가 어떤 값을 환경 변수로 관리해야 하는지를 Next.js App Router 기준으로 알아보겠습니다.

환경 변수는 값을 암호화하는 기능이 아닙니다. 코드와 설정을 분리하는 방법이며, NEXT_PUBLIC_ 변수는 브라우저에서 확인할 수 있습니다.

목차

  • 먼저 결론부터
  • 상수와 환경 변수를 나누는 기준
  • NEXT_PUBLIC은 비밀 변수가 아니다
  • 상수로 관리하는 것이 좋은 경우
  • 환경 변수로 관리하는 것이 좋은 경우
  • 다양한 실무 예시
  • 환경별 파일 구성
  • 타입과 유효성 검사
  • 서버 비밀값을 안전하게 사용하는 방법
  • 자주 발생하는 실수
  • 체크 리스트
  • 마치며

먼저 결론부터

환경 변수를 사용할지는 다음 순서로 판단하면 됩니다.

  1. 외부에 노출되면 안 되는 값인가?
  2. 개발, 스테이징, 프로덕션 등 배포 환경마다 값이 달라지는가?
  3. 코드를 수정하지 않고 배포 설정에서 값을 주입해야 하는가?
  4. 브라우저에서 필요한 값인가, 서버에서만 필요한 값인가?
값의 성격권장 관리 방법예시
모든 환경에서 동일한 공개 값코드 상수로컬 스토리지 키, 페이지 크기, 고객센터 이메일
환경마다 다른 공개 값NEXT_PUBLIC_ 환경 변수Analytics ID, 공개 SDK 키, 브라우저가 직접 호출하는 API 주소
환경마다 다른 서버 설정서버 환경 변수내부 API 주소, 사이트 URL, 큐 이름
외부에 노출되면 안 되는 값서버 환경 변수DB URL, API Secret, 결제 Secret Key
운영 중 즉시 바꿔야 하는 값원격 설정 또는 서버 API실시간 기능 플래그, 점검 모드

결국 판단 기준은 단순합니다.

같은 값이면 상수로 두고, 배포 환경에 따라 달라지면 환경 변수로 분리합니다. 비밀값은 반드시 서버 환경 변수로 관리합니다.

상수와 환경 변수를 나누는 기준

환경 변수는 주로 다음 두 가지 목적을 가집니다.

  • 비밀값을 소스 코드와 Git 저장소에서 분리
  • 배포 환경마다 달라지는 설정을 코드에서 분리

반대로 두 조건에 모두 해당하지 않는 값은 상수가 더 단순할 수 있습니다.

예를 들어 모든 환경에서 동일한 페이지네이션 개수를 환경 변수로 만들었다고 가정해보겠습니다.

.env.local
NEXT_PUBLIC_PAGE_SIZE=20
constants/pagination.const.ts
export const PAGE_SIZE = Number(process.env.NEXT_PUBLIC_PAGE_SIZE);

이 값은 보안이 필요하지 않고 개발과 프로덕션에서도 동일합니다. 환경 변수로 관리하면 다음 항목을 추가로 확인해야 합니다.

  • 로컬 .env 파일에 값이 있는지
  • 배포 서비스에 같은 값이 등록되어 있는지
  • 문자열을 숫자로 변환했는지
  • 값이 누락되어 NaN이 되지 않는지
  • 값을 변경한 뒤 다시 빌드했는지

이럴 때는 상수가 더 명확합니다.

constants/pagination.const.ts
export const PAGE_SIZE = 20;

코드를 보는 것만으로 값과 타입을 알 수 있고, 변경 이력도 Git에서 확인할 수 있습니다.

환경 변수의 개수보다 중요한 것은 이유입니다. 각 변수에 환경별 분리 또는 비밀 유지라는 명확한 목적이 있어야 합니다.

NEXT_PUBLIC은 비밀 변수가 아니다

Next.js에서 접두사가 없는 환경 변수는 기본적으로 서버에서만 사용할 수 있습니다.

.env.local
DATABASE_URL="postgresql://..."
PAYMENT_SECRET_KEY="secret-key"

브라우저에서도 사용해야 하는 값에는 NEXT_PUBLIC_ 접두사를 붙입니다.

.env.local
NEXT_PUBLIC_ANALYTICS_ID="G-XXXXXXXXXX"
NEXT_PUBLIC_PAYMENT_PUBLISHABLE_KEY="pk_test_xxxxx"

하지만 NEXT_PUBLIC_은 보안을 위한 접두사가 아닙니다. Next.js는 이 값을 next build 시점에 클라이언트 JavaScript 번들에 포함합니다. 사용자는 개발자 도구나 내려받은 JavaScript 파일에서 값을 확인할 수 있습니다.

lib/analytics.ts
export const analyticsId = process.env.NEXT_PUBLIC_ANALYTICS_ID;

위 코드는 빌드 과정에서 다음과 비슷한 형태의 값으로 대체됩니다.

export const analyticsId = "G-XXXXXXXXXX";

따라서 다음 값에는 절대로 NEXT_PUBLIC_을 붙이면 안 됩니다.

  • 데이터베이스 접속 문자열
  • JWT 서명 키
  • OAuth Client Secret
  • 결제 서비스 Secret Key
  • 관리자용 API 키
  • 이메일 서비스 API 키
NEXT_PUBLIC_ 변수는 이름에 public이 명시된 공개 설정입니다. 노출되면 문제가 되는 값은 접두사를 제거하고 서버 코드에서만 사용해야 합니다.

빌드 이후에는 값이 고정된다

NEXT_PUBLIC_ 변수는 빌드 시점에 포함되므로 빌드가 끝난 뒤 서버의 환경 변수만 변경해도 클라이언트 값은 바뀌지 않습니다. 값을 변경하려면 다시 빌드하고 배포해야 합니다.

이 특성은 하나의 Docker 이미지를 개발, 스테이징, 프로덕션에 차례로 배포할 때 특히 주의해야 합니다. 이미지를 만들 때 들어간 NEXT_PUBLIC_ 값이 다음 환경에서도 그대로 사용되기 때문입니다.

운영 중 즉시 바꿔야 하는 공개 설정이라면 NEXT_PUBLIC_ 대신 서버 API나 원격 설정 서비스를 통해 런타임에 전달하는 방식이 더 적합합니다.

정적인 형태로 접근한다

공개 환경 변수는 다음처럼 전체 이름을 직접 작성하는 것이 좋습니다.

const analyticsId = process.env.NEXT_PUBLIC_ANALYTICS_ID;

동적인 접근은 빌드 시 값이 인라인되지 않습니다.

const key = "NEXT_PUBLIC_ANALYTICS_ID";
 
// 사용하지 않는 것을 권장
const analyticsId = process.env[key];
 
// 사용하지 않는 것을 권장
const env = process.env;
const anotherAnalyticsId = env.NEXT_PUBLIC_ANALYTICS_ID;

상수로 관리하는 것이 좋은 경우

다음 조건을 모두 만족한다면 환경 변수보다 코드 상수를 먼저 고려합니다.

  • 비밀값이 아니다.
  • 모든 배포 환경에서 값이 같다.
  • 운영자가 배포 설정만으로 변경할 필요가 없다.
  • 값의 변경을 코드 리뷰와 Git 이력으로 관리하는 것이 자연스럽다.

로컬 스토리지 키

개발과 프로덕션에서 같은 키를 사용해도 문제가 없다면 상수로 충분합니다.

constants/storage.const.ts
export const STORAGE_KEYS = {
  theme: "my-app:theme",
  recentSearches: "my-app:recent-searches:v1",
} as const;
features/theme/theme-storage.ts
import { STORAGE_KEYS } from "@/constants/storage.const";
 
export const saveTheme = (theme: "light" | "dark") => {
  localStorage.setItem(STORAGE_KEYS.theme, theme);
};

로컬 스토리지 키는 공개되어도 되는 값이고 보안 기능도 없습니다. 값이 모든 환경에서 같다면 NEXT_PUBLIC_STORAGE_KEY를 추가하는 것은 관리 포인트만 늘립니다.

저장하는 데이터 구조가 변경될 수 있다면 환경 변수 대신 v1, v2처럼 버전을 키에 포함하는 것이 좋습니다. 기존 데이터와 새로운 스키마의 충돌을 방지할 수 있습니다.

UI와 도메인 규칙에 가까운 값

constants/app.const.ts
export const APP_NAME = "Recode Log";
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_UPLOAD_SIZE_MB = 10;
export const SUPPORT_EMAIL = "help@example.com";

이 값들이 환경에 따라 달라지지 않는다면 코드 상수가 더 읽기 쉽습니다. 특히 페이지 크기나 최대 업로드 크기가 실제 비즈니스 로직과 연결되어 있다면 코드와 함께 리뷰하고 테스트하는 편이 안전합니다.

동일 출처 API 경로

브라우저가 같은 Next.js 서버의 Route Handler를 호출한다면 도메인을 환경 변수로 만들 필요가 없습니다.

services/user.service.ts
export const getUser = async () => {
  const response = await fetch("/api/user");
 
  if (!response.ok) {
    throw new Error("사용자 정보를 불러오지 못했습니다.");
  }
 
  return response.json();
};

http://localhost:3000/api/user와 https://example.com/api/user를 구분하지 않고 상대 경로를 사용하면 현재 도메인에 맞춰 자동으로 요청됩니다.

# 불필요할 수 있는 설정
NEXT_PUBLIC_API_URL="http://localhost:3000/api"

백엔드 주소가 모든 환경에서 완전히 같고 앞으로도 배포 설정에서 바꿀 이유가 없다면 일반 상수로 관리할 수도 있습니다.

constants/api.const.ts
export const EXTERNAL_API_URL = "https://api.example.com";

환경 변수로 관리하는 것이 좋은 경우

개발과 프로덕션의 API 주소가 다른 경우

브라우저가 백엔드 API를 직접 호출하고 환경마다 주소가 다르다면 공개 환경 변수가 적절합니다.

.env.development.local
NEXT_PUBLIC_API_URL="http://localhost:8080"
.env.production.local
NEXT_PUBLIC_API_URL="https://api.example.com"
services/api-client.ts
export const apiClient = async (path: string, init?: RequestInit) => {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}${path}`,
    init,
  );
 
  if (!response.ok) {
    throw new Error("API 요청에 실패했습니다.");
  }
 
  return response.json();
};

단, API 주소를 숨기기 위해 NEXT_PUBLIC_을 제거하는 것은 해결책이 아닙니다. 브라우저가 직접 요청하는 주소는 네트워크 탭에서 확인할 수 있습니다. 공개 여부가 아니라 환경별 분리가 필요한지를 기준으로 판단해야 합니다.

서버가 백엔드 API를 호출하는 경우

Server Component, 서버 전용 서비스 또는 Route Handler에서만 사용하는 주소는 접두사 없이 관리합니다.

.env.local
BACKEND_API_URL="https://internal-api.example.com"
BACKEND_API_KEY="secret-key"
services/server/user.service.ts
import "server-only";
 
export const getUser = async () => {
  const response = await fetch(`${process.env.BACKEND_API_URL}/user`, {
    headers: {
      Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
    },
  });
 
  if (!response.ok) {
    throw new Error("사용자 정보를 불러오지 못했습니다.");
  }
 
  return response.json();
};

이 방식은 내부 API 주소와 인증 키가 클라이언트 번들에 포함되는 것을 방지합니다.

외부 서비스의 프로젝트가 환경마다 다른 경우

Firebase 설정이나 결제 서비스의 Publishable Key처럼 공개를 전제로 한 값도 개발용 프로젝트와 운영용 프로젝트가 다르다면 환경 변수로 관리하는 것이 좋습니다.

.env.development.local
NEXT_PUBLIC_PAYMENT_PUBLISHABLE_KEY="pk_test_xxxxx"
.env.production.local
NEXT_PUBLIC_PAYMENT_PUBLISHABLE_KEY="pk_live_xxxxx"

공개 키는 노출되어도 된다는 뜻이지 아무런 보호가 필요 없다는 뜻은 아닙니다. 서비스에서 허용 도메인, API 권한, 사용량 제한 등을 설정할 수 있다면 반드시 함께 적용해야 합니다.

다양한 실무 예시

로컬 스토리지 키를 환경별로 나누고 싶은 경우

일반적으로 localhost와 운영 도메인은 저장 공간이 분리되므로 같은 키를 사용해도 충돌하지 않습니다. 하지만 동일한 도메인에서 개발 모드와 운영 모드를 함께 제공하는 등 명확한 분리 목적이 있다면 접두사를 환경 변수로 둘 수 있습니다.

.env.development.local
NEXT_PUBLIC_STORAGE_PREFIX="my-app:dev"
.env.production.local
NEXT_PUBLIC_STORAGE_PREFIX="my-app:prod"
constants/storage.const.ts
const storagePrefix = process.env.NEXT_PUBLIC_STORAGE_PREFIX;
 
export const STORAGE_KEYS = {
  theme: `${storagePrefix}:theme`,
  recentSearches: `${storagePrefix}:recent-searches:v1`,
} as const;

이 경우에도 값이 누락되면 undefined:theme이라는 키가 만들어질 수 있으므로 빌드 전에 유효성 검사가 필요합니다.

사이트 URL

메타데이터, 이메일 링크, OAuth Callback URL을 만들 때 사이트의 절대 주소가 필요할 수 있습니다.

.env.local
SITE_URL="https://example.com"

서버에서만 절대 주소를 만들면 SITE_URL로 충분합니다. 브라우저 코드에서도 값이 필요하고 로컬, Preview, Production 주소가 다르다면 NEXT_PUBLIC_SITE_URL을 사용할 수 있습니다.

다만 브라우저에서 현재 주소만 필요하다면 환경 변수보다 window.location.origin이 더 정확할 수 있습니다. 현재 요청의 호스트를 서버에서 확인할 수 있는 경우에도 고정된 환경 변수가 꼭 필요한지 먼저 확인하는 것이 좋습니다.

기능 플래그

배포할 때만 기능의 활성화 여부가 바뀐다면 공개 환경 변수를 사용할 수 있습니다.

.env.local
NEXT_PUBLIC_ENABLE_NEW_EDITOR="true"
lib/env/client.ts
export const isNewEditorEnabled =
  process.env.NEXT_PUBLIC_ENABLE_NEW_EDITOR === "true";

하지만 장애가 발생했을 때 재빌드 없이 즉시 기능을 끄고 싶다면 적합하지 않습니다. NEXT_PUBLIC_ 값은 빌드 시 고정되므로 원격 기능 플래그 서비스나 서버 API를 사용해야 합니다.

그리고 공개 기능 플래그는 화면을 숨기는 용도로만 사용해야 합니다. 다음처럼 관리자 권한을 판단하는 보안 장치로 사용하면 안 됩니다.

잘못된 예시
// 사용자가 브라우저에서 값을 확인하고 요청을 직접 보낼 수 있습니다.
const canAccessAdmin = process.env.NEXT_PUBLIC_ENABLE_ADMIN === "true";

권한은 서버에서 로그인 정보와 사용자 역할을 다시 확인해야 합니다.

숫자와 Boolean 값

환경 변수는 모두 문자열입니다.

.env.local
API_TIMEOUT_MS="5000"
ENABLE_CACHE="false"
잘못된 예시
// "false"도 비어 있지 않은 문자열이므로 true가 됩니다.
const enableCache = Boolean(process.env.ENABLE_CACHE);

명시적으로 변환하고 범위를 검사해야 합니다.

lib/env/server.ts
const apiTimeoutMs = Number(process.env.API_TIMEOUT_MS);
const enableCache = process.env.ENABLE_CACHE === "true";
 
if (!Number.isFinite(apiTimeoutMs) || apiTimeoutMs <= 0) {
  throw new Error("API_TIMEOUT_MS는 0보다 큰 숫자여야 합니다.");
}

값별 권장 방식 정리

값권장 방식이유
DATABASE_URL서버 환경 변수비밀값이며 환경마다 다름
결제 Secret Key서버 환경 변수브라우저 노출 금지
결제 Publishable KeyNEXT_PUBLIC_브라우저 SDK에서 필요하며 환경마다 다름
Analytics IDNEXT_PUBLIC_공개 값이며 환경별 프로젝트가 다를 수 있음
Sentry DSN상황에 따라 NEXT_PUBLIC_공개 사용을 전제로 하지만 환경별 분리가 필요할 수 있음
로컬 스토리지 키상수공개 값이며 대부분 환경 분리가 불필요
페이지당 항목 수상수UI 또는 도메인 규칙에 가까움
같은 출처의 /api 경로문자열 또는 상수상대 경로로 환경을 자동 구분
외부 API Base URL환경 변수 또는 상수환경별 변경 여부에 따라 결정
고객센터 이메일주로 상수모든 환경에서 같다면 설정 분리 불필요
실시간 기능 플래그원격 설정 또는 서버 API재빌드 없이 변경해야 함

환경별 파일 구성

Next.js는 프로젝트 루트의 .env* 파일을 읽습니다. src 폴더를 사용하는 프로젝트도 환경 변수 파일은 src 내부가 아닌 루트에 둡니다.

환경 변수와 설정 파일 구성 예시

my-project
app
api
page.tsx
constants
storage.const.ts환경과 무관한 공개 상수
lib
env
client.tsNEXT_PUBLIC_ 변수 검증
server.ts서버 전용 변수 검증
.env.example키와 예시 형식만 공유
.env.local개인 로컬 값, Git 제외
.gitignore
package.json

파일별 역할

파일용도
.env모든 환경의 기본값
.env.development개발 환경 기본값
.env.production프로덕션 환경 기본값
.env.test테스트 환경 기본값
.env.local현재 컴퓨터의 로컬 덮어쓰기 값
.env.development.local현재 컴퓨터의 개발 환경 덮어쓰기 값
.env.production.local현재 컴퓨터의 프로덕션 환경 덮어쓰기 값
.env.example팀에 필요한 변수 이름과 형식을 알리는 예시 파일

Next.js는 같은 이름의 변수를 다음 우선순위로 찾고, 먼저 찾은 값을 사용합니다.

  1. 실행 환경의 process.env
  2. .env.$(NODE_ENV).local
  3. .env.local (test 환경 제외)
  4. .env.$(NODE_ENV)
  5. .env

예를 들어 .env와 .env.development.local에 같은 변수가 있다면 개발 환경에서는 .env.development.local의 값이 사용됩니다.

.env.local은 개발 명령뿐 아니라 프로덕션 빌드에도 우선 적용될 수 있습니다. 로컬에서 production 빌드를 테스트할 때 오래된 값이 섞이지 않았는지 확인하세요.

Git에 올릴 파일

비밀값이 들어갈 수 있는 .env* 파일은 Git에서 제외하고, 필요한 변수의 목록만 .env.example로 공유하는 방식을 권장합니다.

.gitignore
.env*
!.env.example
.env.example
# server only
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/DATABASE"
BACKEND_API_KEY=""
 
# client public
NEXT_PUBLIC_ANALYTICS_ID=""
NEXT_PUBLIC_PAYMENT_PUBLISHABLE_KEY=""

프로덕션 값은 Vercel, GitHub Actions, AWS 등 실제 배포 환경의 Secret 또는 Environment Variable 설정에서 주입합니다.

팀에서 비밀값이 없는 .env.development 기본값을 Git으로 관리하기로 결정할 수도 있습니다. 이 경우에도 파일 정책을 명확하게 정하고, 비밀값이 포함되지 않는지 리뷰해야 합니다.

타입과 유효성 검사

process.env.API_URL의 TypeScript 타입은 기본적으로 string | undefined입니다. ! 또는 기본값만 반복해서 사용하면 누락된 설정을 늦게 발견할 수 있습니다.

작은 프로젝트는 직접 검사해도 충분합니다.

lib/env/server.ts
import "server-only";
 
const backendApiUrl = process.env.BACKEND_API_URL;
 
if (!backendApiUrl) {
  throw new Error("BACKEND_API_URL 환경 변수가 필요합니다.");
}
 
export const serverEnv = {
  backendApiUrl,
};

변수가 많아지면 Zod 스키마를 사용하면 타입 변환과 검증을 한곳에서 관리할 수 있습니다.

bash
pnpm add zod server-only

서버 환경 변수

lib/env/server.ts
import "server-only";
import { z } from "zod";
 
const serverEnvSchema = z.object({
  DATABASE_URL: z.string().min(1),
  BACKEND_API_URL: z.url(),
  BACKEND_API_KEY: z.string().min(1),
  API_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
});
 
const parsedServerEnv = serverEnvSchema.parse({
  DATABASE_URL: process.env.DATABASE_URL,
  BACKEND_API_URL: process.env.BACKEND_API_URL,
  BACKEND_API_KEY: process.env.BACKEND_API_KEY,
  API_TIMEOUT_MS: process.env.API_TIMEOUT_MS,
});
 
export const serverEnv = {
  databaseUrl: parsedServerEnv.DATABASE_URL,
  backendApiUrl: parsedServerEnv.BACKEND_API_URL,
  backendApiKey: parsedServerEnv.BACKEND_API_KEY,
  apiTimeoutMs: parsedServerEnv.API_TIMEOUT_MS,
};

server-only를 사용하면 Client Component에서 이 모듈을 잘못 import했을 때 빌드 오류가 발생합니다.

클라이언트 공개 환경 변수

lib/env/client.ts
import { z } from "zod";
 
const clientEnvSchema = z.object({
  NEXT_PUBLIC_ANALYTICS_ID: z.string().min(1),
  NEXT_PUBLIC_PAYMENT_PUBLISHABLE_KEY: z.string().startsWith("pk_"),
});
 
export const clientEnv = clientEnvSchema.parse({
  NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
  NEXT_PUBLIC_PAYMENT_PUBLISHABLE_KEY:
    process.env.NEXT_PUBLIC_PAYMENT_PUBLISHABLE_KEY,
});

공개 환경 변수도 객체를 통째로 읽지 않고 process.env.NEXT_PUBLIC_변수명 형태로 직접 작성합니다. 그래야 Next.js가 빌드할 때 값을 정상적으로 인라인할 수 있습니다.

검증 모듈을 서버와 클라이언트로 분리하면 비밀값의 사용 범위가 명확해지고, 실수로 클라이언트 코드에 서버 설정을 import하는 문제를 줄일 수 있습니다.

서버 비밀값을 안전하게 사용하는 방법

환경 변수에 저장했다고 해서 자동으로 안전해지는 것은 아닙니다. 서버에서 읽은 값을 응답이나 Client Component의 props로 전달하면 결국 브라우저에 노출됩니다.

서버 전용 서비스에서 사용한다

services/server/payment.service.ts
import "server-only";
import { serverEnv } from "@/lib/env/server";
 
export const createPayment = async (amount: number) => {
  const response = await fetch(`${serverEnv.backendApiUrl}/payments`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${serverEnv.backendApiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ amount }),
  });
 
  if (!response.ok) {
    throw new Error("결제 생성에 실패했습니다.");
  }
 
  return response.json();
};

규모가 커지면 환경 변수 접근을 Data Access Layer나 서버 전용 서비스 계층으로 제한하면 어떤 코드가 비밀값을 사용하는지 확인하기 쉬워집니다.

클라이언트에서는 Route Handler를 호출한다

클라이언트 라이브러리에서 서버 비밀값이 필요한 외부 API를 직접 호출하면 안 됩니다. Route Handler를 통해 서버에서 외부 API를 호출합니다.

app/api/profile/route.ts
import { serverEnv } from "@/lib/env/server";
import { NextResponse } from "next/server";
 
export const GET = async () => {
  // 실제 프로젝트에서는 이 위치에서 로그인과 권한을 확인합니다.
  const response = await fetch(`${serverEnv.backendApiUrl}/profile`, {
    headers: {
      Authorization: `Bearer ${serverEnv.backendApiKey}`,
    },
  });
 
  if (!response.ok) {
    return NextResponse.json(
      { message: "프로필을 불러오지 못했습니다." },
      { status: response.status },
    );
  }
 
  const profile = await response.json();
 
  return NextResponse.json({
    id: profile.id,
    name: profile.name,
  });
};
services/profile.service.ts
export const getProfile = async () => {
  const response = await fetch("/api/profile");
 
  if (!response.ok) {
    throw new Error("프로필을 불러오지 못했습니다.");
  }
 
  return response.json();
};

Route Handler는 비밀 키를 숨길 수 있지만 그 자체로 인증 기능을 제공하지는 않습니다. 외부에서 /api/profile을 직접 호출할 수 있으므로 로그인, 권한, 입력값 검증, 요청 횟수 제한 등을 별도로 적용해야 합니다.

또한 외부 API의 전체 응답을 그대로 전달하지 말고 클라이언트에 필요한 필드만 선택해서 반환하는 것이 좋습니다.

자주 발생하는 실수

모든 설정을 NEXT_PUBLIC로 만드는 경우

Client Component에서 편하게 사용하기 위해 모든 변수에 NEXT_PUBLIC_을 붙이면 비밀값까지 번들에 포함될 수 있습니다. 클라이언트에서 정말 필요한 공개 값인지 먼저 확인해야 합니다.

환경 변수를 암호화로 생각하는 경우

.env 파일은 일반 텍스트 파일입니다. 환경 변수도 실행 중인 프로세스가 읽을 수 있는 설정일 뿐입니다. Git에서 제외하고 배포 서비스의 Secret 저장소와 접근 권한을 함께 관리해야 합니다.

next.config의 env에 비밀값을 넣는 경우

next.config의 env 옵션에 정의한 값은 NEXT_PUBLIC_ 접두사 여부와 관계없이 JavaScript 번들에 포함될 수 있습니다.

잘못된 예시 - next.config.mjs
const nextConfig = {
  env: {
    PAYMENT_SECRET_KEY: process.env.PAYMENT_SECRET_KEY,
  },
};
 
export default nextConfig;

비밀값은 next.config의 env 옵션으로 전달하지 않고 서버 코드에서 process.env.PAYMENT_SECRET_KEY로 직접 읽습니다.

NODE_ENV에 staging을 넣는 경우

Next.js의 NODE_ENV는 development, production, test 용도로 사용됩니다. 스테이징을 별도 환경으로 구분하려면 다른 변수를 사용합니다.

.env.local
APP_ENV="staging"

브라우저에서도 환경 이름이 필요하다면 NEXT_PUBLIC_APP_ENV를 사용할 수 있지만, 단순히 현재 개발 모드인지 확인하는 목적이라면 Next.js가 설정하는 process.env.NODE_ENV로 충분합니다.

기본값으로 누락을 숨기는 경우

주의가 필요한 예시
const apiUrl = process.env.BACKEND_API_URL ?? "https://api.example.com";

필수 변수에 운영 주소를 기본값으로 넣으면 로컬 개발 중 실수로 운영 API를 호출할 수 있습니다. 필수 설정은 빠르게 오류를 발생시키고, 정말 선택적인 값에만 안전한 기본값을 사용합니다.

로그에 환경 변수를 출력하는 경우

설정 오류를 확인하기 위해 console.log(process.env)를 사용하면 배포 로그에 비밀값이 남을 수 있습니다. 값 자체 대신 존재 여부나 안전하게 가린 일부만 기록합니다.

console.log({
  hasDatabaseUrl: Boolean(process.env.DATABASE_URL),
  appEnv: process.env.APP_ENV,
});

변경 후 개발 서버를 다시 시작하지 않는 경우

.env* 파일을 변경한 뒤 값이 반영되지 않는다면 개발 서버를 종료하고 다시 실행합니다. 특히 NEXT_PUBLIC_ 변수는 빌드 결과에 포함되므로 프로덕션에서는 재빌드가 필요합니다.

체크 리스트

환경 변수를 추가하기 전에 다음 항목을 확인해봅니다.

  • 모든 환경에서 같은 값이라면 상수로 둘 수 있는가?
  • 같은 출처 API라면 절대 주소 대신 /api 상대 경로를 사용할 수 있는가?
  • 브라우저에서 필요한 값이 아니라면 NEXT_PUBLIC_을 제거했는가?
  • NEXT_PUBLIC_ 값이 사용자에게 공개되어도 문제가 없는가?
  • 운영 중 재빌드 없이 바꿔야 하는 값은 아닌가?
  • 필수 변수의 누락과 형식을 애플리케이션 시작 또는 빌드 시 검증하는가?
  • .env* 파일이 Git에서 제외되어 있는가?
  • .env.example에 필요한 키와 형식이 정리되어 있는가?
  • 서버 전용 환경 변수 접근이 특정 모듈 또는 계층에 모여 있는가?
  • Route Handler와 Server Action에서 인증, 권한, 입력값을 다시 검증하는가?
  • 로그와 오류 응답에 비밀값이 포함되지 않는가?

마치며

환경 변수를 많이 사용하는 것이 좋은 설계는 아닙니다. 환경 변수는 비밀값을 코드에서 분리하거나 배포 환경마다 다른 설정을 주입할 때 가장 가치가 있습니다.

로컬 스토리지 키, 페이지 크기처럼 공개되어 있고 모든 환경에서 동일한 값은 상수가 더 단순합니다. 반대로 개발과 프로덕션의 API 주소, 외부 서비스 프로젝트, 데이터베이스 접속 정보처럼 환경별로 달라지는 값은 환경 변수로 분리하는 것이 좋습니다.

그리고 NEXT_PUBLIC_은 보안 기능이 아니라 공개 범위를 표시하는 이름입니다. 이 기준만 명확히 이해해도 불필요한 환경 변수를 줄이고 비밀값의 노출 위험도 함께 낮출 수 있습니다.

참고 자료

`Next.js 공식 문서 - Environment Variables` `Next.js 공식 문서 - Data Security`
이전글

React 웹에서 Apple 로그인 구현하기 - Sign in with Apple JS와 Node.js 검증

다음글

EAS 없이 Expo 앱 로컬 빌드해서 Play Console 내부 테스트 배포하기


관련 태그

  • Next.js
  • Environment
  • Security