Django Admin
Django Admin의 개념 및 목적
개념
- 신뢰할 수 있는 사용자가 사이트 콘텐츠를 편집할 수 있는 도구"
목적
- 데이터 CRUD 관리: 코딩 없이 GUI를 통해 데이터를 생성, 조회, 수정, 삭제 함
- 모델 유효성 검증: 모델에 정의된 clean() 메서드나 필드 제약 조건을 자동으로 적용
- 권한 제어: 어떤 사용자가 어떤 모델에 접근할 수 있는지 세밀하게 조정
Admin 등록 및 기본 구조
from django.contrib import admin
from .models import QuestionCategory
admin.site.register(QuestionCategory)
클래스 데코레이터 방식
- 설정을 더 세밀하게 제어하기 위해 ModelAdmin 클래스를 상속받아 등록하는 방식을 주로 사용
@admin.register(QuestionCategory)
class QuestionCategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'type', 'parent', 'created_at')
list_filter = ('type',)
search_fields = ('name',)
핵심 속성
list_display
- 모델 목록 페이지에서 보여줄 필드들을 튜플/리스트로 정의
list_filter
- 특정 필드(주로 Choice나 ForeignKey)를 기준으로 데이터를 필터링하는 기능을 제공
search_fields
- "검색 기능을 활성화하며, 어떤 필드에서 검색할지 정의
fields / fieldsets
- 상세 수정 페이지에서 필드의 순서나 그룹화를 설정
readonly_fields
- 수정이 불가능하고 읽기만 가능한 필드를 지정합니다. (예: 생
raw_id_fields
- 관계형 필드에서 드롭다운 대신 ID를 직접 입력하거나 팝업으로 선택하게 합니다. (데이터가 많을용)
is_staff
- Admin 페이지에 로그인할 수 있는 권한만 부여하며, 구체적인 모델 접근 권한은 별도로 할당해야 함
데이터 유효성 검사
- 모델에 작성한 clean() 메서드는 Admin에서도 그대로 작동
- 사용자가 Admin 페이지에서 데이터를 입력하고 Save를 누름
- Django는 모델의 full_clean()을 호출, 이 과정에서 사용자가 정의한 clean()이 실행
- 조건에 맞지 않으면 Admin 화면 상단에 ValidationError 메시지가 출력되며 저장이 차단
def clean(self):
"""계층 구조 유효성 검사"""
if self.type == 'large' and self.parent is not None:
raise ValidationError({'parent': '대분류는 부모 카테고리를 가질 수 없습니다.'})
if self.type == 'medium':
if self.parent is None or self.parent.type != 'large':
raise ValidationError({'parent': '중분류의 부모는 [대분류]여야 합니다.'})
if self.type == 'small':
if self.parent is None or self.parent.type != 'medium':
raise ValidationError({'parent': '소분류의 부모는 [중분류]여야 합니다.'})
카테고리 조건
질의응답 카테고리 등록
- 스태프 / 관리자 권한을 가진 유저는 어드민 페이지 내의 질의응답 관리 메뉴의
- 카테고리 관리 메뉴에 접속하여 질의응답 카테고리를 등록 가능
카테고리 분류
- 대분류 카테고리 → 프론트엔드, 백엔드 등
- 중분류 카테고리 → 프로그래밍 언어, 웹프레임워크, Web, OS, 라이브러리 등
- 소분류 카테고리 → JavaScript, Python, Django, React Next.js, FastAPI 등
카테고리 등록 시 입력 항목
- 카테고리 종류 → 대분류, 중분류, 소분류
- 카테고리 이름
- 부모 카테고리 → 중, 소분류 카테고리의 경우
카테고리 목록 조회
- 스태프 / 관리자 권한을 가진 유저는 어드민 페이지 내의 질의응답 관리 메뉴의
- 카테고리 관리 메뉴에 접속하여 시스템에 등록된 질의응답 카테고리들을 목록으로 조회 가능
- 카테고리 목록 조회 시 검색필터 + 검색 기능을 활용하여 조회 가능
카테고리 목록 조회에서 확인 가능한 항목
- 카테고리 ID / 카테고리명 / 카테고리 분류 타입 ( 대, 중, 소 )
- 자식 카테고리
- 카테고리 분류가 대, 중일 경우 자식 카테고리의 명칭
- 소분류일 경우 빈칸
- 부모 카테고리
- 카테고리 분류가 중, 소일 경우 부모 카테고리의 명칭
- 대분류일 경우 빈칸
- 등록일시
- 수정일시
카테고리 삭제
- 스태프 / 관리자 권한을 가진 유저는 어드민 페이지 내의 질의응답 관리 메뉴의
- 카테고리 관리 메뉴에 접속하여 시스템에 등록된 질의응답 카테고리를 삭제 가능
- 조회된 카테고리 목록에서 삭제할 카테고리를 선택하고 삭제하기 버튼을 클릭하여 삭제
- 카테고리 삭제 전, 대, 중, 소분류에 따라 경고 메시지 팝업이 나옴
대분류의 경우
- 해당 카테고리에 속한 중분류, 소분류 카테고리가 함께 삭제
- 각 카테고리에 속한 질의응답은 일반질문 카테고리로 전환 / 삭제된 항목 복구 불가
중분류의 경우
- 해당 카테고리에 속한 소분류 카테고리가 함께 삭제
- 각 카테고리에 속한 질의응답은 일반질문 카테고리로 전환 / 삭제된 항목 복구 불가
소분류의 경우
- 해당 카테고리에 속한 질의응답은 일반질문 카테고리로 전환 / 삭제된 항목 복구 불가
현재 카테고리 기능
1. 데이터 모델 구조 (Model)
- Question 모델에서 category 필드를 통해 QuestionCategory 모델을 참조
class Question(TimeStampedModel):
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="questions")
category = models.ForeignKey(QuestionCategory, on_delete=models.PROTECT, related_name="questions")
title = models.CharField(max_length=50)
content = models.TextField()
view_count = models.BigIntegerField(default=0)
카테고리 등록 구현 🔴
질의응답 카테고리 등록
- 스태프 / 관리자 권한을 가진 유저는 어드민 페이지 내의 질의응답 관리 메뉴의
- 카테고리 관리 메뉴에 접속하여 질의응답 카테고리를 등록 가능
카테고리 분류
- 대분류 카테고리 → 프론트엔드, 백엔드 등
- 중분류 카테고리 → 프로그래밍 언어, 웹프레임워크, Web, OS, 라이브러리 등
- 소분류 카테고리 → JavaScript, Python, Django, React Next.js, FastAPI 등
카테고리 등록 시 입력 항목
- 카테고리 종류 → 대분류, 중분류, 소분류
- 카테고리 이름
- 부모 카테고리 → 중, 소분류 카테고리의 경우
모델 수정 🟢
from django.db import models
from django.core.exceptions import ValidationError
from apps.core.models import TimeStampedModel
class QuestionCategory(TimeStampedModel):
CATEGORY_TYPES = (
('large', '대분류'),
('medium', '중분류'),
('small', '소분류'),
)
name = models.CharField(max_length=50, verbose_name="카테고리 이름")
type = models.CharField(
max_length=10,
choices=CATEGORY_TYPES,
default='large',
verbose_name="카테고리 종류"
)
parent = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='children',
verbose_name="부모 카테고리"
)
class Meta:
db_table = "question_categories"
verbose_name = "질의응답 카테고리"
verbose_name_plural = "질의응답 카테고리 목록"
def __str__(self) -> str:
return f"[{self.get_type_display()}] {self.name}"
def clean(self):
"""계층 구조 유효성 검사"""
if self.type == 'large' and self.parent is not None:
raise ValidationError({'parent': '대분류는 부모 카테고리를 가질 수 없습니다.'})
if self.type == 'medium':
if self.parent is None or self.parent.type != 'large':
raise ValidationError({'parent': '중분류의 부모는 [대분류]여야 합니다.'})
if self.type == 'small':
if self.parent is None or self.parent.type != 'medium':
raise ValidationError({'parent': '소분류의 부모는 [중분류]여야 합니다.'})
어드민 설정 🟢
- RoleChoices를 사용하여 ST(Student)와 USER를 제외한 운영진에게만 등록 및 수정 권한을 부여
from django.contrib import admin
from apps.qna.models.question.question_category import QuestionCategory
from apps.user.models import RoleChoices
@admin.register(QuestionCategory)
class QuestionCategoryAdmin(admin.ModelAdmin):
list_display = ('id', 'type', 'name', 'parent', 'created_at')
list_filter = ('type',)
search_fields = ('name',)
fieldsets = (
('기본 정보', {'fields': ('type', 'name')}),
('계층 설정', {
'fields': ('parent',),
'description': '중분류는 대분류를, 소분류는 중분류를 부모로 선택해야 합니다.'
}),
)
def has_add_permission(self, request):
"""등록 권한: AD, OM, LC, TA만 허용"""
excluded_roles = [RoleChoices.ST, RoleChoices.USER]
return request.user.is_authenticated and \
request.user.role not in excluded_roles and \
request.user.is_staff
def has_change_permission(self, request, obj=None):
"""수정 권한: AD, OM, LC, TA만 허용"""
excluded_roles = [RoleChoices.ST, RoleChoices.USER]
return request.user.is_authenticated and \
request.user.role not in excluded_roles and \
request.user.is_staff
def has_view_permission(self, request, obj=None):
"""조회 권한: 모든 스태프 허용"""
return request.user.is_authenticated and request.user.is_staff
def has_delete_permission(self, request, obj=None):
"""삭제 권한: 현재 요구사항에 따라 AD(최고관리자)만 가능하게 설정 가능"""
return request.user.is_authenticated and request.user.role == RoleChoices.AD
작동 흐름
생성(Create) 기능이 동작하는 원리
- admin.py에서 권한만 체크 / 실제 생성은 Django가 내부적으로 처리
- 자동 폼(Form) 생성
- Django Admin은 QuestionCategory 모델의 필드(name, type, parent)를 보고
- 권한 확인
- has_add_permission이 True를 반환하면 Django는 화면에 "추가" 버튼을 노출
- 데이터 검증 및 저장
- 관리자가
[저장]을 누르면 Django Admin은 모델의 clean() 메서드를 호출하여
- 검증이 통과되면 Django 내부의 save() 메서드가 실행되어 DB의
- question_categories 테이블에 데이터를 넣음
삭제(Delete) 기능이 동작하는 원리
- Django Admin의 표준 기능을 그대로 사용하면서, 모델에 설정한 '삭제 정책'에 따라 작동
- 삭제 버튼 노출
- has_delete_permission이 True인 관리자(AD)에게만 삭제 버튼이 노출
- 연쇄 삭제(CASCADE)
- 모델에서 parent = models.ForeignKey(..., on_delete=models.CASCADE)
- 설정했기 때문에, Django는 "부모가 지워지면 자식도 지워라"라는 명령을 DB 수준에서 수행
- 데이터 보호(SET_DEFAULT)
- 질문 모델에서 category = models.ForeignKey(..., on_delete=models.SET_DEFAULT)
- 카테고리가 삭제되는 순간 연결된 질문들의 카테고리 ID가 자동으로 '일반질문' ID로 업데이트
간략한 코드가 가능한 이유
- 모델(models.py)에 "데이터의 규칙과 삭제 시 행동"을 정의
- 어드민(admin.py)에는 "누가 이 기능을 쓸 수 있는지"만 명시
- Django Admin이라는 엔진이 이 두 정보를 읽어서
- "아, 이 유저는 권한이 있으니 폼을 보여주고, 저장할 때는 이 규칙을 검사하고,
- 삭제할 때는 저 정책을 따라야겠구나"라고 스스로 판단해서 로직을 실행
결과

