본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 08. 18:57

AI 어시스턴트를 위한 Django 난독화: 우리가 고생하며 발견한 6가지 보이지 않는 계약

요약

AI 어시스턴트(Claude Code, Cursor 등)의 코드 접근을 제한하기 위해 Django 소스 코드를 난독화하는 과정에서 발생하는 기술적 문제와 해결책을 다룹니다. Django의 인트로스펙션 특성으로 인해 발생하는 '보이지 않는 이름 계약' 위반 사례와 이를 해결하기 위한 탐지기 구현 과정을 설명합니다.

핵심 포인트

  • Django는 문자열, 템플릿, 마이그레이션 등에서 식별자 이름을 참조하는 특성이 있음
  • 난독화 시 Django의 인트로스펙션 레이어가 깨질 수 있는 위험 존재
  • 마이그레이션 파일의 클래스 참조 오류 등 구체적인 버그 사례 제시
  • AI 어시스턴트 보안을 위한 난독화 워크스페이스 구축 가이드

난독화된 워크스페이스를 대상으로 Django 테스트 스위트를 재실행할 때마다 무엇이 깨졌는지, 그리고 다음 라운드를 통과(green)시키기 위해 탐지기(detector)에 무엇을 추가해야 했는지에 대한 과정입니다.

설정 (The setup)

PromptCape는 소스 코드가 AI 어시스턴트(Claude Code, Cursor 등)에 도달하기 전에 난독화하여, AI가 실제 클래스, 메서드, 필드 이름 대신 이름이 변경된 식별자(identifier)를 기반으로 작업하도록 합니다. 우리는 이전 포스트에서 Java 파이프라인일반적인 Python 흐름을 다룬 바 있습니다.

이 포스트는 특히 Django에 관한 것입니다. Django는 우리가 지금까지 통합한 그 어떤 프레임워크보다 더 많은 **보이지 않는 이름 계약 (invisible name contracts)**을 가지고 있습니다. Python 코드 내부의 문자열, 템플릿(templates), 마이그레이션 파일(migration files), URL 설정(URL configurations), 어드민 등록(admin registrations) 등 모든 곳에서 식별자 이름을 참조합니다. Python에는 컴파일러가 없기 때문에 컴파일러가 이를 잡아낼 수 없습니다. 정적 임포트 검증기(static import verifier)도 이것들이 임포트(import)가 아니기 때문에 잡아낼 수 없습니다. 이것들은 Django의 인트로스펙션 레이어(introspection layer)가 getattr(form, 'clean_<field>', None)을 호출하고 아무것도 찾지 못하는 순간에만 표면 위로 드러납니다.

아래 이야기는 난독화된 django-blog 테스트 앱(Post / Comment / Tag 모델, CBVs + FBVs, ModelForm + standalone Form, 어드민 등록, pytest-django)을 실행했을 때 나타난 버그들의 실제 순서입니다. 각 버그는 하나의 계약을 드러냈고, 각 계약은 DjangoDetector의 변경을 이끌어냈습니다. 16개의 테스트, 통과(green)를 위한 6번의 반복.

반복 1 (Iteration 1) — 'django.db.migrations' has no attribute 'Cls_270e5090'

첫 번째 난독화. 수집(collection) 단계에서 즉시 테스트 실패:

ERROR tests/test_blog.py::test_post_creation_persists_all_fields
E   AttributeError: module 'django.db.migrations' has no attribute 'Cls_270e5090'
    blog/migrations/0001_initial.py:7: AttributeError

모든 Django 마이그레이션 파일의 7행은 다음과 같습니다:

class Migration(migrations.Migration):

난독화 도구(Obfuscator)는 클래스 정의에서 Migration을 포착하여 이를 등록하고, migrations.Migration(기본 클래스 참조)을 포함하여 이후에 나타나는 모든 발생 사례를 다시 작성했습니다. Django의 migrations 모듈에는 Cls_270e5090이라는 속성이 없으므로, 해당 클래스를 로드할 수 없게 됩니다.

유혹적인 해결책은 Migration을 보호된 이름(protected names) 목록에 추가하는 것입니다. 그렇게 하면 한 가지 사례는 해결되겠지만, 바로 다음 줄에는 migrations.CreateModel(...)이 나오고, 그다음에는 migrations.AddField(...), 그다음에는 migrations.RunPython(...)이 나옵니다. 대략 20여 개의 연산 클래스(operation classes)와 몇 가지 클래스 수준의 속성(initial, dependencies, operations, replaces, atomic)이 존재합니다. 더 심각한 문제는, 각 CreateModel(name=..., fields=..., options=..., bases=..., managers=...) 호출이 매우 일반적인 키워드 인자(kwargs)를 사용한다는 점입니다. name, fields, options, bases는 실제 코드베이스에서 두 번째마다 나타나는 사용자 식별자와 충돌합니다. 이 모든 것을 프로젝트 전역 제외 목록(exclusion list)에 추가하는 것은 난독화의 효과를 무력화할 것입니다.

