본문 바로가기

설정과 비밀값 관리

1. 코드에 적힌 값들

지금까지 만든 코드에는 코드에 직접 적어둔 값이 여러 개 있습니다. 6장에서 만든 app/config.py를 다시 보겠습니다.

SQLALCHEMY_DATABASE_URL = "sqlite:///./blogs.db"
SECRET_KEY = "9f2c8e1b47a5d3f6089b2e7c4a1d5f83b6e0c9a2d7f4b1e8c3a6d9f2b5e8c1a4"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60

문제가 두 가지 있습니다.

비밀 키가 코드에 있습니다. 이 파일을 GitHub에 올리는 순간 누구나 볼 수 있게 됩니다. 비밀 키를 아는 사람은 아무 사용자로든 위장하는 토큰을 직접 만들 수 있습니다. 4장에서 배운 대로, JWT는 비밀 키만 있으면 유효한 서명을 만들 수 있기 때문입니다.

그리고 환경마다 값이 달라야 합니다. 내 컴퓨터에서는 SQLite를, 실서버에서는 PostgreSQL을 쓰고 싶습니다. CORS 허용 목록도 다릅니다. 배포할 때마다 코드를 고쳐야 한다면 실수가 생깁니다.

두 문제 모두 답은 같습니다. 값을 코드 밖으로 빼는 것입니다.

2. 환경 변수

환경 변수는 운영체제가 프로그램에게 전달하는 값입니다. 프로그램 밖에 있으므로 코드에 포함되지 않습니다.

터미널에서 직접 설정해볼 수 있습니다.

# Windows PowerShell
$env:SECRET_KEY = "abc123"
python -c "import os; print(os.environ['SECRET_KEY'])"
# macOS / Linux
export SECRET_KEY="abc123"
python -c "import os; print(os.environ['SECRET_KEY'])"

파이썬에서는 os.environ으로 읽습니다. 그런데 이 방식만 쓰면 불편한 점이 있습니다.

import os

SECRET_KEY = os.environ["SECRET_KEY"]  # 없으면 KeyError
PORT = int(os.environ.get("PORT", "8000"))  # 매번 형변환
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"  # 불리언은 더 번거롭습니다
  • 값이 없을 때의 처리를 매번 적어야 합니다.
  • 환경 변수는 항상 문자열이라 숫자나 불리언으로 바꿔야 합니다.
  • 터미널을 새로 열 때마다 다시 설정해야 합니다.

3. pydantic-settings

pydantic-settings를 사용하면 설정값을 타입에 맞게 읽고 검증할 수 있습니다. 프로젝트의 가상환경을 활성화한 뒤 명시적으로 설치합니다.

pip install pydantic-settings

6-3절에서 배운 방식으로 실행용 requirements.txt에도 반영하세요. 다른 패키지를 통해 함께 설치되었더라도 우리 코드가 직접 사용하는 의존성으로 기록합니다.

3.1 설정 클래스 만들기

app/config.py를 아래와 같이 바꿉니다.

from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    # 필수 값입니다. 없으면 서버가 시작되지 않습니다.
    secret_key: str

    # 기본값이 있는 값들입니다.
    database_url: str = "sqlite:///./blogs.db"
    algorithm: str = "HS256"
    access_token_expire_minutes: int = 60
    debug: bool = False
    allowed_origins: list[str] = ["http://127.0.0.1:8000"]


@lru_cache
def get_settings() -> Settings:
    return Settings()


settings = get_settings()

2장에서 배운 Pydantic 모델과 똑같이 생겼습니다. 타입 힌트를 적으면 자동으로 변환과 검증이 이루어집니다.

적은 것동작
secret_key: str필수입니다. 없으면 시작할 때 에러가 납니다
access_token_expire_minutes: int = 60환경 변수의 "30"을 숫자 30으로 바꿔줍니다
debug: bool = False"true", "1", "yes"를 True로 인식합니다
allowed_origins: list[str]JSON 배열 형태로 적으면 리스트가 됩니다

필수 값이 없으면 서버가 아예 시작되지 않는다는 점이 중요합니다. 비밀 키를 설정하지 않고 배포했을 때, 서버가 조용히 잘못된 값으로 돌아가는 것보다 바로 멈추는 편이 안전합니다.

3.2 .env 파일 만들기

2절에서 터미널에 설정한 SECRET_KEY=abc123은 .env보다 우선합니다. 아래 명령으로 연습용 환경 변수를 지운 뒤 진행하세요.

