본문 바로가기
개발 일기장/파이콘 준비위원회

20250501-20250505 한 일 기록

by 룰루루 2025. 5. 5.

어떤 업무를 했는지 구체적으로 적기는 애매하지만, 개발 단에서 총 세 가지의 오류 / 나름의 발견이 있어서 까먹기 전에 이를 기록해보려고 한다. 

  1. 로컬에서 django 어드민에 접속하지 못하는 문제를 CSRF 설정을 변경하여 해결함
  2. 어드민의 쿼리 효율성을 고려해봄
  3. 어드민에서 fieldset 필드에 반복되는 코드를 커스텀 mixin 클래스를 선언하여 줄여봄

✅ CSRF 설정 변경

우선, CSRF가 무엇인지를 알고 가자. 나도 '공격자가 사용자를 위장하여 사용자의 명의/권한으로 행동하는 것'이라고만 알고 있어서, perplexity에 검색해봤다(이 친구가 hallucination이 적고 실제 검색 기반으로 많이 사용한다고 들어서, 앞으로 단편적인 지식 검색은 요 친구를 사용해보려고 한다). 

 

CSRF는 (cross site request forgery)로, '피해자'가 '공격 대상 사이트'에 로그인 한 상태에서 '공격자의 악성 사이트'에 접속했을 때 발생할 수 있는 공격이라고 한다. 이때 피해자의 정보가 탈취되며, 공격자는 이 피해자의 정보로 공격 대상 사이트에 피해자의 신분을 가장하여 피해자가 원하지 않는 요청들을 보낼 수 있다. 

 

이를 방지하는 방법 중 하나는 CSRF 토큰을 사용하는 것이다. 서버가 매 세션마다 CSRF 토큰을 발급하고, 매 요청마다 해당 토큰을 검증한다. 그렇기에 공격자의 입장에서는 중간에 사용자의 정보만 탈취해서는 CSRF 토큰 검증을 통과할 수 없게 된다. 

 

장고(django) 서버에서도 이러한 CSRF과 관련된 여러 설정이 있었다. 이번 이슈는 settings.py의 여러 변수들 중 'CSRF_COOKIE_DOMAIN'과 관련된 이슈였다. 

 

CSRF_COOKIE_DOMAIN 변수는 '장고 서버의 도메인에서 서브도메인으로부터 요청이 들어왔을 때 그 요청에 대해서는 CSRF를 검증하지 않기 위해서' 사용한다고 이해했다. CSRF 검증은 정확하게 같은 도메인으로부터 온 요청이 아니면 적용되기 때문에, 서브도메인에서 요청을 보내게 될 경우에는 CSRF 검증 적용 대상이 된다고 이해했다. 

 

그래서 해당 서버에서도 CSRF_COOKIE_DOMAIN의 값이 'a.com'으로 되어있었다. 다만 나는 당시 어드민 페이지를 작업 중이어서 변경사항이 잘 반영되었는지 보려면 로컬 환경에서 어드민에 접속해야 했다. 그런데 로컬 환경에서도 CSRF_COOKIE_DOMAIN 변수의 값은 여전히 'a.com' 이었기 때문에, 브라우저에서 해당 토큰을 저장하지 못했다. 그래서 403 Forbidden 오류와 함께 'CSRF cookie not set'이라는 에러 메시지를 받았다. 

 

당시 응답 헤더의 Set-Cookie 속성의 Domain 값이 'a.com'이었고, 그 말은 해당 쿠키(여기서는 서버에서 보낸 CSRF 토큰)는 브라우저가 'a.com'으로 요청을 보낼 때만 유효하게 사용된다는 의미였다. 그러므로 현재 브라우저는 로컬호스트로 요청을 보내고 있었기 때문에, 해당 값을 아예 저장하지 않았던 것이었다. 

 

해당 값을 로컬에서 서버를 실행할 때만 None으로 바꿔주었더니, 이제는 브라우저에 csrf 토큰이 잘 저장되었다. 

# CSRF_COOKIE_DOMAIN = 'a.com'	# 기존 코드
CSRF_COOKIE_DOMAIN = None if IS_LOCAL else 'a.com'	# 수정한 코드

 

✅ 어드민 쿼리 효율성 고려

이제는 어드민에 접속이 되었다. 다만 내가 작업하는 어드민 페이지의 B 모델은 A 모델을 ForeignKey로 갖고 있었다. 그리고 나는 어드민에서 개별 B 인스턴스 페이지를 조회할 때 해당 B 인스턴스가 외래키로 갖고 있는 A 인스턴스에 대한 정보도 read-only로 같이 보여주고 싶었다.

 

이는 ModelAdmin 클래스의 fieldset 속성을 사용하면 되었다. 다만 이렇게 했을 때, 여러 페이지를 조회하게 될 경우 쿼리가 불필요하게 많이 발생할 수 있겠다는 생각이 들었다. 

 

예를 들면 이런 식이다. 

 

모델 B의 인스턴스인 b1과 b2를 개별 페이지로 조회한다고 치자. 그러면 b1의 개별 조회 페이지에 접속할 때 b1이 참조하는 모델 A의 a1에 대한 정보를 불러오기 위해 쿼리가 한번 더 실행될 것이다. 그러다가 b2의 개별 조회 페이지에 접속하면 그때 또 b2가 참조하는 모델 A의 a2에 대한 정보를 불러오기 위해 추가 쿼리가 실행될 것이다.

 

물론 만약 어드민 사용자가 필요한 b 인스턴스 하나만 수정하고 로그아웃한다면 불필요한 쿼리는 많이 발생하진 않을 것이다. 그래도 인스턴스 하나를 조회할 때마다 굳이 추가 쿼리를 발생시킬 필요는 없다고 판단했다. 

 

그래서 해당 B 모델에 대한 쿼리셋 메소드(get_queryset)를 찾아보니, 전체 목록 조회 페이지에 접속할 때 get_queryset()이 한 번 실행되는 것으로 보였다. 여기서 마치 eager loading처럼 B 모델에 대한 정보 + A 모델에 대한 정보까지 모두 불러오면 추가 쿼리가 실행되지 않을 것이라 판단했고, 해당 메소드를 다음과 같이 재정의했다.

# admin.py
class BAdmin(admin.ModelAdmin):

    def get_queryset(self, request):
    	# return super().get_queryset(request)	# 기존 코드
        return super().get_queryset(request).select_related("A")	# 수정한 코드

 

✅ 어드민 custom mixins 클래스 선언

위의 문제와도 연결된다. 모델 B가 참조하는 모델 A의 필드들을 inline 형식으로 보여주고 싶었다. 그러면 admin.ModelAdmin의 fieldsets 속성을 이용하면 되었다. 

 

문제는 이렇게 할 경우 반복되는 코드의 양이 꽤 많다는 점이었다. 예를 들어 inline 형식에서 모델 A의 필드 a, b, c, d, e를 보여주고 싶을 경우, 아래와 같은 반복 코드가 발생했다. 

class BAdmin(admin.ModelAdmin):
    readonly_fields = [
        'id',
        'get_a_a',
        'get_a_b',
        'get_a_c',
        'get_a_d',
        'get_a_e',
    ]

    def get_a_a(self, obj):
        if obj.a:
            return obj.a.a
        return None
    
    def get_a_b(self, obj):
        if obj.a:
            return obj.a.b
        return None
    
    def get_a_c(self, obj):
        if obj.a:
            return obj.a.c
        return None
    
    def get_a_d(self, obj):
        if obj.a:
            return obj.a.d
        return None
    
    def get_a_e(self, obj):
        if obj.a:
            return obj.a.e
        return None

    get_a_a.short_description = "A a"
    get_a_b.short_description = "A b"
    get_a_c.short_description = "A c"
    get_a_d.short_description = "A d"
    get_a_e.short_description = "A e"

    fieldsets = (
        (None, {
            'fields': ('id', )  # 모델 B의 필드들
        }),
        ('모델 A 정보', {
            'fields': ('get_a_a', 'get_a_b', 'get_a_c', 'get_a_d', 'get_a_e'),
            'classes': ('collapse',),
        }),
    )

 

보여주고 싶은 모델 A의 필드 개수가 많거나, 모델 B가 모델 A 말고도 참조하는 모델이 여러 개라면 중복 코드의 양이 너무 많아지는 문제가 있었다. 그래서 python 단에서 상속을 통해서, 커스텀 mixins 클래스를 정의한 다음 해당 BAdmin에서 그 mixins를 상속받게 하는 방법을 사용했다. 

 

mixins의 코드는 다음과 같다. 

# admin_mixins.py
class RelatedReadonlyFieldsMixin:
    related_readonly_config = {}

    def _generate_related_getter(self, rel, field, prefix=""):
        def _func(admin_self, obj):
            related = getattr(obj, rel)
            return getattr(related, field) if related else None

        _func.short_description = f"{prefix} {field.replace('_', ' ')}"
        return _func

    def _register_dynamic_readonly_fields(self):
        for rel, fields in self.related_readonly_config.items():
            for field in fields:
                method_name = f"get_{rel}_{field}"
                getter = self._generate_related_getter(rel, field, prefix=rel.capitalize())
                setattr(self.__class__, method_name, getter)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._register_dynamic_readonly_fields()

    def get_readonly_fields(self, request, obj=None):
        base = super().get_readonly_fields(request, obj)
        generated = [f"get_{rel}_{field}" for rel, fields in self.related_readonly_config.items() for field in fields]
        return list(base) + generated

 

그러면 admin.py의 BAdmin은 다음과 같이 선언할 수 있다. 중복 코드가 확 줄어든다. 

# admin.py
class BAdmin(RelatedReadonlyFieldsMixin, admin.ModelAdmin):
    fields = ["id", ]	# 모델 B의 다른 필드들
    readonly_fields = ["id"]
    related_readonly_config = {	# RelatedReadonlyFieldsMixin에서 참고하는 부분
        "a": ["a", "b", "c", "d", "e"]
    }

 

'개발 일기장 > 파이콘 준비위원회' 카테고리의 다른 글

20250501 질문 정리  (0) 2025.05.04
20250420 질문 정리  (0) 2025.04.20