환경변수의 Single Source of Truth를 찾아가는 여정

19 min

들어가며

입사 후 처음 마주한 환경변수 관리 상황은 혼돈 그 자체였다.

GitHub Secret, Google Sheet, Azure Key Vault… 환경변수가 여기저기 분산되어 있었고, 새로운 프로젝트를 세팅하거나 팀원이 온보딩할 때마다 “이 환경변수 어디서 가져오면 돼요?”라는 질문이 반복됐다. 슬랙 DM으로 .env 파일이 오가고, 누군가는 오래된 값을 쓰고 있고, 스테이지별로 어떤 값이 맞는지 아무도 확신하지 못했다.

특히 우리 회사는 Next.js를 활용하는 특성상, 서버사이드에서 써드파티 API를 직접 호출하거나, Identity 서비스를 통해 유저를 조작하는 로직도 있었고, 클라이언트마다 런타임으로 환경을 바꿔야 하는 경우도 많았다. 거기에 재택근무를 섞어가며 일하고 있었기 때문에, 로컬 환경을 빠르게 구축하고 팀 전체가 동기화된 상태를 유지하는 것이 점점 더 절실해졌다.

이 글은 그 카오스에서 출발해 Single Source of Truth(SSOT) 를 확립하기까지의 여정을 기록한 것이다.

google sheetgithub secretazure key vault
Google Sheet에 분산된 환경변수GitHub Secret에 저장된 환경변수Azure Key Vault 환경변수

심지어 azure key vault는 권한도 없고, 권위적인 Devops분의 태도도 신입이였던 나에게는 부담으로 다가왔다.


1. 처음 시도: Google Sheet 기반 CLI (sheetEnv)

가장 먼저 떠올린 아이디어는 심플했다.

“Google Sheet에 환경변수를 정리하고, 이걸 파싱해서 로컬에 .env 파일을 만들어주는 CLI를 만들자.”

실제로 sheetEnv라는 이름으로 개발을 시작했다. Google Sheets API를 통해 시트를 읽어오고, 프로젝트별/스테이지별 환경변수를 .env 파일로 생성하는 구조였다. 가벼운 생각으로는 기존에 사용중이던 시트를 그대로 활용하고자하는 목표였다.

하지만 금방 벽에 부딪혔다.

WARNING

  • Google API 인증을 위한 서비스 계정 키가 각 개발자 로컬에 필요했다
  • 결국 “환경변수를 관리하기 위한 환경변수”가 필요한 아이러니한 상황
  • 바퀴를 재발명하고 있다는 느낌이 강했다

2. 다른 팀들은 어떻게 하고 있을까?

sheetEnv를 포기한 뒤, 주변 개발자들에게 물어봤다. 답변을 정리하면 대략 이랬다.

방식장점단점
Private Repository에 직접 저장간단함, Git 히스토리 추적보안 취약, 수동 동기화
HashiCorp Vault강력한 보안, 동적 시크릿CI/CD 연동 복잡, 학습 곡선
AWS Secrets ManagerAWS 생태계 통합AWS 인프라 전제
Infisical / DopplerDX 우수, SDK 제공외부 SaaS 의존

생각보다 private repository에 .env 파일을 저장하는 경우가 많았다. Vault는 강력하지만 CI/CD 연동과 학습 곡선이 부담스러웠고, AWS Secrets Manager는 우리 인프라와 맞지 않았다.

그런데 문득 생각이 들었다.

“우리는 Azure 인프라를 쓰고 있잖아. Azure에도 분명 비슷한 게 있을 텐데?“


3. Azure App Service의 환경변수를 SSOT

조사해보니 답은 이미 우리 인프라 안에 있었다.

  • Azure App Service → Application Settings에 환경변수가 이미 관리되고 있었다
  • AKS(Azure Kubernetes Service) → shared ConfigMap에 환경변수가 있었다
  • Azure CLI(az)로 이 값들을 프로그래밍 방식으로 가져올 수 있었다

핵심 인사이트는 이거였다:

IMPORTANT

배포 환경의 환경변수가 곧 진실의 원천이어야 한다.

GitHub Secret이든 Google Sheet든, 결국 실제로 서비스가 돌아가는 곳의 환경변수가 가장 정확한 값이다. 그렇다면 거기서 직접 가져오면 되지 않나?

사내 CLI 탄생

Azure CLI를 기반으로 사내 CLI 패키지를 만들었다. 핵심 기능은 다음과 같다.

# Azure App Service에서 환경변수를 가져와 .env 파일 생성
ich-cli env pull

App Service의 경우:

  • Deployment Slot 기반으로 스테이지(dev, staging, production)를 구분
  • CLI가 slot을 자동 감지하고, 해당 slot의 환경변수를 파싱

AKS의 경우:

  • shared ConfigMap에서 환경변수를 읽어오는 방식

인프라 레포지토리의 경우:

  • Key vault에서 필요한 환경변수를 명시적 읽어오는 방식

