[Django/DB] Many to many relationship

문지은·2023년 5월 9일
0

Django + Database

목록 보기
5/12
post-thumbnail

개요

  • 병원에 내원하는 환자와 의사의 예약 시스템을 구축하라는 업무를 지시 받았다고 했을 때,
    • 필요한 데이터 베이스 모델을 고민해보고 모델링 진행하기
    • 모델링을 하는 이유는 현실 세계를 최대한 유사하게 반영하기 위함이다.
  • 무엇부터 고민해야 할까?
    • 병원 시스템에서 가장 핵심이 되는 것은? → 의사와 환자
    • 이 둘의 관계를 어떻게 표현할 수 있을까?
  • 우리 일상에 가까운 예시를 통해 DB를 모델링하고 그 내부에서 일어나는 데이터의 흐름을 어떻게 제어할 수 있을지 고민해보자.

[참고] 데이터 모델링

  • 주어진 개념으로부터 논리적인 데이터 모델을 구성하는 작업
  • 물리적인 데이터베이스 모델로 만들어 고객의 요구에 따라 특정 정보 시스템의 데이터베이스에 반영하는 작업

용어

  • target model : 관계 필드를 가지지 않은 모델
  • source model : 관계 필드를 가진 모델

N:1 의 한계

  • 의사와 환자간 예약 시스템을 구현해보자
  • 한 명의 의사에게 여러 환자가 예약할 수 있다고 모델 관계 설정 (N:1)
# hospitals/models.py

from django.db import models

# Create your models here.
class Doctor(models.Model):
    name = models.TextField()

    def __str__(self):
        return f'{self.name} 전문의'

class Patient(models.Model):
    doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE)
    name = models.TextField()

    def __str__(self):
        return f'{self.pk}번 환자 {self.name}'
  • Migration 진행 및 shell_plus 실행
$ python manage.py makemigrations
$ python manage.py migrate

$ python manage.py shell_plus
In [1]: doctor1 = Doctor.objects.create(name='alice')

In [2]: patient1 = Patient.objects.create(name='carol', doctor=doctor1)

In [3]: doctor2 = Doctor.objects.create(name='bella')

In [4]: patient2 = Patient.objects.create(name='dane', doctor=doctor2)
  • hospitals_patient 과 hospitals_doctor 테이블이 차례대로 아래와 같이 생성된다.

동시에 예약 할 수는 없을까?

In [6]: patient4 = Patient.objects.create(name='carol', doctor=doctor1, doctor2)
    Cell In[6], line 1
    patient4 = Patient.objects.create(name='carol', doctor=doctor1, doctor2)
                                                                            ^
SyntaxError: positional argument follows keyword argument

  • 동일한 환자지만 다른 의사에게 예약하기 위해서는 객체를 하나 더 만들어서 예약을 진행해야 함
    • 새로운 환자 객체를 생성할 수 밖에 없음
  • 외래키 컬럼에 ‘1, 2’ 형태로 참조하는 것은 Integer 타입이 아니기 때문에 불가능
    • 예약 테이블을 따로 만들자!!!

중개 모델

  • 환자 모델의 외래 키를 삭제하고 별도의 예약 모델을 새로 작성
  • 예약 모델은 의사와 환자에 각각 N:1 관계를 가짐
# hospitals/models.py

class Patient(models.Model):
    # 외래 키 삭제
    # doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE)
    name = models.TextField()

    def __str__(self):
        return f'{self.pk}번 환자 {self.name}'

class Reservation(models.Model):
    doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE)
    patient = models.ForeignKey(Patient, on_delete=models.CASCADE)

    def __str__(self):
        return f'{self.doctor_id}번 의사의 {self.patient_id}번 환자'
  • 데이터베이스 초기화 후 Migration 진행 및 shell_plus 실행
    1. migration 파일 삭제
    2. 데이터베이스 파일 삭제
$ python manage.py makemigrations
$ python manage.py migrate

$ python manage.py shell_plus
  • 의사와 환자 생성 후 예약 만들기
In [2]: doctor1 = Doctor.objects.create(name='alice')

