왜 설정 관리가 반복적으로 사고를 만드는가

운영 사고의 상당수는 코드가 아니라 설정에서 시작된다. 스테이징 DB 주소가 프로덕션 코드에 섞여 있거나, 배포 직전에 누군가 로컬 .env를 커밋해 시크릿이 유출되는 식이다. 12-factor의 세 번째 원칙 "설정은 환경변수에 저장하라"는 이 문제의 방향을 제시하지만, 원문을 문자 그대로 지키면 오히려 관리가 어려워진다. 환경변수가 수십 개로 불어나면 어디서 주입되는지 추적이 불가능해지기 때문이다. 핵심은 "환경변수만 써라"가 아니라 설정을 코드에서 분리하고, 환경 간 차이를 명시적으로 만들라는 의도다.

설정의 세 가지 유형을 구분한다

모든 설정을 같은 방식으로 다루면 안 된다. 성격에 따라 저장 위치와 접근 방식이 달라야 한다.

유형예시저장 위치환경별 차이
비밀값DB 비밀번호, API 키시크릿 매니저있음
환경 종속값DB 호스트, 로그 레벨환경변수 / 설정 파일있음
불변 상수재시도 횟수, 타임아웃코드 또는 기본값없음

불변 상수까지 환경변수로 빼는 것은 과잉이다. 환경마다 달라질 이유가 없는 값은 코드에 두고, 정말 환경에 종속된 값만 밖으로 꺼낸다.

계층형 설정 로딩: 기본값 위에 환경을 덮어쓴다

실무에서 검증된 패턴은 "기본값 → 환경별 파일 → 환경변수" 순으로 덮어쓰는 것이다. 코드에 안전한 기본값을 두고, 환경변수는 오직 차이나는 부분만 담당한다. 아래는 파이썬에서 타입 검증까지 포함한 예시다.

from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=(".env", f".env.{__import__('os').getenv('APP_ENV', 'local')}"),
        env_prefix="APP_",
    )
    app_env: str = "local"
    db_host: str = "localhost"          # 안전한 기본값
    db_pool_size: int = 5               # 불변 상수는 코드에
    log_level: str = "INFO"
    db_password: str                    # 기본값 없음 → 미주입 시 즉시 실패

settings = Settings()  # 부팅 시점에 검증, 잘못된 타입이면 예외

db_password처럼 기본값이 없는 필드는 주입되지 않으면 애플리케이션이 부팅 시점에 죽는다. 런타임 한복판에서 KeyError를 만나는 것보다 훨씬 낫다.

비밀값은 파일이 아니라 주입으로

시크릿을 .env에 두면 언젠가 반드시 커밋된다. Git 히스토리에 한 번 들어간 시크릿은 회수가 사실상 불가능하다. CI/CD 파이프라인에서는 시크릿 매니저나 CI 시크릿 저장소에서 배포 직전에 주입한다.

# .github/workflows/deploy.yml
jobs:
  deploy:
    environment: production          # 환경별 시크릿 스코프 분리
    steps:
      - name: Render config
        env:
          APP_DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
          APP_ENV: production
        run: |
          # 시크릿을 로그에 남기지 않도록 마스킹 확인
          echo "::add-mask::$APP_DB_PASSWORD"
          ./scripts/deploy.sh

GitHub Actions의 environment를 쓰면 production 시크릿을 스테이징 워크플로가 읽지 못하도록 격리할 수 있다. 시크릿을 echo로 출력하는 실수는 add-mask로 방어한다.

환경변수만으로는 부족한 지점

12-factor가 나온 2011년과 지금은 다르다. 환경변수는 프로세스 단위 평면 문자열이라 중첩 구조나 목록을 표현하기 어렵고, 컨테이너 오케스트레이션에서는 오히려 ConfigMap과 Secret으로 나누는 편이 낫다. 원칙의 정신인 "코드-설정 분리"는 지키되, 저장 매체는 환경에 맞춰 선택한다.

# k8s: 일반 설정과 비밀값을 분리
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  APP_ENV: "production"
  APP_LOG_LEVEL: "WARNING"
---
apiVersion: v1
kind: Secret
metadata:
  name: app-secret
type: Opaque
stringData:
  APP_DB_PASSWORD: ""

주의점: 설정 드리프트와 검증

가장 흔한 사고는 환경 간 설정 항목이 어긋나는 "드리프트"다. 스테이징에만 있는 키가 프로덕션에는 없어서 배포 후에야 발견된다. 방어책은 두 가지다. 첫째, 필수 설정 목록을 코드로 선언하고 부팅 시 검증한다(위 pydantic 예시가 이 역할을 한다). 둘째, 환경 간 설정 키 집합을 CI에서 비교한다.

# 환경 간 키 누락 검사 (배포 전 CI에서 실행)
diff <(grep -oE '^APP_[A-Z_]+' .env.staging | sort) \
     <(grep -oE '^APP_[A-Z_]+' .env.production | sort) \
  && echo "OK: 키 집합 일치" \
  || { echo "환경 간 설정 키 불일치"; exit 1; }

값이 아니라 키의 존재 여부를 비교하는 것이 핵심이다. 값은 환경마다 달라야 정상이지만, 키가 빠지는 것은 거의 항상 실수다.

정리

12-factor의 설정 원칙은 "환경변수를 쓰라"가 아니라 "설정을 코드에서 분리하고 차이를 명시하라"로 읽어야 한다. 설정을 유형별로 나누고, 계층형으로 로딩하며, 비밀값은 주입하고, 부팅 시점에 검증하는 것 — 이 네 가지가 원칙을 현실에 맞게 옮기는 방법이다.