장고 고급 #7 배포 보안 — settings 분리, ALLOWED_HOSTS, CSRF, secret 관리

9 분 소요

장고 고급 시리즈의 마지막. 중급 #5 메시지/세션/쿠키의 쿠키 보안 옵션 위에, 운영 배포 전 반드시 점검해야 할 항목들을 정리합니다. settings 분리부터 HSTS, secret 관리, 자동 점검 명령까지.

장고는 개발 친화적인 기본값으로 시작합니다. 그게 그대로 운영에 올라가면 위험한 지점이 많습니다. 이 글의 목적은 그 차이를 좁히는 것입니다.

큰 그림 #

운영 보안의 축을 셋으로 묶으면:

키워드
환경 분리settings 분리, env vars, secret 관리
전송 보안HTTPS, HSTS, secure cookies, CSRF, proxy 헤더
정보 노출DEBUG, ALLOWED_HOSTS, 에러 페이지, 로그

settings 분리 — 두 갈래 #

패턴 1: 파일 분할 #

구조
myproject/
├── settings/
│   ├── __init__.py
│   ├── base.py        # 공통
│   ├── dev.py         # 개발
│   ├── test.py        # 테스트
│   └── prod.py        # 운영
settings/base.py
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent.parent

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    ...
    "myapp",
]

MIDDLEWARE = [...]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ.get("DB_NAME"),
        ...
    }
}

# ⚠ 여기에는 비밀 값을 절대 두지 않음
settings/dev.py
from .base import *

DEBUG = True
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
SECRET_KEY = "dev-only-do-not-use-in-prod"
INSTALLED_APPS += ["debug_toolbar"]
INTERNAL_IPS = ["127.0.0.1"]
settings/prod.py
from .base import *
import os

DEBUG = False
SECRET_KEY = os.environ["SECRET_KEY"]   # 없으면 KeyError → 빠른 실패
ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")

# ... 운영 전용 설정들 (아래 섹션들에서)
실행
DJANGO_SETTINGS_MODULE=myproject.settings.prod gunicorn myproject.wsgi:application

또는 manage.py의 default를 환경 변수로:

manage.py
os.environ.setdefault(
    "DJANGO_SETTINGS_MODULE",
    "myproject.settings.dev",   # 기본은 dev, 운영은 환경에서 덮어씀
)

패턴 2: 단일 파일 + 환경 변수 분기 #

settings.py — 단일 파일
import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

ENVIRONMENT = os.environ.get("DJANGO_ENV", "development")
PRODUCTION = ENVIRONMENT == "production"

DEBUG = not PRODUCTION

if PRODUCTION:
    SECRET_KEY = os.environ["SECRET_KEY"]
    ALLOWED_HOSTS = os.environ["ALLOWED_HOSTS"].split(",")
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.postgresql",
            "NAME": os.environ["DB_NAME"],
            "USER": os.environ["DB_USER"],
            "PASSWORD": os.environ["DB_PASSWORD"],
            "HOST": os.environ["DB_HOST"],
            "PORT": os.environ.get("DB_PORT", "5432"),
        }
    }
else:
    SECRET_KEY = "dev-secret"
    ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
    DATABASES = {
        "default": {
            "ENGINE": "django.db.backends.sqlite3",
            "NAME": BASE_DIR / "db.sqlite3",
        }
    }
파일 분할단일 파일
가독성명확 (어디에 뭐가)한곳에서 비교
설정 차이 추적파일 diff 어려움직관적
새 환경 추가새 파일if 한 줄
큰 프로젝트좋음곧 복잡
작은 프로젝트과함충분

작은 ~ 중간 규모는 단일 파일 + 환경 변수, 큰 프로젝트는 파일 분할이 보통.

환경 변수 검증 — django-environ / pydantic-settings #

원시 os.environ은 누락/타입 변환 실수가 잦습니다. 검증 도구를 사용:

django-environ #

설치
pip install django-environ
settings.py
import environ

env = environ.Env(
    DEBUG=(bool, False),
    ALLOWED_HOSTS=(list, []),
    DATABASE_URL=(str, ""),
)
environ.Env.read_env()   # .env 파일 로드 (있으면)

DEBUG = env("DEBUG")
SECRET_KEY = env("SECRET_KEY")
ALLOWED_HOSTS = env("ALLOWED_HOSTS")

# DATABASE_URL 한 줄로 DB 설정
DATABASES = {"default": env.db()}

# CACHE_URL도 한 줄
CACHES = {"default": env.cache()}

.env 파일 (절대 git에 커밋 X):

