블로그로 돌아가기
Python

Python .env 파일 안 읽힘 해결: 경로·우선순위·Docker·systemd 진단법

Python에서 .env 파일이 읽히지 않을 때 경로, 로드 시점, 기존 환경변수 우선순위를 진단하고 python-dotenv·Django·Docker Compose·systemd 환경별 설정 방법을 정리합니다.

dotenvpython-envdjangoload_dotenv
Python .env 파일이 읽히지 않을 때 경로, 환경변수 우선순위, Docker와 systemd 설정을 진단하는 가이드

.env 파일이 있는데도 os.getenv("SECRET_KEY")None이라면, 우선 파일 경로, load_dotenv() 호출 시점, 기존 환경변수의 우선순위를 확인하세요. python-dotenv는 기본적으로 이미 설정된 환경변수를 덮어쓰지 않으므로, Docker·systemd·CI가 주입한 값이 .env 값보다 우선하는 경우도 있습니다.

먼저 확인할 원인

.env 로딩 문제는 대개 다음 셋 중 하나입니다.

  1. 자동 탐색 결과가 기대한 .env 파일이 아니다.
  2. 환경변수를 읽는 모듈을 import한 뒤에 load_dotenv()를 호출했다.
  3. 같은 키가 이미 프로세스 환경에 있어 .env 값이 반영되지 않았다.

load_dotenv()의 기본값은 override=False입니다. 따라서 같은 키가 os.environ에 있으면 기존 값을 유지합니다. 또한 반환값만으로 기대한 키가 로드됐는지, 어떤 값이 적용됐는지를 판단하지 말고 파일의 키 존재 여부와 프로세스 환경을 따로 확인해야 합니다.

자동 탐색 대신 .env 경로를 명시하기

다음 호출은 python-dotenv.env 파일을 탐색하게 합니다.

from dotenv import load_dotenv load_dotenv()

간단한 스크립트에서는 충분할 수 있습니다. 그러나 관리 명령, 테스트, WSGI·ASGI 서버, 서비스 매니저처럼 실행 방식이 바뀌면 어떤 파일을 선택했는지 추적하기 어려워집니다. dotenv_path를 명시하면 이 문제를 줄일 수 있습니다. dotenv_pathstream을 모두 생략했을 때 find_dotenv()가 사용됩니다.

프로젝트 기준 경로와 로드 순서 고정하기

아래 예시는 app/config.py가 있고 프로젝트 루트에 .env를 두는 구조입니다.

myproject/ ├── .env └── app/ └── config.py
# app/config.py import os from pathlib import Path from dotenv import load_dotenv PROJECT_ROOT = Path(__file__).resolve().parents[1] ENV_FILE = PROJECT_ROOT / ".env" # 로컬 개발에서만 .env가 존재하면 읽는다. # Docker, systemd, CI에서는 이미 주입된 os.environ 값을 유지한다. if ENV_FILE.is_file(): load_dotenv(dotenv_path=ENV_FILE, override=False, encoding="utf-8") SECRET_KEY = os.getenv("SECRET_KEY") DEBUG = os.getenv("DEBUG", "false").strip().lower() in {"1", "true", "yes", "on"} if not SECRET_KEY: raise RuntimeError("SECRET_KEY 환경변수가 설정되지 않았습니다.")

이 코드는 .env가 있을 때만 읽고, 컨테이너나 서비스 관리자가 미리 주입한 SECRET_KEY는 기본적으로 유지합니다. load_dotenv()의 기본 인코딩은 UTF-8이며 기본 override 값은 False입니다. 중요한 점은 다른 설정 모듈이 os.getenv() 결과를 import 시점에 상수로 저장하기 전에 이 코드를 실행하는 것입니다. 이미 저장된 상수는 나중에 .env를 읽어도 자동으로 갱신되지 않습니다.

비밀값 없이 현재 상태 진단하기

비밀값을 로그에 출력하지 않고, 파일에 키가 있는지와 프로세스에서 키를 사용할 수 있는지만 확인합니다. 아래 예시는 diagnose_env.py를 프로젝트 루트에 둔 경우입니다.

# diagnose_env.py import os from pathlib import Path from dotenv import dotenv_values, load_dotenv PROJECT_ROOT = Path(__file__).resolve().parent ENV_FILE = PROJECT_ROOT / ".env" if not ENV_FILE.is_file(): raise FileNotFoundError(f".env 파일을 찾을 수 없습니다: {ENV_FILE}") file_values = dotenv_values(ENV_FILE) load_dotenv(ENV_FILE, override=False, encoding="utf-8") print({ "env_file": str(ENV_FILE), "secret_key_declared_in_file": "SECRET_KEY" in file_values, "secret_key_available_to_process": bool(os.getenv("SECRET_KEY")), })

dotenv_values().env 내용을 딕셔너리로 파싱하지만 os.environ은 변경하지 않습니다. 따라서 파일에 키가 선언됐는지와 현재 프로세스가 그 키를 사용할 수 있는지를 분리해 볼 수 있습니다. SECRET_KEY 값, 데이터베이스 URL, 토큰처럼 민감한 값 자체는 진단 로그에 포함하지 마세요.

override=True를 써야 하는 경우

다음 코드는 .env 값으로 기존 프로세스 환경을 덮어씁니다.

load_dotenv(ENV_FILE, override=True)