이렇게 함으로써 프론트엔드 팀의 모든 프로젝트(SaaS 웹앱, 어드민, 클라이언트, 인프라)가 하나의 CLI를 통해 환경변수를 관리하게 되었다.

여담: npm i -g의 함정

처음에는 npm i -g @icloudhospital/ich-cli로 글로벌 설치를 안내했었다. 그런데 CLI를 업데이트할 때마다 팀원들에게 “다시 설치해주세요”라고 공지해야 하는 번거로움이 생겼다. 버전이 로컬에 고정되어 버리니, 누군가는 옛날 버전으로 환경변수를 뽑고 있는 상황이 발생한 것이다. 결국 npx로 전환했다.

npx @icloudhospital/ich-cli env pull

npx는 항상 최신 버전을 받아서 실행하기 때문에, 별도의 동기화 없이 모든 팀원이 동일한 버전의 CLI를 사용할 수 있게 되었다.


4. Next.js의 Environment Variable Load Order 활용

우리 프론트엔드는 전부 Next.js를 사용한다. Next.js는 환경변수 로딩에 명확한 우선순위를 가지고 있다.

1. process.env
2. .env.$(NODE_ENV).local
3. .env.local (Not checked when NODE_ENV is test)
4. .env.$(NODE_ENV)
5. .env

이 로딩 순서를 활용해서 CLI가 생성하는 파일을 설계했다.

.env.development        ← Azure dev 환경변수 (CLI가 생성)
.env.development.local  ← 개발자 로컬 오버라이드 (개발자가 관리)
.env.production         ← Azure prd 환경변수 (CLI가 생성)
.env.production.local   ← 개발자 로컬 오버라이드 (개발자가 관리)
TIP

.env.*.local 파일은 load order에서 더 높은 우선순위를 가진다. 즉, 개발자가 로컬에서 API_URL=http://localhost:3000 같은 값을 .env.development.local에 넣어두면, CLI가 생성한 .env.development의 값 위에 덮어씌워진다.

# .env.development (CLI가 Azure에서 가져온 값)
API_URL=https://dev-api.example.com

# .env.development.local (개발자 로컬 오버라이드)
API_URL=http://localhost:3000

덕분에 개발자들은 매번 URL을 수동으로 바꿀 필요 없이, next devnext buildNODE_ENV만 바꿔가며 dev/prd 환경을 로컬에서 바로 테스트하고 도커 빌드까지 검증할 수 있게 되었다.


5. 새로운 문제: 빌드타임 vs 런타임 환경변수

여기까지는 순조로웠다. 그런데 새로운 문제가 등장했다.

Next.js에서 NEXT_PUBLIC_ prefix가 붙은 환경변수는 빌드타임에 값이 인라인된다. 즉, 빌드 시점에 실제 값으로 대체되어 번들에 포함된다.

// 빌드 전
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

// 빌드 후 (번들 안에서)
const apiUrl = "https://api.example.com";

그런데 왜 Next.js는 이런 인라인 전략을 택했을까? 이 부분이 궁금해서 조사해봤다.

근본적으로 브라우저는 process.env에 접근할 수 없다. 클라이언트 코드에 환경변수를 전달하려면

(1) 빌드타임에 인라인
(2) 서버가 HTML 응답에 주입
(3) API로 가져오는 방법

Next.js는 정적 빌드(next export, SSG)처럼 서버가 없는 환경에서도 동작해야 하기 때문에, 가장 범용적인 (1)을 기본 전략으로 선택한 것이다.

이 인라인은 내부적으로 webpack의 DefinePlugin을 통해 이루어진다. process.env.NEXT_PUBLIC_X를 문자열 리터럴로 직접 치환하는 방식인데, 이게 단순한 값 대입이 아니라 Dead Code Elimination이라는 부수 효과를 가진다.

// DefinePlugin 치환 전
if (process.env.NEXT_PUBLIC_FEATURE_FLAG === 'true') {
  // feature code
}

// 치환 후 (값이 'false'인 경우)
if ('false' === 'true') {
  // feature code
}

// Terser 최소화 후 → 조건이 항상 false이므로 코드 블록이 통째로 제거됨

즉, 환경변수 값에 따라 사용하지 않는 코드가 번들에서 자동으로 제거되어 최종 번들 크기를 줄일 수 있다. 이 패턴은 원래 Create React App의 REACT_APP_ 컨벤션에서 시작된 것으로, Next.js가 v9.4에서 동일한 방식을 도입했다.

CAUTION

합리적인 설계 결정이지만, 트레이드오프는 명확하다. 값이 빌드 시점에 고정되기 때문에, 동일한 빌드 결과물로 다른 환경에 배포하는 것이 불가능하다. 그리고 이게 바로 우리에게 문제가 된 지점이었다.

이로 인해 문제가 발생했다.