.env
DEBUG=False
SECRET_KEY=...
ALLOWED_HOSTS=myapp.com,www.myapp.com
DATABASE_URL=postgres://user:pass@db:5432/mydb
CACHE_URL=redis://cache:6379/1

DATABASE_URL 하나가 ENGINE/NAME/USER/PASSWORD/HOST/PORT를 다 채워줘서 12factor 스타일 배포에 잘 어울립니다.

pydantic-settings #

장고에서도 모던 파이썬 실전에서 본 그것을 쓸 수 있습니다.

myproject/config.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    debug: bool = False
    secret_key: str
    allowed_hosts: list[str] = []
    database_url: str

    model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)

settings = Settings()   # 누락 변수면 startup 시점에 ValidationError
settings.py
from myproject.config import settings as cfg

DEBUG = cfg.debug
SECRET_KEY = cfg.secret_key
ALLOWED_HOSTS = cfg.allowed_hosts

시작 시점에 검증 되는 게 큰 장점 — 런타임에 KeyError가 나지 않습니다.

DEBUG=False의 의미 #

DEBUG=True가 운영에 새어 나가면 일어나는 일:

  • 에러 페이지에 settings, 환경 변수, 스택트레이스, SQL 쿼리가 다 노출됨 → SECRET_KEY까지 보일 수 있음
  • 정적 파일을 장고가 직접 서빙 (느림, 부하)
  • ALLOWED_HOSTS가 무시됨 (host 헤더 검증 안 함)

DEBUG=False에서:

  • 404, 500 페이지가 단순화 — 디테일 없음
  • ALLOWED_HOSTS가 활성 (안 맞으면 400)
  • 정적 파일은 직접 안 서빙 (collectstatic + nginx/CDN 필요)

커스텀 에러 페이지 #

myapp/views.py
def custom_404(request, exception):
    return render(request, "404.html", status=404)

def custom_500(request):
    return render(request, "500.html", status=500)
urls.py
handler404 = "myapp.views.custom_404"
handler500 = "myapp.views.custom_500"

운영 배포 전에 두 페이지를 만들어 두면 이미지/스타일 적용된 친절한 에러 화면이 됩니다.

ALLOWED_HOSTS #

DEBUG=False에서 활성. 요청의 Host: 헤더가 이 리스트에 없으면 400 Bad Request.

prod
ALLOWED_HOSTS = ["myapp.com", "www.myapp.com"]

와일드카드 함정 #

🚫 위험
ALLOWED_HOSTS = ["*"]

모든 호스트 허용 — DNS rebinding, host header 공격을 무방비로 받습니다. 절대 운영에 두면 안 됨.

✅ 서브도메인 와일드카드
ALLOWED_HOSTS = [".myapp.com"]   # 점으로 시작 — *.myapp.com의미

헬스체크 IP #

로드밸런서 헬스체크가 IP로 직접 들어오면 host 헤더가 IP가 됩니다. 그것도 명시:

ALB / k8s 헬스체크
ALLOWED_HOSTS = ["myapp.com", ".myapp.com", "10.0.0.0/8"]   # CIDR도 가능 (5.x)

또는 헬스체크 path만 별도 처리하는 미들웨어를 만들기도.

SECRET_KEY 관리 #

장고가 세션, CSRF, 비밀번호 reset 토큰 등을 서명하는 데 사용. 유출되면 세션 위조 가능.

규칙:

  • 절대 git에 X.env, secrets manager (AWS SM, Vault, GCP Secret Manager)
  • 환경별로 다른 값
  • 로그에 절대 X
  • 문서/README에 절대 X

생성 #

새 키
from django.core.management.utils import get_random_secret_key
print(get_random_secret_key())

또는:

간단히
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"

Rotation #

키를 바꿔야 할 때 (유출, 정기 교체):

장고 4.1+ 부터 **SECRET_KEY_FALLBACKS**가 있습니다. 새 키로 서명하되, 옛 키로 서명된 세션도 잠시 받음.

rotation 절차
SECRET_KEY = "new-key"
SECRET_KEY_FALLBACKS = ["old-key"]   # 잠시 유지

며칠 후 옛 세션이 다 만료되면 fallback 제거.

CSRF — Cross-Site Request Forgery #

장고는 CSRF 토큰 검증을 기본 활성. POST/PUT/DELETE에 csrfmiddlewaretoken 또는 X-CSRFToken 헤더가 필요합니다.

CSRF_TRUSTED_ORIGINS — 4.0+ 필수 #

