Django 모델에 비밀키를 안전하게 저장하기 (Fernet 버전)

보안은 개발자의 책임 – DB에 원문 그대로 저장하면 안 됩니다.

1. 왜 암호화가 필요한가?



  • API Key / Secret / OAuth 토큰 같은 값은 한 번 새어나가면 서비스 전체가 뚫린 것과 마찬가지입니다.
  • settings.py에 하드코딩하면 → Git 저장소, 배포 아티팩트, 개발자 노트북 등에서 그대로 노출될 수 있습니다.
  • 환경변수에 넣더라도 → 그 값을 그대로 DB에 저장하면 DB가 털리는 순간 그대로 평문 노출입니다.

또 한 가지 꼭 짚고 가야 할 점:

  • 비밀번호(password)

  • 서버가 “원문”을 알 필요가 없음 → 해시(BCrypt, Argon2) 사용

  • API Key / Secret / 토큰

  • 서버가 원문을 다시 사용해야 함 → 복호화 가능한 암호화 필요

정리

  • 다시 써야 하는 민감 데이터암호화 후 저장
  • 다시 쓸 필요 없는 데이터(비밀번호 등)해시 후 저장

2. Fernet이 뭔가요?

cryptography.fernet.Fernet은 다음을 한 번에 제공하는 고수준 API입니다.

  • 내부적으로 안전한 알고리즘(AES + HMAC 등)을 사용
  • 랜덤 IV, 타임스탬프, 무결성 검증(HMAC) 등을 알아서 처리
  • 개발자는 “키를 하나 잘 보관”하고

  • encrypt(평문) → 토큰 문자열

  • decrypt(토큰) → 평문 만 호출하면 됨

즉, AES-CBC/IV/패딩/HMAC을 직접 신경 쓰는 대신:

from cryptography.fernet import Fernet

key = Fernet.generate_key()      # 키 생성
f = Fernet(key)

token = f.encrypt(b"hello")      # 암호화
plain = f.decrypt(token)         # 복호화

이 정도만 이해하고 있어도 충분히 쓸 수 있습니다.

우리가 챙길 일은 딱 두 가지

  1. 키 관리 (환경변수, Secret Manager 등)
  2. Django에서 쓰기/읽기 시 자동으로 encrypt/decrypt 하게 만들기

3. Fernet 키 생성 및 Django 설정



3-1. Fernet 키 생성

# Fernet 키 1개 생성 (Base64 문자열로 출력됨)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

출력 예:

twwhe-3rGfW92V5rvvW3o4Jhr0rVg7x8lQM6zCj6lTY=

이 문자열을 서버 환경변수에 넣습니다.

export DJANGO_FERNET_KEY="twwhe-3rGfW92V5rvvW3o4Jhr0rVg7x8lQM6zCj6lTY="

3-2. settings.py에 Fernet 인스턴스 준비

# settings.py
import os
from cryptography.fernet import Fernet

DJANGO_FERNET_KEY = os.environ["DJANGO_FERNET_KEY"]  # 문자열
FERNET = Fernet(DJANGO_FERNET_KEY.encode("utf-8"))

이제 Django 어디서든 from django.conf import settingssettings.FERNET.encrypt(...), settings.FERNET.decrypt(...)를 쓸 수 있습니다.


4. Django 모델에 Fernet 암호화 필드 추가

복잡한 블록 암호 구현 대신, 아주 단순한 커스텀 필드 하나만 만들면 됩니다.

4-1. 설계 포인트

  • 실제 DB에는 암호화된 토큰 문자열만 저장
  • 모델/뷰/서비스 코드에서는 항상 복호화된 평문만 보게 만들기
  • _secret + secret 패턴으로 DB 컬럼과 실제 의미를 분리

4-2. EncryptedTextField 구현

# myapp/utils/encrypted_field.py
from django.db import models
from django.conf import settings


class EncryptedTextField(models.TextField):
    """
    Fernet 기반 암호화 TextField
    - 저장 시: 평문(str) -> Fernet 토큰(str)으로 암호화
    - 조회 시: Fernet 토큰(str) -> 평문(str)으로 복호화
    """

    description = "Fernet encrypted text field"

    def get_prep_value(self, value):
        """
        Python 객체 -> DB에 저장될 값
        """
        if value is None:
            return None

        # 이미 토큰이 들어왔는지 판단하는 로직도 추가할 수 있지만,
        # 보통은 평문만 들어오도록 사용하는 것이 좋습니다.
        if isinstance(value, str):
            # 빈 문자열은 암호화 없이 그대로 저장할 수도 있음 (선택사항)
            if value == "":
                return ""

            token = settings.FERNET.encrypt(value.encode("utf-8"))
            return token.decode("utf-8")

        return value

    def from_db_value(self, value, expression, connection):
        """
        DB 값 -> Python 객체
        """
        if value is None:
            return None

        # 마이그레이션 등으로 인해 평문이 섞여 있을 수 있으므로
        # decrypt 실패 시에는 평문으로 간주하고 그대로 반환
        try:
            token = value.encode("utf-8")
            decrypted = settings.FERNET.decrypt(token)
            return decrypted.decode("utf-8")
        except Exception:
            return value

    def to_python(self, value):
        """
        ORM이 Python 객체로 다룰 값
        - from_db_value 이후에 한번 더 호출될 수 있음
        - 여기서는 특별한 처리를 하지 않고 그대로 반환
        """
        return value

실무 팁

  • 초기에 기존 평문 데이터가 DB에 들어있다면, from_db_value에서 decrypt 실패 시 평문으로 반환하게 해두고 점진적으로 읽기 → 다시 저장될 때 암호화된 값으로 변환되도록 할 수 있습니다.

