환경변수와 dotenv 운영

12-factor 원칙·.env 파일·Pydantic Settings·시크릿 관리

MINERVA 04편이 .env 우선순위를, 11-0편이 RAGConfig 환경변수 의존을, 11-1편이 Docker/K8s 시크릿 주입을 다룬다. 본 글은 그 토대인 환경변수 기초를 정리한다. os.environ·python-dotenv·환경별 분리·Pydantic BaseSettings·시크릿 관리·시스템 env 우선순위까지.

Engineering
저자

Kwangmin Kim

공개

2026년 05월 06일

1 왜 환경변수를 알아야 하는가

MINERVA 시리즈 여러 편이 환경변수 운영에 의존한다.

환경변수 사용
04 FastAPI 서빙 _load_env_files() 우선순위 (.env > .env.cloud > .env.local), WARMUP_ON_STARTUP, MINERVA_LOG_LEVEL
07-0 프로덕션 배포 환경별 .env 파일, Docker --env-file
11-0 Config 의존성 field(default_factory=lambda: os.getenv(...)), YAML ${VAR:default} 치환
11-1 Config 운영 패턴 Docker/K8s 시크릿 주입, /health/config 검증

환경변수는 코드와 설정을 분리하고 시크릿을 코드에서 격리하는 표준 메커니즘이다. 12-factor app(클라우드 친화 앱 12원칙)의 3번 원칙이 “Config는 환경변수에 저장”인 이유.

2 12-factor의 핵심 — 코드와 설정의 분리

정의: 환경변수

운영체제 또는 프로세스가 보유하는 key=value 쌍의 집합. 프로세스 시작 시 부모로부터 상속되며, 자식 프로세스에 전달된다. Python에서는 os.environ dict로 접근한다.

같은 코드를 다른 환경(개발·스테이징·운영)에서 다르게 동작시키려면 다음 셋 중 하나다.

방법 장점 단점
코드 안에 if/else 단순 환경 추가 시 코드 수정, 운영에 개발 자격증명 노출 위험
설정 파일 (YAML/JSON) 구조화 시크릿이 파일에 들어가면 git 노출 위험
환경변수 코드 무수정, 시크릿 격리, OS·컨테이너·CI 모두 표준 지원 타입이 모두 string, 구조 표현 어려움

환경변수 단독으로는 한계가 있어 (string 타입, 구조 부족), MINERVA에서는 환경변수 + YAML 파일 결합을 사용한다 — 시크릿은 환경변수, 구조화된 설정은 YAML.

3 os.environ — 표준 라이브러리

import os

# 1. 읽기 — getenv (없으면 None 또는 default)
api_key = os.getenv("AZURE_OPENAI_API_KEY")
log_level = os.getenv("LOG_LEVEL", "INFO")             # default 지정

# 2. 읽기 — environ dict (없으면 KeyError)
api_key = os.environ["AZURE_OPENAI_API_KEY"]            # 필수 시 명시적 KeyError로 빠른 실패

# 3. 쓰기 (현재 프로세스만, 부모 영향 X)
os.environ["TEMP_VAR"] = "value"

주의: 모든 값이 string이다. boolean·int·list 표현은 직접 파싱.

warmup = os.getenv("WARMUP_ON_STARTUP", "true").lower() in ("true", "1", "yes")
port = int(os.getenv("PORT", "8000"))
allowed_origins = os.getenv("CORS_ORIGINS", "").split(",")

타입 변환을 매번 직접 작성하면 실수가 누적된다 → Pydantic BaseSettings로 한 곳에 모은다 (아래).

4 .env 파일 — python-dotenv

로컬 개발에서 환경변수를 매번 셸에 export하기 번거롭다. .env 파일에 모아두고 프로세스 시작 시 자동 로드한다.

pip install python-dotenv
# .env (프로젝트 루트)
AZURE_OPENAI_API_KEY=sk-abc123...
AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com/
LOG_LEVEL=DEBUG
WARMUP_ON_STARTUP=false
from dotenv import load_dotenv