- 대분류 / 중분류 / 소분류 의 카테고리를 각자 추가 가능
- 중분류 / 소분류는 각각 부모 카테고리 선택 가능
- 대분류는 부모 카테고리 선택 불가

- 상위 카테고리 삭제 시 하위 카테고리 전부 삭제(ex. 대분류 삭제시 중분류 / 소분류도 같이 삭제 )
2025.12.28
카테고리 목록 조회 / 카테고리 삭제 구현
카테고리 목록 조회 구현 🔴
카테고리 목록 조회
- 스태프 / 관리자 권한을 가진 유저는 어드민 페이지 내의 질의응답 관리 메뉴의
- 카테고리 관리 메뉴에 접속하여 시스템에 등록된 질의응답 카테고리들을 목록으로 조회 가능
- 카테고리 목록 조회 시 검색필터 + 검색 기능을 활용하여 조회 가능
카테고리 목록 조회에서 확인 가능한 항목
- 카테고리 ID / 카테고리명 / 카테고리 분류 타입 ( 대, 중, 소 )
- 자식 카테고리
- 카테고리 분류가 대, 중일 경우 자식 카테고리의 명칭
- 소분류일 경우 빈칸
- 부모 카테고리
- 카테고리 분류가 중, 소일 경우 부모 카테고리의 명칭
- 대분류일 경우 빈칸
- 등록일시
- 수정일시
조회 구현
from django.contrib import admin
from apps.qna.models.question.question_category import QuestionCategory
from apps.user.models.user import RoleChoices
@admin.register(QuestionCategory)
class QuestionCategoryAdmin(admin.ModelAdmin):
list_display = (
'id',
'name',
'get_type_display_custom',
'get_children_names',
'get_parent_name',
'created_at',
'updated_at'
)
list_filter = ('type', 'created_at')
search_fields = ('name', 'parent__name')
ordering = ('type', 'parent', 'id')
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset.select_related('parent').prefetch_related('children')
@admin.display(description='분류 타입')
def get_type_display_custom(self, obj):
return obj.get_type_display()
@admin.display(description='자식 카테고리')
def get_children_names(self, obj):
if obj.type == 'small':
return "-"
children = obj.children.all()
if children:
return ", ".join([child.name for child in children])
return "-"
@admin.display(description='부모 카테고리')
def get_parent_name(self, obj):
if obj.type == 'large':
return "-"
if obj.parent:
return f"{obj.parent.name} ({obj.parent.get_type_display()})"
return "-"
def _has_common_permission(self, request):
"""
[공통 권한 체크]
- ST(학생), USER(일반) 제외
- 어드민 접속 권한(is_staff)이 있는 운영진(AD, OM, LC, TA)만 허용
"""
if not request.user.is_authenticated:
return False
excluded_roles = [RoleChoices.ST, RoleChoices.USER]
return request.user.role not in excluded_roles and request.user.is_staff
def has_add_permission(self, request):
return self._has_common_permission(request)
def has_change_permission(self, request, obj=None):
return self._has_common_permission(request)
def has_delete_permission(self, request, obj=None):
return self._has_common_permission(request)
def has_view_permission(self, request, obj=None):
return self._has_common_permission(request)
결과