도커 이미지를 빌드할 때 NEXT_PUBLIC_* 환경변수가 반드시 주입되어야 했기에, 이 값들이 GitHub Secret에도 있어야 했다. 그러면 다시 환경변수가 분산되는 문제로 돌아간다.

또한 NEXT_PUBLIC_* 환경변수를 클라이언트 코드에서 런타임에 참조하고 싶은 상황도 생겼다. 하지만 빌드타임에 값이 대체되어 버리기 때문에, 동일한 도커 이미지로 다른 환경(dev, staging, prod)에 배포하면서 환경변수만 바꾸는 것이 불가능했다.

// 이렇게 쓰면 빌드 시점의 값으로 고정됨
const value = process.env.NEXT_PUBLIC_VALUE // ❌ 런타임에 변경 불가

기존에는 이 문제를 Next.js의 publicRuntimeConfigserverRuntimeConfig로 우회하고 있었다.

// next.config.js
module.exports = {
  publicRuntimeConfig: {
    API_URL: process.env.API_URL,
  },
  serverRuntimeConfig: {
    SECRET_KEY: process.env.SECRET_KEY,
  },
}

빌드타임, 런타임, 서버사이드 등 여러 곳에서 공통으로 쓰이는 환경변수를 여기에 명시해두고 getConfig()를 통해 접근하는 방식이었다. 하지만 이 방법에는 한계가 있었다.

WARNING

  • 공식 문서에서도 더 이상 권장하지 않는 방식이라고 명시되어 있다
  • App Router에서는 지원되지 않는다
  • getServerSideProps를 사용하는 페이지에서만 동작하는 제약이 있었다

deprecated 되어가는 방식에 계속 의존할 수는 없었기에, 다른 방향을 찾아야 했다.


6. 런타임 환경변수 해결: next-runtime-env

이 문제를 고민하던 중 카카오엔터테인먼트 기술 블로그의 글을 발견했다. 같은 문제를 다루고 있었고, 그 과정에서 next-runtime-env 패키지를 알게 되었다.

expatfilenext-runtime-env

Loading repository data...

-- -- --

원리는 굉장히 간단하다.

  1. 서버에서 process.envNEXT_PUBLIC_* 값을 읽어 <script> 태그로 window.__ENV에 주입
  2. 클라이언트에서는 window.__ENV를 통해 런타임에 환경변수를 참조
// app/layout.tsx
import { PublicEnvScript } from 'next-runtime-env'

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <PublicEnvScript />
      </head>
      <body>{children}</body>
    </html>
  )
}
// 클라이언트 컴포넌트에서
import { env } from 'next-runtime-env'

const apiUrl = env('NEXT_PUBLIC_API_URL') // ✅ 런타임에 값을 읽음

코드 자체는 단순하지만, 추상화가 잘 되어 있어서 바로 도입할 수 있었다. 이걸 모든 프로젝트에 적용하면서:

  • 하나의 도커 이미지로 여러 환경에 배포 가능
  • NEXT_PUBLIC_* 환경변수를 GitHub Secret에 중복 관리할 필요 없음
  • 빌드 프로세스가 간소화됨

최종 구조

모든 개선을 거친 최종 환경변수 관리 구조는 이렇다.

┌─────────────────────────────────────────────────┐
│              Single Source of Truth              │
│                                                  │
│   Azure App Service (Slots) / AKS ConfigMap      │
└──────────────────────┬──────────────────────────┘

                   사내 CLI

         ┌─────────────┴─────────────┐
         │                           │
    로컬 개발                     Docker Build
         │                           │
  .env.development              next-runtime-env
  .env.production               (런타임 환경변수)

  .env.*.local
  (개발자 오버라이드)
BeforeAfter
GitHub Secret, Google Sheet, Key Vault 분산Azure App Service/AKS가 SSOT
슬랙으로 .env 파일 공유CLI 한 줄로 환경변수 동기화
스테이지별 수동 관리Slot 기반 자동 감지
환경별 도커 이미지 빌드하나의 이미지 + 런타임 환경변수

마치며

돌이켜보면, 핵심은 “환경변수의 진실의 원천을 어디에 둘 것인가”라는 질문 하나였다.

Google Sheet도, private repository도, Vault도 결국은 복사본이다. 실제 서비스가 돌아가는 Azure 환경의 설정값이 가장 정확한 원본이고, 거기서 직접 가져오면 동기화 문제는 자연스럽게 해결된다.

완벽한 해결책은 아니다. Azure CLI 인증이 필요하고, 오프라인에서는 사용할 수 없다는 한계도 있다. 하지만 적어도 “이 환경변수 값 맞아?”라는 질문은 더 이상 나오지 않게 되었다.

환경변수 관리로 고통받고 있는 팀이 있다면, 거창한 솔루션을 찾기 전에 이미 쓰고 있는 인프라 안에 답이 있지 않은지 먼저 살펴보길 추천한다.

ich-cli env pull 실행 화면
ich-cli env pull 실행 화면