In [3]: patient1 = Patient.objects.create(name='carol')

In [5]: Reservation.objects.create(doctor=doctor1, patient=patient1)
Out[5]: <Reservation: 1번 의사의 1번 환자>
  • 예약 정보 조회
# 의사 -> 예약 정보 찾기
In [6]: doctor1.reservation_set.all()
Out[6]: <QuerySet [<Reservation: 1번 의사의 1번 환자>]>

# 환자 -> 예약 정보 찾기
In [7]: patient1.reservation_set.all()
Out[7]: <QuerySet [<Reservation: 1번 의사의 1번 환자>]>
  • 1번 의사에게 새로운 환자 예약이 생성된다면
In [8]: patient2 = Patient.objects.create(name='dane')

In [10]: Reservation.objects.create(doctor=doctor1, patient=patient2)
Out[10]: <Reservation: 1번 의사의 2번 환자>

  • 1번 의사의 예약정보 조회
# 의사 -> 환자 목록
    
In [11]: doctor1.reservation_set.all()
Out[11]: <QuerySet [<Reservation: 1번 의사의 1번 환자>, <Reservation: 1번 의사의 2번 환자>]>

Django ManyToManyField

Django는 ManyToManyField를 통해 중개 테이블을 자동으로 생성한다.

  • 환자 모델에 Django ManyToManyField 작성
class Patient(models.Model):
    # 외래 키 삭제
    # doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE)
    # ManyToManyField 작성
    doctors = models.ManyToManyField(Doctor)
    name = models.TextField()

    def __str__(self):
        return f'{self.pk}번 환자 {self.name}'
    
# Reservation Class 주석 처리
# class Reservation(models.Model):
#     doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE)
#     patient = models.ForeignKey(Patient, on_delete=models.CASCADE)

#     def __str__(self):
#         return f'{self.doctor_id}번 의사의 {self.patient_id}번 환자'
  • 데이터베이스 초기화 후 Migration 진행 및 shell_plus 실행
    1. migration 파일 삭제
    2. 데이터베이스 파일 삭제
$ python manage.py makemigrations
$ python manage.py migrate

$ python manage.py shell_plus
  • 생성된 중개 테이블 hospitals_patient_doctors 확인
  • 의사 1명과 환자 2명 생성
In [2]: doctor1 = Doctor.objects.create(name='alice')

In [3]: patient1 = Patient.objects.create(name='carol')

In [4]: patient2 = Patient.objects.create(name='dane')
  • 예약 생성 (환자가 의사에게 예약)
# patient1의 doctor1에게 예약
In [5]: patient1.doctors.add(doctor1)

# patient1 : 자신이 예약한 의사 목록 확인
In [6]: patient1.doctors.all()
Out[6]: <QuerySet [<Doctor: alice 전문의>]>

# doctor1 : 자신의 예약된 환자 목록 확인
In [7]: doctor1.patient_set.all()
Out[7]: <QuerySet [<Patient: 1번 환자 carol>]>
  • 예약 생성 (의사가 환자를 예약)
# doctor1이 patient2을 예약
In [8]: doctor1.patient_set.add(patient2)

# doctor1 : 자신의 예약 환자목록 확인
In [9]: doctor1.patient_set.all()
Out[9]: <QuerySet [<Patient: 1번 환자 carol>, <Patient: 2번 환자 dane>]>

# patient1, 2 : 자신이 예약한 의사 목록 확인
In [11]: patient1.doctors.all()
Out[11]: <QuerySet [<Doctor: alice 전문의>]>

In [12]: patient2.doctors.all()
Out[12]: <QuerySet [<Doctor: alice 전문의>]>
  • 예약 현황 확인

  • 예약 취소하기 (삭제)
    • 기존에는 해당하는 Reservation을 찾아서 지워야 했다면 이제는 .remove() 사용
# doctor1이 patient1 진료 예약 취소

In [13]: doctor1.patient_set.remove(patient1)

In [16]: doctor1.patient_set.all()
Out[16]: <QuerySet [<Patient: 2번 환자 dane>]>