올바른 해결책은 **마이그레이션 파일이 기계에 의해 생성된다(machine-generated)**는 점을 인식하는 것입니다. Django는 python manage.py makemigrations를 통해 이 파일들을 작성합니다. 사용자는 이를 수동으로 편집하지 않습니다. 이 파일들은 오로지 프레임워크 내부(internals)만을 참조합니다. 파일 내용 중 이름을 변경해야 할 것은 아무것도 없습니다.

따라서: migrations/, alembic/, versions/ 디렉토리를 완전히 건너뜁니다. 난독화 도구의 수집 단계(collection pass)에서 이들을 방문하지 않으며, 난독화 단계(obfuscation pass)에서도 이들을 다시 작성하지 않습니다. 이들은 워크스페이스로 있는 그대로 복사됩니다. Django의 migrate 명령은 예상했던 파일들이 변경되지 않은 상태로 유지되는 것을 확인합니다.

// ObfuscationEngine.java
private static final Set<String> MACHINE_GENERATED_DIR_NAMES = Set.of(
        "migrations", "alembic", "versions"
...

정보 유출은 없습니다. 마이그레이션 파일은 이미 models.py(AI가 난독화된 상태로 보는 파일)에 노출되어 있는 모델 클래스 이름과 필드 이름을 참조하기 때문입니다. 마이그레이션은 AI가 이미 가지고 있는 것과 동일한 이름을 기계 생성 형태로 포함하고 있습니다. 이들을 건너뛰는 데 드는 비용은 없습니다.

반복 2 — urlpatterns에는 패턴이 전혀 없는 것으로 보입니다

동일한 테스트를 다시 실행합니다:

django.core.exceptions.ImproperlyConfigured: The included URLconf 'mysite.urls'
does not appear to have any patterns in it. If you see the 'urlpatterns'
variable with valid patterns in the file then the issue is probably caused
...

mysite/urls.py는 다음과 같이 정의되어 있습니다:

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("blog.urls")),
...

난독화 도구(Obfuscator)가 urlpatterns를 모듈 수준의 할당 대상(module-level assignment target)으로 인식하여 이를 재작성했습니다. Django의 URL 리졸버(URL resolver)는 getattr(module, "urlpatterns")를 통해 가져온 URLconf 모듈에서 urlpatterns라는 리터럴 이름을 찾습니다. 이때 아무것도 찾지 못하면 "패턴을 찾을 수 없음(no patterns found)"이라는 오류를 보고합니다.

이것은 모듈 수준의 관례 (module-level convention) 패턴입니다. Django는 urls.py 파일에서 정확한 이름을 가진 몇 가지 명명된 상수(named constants)를 읽어옵니다:

이름목적
urlpatternsURL 경로(routes) 목록
...

이 모든 항목은 보호가 필요합니다. 이들은 프로젝트 전역의 Django API 이름 목록에 포함되며, 프로젝트 어디에서든 from django 또는 import django가 나타나는 즉시 조건 없이 적용됩니다.

별도로 나타난 동일 범주의 몇 가지 이름들은 다음과 같습니다:

  • INSTALLED_APPS, MIDDLEWARE, DATABASES, TEMPLATES, ROOT_URLCONF, STATIC_URL, MEDIA_URL, LANGUAGE_CODE, SECRET_KEY, DEBUG, ALLOWED_HOSTS, AUTH_USER_MODEL, DEFAULT_AUTO_FIELD 등 — 모두 이름으로 조사(introspected)되는 settings.py 모듈 수준의 상수들입니다.

반복 3 — ModelForm has no model class specified

다음은 폼(Forms)입니다:

ValueError: ModelForm has no model class specified.
  File ".../django/forms/models.py", line 362, in __init__
    if opts.model is None:
...

사용자의 PostForm:

class PostForm(forms.ModelForm):
    class Meta:
        model = Post
...

난독화 후:

class Cls_d367f020(forms.ModelForm):
    class Cls_7f994c64:               # <- 기존 Meta
        fld_08db7fa3 = Cls_b8106b41   # <- 기존 model = Post
...

세 가지 층위의 손상이 발생했습니다:

  1. Meta는 Django가 모든 Form/ModelForm/Serializer/Model 서브클래스에서 조사(introspect)하는 마법 같은 내부 클래스(inner-class) 이름입니다. 이 이름을 변경하면 내부 클래스가 보이지 않게 됩니다.
  2. modelMeta 내부에 있는 속성으로, ModelForm이 어떤 모델에 바인딩될지를 알려줍니다.
  3. fields는 어떤 모델 필드를 노출할지 나열하는 속성입니다.

해결책: Meta, model, fields (그리고 widgets, error_messages, field_classes, localized_fields, help_texts, labels, read_only_fields, extra_kwargs, abstract, proxy, managed, app_label, indexes, constraints, get_latest_by, default_manager_name, default_related_name 등 수십 개의 다른 Meta 속성들)를 보호 목록(protected list)에 추가합니다.

modelfields는 지나치게 일반적인 이름이라서, 관련 없는 수많은 사용자 코드까지 과도하게 보호하게 될 것입니다. 하지만 그 대안은 Django ModelForm이 아예 작동하지 않는 것입니다. 우리는 이 트레이드오프(trade-off)를 선택했습니다.

이것이 바로 내부 클래스 관례 (inner-class convention) 패턴입니다. Django는 메타데이터를 부착하기 위해 다른 클래스 내부에 class Meta:를 사용합니다. 외부 클래스의 동작은 Django가 리터럴 이름(literal name)으로 Meta를 찾고, 리터럴 이름으로 그 속성들을 읽는 것에 의존합니다.

반복 4 — no such table: blog_cls_b8106b41

테스트가 DB 계층으로 진행되었습니다:

django.db.utils.OperationalError: no such table: blog_cls_b8106b41

Django의 ORM은 app_label_classname.lower()를 통해 모델 클래스 이름으로부터 기본 DB 테이블 이름을 생성합니다. 다음과 같은 경우:

class Post(models.Model):
    title = models.CharField(...)

Django는 blog_post 테이블을 생성합니다. 마이그레이션 파일(Iteration 1에서 확인한 것과 토씨 하나 틀리지 않고 복사됨)은 해당 테이블을 선언합니다. 하지만 난독화된 models.py는 다음과 같이 선언합니다:

class Cls_b8106b41(models.Model):
    title = models.CharField(...)

이제 Django의 ORM은 blog_cls_b8106b41 테이블을 기대합니다. 하지만 마이그레이션은 blog_post를 생성했습니다. 이 둘은 결코 일치하지 않습니다.

해결책은 모델을 감지하는 AST (Abstract Syntax Tree) 스캔을 약간 확장하는 것입니다. 기존 탐지기는 필드 이름을 추출하기 위해 이미 models.Model을 상속받는 클래스들을 스캔하고 있었습니다. 이 동일한 스캔 과정에서 필드와 함께 **클래스 이름 (class name)**을 `

수정 사항은 CBV (Class-Based View)의 인트로스펙션 지점들을 API 이름 목록에 추가하는 것입니다:

  • CBV 클래스 속성: template_name, template_name_field, template_name_suffix, context_object_name, paginate_by, paginator_class, page_kwarg, queryset, form_class, form_kwargs, success_url, initial, slug_url_kwarg, pk_url_kwarg, slug_field, raise_exception, redirect_field_name.
  • CBV 메서드: get_queryset, get_context_data, get_object, get_form_class, get_form_kwargs, get_form, get_initial, get_template_names, get_success_url, get_absolute_url, form_valid, form_invalid, dispatch, setup, as_view, http_method_not_allowed, http_method_names.
  • 암묵적 훅 (Implicit hooks): post_save, pre_save, post_delete, pre_delete.

CBV 클래스 속성 이름은 선언적 속성 관례(declarative-attribute convention) 패턴입니다. 즉, 프레임워크의 기본 클래스가 읽기를 기대하는 이름과 일치하는 클래스 레벨 속성을 선언하고, 기본 클래스의 디스패처가 호출하는 이름과 일치하는 메서드를 오버라이드합니다. 이는 Spring의 @Bean을 이용한 메서드나 JPA의 @Entity를 이용한 클래스와 같은 형태입니다. 즉, 데코레이터(decorator) 대신 이름 관례(name conventions)를 통해 Python으로 표현되는 선언적 메타데이터입니다.

반복 6 — clean_body가 조용히 사라짐

이전 실패 사례는 독립형 폼(standalone form)에서 발생했습니다:

class CommentForm(forms.Form):
    author_name = forms.CharField(max_length=100)
    body = forms.CharField(widget=forms.Textarea())
...

테스트는 5자 이상의 최소 길이를 요구하여 유효성 검사 실패를 기대합니다:

def test_comment_form_requires_minimum_body_length():
    form = CommentForm(data={

메서드 본문은 동일합니다. 메서드 이름(NAME)이 변경되었습니다. Django의 `BaseForm._clean_fields()`는 선언된 필드들을 순회하며 다음과 같은 작업을 수행합니다:

clean_method = getattr(self, f"clean_{name}", None)
if clean_method is not None:
value = clean_method()


`getattr(self, "clean_body", None)`은 `None`을 반환합니다. 사용자가 작성한 검증기(validator)가 소리 없이 실행되지 않는 것입니다. 폼(form)은 `

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0