다른 도메인에서 우리 사이트로 form을 POST 하는 경우 (서브도메인, 결제 콜백 등)가 있다면:

신뢰 origin
CSRF_TRUSTED_ORIGINS = [
    "https://myapp.com",
    "https://www.myapp.com",
    "https://payments.example.com",
]

스킴(https://) 포함 필수. 4.0부터 의무화됐습니다.

쿠키 옵션 #

prod
CSRF_COOKIE_SECURE = True       # HTTPS에서만 전송
SESSION_COOKIE_SECURE = True    # 세션도 동일

CSRF_COOKIE_HTTPONLY = False    # JS가 토큰을 읽어 헤더에 넣을 수 있어야 함
SESSION_COOKIE_HTTPONLY = True  # 세션은 JS 접근 차단

CSRF_COOKIE_SAMESITE = "Lax"    # CSRF 기본 방어
SESSION_COOKIE_SAMESITE = "Lax"
쿠키 옵션의미
SecureHTTPS에서만 전송
HttpOnlyJS (document.cookie) 접근 차단
SameSite=Lax다른 사이트가 보낸 요청에 자동 첨부 안 함 (top-level GET만 OK)
SameSite=Strict다른 사이트 요청엔 절대 안 첨부
SameSite=None모든 요청에 첨부 (Secure 필수)

중급 #5의 옵션들이 운영에서 한 번에 모입니다.

HTTPS / HSTS #

SECURE_SSL_REDIRECT #

HTTP → HTTPS 자동 리다이렉트
SECURE_SSL_REDIRECT = True

장고가 HTTP 요청을 받으면 301로 HTTPS로 보냅니다. 보통은 nginx/ALB 단에서 처리하지만, 장고 측에 한 번 더 두면 안전.

SECURE_PROXY_SSL_HEADER — 프록시 뒤일 때 #

리버스 프록시 (nginx, ALB, Cloudflare)가 SSL 종단을 한다면, 장고 입장에서는 HTTP처럼 보입니다. 프록시가 보내주는 헤더로 원래 HTTPS 였음을 알려야 합니다.

proxy 뒤
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

⚠ 신뢰할 수 있는 프록시 뒤가 아니면 헤더 위조 위험. 프록시가 항상 이 헤더를 덮어쓰도록 설정한 환경에서만.

HSTS — Strict-Transport-Security #

브라우저에게 “이 도메인은 HTTPS만 쓴다” 라고 알리는 헤더.

HSTS
SECURE_HSTS_SECONDS = 31536000           # 1년
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

⚠ 처음에는 작은 값으로 시작 (예: 60초). HSTS가 한 번 캐시되면 그 기간 내내 HTTP 접근 자체가 막힘 — 인증서 문제가 생기면 사용자가 사이트에 못 들어옵니다. 안정 확인 후 점차 늘립니다.

preload list #

SECURE_HSTS_PRELOAD = True와 함께 hstspreload.org에 등록하면 브라우저 빌트인 HTTPS 강제 리스트에 포함됩니다. 첫 방문에도 HTTP 시도 자체가 안 일어남. 등록은 신중히 — 빼는 데 몇 달 걸립니다.

다른 보안 헤더 #

X_FRAME_OPTIONS #

clickjacking 방어
X_FRAME_OPTIONS = "DENY"

장고 기본은 SAMEORIGIN. iframe에 우리 사이트가 들어가는 걸 막아 clickjacking 공격을 차단. 결제, 로그인 페이지에 특히 중요.

Content Security Policy (CSP) #

XSS 등의 마지막 방어선. **django-csp**가 표준.

설치
pip install django-csp
settings.py
MIDDLEWARE = [..., "csp.middleware.CSPMiddleware"]

CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", "https://cdn.jsdelivr.net")
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_IMG_SRC = ("'self'", "data:", "https:")
CSP_CONNECT_SRC = ("'self'", "https://api.myapp.com")

처음에는 report-only 모드로 시작 — 위반 시 차단 대신 리포트만:

report-only
CSP_REPORT_ONLY = True
CSP_REPORT_URI = "/csp-report/"

며칠 모니터링하며 위반을 잡고 정책 조정 후 enforce 모드로.

SECURE_CONTENT_TYPE_NOSNIFF, SECURE_REFERRER_POLICY #

기타
SECURE_CONTENT_TYPE_NOSNIFF = True       # 기본 True (5.x)
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"

비밀번호 — 해셔, 검증기 #

PASSWORD_HASHERS — 강한 해시 #

순서 — 첫 항목이 새 패스워드에 사용
PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.Argon2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
]

기본은 PBKDF2 — 충분히 안전합니다. Argon2가 현대 표준 (2015 비밀번호 해싱 컴퍼티션 우승). 추가 패키지 필요:

argon2
pip install argon2-cffi

해셔를 추가하고 첫 항목에 두면 새 비밀번호는 Argon2, 기존 PBKDF2 비밀번호는 사용자가 다음에 로그인할 때 자동으로 Argon2로 재해시됨.

AUTH_PASSWORD_VALIDATORS #

검증기 — 기본 + alpha
AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
        "OPTIONS": {"min_length": 12},   # 8 → 12 권장
    },
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]

NIST가이드 기준 최소 12자 권장. 복잡도 규칙 (특수문자 강제)보다는 길이가 효과적이라는 게 현대 권장 사항.

로깅 #

운영 로깅 패턴 #

settings/prod.py
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "verbose": {
            "format": "{levelname} {asctime} {name} {process:d} {message}",
            "style": "{",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "verbose",
        },
    },
    "root": {
        "handlers": ["console"],
        "level": "INFO",
    },
    "loggers": {
        "django.request": {
            "handlers": ["console"],
            "level": "ERROR",
            "propagate": False,
        },
        "django.security": {
            "handlers": ["console"],
            "level": "INFO",
            "propagate": False,
        },
    },
}

규칙:

  • stdout/stderr로 — 컨테이너/systemd가 수집
  • 파일 로깅 X (회전, 권한, 디스크 모두 골치)
  • ERROR는 별도 알림 (Sentry 등)

Sentry 통합 #

설치
pip install "sentry-sdk[django]"
settings/prod.py
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration

sentry_sdk.init(
    dsn=os.environ["SENTRY_DSN"],
    integrations=[DjangoIntegration()],
    traces_sample_rate=0.1,
    send_default_pii=False,    # 개인정보 자동 수집 X
)

운영 에러 추적의 사실상 표준.

manage.py check --deploy #

장고가 제공하는 배포 점검 자동화 명령. 보안 권장 사항을 체크해서 경고를 줍니다.

실행
DJANGO_SETTINGS_MODULE=myproject.settings.prod python manage.py check --deploy

출력 예:

?: (security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting...
?: (security.W008) Your SECURE_SSL_REDIRECT setting is not set to True.
?: (security.W012) SESSION_COOKIE_SECURE is not set to True.
?: (security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE to True.
?: (security.W018) You should not have DEBUG set to True in deployment.
?: (security.W019) You have 'django.middleware.clickjacking.XFrameOptionsMiddleware' in your MIDDLEWARE, but X_FRAME_OPTIONS is not set to 'DENY'.
?: (security.W020) ALLOWED_HOSTS must not be empty in deployment.

CI에 넣어 통과 못 하면 배포 중단 — 운영 전 필수 게이트로 두는 걸 권장.

.github/workflows/deploy.yml — 일부
- name: Django deploy check
  run: |
    DJANGO_SETTINGS_MODULE=myproject.settings.prod \
    python manage.py check --deploy --fail-level WARNING

secret 관리 — 어디에 둘 것인가 #

1) .env + 서버 디스크 #

작은 프로젝트. .env를 git에서 제외하고 서버에 직접 배치. 단순하지만 rotation, 다중 환경, 권한에 약함.

2) Cloud secrets manager #

  • AWS Secrets Manager / AWS Systems Manager Parameter Store
  • GCP Secret Manager
  • Azure Key Vault

런타임에 IAM으로 인증해서 가져옴. rotation 자동화, 감사 로그까지 정리합니다.

settings.py — boto3 예
import boto3, json

if PRODUCTION:
    client = boto3.client("secretsmanager")
    secret = json.loads(client.get_secret_value(SecretId="myapp/prod")["SecretString"])
    SECRET_KEY = secret["django_secret"]
    ...

3) HashiCorp Vault #

자체 호스팅 / 멀티 클라우드 환경.

4) Kubernetes Secrets / Sealed Secrets #

쿠버네티스 위라면 자연스러운 선택입니다. Sealed Secrets로 git에 암호화된 형태로 저장.

절대 하면 안 되는 것 #

  • 코드/git에 평문
  • README, 위키, 슬랙
  • 환경 변수에 평문으로 git의 docker-compose.yml

자주 만나는 함정 #

1) DEBUG=True가 운영에 #

가장 흔하고 가장 치명적. CI에서 차단:

CI에서
python -c "import django; from django.conf import settings; assert not settings.DEBUG"