In [17]: patient1.doctors.all()
Out[17]: <QuerySet []>
# patient2가 doctor1 진료 예약 취소

In [18]: patient2.doctors.remove(doctor1)

In [19]: patient2.doctors.all()
Out[19]: <QuerySet []>

In [20]: doctor1.patient_set.all()
Out[20]: <QuerySet []>
  • target model이 source model을 참조할 때 사용할 manager game
  • ForeignKey()의 related_name 과 동일
class Patient(models.Model):
    # ManyToManyField - related_name 작성
    doctors = models.ManyToManyField(Doctor, related_name='patients')
    name = models.TextField()

    def __str__(self):
        return f'{self.pk}번 환자 {self.name}'
  • Migration 진행 및 shell_plus 실행
$ python manage.py makemigrations
$ python manage.py migrate

$ python manage.py shell_plus
  • related_name 설정 값 확인하기
# 1번 의사 조회하기
In [1]: doctor1 = Doctor.objects.get(pk=1)

# 에러 발생(related_name 을 설정하면 기존 _set manager는 사용할 수 없음)
In [2]: doctor1.patient_set.all()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[2], line 1
----> 1 doctor1.patient_set.all()

AttributeError: 'Doctor' object has no attribute 'patient_set'

# 변경 후
In [3]: doctor1.patients.all()
Out[3]: <QuerySet []>

through argument

  • 중개 모델을 직접 작성하는 방법?
    • 중개 테이블을 수동으로 지정하려는 경우 through 옵션을 사용하여 사용하려는 중개 테이블을 나타내는 Django 모델을 지정할 수 있음
  • 가장 일반적인 용도는 중개테이블에 추가 데이터를 사용해 다대다 관계와 연결하려는 경우
  • through 설정 및 Reservation Class 수정
    • 이제는 예약 정보에 증상과 예약일이라는 추가 데이터가 생김
class Patient(models.Model):
    doctors = models.ManyToManyField(Doctor, through='Reservation')
    name = models.TextField()

    def __str__(self):
        return f'{self.pk}번 환자 {self.name}'
    

class Reservation(models.Model):
    doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE)
    patient = models.ForeignKey(Patient, on_delete=models.CASCADE)
    symptom = models.TextField()
    reserved_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return f'{self.doctor_id}번 의사의 {self.patient_id}번 환자'
  • 데이터베이스 초기화 후 Migration 진행 및 shell_plus 실행
    1. migration 파일 삭제
    2. 데이터베이스 파일 삭제
$ python manage.py makemigrations
$ python manage.py migrate

$ python manage.py shell_plus
  • 의사 1명과 환자 2명 생성
In [1]: doctor1 = Doctor.objects.create(name='alice')

In [2]: patient1 = Patient.objects.create(name='carol')

In [3]: patient2 = Patient.objects.create(name='dane')
  • 예약 생성 1 - Reservation class를 통한 예약 생성
In [4]: reservation1 = Reservation(doctor=doctor1, patient=patient1, symptom='headache')

In [5]: reservation1.save()

In [6]: doctor1.patient_set.all()
Out[6]: <QuerySet [<Patient: 1번 환자 carol>]>

In [7]: patient1.doctors.all()
Out[7]: <QuerySet [<Doctor: alice 전문의>]>
  • 예약 생성 2 - Patient 객체를 통한 예약 생성
    • through_defaults 값에 딕셔너리 타입으로 입력
In [8]: patient2.doctors.add(doctor1, through_defaults={'symptom':'flu'})

In [9]: doctor1.patient_set.all()
Out[9]: <QuerySet [<Patient: 1번 환자 carol>, <Patient: 2번 환자 dane>]>

In [10]: patient2.doctors.all()
Out[10]: <QuerySet [<Doctor: alice 전문의>]>
  • 예약 삭제
In [11]: doctor1.patient_set.remove(patient1)

In [12]: patient2.doctors.remove(doctor1)

