Django Admin·ORM과 관계
7주 Admin·8주 ORM 검색·9주 과제와 제출 관계·제약·트랜잭션 검증하기
이번 문서에서 만드는 것
6주까지 만든 Member CRUD를 이어서 사용합니다. 7주는 Admin과 코드 구조, 8주는 ORM 조회, 9주는 과제·제출 관계와 트랜잭션입니다. 각 주차의 결과를 커밋으로 나누어 다음 단계의 기준점으로 남깁니다.
7주: 관리자 화면과 구조 정리
lions/admin.py에 작성합니다.
from django.contrib import adminfrom .models import Member
@admin.register(Member)class MemberAdmin(admin.ModelAdmin): list_display = ["id", "name", "track"] search_fields = ["name"] list_filter = ["track"]python manage.py createsuperuserpython manage.py runserver/admin/에서 직접 만든 관리자 계정으로 로그인합니다. 실제 회원 개인정보 대신 가상 회원 세 명을 등록하고 검색과 트랙 필터를 확인합니다. Admin은 운영자용 데이터 관리 도구이며 모든 회원에게 superuser 권한을 주는 방식으로 사용하는 화면이 아닙니다.
프로젝트 URL은 include만, 앱 URL은 기능별 경로, view는 요청과 응답, Form은 입력 검증, model은 데이터 구조를 담당하는지 살펴봅니다. 파일이 짧은 지금은 Service·Repository 추상화를 의무적으로 추가하지 않습니다. 여러 view가 같은 업무 규칙을 반복할 때 함수로 추출합니다.
7주 권장 80분: Admin 25분, 검색·필터 15분, 구조 리뷰 25분, 기록 15분. 완료 기준은 관리자와 일반 화면의 목적 차이를 설명하고 회원 필드 변경 시 수정할 파일을 찾는 것입니다.
8주: QuerySet을 읽고 조합하기
python manage.py shell에서 한 줄씩 실행합니다. 명령을 실행하는 개발 DB인지 먼저 확인합니다.
from lions.models import MemberMember.objects.filter(track="frontend").order_by("name", "id")Member.objects.filter(name__icontains="가").values("id", "name")Member.objects.count()Member.objects.filter(pk=999999).exists()filter는 없으면 빈 QuerySet, get은 정확히 한 개를 요구하며 없으면 DoesNotExist를 발생시킵니다. view에서 사용자 입력 ID를 조회할 때는 get_object_or_404가 유용합니다. QuerySet은 지연 평가되지만 반복·list 변환·직렬화 등 실제 데이터가 필요한 시점에는 DB 조회가 발생합니다.
목록 view의 조회 부분을 다음으로 바꾸어 서버 검색을 추가합니다. 기존 @require_GET와 함수의 나머지 구조는 유지합니다.
keyword = request.GET.get("q", "").strip()members = Member.objects.order_by("id")if keyword: members = members.filter(name__icontains=keyword)return render(request, "lions/member_list.html", { "members": members, "keyword": keyword,})목록 템플릿의 h1 아래에 추가합니다.
<form method="get"> <label for="q">이름 검색</label> <input id="q" name="q" value="{{ keyword }}"> <button type="submit">검색</button></form>/members/?q=가를 새 탭에서 열어도 같은 검색이 재현되어야 합니다. 조회는 GET, 데이터 변경은 POST라는 구분도 확인합니다.
8주 권장 90분: ORM 예측 20분, 검색 구현 30분, 빈 결과·정렬 검사 25분, 리뷰 15분. 완료 기준은 빈 검색과 결과 없음, 같은 이름의 다른 ID를 구분하고 검색 조건이 URL에 남는 것입니다.
9주: 관계와 DB 제약
관계는 Member 한 명이 여러 과제를 제출하고 Assignment 하나에 여러 회원이 제출하는 구조입니다. 제출 자체에 제출 시각이 있으므로 Submission을 별도 모델로 둡니다. lions/models.py의 기존 Member 아래에 추가합니다.
class Assignment(models.Model): title = models.CharField(max_length=100) due_at = models.DateTimeField()
def __str__(self): return self.title
class Submission(models.Model): member = models.ForeignKey(Member, on_delete=models.PROTECT, related_name="submissions") assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE, related_name="submissions") submitted_at = models.DateTimeField(auto_now_add=True)
class Meta: constraints = [ models.UniqueConstraint(fields=["member", "assignment"], name="unique_member_assignment") ]회원 삭제는 제출 기록이 있으면 막고 과제 삭제는 해당 제출도 함께 지웁니다. 두 정책이 제품에 맞는지 먼저 합의합니다. 단순히 FK를 외우지 말고 어떤 기록을 보존해야 하는지 설명합니다.
python manage.py makemigrations lionspython manage.py migratelions/admin.py 끝에 추가하여 과제와 제출을 입력할 수 있게 합니다.
from .models import Assignment, Submissionadmin.site.register(Assignment)admin.site.register(Submission)Admin에서 과제 하나와 제출 두 개를 만듭니다. 같은 회원·과제 조합을 다시 등록하면 중복 검증 오류가 보여야 합니다. 여러 요청이 애플리케이션의 사전 조회를 동시에 통과해도 DB의 unique 제약이 최종적으로 중복 저장을 막습니다.
심화: 관계를 따라갈 때의 조회 비용
제출마다 회원을 별도로 읽으면 목록 길이에 따라 추가 조회가 증가합니다. 단일 FK를 함께 읽을 때 select_related, 여러 역방향 객체를 묶어 읽을 때 prefetch_related를 고려합니다.
# shell에서 실행하는 조회 예제from lions.models import Submission, Assignmentsubmissions = Submission.objects.select_related("member", "assignment")for item in submissions: print(item.member.name, item.assignment.title)
assignments = Assignment.objects.prefetch_related("submissions__member")for assignment in assignments: print(assignment.title, [s.member.name for s in assignment.submissions.all()])무조건 모든 관계를 불러오기보다 실제 화면이 읽는 필드와 쿼리 수를 비교합니다. prefetch_related 뒤에 관계를 다시 다른 조건으로 filter하면 미리 읽은 결과가 그대로 사용되지 않을 수 있습니다.
트랜잭션으로 묶을 규칙
atomic은 여러 DB 작업을 성공·실패 단위로 묶습니다. 유효하지 않은 값을 자동으로 검증하거나 모든 동시 요청을 순서대로 실행해 주지는 않습니다.
새 파일 lions/services.py를 만듭니다. 현재는 제출 저장 하나지만 다음처럼 업무 경계를 명시하면 알림 기록 등 DB 작업을 함께 묶을 위치가 생깁니다.
from django.db import transactionfrom .models import Submission
@transaction.atomicdef submit_assignment(member, assignment): submission, created = Submission.objects.get_or_create( member=member, assignment=assignment, ) return submission, created심화: 외부 작업과 동시성
위 함수는 같은 제출을 반복하면 기존 행을 돌려주는 계약입니다. 이를 오류로 다루고 싶다면 반환값 created를 보고 요청 계층에서 결정합니다. 메일 같은 외부 작업은 DB rollback으로 취소되지 않으므로 필요할 때 transaction.on_commit과 재시도 정책을 별도로 설계합니다.
정원 제한처럼 “현재 개수를 세고 추가”하는 규칙은 unique 제약 하나로 해결되지 않습니다. PostgreSQL 등에서 대상 행 잠금과 트랜잭션을 검토해야 하며 SQLite의 `select_for_update()`는 행 잠금 효과가 없습니다. 이 실습으로 운영 동시성까지 검증했다고 판단하지 않습니다.
검증과 남은 연결
기존 lions/tests.py에 추가합니다.
from django.utils import timezonefrom django.db.models.deletion import ProtectedErrorfrom .models import Assignment, Submissionfrom .services import submit_assignment
class SubmissionTests(TestCase): def test_repeat_submission_is_one_row(self): member = Member.objects.create(name="가람", track="backend") assignment = Assignment.objects.create(title="ORM 실습", due_at=timezone.now()) first, created = submit_assignment(member, assignment) second, created_again = submit_assignment(member, assignment) self.assertTrue(created) self.assertFalse(created_again) self.assertEqual(first.pk, second.pk) self.assertEqual(Submission.objects.count(), 1) with self.assertRaises(ProtectedError): member.delete()9주 권장 100분: 관계 설계 20분, 모델·migration 25분, 조회 비교 20분, 제약 테스트 25분, 리뷰 10분입니다. 제출물이 있는 회원을 기존 삭제 화면에서 지우면 아직 ProtectedError를 처리하지 않아 500이 발생합니다. 다음 프로젝트 문서에서 이 예외를 사용자 응답으로 연결합니다. 이를 해결하기 전까지 기능 완성으로 보지 않습니다.
완료 기준: ERD, 삭제 정책, 중복 제출 테스트를 함께 제출하고 atomic·unique 제약·행 잠금의 역할 차이를 설명합니다.
더 읽어보기
연결된 PBL 미션과 VOD
| 주차·미션 | 참고 VOD 범위 |
|---|---|
| 7주 · Admin과 구조 정리 | Python 첫걸음 13·14장 |
| 8주 · ORM | Python 첫걸음 13·14장 |
| 9주 · 관계와 트랜잭션 | Python 첫걸음 13·14장 |
5–10주는 동일한 Django 구조·CRUD VOD 범위를 반복 참조한다. 주차별 산출물은 서로 다르므로 문서 실습을 순서대로 확장한다.
강좌 안내: 참고 강좌 1. 이 문서는 영상 전체를 옮긴 전사 자료가 아닙니다. 세부 내용은 위 공식 문서에서 확인합니다.