5. 모델에 적용하기

# myapp/models.py
from django.db import models
from .utils.encrypted_field import EncryptedTextField


class MyModel(models.Model):
    name = models.CharField(max_length=100)
    _secret = EncryptedTextField()   # 실제 DB 컬럼 (Fernet 토큰)

    @property
    def secret(self):
        """
        외부에서 사용할 때는 항상 평문으로 접근
        """
        return self._secret

    @secret.setter
    def secret(self, value: str):
        """
        평문을 할당하면 저장 시 자동으로 암호화됨
        """
        self._secret = value
  • _secret

  • 실제 DB 컬럼 이름

  • DB에는 Fernet 토큰(암호화된 문자열)이 저장됨
  • secret

  • 코드에서 사용하는 “논리적인” 필드

  • 항상 평문만 다루게 됨

이 패턴 덕분에, 나중에 모델을 보는 사람도

obj.secret  # 평문
obj._secret # 암호화된 값이 들어있는 컬럼

관계를 명확히 이해할 수 있습니다.


6. 사용 예시

6-1. Django shell

>>> from myapp.models import MyModel

# 새 객체 생성
>>> obj = MyModel(name="Test")
>>> obj.secret = "my-very-secret-key"   # 평문으로 할당
>>> obj.save()

# DB에 저장된 실제 컬럼은 암호화된 토큰
>>> MyModel.objects.get(id=obj.id)._secret
'gAAAAABm...4xO8Uu1f0L3K0Ty6fX5y6Zt3_k6D7eE-'  # 예시

# 모델에서 사용할 때는 평문
>>> obj.secret
'my-very-secret-key'

6-2. API에서 사용

실제로는 클라이언트에게 secret을 그대로 내려주기보다는, 백엔드 내부에서만 쓰는 경우가 많습니다. 그래도 예시로 보면:

# myapp/views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from .models import MyModel


class SecretView(APIView):
    def get(self, request, pk):
        obj = MyModel.objects.get(pk=pk)
        # obj.secret는 평문 (이미 복호화된 값)
        return Response({"secret": obj.secret})

실무에서는 보통:

  • obj.secret으로 외부 API 호출에 사용만 하고
  • 클라이언트에는 절대 그대로 노출하지 않는 패턴을 많이 사용합니다.

7. Fernet을 쓸 때 알아둘 점들

7-1. 검색/필터링 제약

Fernet은 매번 암호화 결과가 달라지는 구조입니다.

obj1.secret = "abc"
obj2.secret = "abc"

둘 다 "abc"지만, DB에 저장된 _secret 값은 서로 완전히 다른 토큰입니다.

그래서 다음과 같은 쿼리는 동작하지 않습니다.

MyModel.objects.filter(_secret="abc")       # 의미 없음
MyModel.objects.filter(_secret=obj._secret) # 토큰 그대로 비교는 가능하지만,
                                           # 평문 기준 검색이 안 됨

즉, 평문 기준 검색/정렬이 필요한 값이라면:

  • 별도의 검색용 컬럼(해시, prefix 등) 을 두거나
  • 애초에 암호화 대상 설계를 다시 고민해야 합니다.

7-2. 키 변경(로테이션) 전략

한 키만 계속 쓰지 말고, 주기적으로 바꾸는 것이 이상적입니다.

  • 간단한 스타트:

  • DJANGO_FERNET_KEY 하나로 시작

  • 나중에 고도화:

  • OLD_KEY, NEW_KEY 두 개를 두고

    • 암호화: NEW_KEY로만
    • 복호화: OLD/NEW 둘 다 시도
    • 점진적으로 데이터가 NEW_KEY로 재암호화되면 OLD_KEY 제거

이 부분은 글 한 편 분량이라, 여기서는 개념만 짚고 넘어갑니다. 🙂


8. 보안 체크리스트 (Fernet 버전)

항목 체크
키 관리 DJANGO_FERNET_KEY는 Git에 올리지 말고, AWS Secrets Manager / Parameter Store / Vault 등에서 로드
권한 분리 운영서버에서만 키를 주입하고, 개발/로컬 환경은 다른 키 사용
암호화 대상 진짜로 필요한 값(API Key, Secret, 토큰 등)만 암호화하고, 나머지는 일반 컬럼으로 유지
로그 평문/토큰/키를 절대 로그에 찍지 않기 (특히 DEBUG 로그 주의)
백업 DB 백업을 가져가더라도, 키가 없으면 의미 없는 데이터가 되도록 만들기
테스트 단위 테스트에서 encrypt -> decrypt 결과 일치, 마이그레이션(기존 평문) 처리까지 검증

9. 마무리

  • 저수준 AES 구현은 설명도 어렵고, 실수도 많이 나옵니다.
  • 대신 Fernet을 사용하면:

  • 안전한 알고리즘 조합

  • 랜덤 IV, 타임스탬프, 무결성 검증
  • 간단한 API 를 한 번에 얻을 수 있습니다.
  • Django에서는

  • EncryptedTextField 같은 커스텀 필드 +

  • _secret / secret 프로퍼티 패턴을 쓰면 개발자는 평문만 다루는 코드를 작성하면서도 DB에는 항상 암호화된 값만 저장되도록 만들 수 있습니다.

결론

  • 민감 데이터 = 항상 암호화해서 저장한다
  • 복잡한 암호 알고리즘은 라이브러리에 맡긴다” 이 두 가지만 지켜도, Django 서비스의 보안 레벨이 크게 올라갑니다. 🔐

image