정리

  • M:N 관계로 맺어진 두 테이블에는 변화가 없음
  • Django의 ManyToManyField은 중개 테이블을 자동으로 생성함
  • Django의 ManyToManyField는 M:N 관계를 맺는 두 모델 어디에 위치해도 상관 없음
    • 대신 필드 작성 위치에 따라 참조와 역참조 방향을 주의할 것
  • N:1은 완전한 종속의 관계였지만 M:N은 의사에게 진찰받는 환자, 환자를 진찰하는 의사의 두 가지 형태로 모두 표현이 가능한 것!

ManyToManyField

ManyToManyField란?

  • ManyToManyField(to, **options)
  • 다대다 (M:N, many-to-many) 관계 설정 시 사용하는 모델 필드
  • 하나의 필수 위치 인자(M:N 관계로 설정할 모델 클래스)가 필요
  • 모델 필드의 Related Manager를 사용하여 관련 개체를 추가, 제거 또는 만들 수 있음
    • add(), remove(), create(), clear()

데이터베이스에서의 표현

  • Django는 다대다 관계를 나타내는 중개 테이블을 만듦
  • 테이블 이름은 ManyToManyField 이름과 이를 포함하는 모델의 테이블 이름을 조합하여 생성됨
  • db_table arguments을 사용하여 중개 테이블의 이름을 변경할 수도 있음

ManyToManyField’s Arguments

  • target model이 source model을 참조할 때 사용할 manager name
  • ForeignKey의 related_name과 동일

through

  • 중개 테이블을 직접 작성하는 경우, through 옵션을 사용하여 중개 테이블을 나타내는 Django 모델을 지정
  • 일반적으로 중개 테이블에 추가 데이터를 사용하는 다대다 관계와 연결하려는 경우(extra data with a many-to-many relationship)에 사용됨

symmetrical

  • 기본 값 : True
  • ManyToManyField가 동일한 모델(on self)을 가리키는 정의에서만 사용
# 예시

class Person(models.Model):
    friends = models.ManyToManyField('self')
    friends = models.ManyToManyField('self', symmetrical=False)
  • True일 경우
    • _set 매니저를 추가하지 않음
    • source 모델의 인스턴스가 target 모델의 인스턴스를 참조하면 자동으로 target 모델 인스턴스도 source 모델 인스턴스를 참조하도록 함(대칭)
    • 대칭을 원하지 않는 경우 False로 설정

Relative Manager

  • N:1 혹은 M:N 관계에서 사용 가능한 문맥(context)
  • Django는 모델 간 N:1 혹은 M:N 관계가 설정되면 역참조시에 사용할 수 있는 manager를 생성
    • 우리가 이전에 모델 생성 시 objects 라는 매니저를 통해 queryset api를 사용했던 것처럼 related manager를 통해 queryse api를 사용할 수 있게 됨
  • 같은 이름의 메서드여도 각 관계(N:1, M:N)에 따라 다르게 사용 및 동작됨
    • N:1에서는 target 모델 객체만 사용 가능
    • M:N 관계에서는 관련된 두 객체에서 모두 사용 가능
  • 메서드 종류
    • add(), remove(), create(), clear(), set() 등

add()

  • 지정된 객체를 관련 객체 집합에 추가
  • 이미 존재하는 관계에 사용하면 관계가 복제되지 않음
  • 모델 인스턴스, 필드 값(PK)을 인자로 허용

remove()

  • 관련 객체 집합에서 지정된 모델 개체를 제거
  • 내부적으로 QuerySet.delete()를 사용하여 관계 삭제
  • 모델 인스턴스, 필드 값(PK)을 인자로 허용

중개 테이블 생성 규칙

  1. 소스(source model) 및 대상(target model)이 다른 경우
    • id
    • <containing_model>_id
    • <other_model>_id
  2. ManyToManyField가 동일한 모델을 가리키는 경우
    • id
    • from__id
    • to__id

📍 프로젝트 전체 코드 확인하기

https://github.com/mjieun0956/TIL/tree/master/Database/05.%20Many%20to%20Many%20relationship/hospitals

profile
코드로 꿈을 펼치는 개발자의 이야기, 노력과 열정이 가득한 곳 🌈

0개의 댓글