load_dotenv()                                  # 현재 디렉토리·상위에서 .env 자동 탐색
# 이제 os.getenv("AZURE_OPENAI_API_KEY")가 작동

4.1 환경별 .env 파일 — 분리 운영

같은 프로젝트에 여러 환경이 있으면 파일을 분리한다.

프로젝트/
├── .env.local          # 로컬 개발 (Ollama, 디버그)
├── .env.cloud          # 클라우드 개발/스테이징 (Azure)
├── .env.production     # 운영 (시크릿 매니저에서 주입)
└── .env.example        # 템플릿 — git에 커밋, 실제 값은 비움
# .env.example (git에 커밋되는 템플릿)
AZURE_OPENAI_API_KEY=
AZURE_OPENAI_ENDPOINT=
LOG_LEVEL=INFO
WARMUP_ON_STARTUP=true
load_dotenv(".env.production")                 # 명시적 로드
# 또는 환경변수로 어느 파일을 로드할지 결정
load_dotenv(f".env.{os.getenv('ENVIRONMENT', 'local')}")

5 MINERVA의 우선순위 패턴 — _load_env_files()

MINERVA 04편이 다음 패턴을 사용한다.

from pathlib import Path
from dotenv import load_dotenv

def _load_env_files() -> None:
    repo_root = Path(__file__).resolve().parent.parent.parent.parent
    for env_name in [".env", ".env.cloud", ".env.local"]:
        env_path = repo_root / env_name
        if env_path.exists():
            load_dotenv(env_path, override=False)   # 시스템 env가 우선
            return                                    # 첫 번째 파일만 로드

핵심 설계:

  • 첫 번째 존재하는 파일만 로드.env > .env.cloud > .env.local 순서. merge 아님
  • override=False — Docker --env-file이나 K8s Secret으로 주입된 시스템 env가 .env 파일보다 우선. 운영에서는 .env 파일이 없거나 무시되도록

이 패턴이 운영·개발 둘 다 한 코드로 처리되게 한다.

환경 사용 파일 시스템 env 결과
로컬 개발 .env.local 존재 비활성 .env.local에서 모든 값
Docker .env* 없음 --env-file로 주입 시스템 env만
K8s .env* 없음 Secret으로 주입 시스템 env만
클라우드 VM .env.cloud 존재 일부 override 시스템 env가 있으면 시스템, 없으면 .env.cloud

6 Pydantic BaseSettings — 타입 안전 환경변수

os.getenv + 수동 파싱이 누적되면 한 곳에 모으는 게 좋다. pydantic-settings(Pydantic 2.x)가 이를 자동화한다.

pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,                  # AZURE_X와 azure_x 동일 인식
    )

    # Azure OpenAI
    azure_openai_api_key: str
    azure_openai_endpoint: str
    azure_openai_api_version: str = "2024-12-01-preview"

    # 운영 토글
    log_level: str = "INFO"
    warmup_on_startup: bool = True             # 자동 boolean 변환
    port: int = 8000                            # 자동 int 변환
    cors_origins: list[str] = ["http://localhost:5173"]   # 자동 list 변환


settings = Settings()                           # 환경변수에서 자동 로드
print(settings.azure_openai_api_key)            # 타입 안전 접근

자동 처리되는 것:

  • 타입 변환: WARMUP_ON_STARTUP=truebool True
  • 검증: 필수 필드 누락 시 ValidationError로 즉시 실패 (운영에서 시크릿 누락 catch)
  • default 값: 환경변수 없으면 dataclass default 사용
  • case insensitive: 대소문자 자유

이 패턴이 MINERVA 11-0편 Config 의존성RAGConfig와 동일한 철학이다.

7 시크릿 관리 — 절대 git에 commit하지 않는다

# .gitignore (필수)
.env
.env.local
.env.cloud
.env.production
*.pem
*.key
secrets/
# .env.example (git에 커밋, 실제 값 비움)
AZURE_OPENAI_API_KEY=
AZURE_OPENAI_ENDPOINT=

새 개발자 온보딩:

git clone repo
cp .env.example .env.local
# .env.local 편집해 실제 값 채움
한 번 commit한 시크릿은 영구다