# Windows PowerShell
Remove-Item Env:SECRET_KEY -ErrorAction SilentlyContinue
# macOS/Linux
unset SECRET_KEY

프로젝트 최상위 폴더에 .env 파일을 만듭니다.

SECRET_KEY=9f2c8e1b47a5d3f6089b2e7c4a1d5f83b6e0c9a2d7f4b1e8c3a6d9f2b5e8c1a4
DATABASE_URL=sqlite:///./blogs.db
ACCESS_TOKEN_EXPIRE_MINUTES=60
DEBUG=true
ALLOWED_ORIGINS=["http://127.0.0.1:8000", "http://127.0.0.1:5500"]

환경 변수 이름은 대문자로 적는 것이 관례이며, pydantic-settings가 대소문자를 구분하지 않고 찾아줍니다. secret_key 필드는 SECRET_KEY 환경 변수와 연결됩니다.

비밀 키는 아래 명령으로 새로 만드세요. 책에 적힌 값을 그대로 쓰면 안 됩니다.

python -c "import secrets; print(secrets.token_hex(32))"

3.3 .gitignore에 추가하기

가장 중요한 단계입니다. .gitignore에 .env가 있는지 반드시 확인하세요.

.env
.env.*
!.env.example

!.env.example은 "이 파일만은 예외로 올린다"는 뜻입니다.

3.4 .env.example 만들기

.env를 올리지 않으면 새로 합류한 사람은 어떤 값이 필요한지 알 수 없습니다. 그래서 값을 뺀 목록을 함께 올립니다.

.env.example 파일을 만듭니다.

# 필수: python -c "import secrets; print(secrets.token_hex(32))" 로 생성하세요
SECRET_KEY=

# 선택: 기본값은 sqlite:///./blogs.db 입니다
DATABASE_URL=

# 선택: 토큰 유효 시간(분), 기본값 60
ACCESS_TOKEN_EXPIRE_MINUTES=

# 선택: 개발 중에만 true
DEBUG=false

받은 사람은 이 파일을 복사해서 .env로 이름을 바꾸고 값을 채우면 됩니다.

cp .env.example .env

4. 코드에서 사용하기

4.1 다른 파일들 수정

아래 코드는 변경할 부분입니다. app/database.py에서 기존 SQLALCHEMY_DATABASE_URL import를 제거하고 settings를 가져오도록 바꿉니다. 나머지 import, SessionLocal, Base, get_db는 유지하세요.

from app.config import settings

engine = create_engine(
    settings.database_url,
    connect_args={"check_same_thread": False},
)

app/security.py에서도 기존 ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY import를 제거하고 아래와 같이 바꿉니다. 날짜·JWT 관련 import와 비밀번호 해싱·검증 함수는 유지하세요.

from app.config import settings


def create_access_token(user_id: int) -> str:
    expire = datetime.now(timezone.utc) + timedelta(
        minutes=settings.access_token_expire_minutes
    )
    payload = {"sub": str(user_id), "exp": expire}
    return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)


def decode_access_token(token: str) -> dict | None:
    try:
        return jwt.decode(
            token, settings.secret_key, algorithms=[settings.algorithm]
        )
    except jwt.InvalidTokenError:
        return None

app/main.py에서는 CORS와 문서 설정에 사용합니다.

from app.config import settings

app = FastAPI(
    title="위니브 블로그 API",
    version="1.0.0",
    lifespan=lifespan,
    # 개발 중에만 문서를 보여줍니다
    docs_url="/docs" if settings.debug else None,
    redoc_url="/redoc" if settings.debug else None,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=settings.allowed_origins,
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)

3장에서 배운 문서 숨기기가 여기서 자연스럽게 이어집니다. DEBUG=false인 실서버에서는 /docs가 404가 되고, 개발 중에는 그대로 보입니다.

4.2 확인하기

서버를 실행합니다.

fastapi dev app/main.py

.env의 DEBUG 값을 false로 바꾸고 서버를 다시 실행해보세요. /docs에 접속하면 404가 나옵니다.

이번에는 .env 파일에서 SECRET_KEY 줄을 지우고 실행해보세요.

pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
secret_key
  Field required [type=missing, ...]

서버가 시작되지 않습니다. 의도한 동작입니다.

5. .env 없이 배포하기

실서버에서는 보통 .env 파일을 두지 않고 환경 변수를 직접 설정합니다. 파일이 남아 있으면 그 자체가 유출 경로가 되기 때문입니다.

pydantic-settings는 환경 변수를 먼저 보고, 없을 때 .env를 봅니다. 그래서 코드를 고치지 않아도 됩니다.

우선순위출처
1실제 환경 변수
2.env 파일
3클래스에 적은 기본값

배포 환경별로 값을 설정하는 방법은 아래와 같습니다.

# Docker
docker run -e SECRET_KEY=... -e DATABASE_URL=... myapp

# systemd 서비스 파일
Environment="SECRET_KEY=..."

클라우드 서비스들은 대부분 관리 화면에서 환경 변수를 등록하는 기능을 제공합니다.

비밀 키를 실수로 커밋했다면

파일을 지우고 다시 커밋해도 소용없습니다. Git은 모든 기록을 남기기 때문에, 이전 커밋을 열면 그 값이 그대로 있습니다.

그 키는 이미 유출된 것으로 간주하고 새로 발급해야 합니다. 비밀 키를 바꾸면 기존에 발급된 토큰은 모두 무효가 되어 사용자들이 다시 로그인해야 하지만, 그것이 유출된 키를 그대로 두는 것보다 낫습니다.

공개 저장소라면 더 급합니다. GitHub에 올라온 키를 자동으로 찾아 악용하는 도구들이 있어, 몇 분 안에 발견됩니다.

6. 환경별로 설정 나누기

환경마다 값이 다른 것을 정리하면 아래와 같습니다.

설정개발실서버
DEBUGtruefalse
DATABASE_URLSQLite 파일PostgreSQL 주소
ALLOWED_ORIGINSlocalhost 포함실제 도메인만
ACCESS_TOKEN_EXPIRE_MINUTES길게 (테스트 편의)짧게 (보안)
문서 공개공개비공개

환경 이름 자체를 설정으로 두는 방법도 있습니다.

from typing import Literal

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    environment: Literal["local", "staging", "production"] = "local"
    secret_key: str

    @property
    def is_production(self) -> bool:
        return self.environment == "production"

Literal을 쓰면 오타를 냈을 때 서버가 시작되지 않습니다. ENVIRONMENT=prodction처럼 잘못 적어도 조용히 넘어가는 일이 없습니다.

6.1 실서버에서 위험한 설정 막기

설정끼리 어긋나는 조합을 미리 막을 수도 있습니다.

from pydantic import model_validator
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    environment: str = "local"
    secret_key: str
    allowed_origins: list[str] = ["*"]

    @model_validator(mode="after")
    def check_production_safety(self):
        if self.environment == "production":
            if "*" in self.allowed_origins:
                raise ValueError("실서버에서는 CORS를 '*'로 열 수 없습니다")
            if len(self.secret_key) < 32:
                raise ValueError("비밀 키가 너무 짧습니다")
        return self

실수로 위험한 설정을 넣은 채 배포하면 서버가 시작되지 않습니다. 배포한 뒤에 발견하는 것보다 훨씬 낫습니다.

7. 설정 관리 점검표

항목확인
비밀 키가 코드에 없는가grep -r "SECRET_KEY" app/ 로 확인
.env가 .gitignore에 있는가git status에 .env가 안 보이는가
.env.example이 있는가새 팀원이 무엇을 채워야 할지 아는가
필수 값이 없을 때 멈추는가기본값을 주지 않았는가
실서버에서 문서가 닫히는가DEBUG=false로 확인
CORS가 *가 아닌가실제 도메인만 적었는가

연습문제

  1. 6장에서 만든 구조에 pydantic-settings를 적용해보세요. .env와 .env.example을 함께 만들어보세요.
  2. SECRET_KEY를 지우고 서버를 실행해서 어떤 에러가 나오는지 확인해보세요.
  3. .env에 ACCESS_TOKEN_EXPIRE_MINUTES=1을 넣고, 1분 뒤에 토큰이 만료되는지 확인해보세요.
  4. DEBUG=false로 두고 /docs, /redoc, /openapi.json에 각각 접속해보세요.
  5. model_validator를 사용해 "개발 환경이 아닌데 SQLite를 쓰고 있으면 에러"를 내도록 만들어보세요.
  6. .env 파일을 실수로 커밋했다고 가정하고, git log -p -- .env로 내용이 기록에 남는지 직접 확인해보세요.