또는 manage.py check --deploy --fail-level WARNING.

2) SECRET_KEY의 git 커밋 이력 #

코드에 한 번 들어갔다 빠진 SECRET_KEY도 git history에 영원히 남습니다. 그 키는 즉시 rotation. git filter-branch / BFG로 history 청소도 같이.

3) 정적 파일 누락 #

DEBUG=False에서 장고는 정적 파일을 안 줍니다. **반드시 collectstatic**을 배포 단계에 넣고, nginx/CDN/whitenoise 중 하나로 서빙.

배포 단계
python manage.py collectstatic --noinput

whitenoise는 장고 자체에서 정적 파일을 서빙하게 해주는 미들웨어 — 단순한 사이트에 편함.

4) ALLOWED_HOSTS 누락 시 빈 응답 #

배포했는데 모든 요청이 400 Bad Request — 십중팔구 ALLOWED_HOSTS 누락. 장고 로그에 명확히 나옵니다.

5) ALB/Cloudflare의 X-Forwarded-* 신뢰 #

SECURE_PROXY_SSL_HEADER를 켰는데 정작 프록시 앞에서 들어오는 요청이 헤더를 위조할 수 있는 환경 — 모든 요청이 원래 IP가 아닌 X-Forwarded-For의 가짜 IP로 인식됨. 프록시가 항상 헤더를 덮어쓰는 토폴로지에서만.

6) Admin 노출 #

/admin/ URL을 그대로 두면 봇 공격의 1번 타겟. 권장:

  • URL 변경 (/secret-admin-path-12345/)
  • IP 제한 (nginx 단에서)
  • 2FA (django-otp)
  • VPN 안에서만 접근

점검 체크리스트 #

배포 전 한 번:

  • DEBUG = False
  • SECRET_KEY가 환경에서 옴, git에 없음
  • ALLOWED_HOSTS 명시 (* 금지)
  • HTTPS 강제 (SECURE_SSL_REDIRECT)
  • HSTS 설정 (점진적으로 늘리기)
  • SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE = True
  • CSRF_TRUSTED_ORIGINS 명시
  • X_FRAME_OPTIONS = "DENY"
  • CSP 설정 (먼저 report-only)
  • 비밀번호 검증기 길이 12자 이상
  • Argon2 또는 PBKDF2
  • manage.py check --deploy 통과
  • 정적 파일 collectstatic + 서빙
  • 에러 추적 (Sentry 등)
  • 백업 자동화 (DB)
  • Admin 보호

정리 #

이번 글에서 잡은 것:

  • settings 분리: 파일 분할 또는 환경 변수 분기 — 작은 프로젝트는 후자
  • env 검증: django-environ (DATABASE_URL 한 줄), pydantic-settings
  • DEBUG=False가 트리거하는 것들 — ALLOWED_HOSTS 활성, 정적 파일 안 서빙, 에러 디테일 비공개
  • ALLOWED_HOSTS의 와일드카드 함정, 헬스체크 IP 처리
  • SECRET_KEY — git 금지, secrets manager, SECRET_KEY_FALLBACKS로 rotation
  • CSRF: CSRF_TRUSTED_ORIGINS (4.0+ 필수), 쿠키 옵션 (Secure/HttpOnly/SameSite)
  • HTTPS: SECURE_SSL_REDIRECT, SECURE_PROXY_SSL_HEADER, HSTS (점진적), preload
  • 헤더: X_FRAME_OPTIONS=DENY, django-csp (report-only부터)
  • 비밀번호: Argon2, 길이 12+
  • 로깅: stdout, ERROR는 Sentry
  • manage.py check --deploy — CI 게이트
  • secret 관리: secrets manager 권장, git/README 절대 X
  • 함정: DEBUG 새어 나감, SECRET_KEY history, collectstatic 누락, X-Forwarded 위조, /admin/ 노출
  • 점검 체크리스트로 배포 전 확인

시리즈를 마무리하며 #

기초 7편중급 7편 → 고급 7편으로 21편의 장고 도구상자를 한곳에 정리했습니다.

이제 그 위에 API를 한 프로젝트로 쌓는 흐름이 남았습니다. 다음 시리즈 장고 실전 — DRF #1에서는 같은 장고 위에 REST API를 본격적으로 만듭니다. DRF의 ViewSet/Serializer/Permission, JWT, 페이지네이션, OpenAPI 자동 문서, Celery 비동기 작업, 테스트와 배포까지 — 6편으로 운영 가능한 장고 API 프로젝트를 한 번에 짓는 흐름입니다.

X