git history에 한 번 들어간 시크릿은 force-push로 지워도 추적이 어렵다. 즉시 시크릿을 rotation(새 값으로 교체)하고 영향 범위를 점검해야 한다. 처음부터 commit하지 않는 게 유일한 안전책이다.

GitHub의 secret scanning이 일부 패턴(AWS key, Stripe key 등)을 자동 감지하지만 모든 시크릿을 catch하지 못한다. pre-commit hook으로 git-secretsdetect-secrets 도구를 추가하는 것이 권장된다.

8 운영 환경 — 시크릿 매니저

운영에서는 .env 파일을 두지 않는다. 시크릿 매니저에서 가져와 컨테이너 시작 시 환경변수로 주입한다.

매니저 통합
Azure Key Vault Container Apps secretRef, AKS CSI Driver
AWS Secrets Manager ECS Task Definition, EKS External Secrets
GCP Secret Manager Cloud Run env, GKE External Secrets
HashiCorp Vault Kubernetes Vault Agent Injector

MINERVA 11-1편의 K8s 예시:

# K8s Secret 리소스 생성
apiVersion: v1
kind: Secret
metadata:
  name: minerva-secrets
type: Opaque
stringData:
  AZURE_OPENAI_API_KEY: "sk-..."             # 실제 운영에서는 외부 매니저에서 동기화
---
# Deployment에서 envFrom으로 한 번에 주입
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
      - name: api
        envFrom:
        - secretRef:
            name: minerva-secrets

9 CI/CD에서 시크릿 — GitHub Secrets

MINERVA 07-1편 GitHub Actions의 패턴.

# .github/workflows/integration.yml
jobs:
  integration:
    runs-on: ubuntu-22.04
    env:
      AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}    # GitHub Secrets
      AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
    steps:
      - run: pytest -m integration

GitHub Secrets는:

  • 저장소 또는 organization 단위 설정
  • 워크플로 로그에 출력 시 자동 마스킹 (***)
  • pull request from fork에는 노출 안 됨 (보안 기본값)

그러나 echo $SECRET 같은 의도적 노출은 막지 못한다. 시크릿을 사용하는 step에서 의도치 않게 stdout에 출력하지 않도록 주의.

10 Docker에서 환경변수 주입

# 1. --env-file
docker run --env-file .env.production -p 8000:8000 minerva:latest

# 2. -e 개별
docker run -e AZURE_OPENAI_API_KEY="sk-..." minerva:latest

# 3. docker-compose
# docker-compose.yml
services:
  api:
    image: minerva:latest
    env_file:
      - .env.production
    environment:
      WARMUP_ON_STARTUP: "true"                # env_file 위에 추가/override
ENV로 Dockerfile에 굽지 않는다
# 잘못된 패턴
ENV AZURE_OPENAI_API_KEY=sk-abc123...

ENV 지시자는 이미지 레이어에 영구 baking된다. 이미지를 공유하는 순간 시크릿 노출. 환경변수는 항상 런타임에 주입한다.

MINERVA 11-1편 Reproducible Build의 원칙: 이미지에는 commit hash·build_date 같은 변경 빈도 낮은 메타만 baking, 시크릿은 runtime injection.

11 YAML에서 환경변수 참조 — ${VAR:default} 치환

MINERVA 11-0편이 RAGConfig YAML에서 환경변수를 참조하는 패턴.

# data/configs/default.yaml
embedding:
  model: ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME:text-embedding-ada-002}
  api_version: ${AZURE_OPENAI_API_VERSION:2024-08-01-preview}
import re, os, yaml

def _replace_env(content: str) -> str:
    def replace(match):
        var_expr = match.group(1)
        if ":" in var_expr:
            name, default = var_expr.split(":", 1)
        else:
            name, default = var_expr, ""
        return os.getenv(name, default)
    return re.sub(r"\$\{([^}]+)\}", replace, content)


with open("config.yaml") as f:
    content = _replace_env(f.read())
config = yaml.safe_load(content)