로컬 개발에서 이전 셸 환경이 남아 있고, .env의 테스트 값을 명시적으로 적용해야 할 때는 사용할 수 있습니다. 반면 운영 환경에서는 Docker, systemd, CI/CD가 주입한 값을 로컬 파일이 덮어쓸 수 있으므로 기본값인 override=False를 유지하는 편이 안전합니다. django-environoverwrite=True를 명시하지 않는 한 기존 환경변수를 덮어쓰지 않습니다.

Docker Compose의 .env와 컨테이너 환경변수는 다르다

Docker Compose 프로젝트 루트의 .env는 주로 compose.yaml 안의 ${VARIABLE} 보간에 사용됩니다. 이 파일이 존재한다고 해서 값이 컨테이너 내부 환경변수로 자동 전달되지는 않습니다. 컨테이너에 전달하려면 서비스의 env_file 또는 environment를 설정해야 합니다.

# compose.yaml services: web: build: . env_file: - ./config/app.env command: gunicorn myapp.wsgi:application --bind 0.0.0.0:8000

env_file 경로는 compose.yaml이 있는 디렉터리를 기준으로 해석됩니다. 같은 키가 있다면 environment 항목이 env_file의 값을 우선하며, 여러 env_file을 지정하면 뒤에 적은 파일이 앞선 파일의 값을 덮어씁니다.

.env를 이미지에 COPY하는 방식은 피하는 것이 좋습니다. 빌드 컨텍스트에 비밀 파일이 포함되면 이미지 레이어에 민감한 값이 남을 수 있습니다. .dockerignore.env 또는 사용하는 비밀 파일 경로를 넣고, 필요한 값은 실행 시점에 주입하세요. Docker 문서도 빌드 컨텍스트에서 .env를 제외하도록 안내합니다.

Compose에서 민감값을 다뤄야 한다면 secrets도 검토할 수 있습니다. Compose secret은 서비스별 접근 권한을 명시하며, 컨테이너에서는 기본적으로 /run/secrets/<secret_name>의 읽기 전용 파일로 마운트됩니다.

systemd에서는 EnvironmentFile=로 전달하기

systemd 서비스는 터미널의 로그인 셸과 별개로 실행됩니다. 터미널에서 export한 값이나 .bashrc 설정이 서비스에 전달된다고 가정하면 안 됩니다. 서비스에 필요한 값은 unit 파일에서 명시적으로 구성하세요.

# /etc/systemd/system/myapp.service [Service] User=myapp WorkingDirectory=/srv/myapp EnvironmentFile=/etc/myapp/myapp.env ExecStart=/srv/myapp/.venv/bin/gunicorn myapp.wsgi:application --bind 127.0.0.1:8000 Restart=on-failure

EnvironmentFile=은 줄 단위 KEY=VALUE 형식의 UTF-8 텍스트 파일을 읽습니다. 이 형식은 셸 스크립트와 완전히 같지 않으므로 export KEY=value 같은 셸 문법을 넣지 않는 편이 좋습니다.

ExecStartPre=에서 .env를 읽고 export하는 방식도 해결책이 아닙니다. ExecStartPre=ExecStart=는 별도 프로세스로 실행되므로, 전처리 프로세스의 환경 변경이 메인 프로세스까지 지속되지 않습니다. 필요한 값은 EnvironmentFile= 또는 Environment=으로 선언하세요.

systemd 문서는 환경변수가 프로세스 트리와 D-Bus 등을 통해 노출될 수 있어 비밀 전달에 적합하지 않을 수 있다고 경고합니다. 장기 비밀은 LoadCredential= 계열 또는 배포 환경에 맞는 비밀 관리 체계를 검토해야 합니다.

Django에서 django-environ을 사용할 때

Django는 .env 파일을 자동으로 읽지 않습니다. django-environ을 사용한다면 경로를 명시하고, settings.py에서 설정값을 사용하기 전에 파일을 읽어야 합니다.

# myproject/settings.py from pathlib import Path import environ BASE_DIR = Path(__file__).resolve().parent.parent ENV_FILE = BASE_DIR / ".env" env = environ.Env(DEBUG=(bool, False)) if ENV_FILE.is_file(): environ.Env.read_env(ENV_FILE, overwrite=False) SECRET_KEY = env("SECRET_KEY") DEBUG = env("DEBUG")

django-environ.read_env()도 기본적으로 기존 환경변수를 덮어쓰지 않습니다. 파일 경로를 생략한 자동 탐색보다 경로를 명시하면 실행 위치가 달라져도 같은 설정 파일을 읽을 수 있습니다.

프로덕션 Django에서는 SECRET_KEY를 소스에 커밋하지 말고 환경변수 또는 별도 파일에서 읽어야 합니다. 또한 DEBUG=True로 배포해서는 안 되며, DEBUG=False일 때는 ALLOWED_HOSTS도 적절히 설정해야 합니다.

적용할 때는 먼저 애플리케이션이 실제로 읽을 .env 경로를 고정하고, 그 다음 로드 시점을 설정 접근보다 앞으로 옮기세요. 이후 로컬 .env와 Docker·systemd·CI 환경변수 중 어느 쪽을 우선할지 정하면, 실행 위치와 배포 방식이 달라져도 설정 로딩 문제를 줄일 수 있습니다. .env.gitignore.dockerignore에 포함하고, 이미 Git이 추적하는 파일은 별도로 추적 해제해야 합니다.