장고 고급 #7 배포 보안 — settings 분리, ALLOWED_HOSTS, CSRF, secret 관리
장고 고급 시리즈의 마지막. 중급 #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 # 운영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"),
...
}
}
# ⚠ 여기에는 비밀 값을 절대 두지 않음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"]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를 환경 변수로:
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE",
"myproject.settings.dev", # 기본은 dev, 운영은 환경에서 덮어씀
)패턴 2: 단일 파일 + 환경 변수 분기 #
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-environimport 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):
DEBUG=False
SECRET_KEY=...
ALLOWED_HOSTS=myapp.com,www.myapp.com
DATABASE_URL=postgres://user:pass@db:5432/mydb
CACHE_URL=redis://cache:6379/1DATABASE_URL 하나가 ENGINE/NAME/USER/PASSWORD/HOST/PORT를 다 채워줘서 12factor 스타일 배포에 잘 어울립니다.
pydantic-settings #
장고에서도 모던 파이썬 실전에서 본 그것을 쓸 수 있습니다.
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 시점에 ValidationErrorfrom 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 필요)
커스텀 에러 페이지 #
def custom_404(request, exception):
return render(request, "404.html", status=404)
def custom_500(request):
return render(request, "500.html", status=500)handler404 = "myapp.views.custom_404"
handler500 = "myapp.views.custom_500"운영 배포 전에 두 페이지를 만들어 두면 이미지/스타일 적용된 친절한 에러 화면이 됩니다.
ALLOWED_HOSTS
#
DEBUG=False에서 활성. 요청의 Host: 헤더가 이 리스트에 없으면 400 Bad Request.
ALLOWED_HOSTS = ["myapp.com", "www.myapp.com"]와일드카드 함정 #
ALLOWED_HOSTS = ["*"]모든 호스트 허용 — DNS rebinding, host header 공격을 무방비로 받습니다. 절대 운영에 두면 안 됨.
ALLOWED_HOSTS = [".myapp.com"] # 점으로 시작 — *.myapp.com의미헬스체크 IP #
로드밸런서 헬스체크가 IP로 직접 들어오면 host 헤더가 IP가 됩니다. 그것도 명시:
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**가 있습니다. 새 키로 서명하되, 옛 키로 서명된 세션도 잠시 받음.
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 하는 경우 (서브도메인, 결제 콜백 등)가 있다면:
CSRF_TRUSTED_ORIGINS = [
"https://myapp.com",
"https://www.myapp.com",
"https://payments.example.com",
]스킴(https://) 포함 필수. 4.0부터 의무화됐습니다.
쿠키 옵션 #
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"| 쿠키 옵션 | 의미 |
|---|---|
Secure | HTTPS에서만 전송 |
HttpOnly | JS (document.cookie) 접근 차단 |
SameSite=Lax | 다른 사이트가 보낸 요청에 자동 첨부 안 함 (top-level GET만 OK) |
SameSite=Strict | 다른 사이트 요청엔 절대 안 첨부 |
SameSite=None | 모든 요청에 첨부 (Secure 필수) |
중급 #5의 옵션들이 운영에서 한 번에 모입니다.
HTTPS / HSTS #
SECURE_SSL_REDIRECT
#
SECURE_SSL_REDIRECT = True장고가 HTTP 요청을 받으면 301로 HTTPS로 보냅니다. 보통은 nginx/ALB 단에서 처리하지만, 장고 측에 한 번 더 두면 안전.
SECURE_PROXY_SSL_HEADER — 프록시 뒤일 때
#
리버스 프록시 (nginx, ALB, Cloudflare)가 SSL 종단을 한다면, 장고 입장에서는 HTTP처럼 보입니다. 프록시가 보내주는 헤더로 원래 HTTPS 였음을 알려야 합니다.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")⚠ 신뢰할 수 있는 프록시 뒤가 아니면 헤더 위조 위험. 프록시가 항상 이 헤더를 덮어쓰도록 설정한 환경에서만.
HSTS — Strict-Transport-Security #
브라우저에게 “이 도메인은 HTTPS만 쓴다” 라고 알리는 헤더.
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
#
X_FRAME_OPTIONS = "DENY"장고 기본은 SAMEORIGIN. iframe에 우리 사이트가 들어가는 걸 막아 clickjacking 공격을 차단. 결제, 로그인 페이지에 특히 중요.
Content Security Policy (CSP) #
XSS 등의 마지막 방어선. **django-csp**가 표준.
pip install django-cspMIDDLEWARE = [..., "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 모드로 시작 — 위반 시 차단 대신 리포트만:
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 비밀번호 해싱 컴퍼티션 우승). 추가 패키지 필요:
pip install argon2-cffi해셔를 추가하고 첫 항목에 두면 새 비밀번호는 Argon2, 기존 PBKDF2 비밀번호는 사용자가 다음에 로그인할 때 자동으로 Argon2로 재해시됨.
AUTH_PASSWORD_VALIDATORS
#
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자 권장. 복잡도 규칙 (특수문자 강제)보다는 길이가 효과적이라는 게 현대 권장 사항.
로깅 #
운영 로깅 패턴 #
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]"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에 넣어 통과 못 하면 배포 중단 — 운영 전 필수 게이트로 두는 걸 권장.
- name: Django deploy check
run: |
DJANGO_SETTINGS_MODULE=myproject.settings.prod \
python manage.py check --deploy --fail-level WARNINGsecret 관리 — 어디에 둘 것인가 #
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 자동화, 감사 로그까지 정리합니다.
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에서 차단:
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 --noinputwhitenoise는 장고 자체에서 정적 파일을 서빙하게 해주는 미들웨어 — 단순한 사이트에 편함.
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 프로젝트를 한 번에 짓는 흐름입니다.