- 모델에 없는 컬럼은
@admin.display를 이용하여 표현
- 대분류의 경우 부모 카테고리를
- / 소분류의 경우 자식 카테고리를 -

- 검색 기능 + 카테고리 필터까지 작동 확인 완료
카테고리 삭제
카테고리 삭제
- 스태프 / 관리자 권한을 가진 유저는 어드민 페이지 내의 질의응답 관리 메뉴의
- 카테고리 관리 메뉴에 접속하여 시스템에 등록된 질의응답 카테고리를 삭제 가능
- 조회된 카테고리 목록에서 삭제할 카테고리를 선택하고 삭제하기 버튼을 클릭하여 삭제
- 카테고리 삭제 전, 대, 중, 소분류에 따라 경고 메시지 팝업이 나옴
대분류의 경우
- 해당 카테고리에 속한 중분류, 소분류 카테고리가 함께 삭제
- 각 카테고리에 속한 질의응답은 일반질문 카테고리로 전환 / 삭제된 항목 복구 불가
중분류의 경우
- 해당 카테고리에 속한 소분류 카테고리가 함께 삭제
- 각 카테고리에 속한 질의응답은 일반질문 카테고리로 전환 / 삭제된 항목 복구 불가
소분류의 경우
- 해당 카테고리에 속한 질의응답은 일반질문 카테고리로 전환 / 삭제된 항목 복구 불가
삭제 구현
from django.contrib import admin
from django.utils.html import format_html
from apps.qna.models.question.question_category import QuestionCategory
from apps.user.models.user import RoleChoices
@admin.register(QuestionCategory)
class QuestionCategoryAdmin(admin.ModelAdmin):
list_display = (
'id',
'name',
'get_type_display_custom',
'get_children_names',
'get_parent_name',
'created_at',
'updated_at'
)
list_filter = ('type', 'created_at')
search_fields = ('name', 'parent__name')
ordering = ('type', 'parent', 'id')
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset.select_related('parent').prefetch_related('children')
def _has_common_permission(self, request):
"""
[공통 권한 체크]
- ST(학생), USER(일반) 제외
- 어드민 접속 권한(is_staff)이 있는 운영진(AD, OM, LC, TA)만 허용
"""
if not request.user.is_authenticated:
return False
excluded_roles = [RoleChoices.ST, RoleChoices.USER]
return request.user.role not in excluded_roles and request.user.is_staff
def has_add_permission(self, request):
return self._has_common_permission(request)
def has_change_permission(self, request, obj=None):
return self._has_common_permission(request)
def has_delete_permission(self, request, obj=None):
return self._has_common_permission(request)
def has_view_permission(self, request, obj=None):
return self._has_common_permission(request)
def delete_view(self, request, object_id, extra_context=None):
obj = self.get_object(request, object_id)
extra_context = extra_context or {}
if obj:
warning_msg = ""
base_msg = " 해당 카테고리의 질의응답은 '일반질문'으로 자동 전환되며, 삭제된 카테고리는 복구할 수 없습니다."
if obj.type == 'large':
warning_msg = f"⚠️ [대분류 삭제 경고] 하위 '중분류' 및 '소분류'가 모두 함께 삭제됩니다!{base_msg}"
elif obj.type == 'medium':
warning_msg = f"⚠️ [중분류 삭제 경고] 하위 '소분류'가 모두 함께 삭제됩니다!{base_msg}"
else:
warning_msg = f"⚠️ [소분류 삭제 경고]{base_msg}"
extra_context['title'] = warning_msg
return super().delete_view(request, object_id, extra_context=extra_context)
@admin.display(description='분류 타입')
def get_type_display_custom(self, obj):
color_map = {
'large': '28a745',
'medium': '17a2b8',
'small': '6c757d',
}
color = color_map.get(obj.type, '333')
return format_html(
'<span style="color: white; background-color: #{}; padding: 4px 8px; border-radius: 4px; font-weight: bold;">{}</span>',
color,
obj.get_type_display()
)
@admin.display(description='자식 카테고리')
def get_children_names(self, obj):
if obj.type == 'small': return "-"
children = obj.children.all()
return ", ".join([child.name for child in children]) if children else "-"
@admin.display(description='부모 카테고리')
def get_parent_name(self, obj):
if obj.type == 'large': return "-"
return f"{obj.parent.name} ({obj.parent.get_type_display()})" if obj.parent else "-"
- Django에서 HTML 코드를 Python 문자열로 만들 때, 보안 사고(XSS 공격)를 막기 위해 사용하는 안전한 함수
사용 이유
- 일반적으로 Python에서 문자열을 합칠 때 f-string을 많이 사용하는데
- 웹 개발에서 HTML 태그를 직접 만들 때 f-string을 쓰면 위험할 수 있다.
user_input = "<script>해킹스크립트()</script>"
html = f"<b>{user_input}</b>"
from django.utils.html import format_html
user_input = "<script>해킹스크립트()</script>"
html = format_html("<b>{}</b>", user_input)
사용처
- Django Admin 페이지에서 많이 사용
- 단순히 글로 표현하지 않고, 색깔이 들어간 뱃지, 이미지 썸네일, 링크 버튼 등을 코드로 그려줌
from django.utils.html import format_html
def type_badge(self, obj):
return format_html(
'<span style="background-color: green; color: white;">{}</span>',
obj.get_type_display()
)
@admin.display(description='분류 타입')
def get_type_display_custom(self, obj):
color_map = {
'large': '28a745',
'medium': '17a2b8',
'small': '6c757d',
}
color = color_map.get(obj.type, '333')
return format_html(
'<span style="color: white; background-color: #{}; padding: 4px 8px; border-radius: 4px; font-weight: bold;">{}</span>',
color,
obj.get_type_display()
)
- 대분류
<span style="color: green; font-weight:bold">[대분류]</span> 뱃지가 초록색으로 표시됨
- 중분류
<span style="color: blue; font-weight:bold">[중분류]</span> 뱃지가 파란색으로 표시됨
- 소분류
<span style="color: gray; font-weight:bold">[소분류]</span> 뱃지가 회색으로 표시됨
새롭게 알게된 내용 ✅
is_staff=True
- Django Admin에 접속하려면 is_staff=True가 필수
어드민 권한
- 일반적으로 Django Admin의 권한 제어는 admin.py에서 처리하는 것이 기본이자 가장 흔한 방식
admin.py에서 전부 처리하는 이유
- Django Admin은 그 자체로 하나의 독립된 "관리 도구"
- 따라서 해당 모델을 관리 화면에서 어떻게 보여주고,
- 누가 제어할지를 한곳(admin.py)에 모아두는 것이 응집도(Cohesion) 측면에서 유리하기 때문
분리하는 경우
- 권한 로직이 매우 복잡해지거나,
- Admin뿐만 아니라 다른 Service 레이어에서도 동일한 권한 체크 로직을 공유해야 한다면 별도로 분리
- apps/qna/permissions/admin_permissions.py 같은 파일을 만들어 로직을 짠 뒤
카테고리DB 저장 됬는지 확인 법
from apps.qna.models import QuestionCategory
print(QuestionCategory.objects.all())
연쇄 삭제(Cascade)
- parent 필드에 models.CASCADE를 설정하면
- 대분류 삭제 시 하위 카테고리들도 함께 삭제되는 로직을 자동으로 수행
on_delete=models.PROTECT
- 현재 질문 모델의 카테고리 컬럼은 이렇게 세팅되어 있다
- 하지만 이렇게 진행하면
- PROTECT는 하위 데이터(질문)가 존재할 경우 상위 데이터(카테고리)의 삭제를 아예 차단 해버림
- 이 카테고리에 질문이 단 하나라도 연결되어 있다면, 관리자 페이지에서 해당 카테고리를 삭제하려고
- 할 때 ProtectedError가 발생하며 삭제가 거부됨
on_delete=SET_DEFAULT
- 하지만 지금 원하는 조건은
- 카테고리가 삭제되더라도 질문(Question) 데이터는 삭제되지 않고 '일반질문' 카테고리로 이동해야 함
- 카테고리를 삭제하면 연결된 질문들의 카테고리 칸이 자동으로 '일반질문'으로 변경
- "질의응답은 일반질문 카테고리로 전환"되어야 한다는 요구사항을 코드 수준에서 자동으로 처리
함수 내부 임포트
순환 참조 방지
- get_default_category 함수 내부에서 QuestionCategory를 임포트할 때,
- 파일 상단이 아닌 함수 내부에서 임포트(Local Import)하여 모델 간의 순환 참조 에러를 방지해야 함
오늘 발생한 문제(발생 했다면) ✅