${VAR:default} 패턴은 docker-compose, ansible, GitHub Actions 등 여러 도구에서 표준에 가깝다.

12 자주 발생하는 오류 패턴

WRONG:

AZURE_OPENAI_API_KEY = "sk-abc123..."          # 코드에 직접

CORRECT:

AZURE_OPENAI_API_KEY = os.environ["AZURE_OPENAI_API_KEY"]   # 환경변수에서

시크릿을 코드에 넣으면 git history에 영구 노출. os.environ으로 환경변수에서 받고, .env 파일은 .gitignore에 등록.

WRONG:

from services.api.routers import qna_chatbot     # router import 시 모듈 코드 실행됨
                                                   # → os.getenv("AZURE_KEY") 호출됨
                                                   # → 환경변수 아직 로드 안 됨

from dotenv import load_dotenv
load_dotenv()                                       # 너무 늦음

CORRECT:

from dotenv import load_dotenv
load_dotenv()                                       # 모든 import 전에 로드

from services.api.routers import qna_chatbot

모듈 import 시점에 os.getenv()가 호출되면 그 시점에 환경변수가 있어야 한다. load_dotenv()는 모든 router import 전에.

WRONG:

debug = bool(os.getenv("DEBUG", ""))            # "false" 문자열이 True로 평가됨

CORRECT:

debug = os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
# 또는 Pydantic BaseSettings 사용 — 자동 변환

환경변수는 모두 string. "false"도 비어있지 않은 문자열이라 bool()은 True. 명시적 비교 또는 Pydantic 사용.

WRONG:

load_dotenv(override=True)                       # 운영 시스템 env를 .env로 덮어씀

CORRECT:

load_dotenv(override=False)                       # 시스템 env가 우선 (운영에서 안전)

override=True.env 파일이 시스템 환경변수를 덮어쓴다. 운영 컨테이너에서 K8s Secret으로 주입한 값을 .env 파일이 덮어쓰는 사고가 발생할 수 있다. False가 안전한 기본값.

13 정리

도구 사용처 비고
os.environ / os.getenv 표준 라이브러리, 모든 환경에서 작동 모든 값 string
python-dotenv 로컬 개발 .env 파일 자동 로드 운영에서는 시스템 env가 우선
pydantic-settings 타입 안전 환경변수 + 검증 + default 한 곳에 모아 관리
.env.example 템플릿 (git 커밋), 실제 값은 비움 새 개발자 온보딩
.gitignore .env, .env.* 등록 필수 시크릿 commit 방지
GitHub Secrets CI/CD에서 시크릿 주입 워크플로 로그 자동 마스킹
Docker --env-file 컨테이너 환경변수 주입 Dockerfile ENV에 굽지 말 것
K8s Secret + envFrom 운영 클러스터 시크릿 외부 매니저(Vault, Key Vault)와 동기화
YAML ${VAR:default} 설정 파일에서 환경변수 참조 환경별 일관 패턴

핵심 원칙 5가지:

  1. 시크릿은 코드·이미지·git에 절대 넣지 않는다
  2. 로컬은 .env.local, 운영은 시스템 env (시크릿 매니저에서)
  3. load_dotenv(override=False) — 시스템 env가 우선
  4. 타입·검증은 Pydantic BaseSettings에 위임
  5. .env.example로 새 개발자 온보딩

14 응용 분야

MINERVA 시리즈 사용처 본 글 절
04 _load_env_files() 우선순위 MINERVA 우선순위 패턴 + override=False
11-0 field(default_factory=lambda: os.getenv(...)) os.environ + Pydantic 패턴
11-0 YAML ${VAR:default} 치환 YAML 환경변수 참조
11-1 Docker --env-file + K8s Secret Docker·K8s 환경변수 주입
07-1 GitHub Secrets CI/CD 시크릿

15 관련 주제

선행 학습

Tier 1 완료

본 글로 Tier 1(async/await, pytest, typing 심화, 환경변수) 완성. 다음은 Tier 2 (GitHub Actions·YAML·logging·Docker Compose).

MINERVA 시리즈 응용

Subscribe

Enjoy this blog? Get notified of new posts by email: