설정을 코드에서 꺼내
실행 환경으로 옮긴다
실제로 읽히는 파일은 .env 이고 이것은 견본이다. 견본만 저장소에 올리는 이유와, 주소 한 줄이 host와 Docker에서 달라지는 이유를 읽는다.
이 파일을 만든 이유
추론 서버 주소를 코드에 적으면 host에서 실행할 때와 container에서 실행할 때 코드를 고쳐야 한다. 설정을 환경변수로 빼면 같은 이미지가 두 환경 모두에서 동작한다.
그런데 실제 값이 담긴 .env는 저장소에 올리면 안 된다. 지금은 비밀이 없지만 6단계에서 database 암호와 인증 열쇠가 여기 들어간다. 그래서 .env는 be/.gitignore와 .dockerignore 양쪽에서 제외하고, 대신 키 목록만 담은 견본을 올린다.
새로 합류한 사람은 이 파일을 복사해 시작한다. “어떤 설정이 있는지”는 공유되고 “값이 무엇인지”는 각자 관리된다.
전체 코드
APP_SERVICE_NAME=local-moe-backend
APP_SERVICE_VERSION=0.1.0
APP_ENVIRONMENT=development
APP_LOG_LEVEL=INFO
# Server root, NOT http://127.0.0.1:8080/v1
# Docker Desktop: http://host.docker.internal:8080
APP_INFERENCE_BASE_URL=http://127.0.0.1:8080
APP_INFERENCE_MODEL=gemma-4-26b-a4b-it
APP_INFERENCE_CONNECT_TIMEOUT=3
APP_INFERENCE_PROBE_TIMEOUT=5
APP_INFERENCE_GENERATION_TIMEOUT=300
APP_ 접두사와 이름의 대응
모든 키가 APP_로 시작한다. config.py의 SettingsConfigDict(env_prefix="APP_")가 그렇게 정했기 때문이다. 시스템에 이미 있는 PATH·HOME 같은 이름과 섞이지 않게 하려는 것이다.
| 환경변수 | Settings 필드 | 기본값 | 검증 |
|---|---|---|---|
APP_SERVICE_NAME | service_name | local-moe-backend | 문자열 |
APP_SERVICE_VERSION | service_version | 0.1.0 | 문자열 |
APP_ENVIRONMENT | environment | development | 세 값 중 하나 |
APP_LOG_LEVEL | log_level | INFO | 다섯 값 중 하나 |
APP_INFERENCE_BASE_URL | inference_base_url | http://127.0.0.1:8080 | 서버 root여야 함 |
APP_INFERENCE_MODEL | inference_model | gemma-4-26b-a4b-it | 비어 있으면 안 됨 |
APP_INFERENCE_CONNECT_TIMEOUT | inference_connect_timeout | 3.0 | 양수·유한 |
APP_INFERENCE_PROBE_TIMEOUT | inference_probe_timeout | 5.0 | 양수·유한 |
APP_INFERENCE_GENERATION_TIMEOUT | inference_generation_timeout | 300.0 | 양수·유한 |
이름이 대문자·밑줄에서 소문자 필드로 자동 대응된다. 값은 전부 문자열로 들어오지만 Settings가 선언된 type으로 바꿔 준다 — 3은 3.0이 되고, 잘못된 값은 서버가 뜨기 전에 거부된다.
주석 두 줄이 가장 중요하다
# Server root, NOT http://127.0.0.1:8080/v1
# Docker Desktop: http://host.docker.internal:8080
APP_INFERENCE_BASE_URL=http://127.0.0.1:8080
/v1을 붙이면 안 되는 이유
경로는 Adapter가 붙인다. 설정에도 있으면 /v1/v1/chat/completions가 되어 404가 난다. 이 오류는 서버가 정상적으로 뜨고 /healthz도 200을 주기 때문에 원인을 찾기 어렵다. 그래서 config.py가 field_validator로 아예 거부하고, test_config.py가 그 거부를 검증한다.
host.docker.internal
이 한 줄이 이 프로젝트 구조의 핵심이다. Container 안에서 127.0.0.1은 그 container 자신을 가리키므로, macOS에서 도는 TurboFieldfareServer에 닿지 않는다. Docker Desktop이 제공하는 이 특별한 이름이 container에서 host를 가리킨다.
Linux Docker에는 기본으로 없어 --add-host 설정이 필요하다. 이 프로젝트는 macOS 전용이라 그대로 쓴다. 10단계의 Compose 통합에서 이 값이 환경변수로 주입될 자리다.
값이 정해지는 순서
1. create_app(Settings(...)) ← 테스트가 인수로 직접 주입
2. 실행 환경의 환경변수 ← docker run -e, compose environment
3. be/.env 파일 ← 개발자의 로컬 값
4. Settings 클래스의 기본값 ← 아무것도 없을 때
1번이 test_health.py가 쓰는 방식이다. 환경변수나 파일을 건드리지 않으므로 개발자의 로컬 환경이 어떻든 테스트 결과가 같다. test_config.py가 _env_file=None을 넘기는 것도 3번을 끄기 위해서다.
2번이 container에서 쓰는 방식이다. 이미지에는 .env가 없으므로(.dockerignore가 제외) 실행 시점에 주입해야 한다. 비밀을 이미지에 굽지 않는다는 원칙이 여기서 실현된다.
실제로 확인하는 방법
cd be
cp .env.example .env # 견본을 복사해 시작
# 1) 파일의 값이 응답에 나타나는가
.venv/bin/uvicorn app.main:app --port 8000 &
curl -s http://127.0.0.1:8000/healthz
# 2) 환경변수가 파일을 이긴다
APP_SERVICE_NAME=from-env .venv/bin/uvicorn app.main:app --port 8001 &
curl -s http://127.0.0.1:8001/healthz
# 3) 잘못된 값은 시작 자체를 막는다
APP_INFERENCE_BASE_URL=http://127.0.0.1:8080/v1 .venv/bin/uvicorn app.main:app
1) {"status":"ok","service":"local-moe-backend", ...}
2) {"status":"ok","service":"from-env", ...} ← 환경변수가 이겼다
3) ValidationError: Use a server root URL without /v1, credentials, query or fragment
→ 서버가 뜨지 않는다
3번이 이 설계의 이득이다. 잘못된 설정이 배포 후 404가 아니라 시작 시점의 명확한 메시지로 드러난다.
자주 발생하는 오류
| 증상 | 원인 | 진단 |
|---|---|---|
| 설정이 반영되지 않음 | .env를 만들지 않음 | 이 파일은 견본이다. cp .env.example .env가 필요하다 |
APP_를 빠뜨림 | 접두사 불일치 | SERVICE_NAME은 무시된다. extra="ignore"라 오류도 나지 않는다 |
container에서 /readyz가 503 | 127.0.0.1을 그대로 씀 | container의 127.0.0.1은 자기 자신이다. host.docker.internal로 바꾼다 |
| 생성만 404 | /v1을 붙임 | validator가 막지만 다른 경로로 주입되면 통과할 수 있다 |
ValidationError로 시작 실패 | 값이 규칙 위반 | 의도된 동작이다. 메시지가 어느 키인지 알려 준다 |
실수로 .env를 commit | .gitignore 확인 필요 | 이미 올렸다면 값을 모두 교체해야 한다 |
설계 선택과 대안
왜 설정 파일(YAML·JSON)이 아닌가?
환경변수는 container·Compose·CI가 모두 이해하는 공통 언어다. 파일을 쓰면 container 안으로 넣는 방법을 따로 정해야 한다. 12요소 앱 원칙이 권하는 방식이기도 하다.
timeout 값을 왜 3·5·300으로 나눴는가?
세 작업의 성격이 다르기 때문이다. 연결은 같은 기기 안이라 3초면 충분하고, 상태 확인은 5초 안에 답해야 의미가 있으며, 생성은 8GB 환경에서 SSD로 Expert를 읽어 가며 답을 만드는 작업이라 300초까지 열어 둔다. 하나로 합치면 상태 확인이 5분을 기다리거나 생성이 5초에 끊긴다.
견본 대신 문서로 적으면?
문서는 코드와 함께 낡는다. 견본 파일은 복사해서 바로 쓸 수 있고 새 설정이 늘 때 함께 고치기 쉽다. 다만 견본과 config.py가 어긋날 수는 있다 — 그것을 막는 자동 검사는 아직 없다.
이전 단계와 다음 파일
이 값들을 읽어 객체로 만드는 코드는 config.py, 그 객체로 HTTP client를 만드는 곳은 main.py다. 잘못된 값이 거부되는지 확인하는 테스트는 test_config.py, container에서 값을 주입하는 방법은 Dockerfile에서 이어진다.