[Django] 데이터베이스 M:N 관계

한결·2023년 4월 11일
0

WEB

목록 보기
20/63
post-thumbnail

INTRO

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

데이터 모델링

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

시작 전 용어 정리

  • target model

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

    • 관계 필드를 가진 모델

N:1의 한계

  • 의사와 환자간 예약 시스템을 구현
  • 지금까지 배운 N:1 관계를 생각해 한 명의 의사에게 여러 환자가 예약할 수 있다고 모델관 계를 설정
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 실행
  • 각각 2명의 의사와 환자를 생성하고 환자는 서로 다른 의사에게 예약을 했다고 가정
  • 의사

  • 환자

  • 1번 환자(carol)가 두 의사 모두에게 방문하려고 함
  • 안 좋은 예시
    • 데이터 고유성 위배 (데이터 중복, 캐롤 데이터 2개)
    • 제 1 정규화 위배 (하나는 하나의 값만 가지는게 좋다)
  • 동일한 환자지만 다른 의사에게 예약하기 위해서는 ㄷ객체를 하나 더 만들어서 예약을 진행해야 함
    • 새로운 환자 객체를 생성할 수 밖에 없음
  • 외래 키 컬럼에 1, 2 형태로 참조하는 것은 integer 타입이 아니기 때문에 불가능
    -> 예약 테이블(중계 테이블)을 따로 만들자

중계 모델

  • 환자 모델의 외래 키를 삭제하고 별도의 예약 모델을 새로 작성
    • 즉, 중계 테이블 생성을 위해 중계 모델을 작성하자
  • 예약 모델은 의사와 환자에 각각 N:1 관계를 가짐
  • 모델을 바꿔줘야 하는 상황 (Patient의 doctor FK 부분을 지우는)

    • 근데 이미 모델에 맞춰서 데이터 다 넣어 놨음
      -> 이 부분을 생각하지 않고 모델을 바꿔버리면 에러 발생
      -> 2가지 방법이 있음
      1. 데이터를 수정해 가며 변경
      2. Migration 날리기 -> 깔끔함
        지금은 데이터도 별로 없고 중요하지 않으니 2번으로 진행
  • 데이터 베이스 초기화 후 Migration 진행 및 shell_plus 실행

    1. migration 파일 삭제
    2. 데이터 베이스 파일 삭제
  • 모델 수정

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}'
    
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}번 환자'
  • DB
  • 의사와 환자 생성 후 예약 만들기



  • 예약 정보 조회
  • 역참조!!!
    -> 1번의사로 예약된거 싹 다 가져올 수 있음

    -> 1번 환자로 예약된거 다 가져오기
  • 1번 의사가 여러 명의 환자 예약을 받는 상황 만들기
    • 환자 추가하고 1번의사한테 예약 걸기


Django ManyToManyField

variable_name = models.ManyToManyField(MTM 관계 맺을 Model명)

  • 우리가 중계 테이블을 만들기 위해 모델 생성하지 않아도
    django에서 자동으로 중계테이블 만들어서 PK들을 가지고 M:N 관계만들어 주는 기능
  1. MTMF 사용하는걸로 모델 변경하면
    또 이미 기존 모델의 형태로 데이터가 들어 있기 때문에 Migration이랑 DB 다 날리고 변경하자
    (migrations 안의 __init__은 지우지 말자)

  2. Model 수정

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)
    doctors = models.ManyToManyField(Doctor)
    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}번 환자'
  1. Migrate 진행
  • 위처럼 따로 중계 테이블 모델 만들어 주지 않았는데 중계 테이블 생성됨
  1. shell_plus 켜서 환자 & 의사 생성
  2. 환자가 의사 예약하는 코드 가독성이 높아짐

  1. 예약 취소하기 (삭제)
  • 기존에는 해당하는 Reservation을 찾아서 지워야 했음
  • 이제는 .remove()만 사용하면 됨
  • 역참조 할때 aaa.bbb_set.all() 이런식으로 했었음
    근데 aaa.bbb.all() 하고 싶다면
    related_name argument 사용하면 됨

'through' argument

  • Django가 중계 테이블 만들어주는데 그럼 직접 중계모델을 작성해서 사용하는 경우는 Django를 사용할때 절대 없을까?
    -> 직접 만들어야 하는 경우는 있음

  • Django가 만들어주는 중계 테이블엔 FK들어가는 column만 있음
    근데, 아까 병원의 상황을 이어가자면 보통 증상, 예약일, 진료일 등 다른 데이터들을 중계 테이블에 넣어야 하는 상황이 분명히 존재함
    -> 이때 직접 중계 테이블을 만들어야 함

  • 근데 MTM이 만들어주는 중계 테이블에 field를 추가 및 삭제 하고 싶음
    -> through 옵션 사용

  • through를 통해 Reservation에 MTM 기능 사용할 수 있도록 모델 변경
class Patient(models.Model):
    # doctor = models.ForeignKey(Doctor, on_delete=models.CASCADE)
    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}번 환자'

  • 위 DB를 보면 django에서 만들어준 중계 테이블이 아닌 내가 작성한 중계 테이블만 있음
    -> 얘를 중계 테이블로 사용하는 MTM설정이 잘 된거임
  1. shell_plus 키고 환자, 의사 생성
  2. 예약 생성 1 (Reservation class를 통한)
  3. 예약 생성 2 (Patient 객체를 통한)
  • through_defaults 값에 딕셔너리 타입으로 입력

  1. 예약 삭제

정리

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

ManyToManyField

ManyToManyField란

  • ManyToManyField(to, **options)
  • 다대다 관계 설정 시 사용하는 모델 필드
  • 하나의 필수 위치인자가 필요
  • 모델 필드의 RelatedManager를 사용하여 관련 개체를 추가, 제거 또는 만들 수 있음
    • add(), remove(), create(), clear() ...

데이터베이스에서의 표현

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

ManyToManyField's Arguments

  1. related_name
    • target model이 source model을 참조할 때 사용할 manager name
    • ForeignKey의 related_name과 동일
  2. through
    • 중개 테이블을 직접 작성하는 경우, through 옵션을 사용하여 중개 테이블을 나타내는 Django 모델을 지정
    • 일반적으로 중개 테이블에 추가 데이터를 사용하는 다대다 관계와 연결하려는 경우에 사용됨
  3. symmetrical
  • 기본 값 : True
  • ManyToManyField가 동일한 모델(on self)을 가리키는 정의에서만 사용
  • True일 경우
    • _set 매니저를 추가하지 않음
    • source 모델의 인스턴스가 target모델의 인스턴스를 참조하면 자동으로 targer 모델 인스턴스도 source 모델 인스턴스를 자동으로 참조하도록 함
    • 즉, 내가 당신의 친구라면 당신도 내 친구가 됨
  • 대칭을 원하지 않는 경우 False로 설정
    • follow 기능 구현에서 다시 확인할 예정

M:N (Article-User)

  • Article과 User의 M:N 관계 설정을 통한 좋아요 기능 구현하기

LIKE

모델 관계 설정

  • ManyToManyField 작성
# articles/models.py
class Article(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    like_users = models.ManyToManyField(settings.AUTH_USER_MODEL)
    title = models.CharField(max_length=30)
    content = models.TextField()
    image = models.ImageField(blank=True, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f'{self.id}번째글 - {self.title}'
  • Migration 진행
    • A랑 B 충돌 일어남
    • 뭔말이냐 하면
    1. like_users 필드 생성 시 자동으로 역참조에는 .article_set 매니저가 생성됨
    2. 그러나 이미 N:1(Article-User)관계에서 이미 해당 매니저를 사용중
      • user.article_set.all() -> 해당 유저가 작성한 모든 게시글 조회
      • user가 작성한 글들과 user가 좋아요를 누른 글을 구분할 수 없게됨
    3. user와 관계된 FK 혹은 MTMF 중 하나에 related_name을 작성해야함
  • MTMF에 related_name 작성 후 Migration
class Article(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    like_users = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='like_articles')
    title = models.CharField(max_length=30)
    content = models.TextField()
    image = models.ImageField(blank=True, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return f'{self.id}번째글 - {self.title}'

  • User-Article간 사용 가능한 related manager 정리
    • article.user
      • 게시글을 작성한 유저 - N:1
    • user.article_set
      • 유저가 작성한 게시글(역참조) - N:1
    • article.like_users
      • 게시글을 좋아요한 유저 - M:N
    • user.like_articles
      • 유저가 좋아요한 게시글(역참조) - M:N

LIKE 구현

  • url 및 view함수 작성
# articles/urls.py
path('<int:article_pk>/like/', views.likes, name='likes'),

# articles/views.py
def likes(request, article_pk):
    article = Article.objects.get(pk=article_pk)
    
    # 좋아요 취소 
    if article.like_users.filter(pk=request.user.pk).exists(): 
        article.like_users.remove(request.user)
    
    # 좋아요
    else:
        article.like_users.add(request.user)

    return redirect('articles:index')
  • .exist() : QuerySet에 존재하는지 안하는지 확인 후 결과를 bool형으로 반환
    • 큰 QuerySet에 있는 특정 개체의 존재와 관련된 검색에 유용
  • index 템플릿에서 각 게시글에 좋아요 버튼 출력하기
    <span>좋아요 : {{article.like_users.count}}개 </span>
    <form action="{% url 'articles:likes' article.pk %}" method = "POST">
      {% csrf_token %}
      {% if request.user in article.like_users.all %}
      <input type="submit" value="좋아요 취소">
      {% else %}
      <input type="submit" value="좋아요">
      {% endif %}
    </form>



3.

  • 데코레이터 및 is_aurthenticated 추가
@require_POST
def likes(request, article_pk):
    if request.user.is_authenticated:
        article = Article.objects.get(pk=article_pk)
        
        # 좋아요 취소 
        if article.like_users.filter(pk=request.user.pk).exists(): 
            article.like_users.remove(request.user)
        
        # 좋아요
        else:
            article.like_users.add(request.user)
    
        return redirect('articles:index')
    else:
        return redirect('accounts:login')
  • 비로그인상태에서 좋아요 클릭하기
  • 로그인화면으로 리다이렉트

M:N (User-User)

  • User 자기 자신과의 M:N 관계 설정을 통한 팔로우 기능 구현하기

Profile

  • 자연스러운 follow 흐름을 위한 프로필 페이지를 먼저 작성

구현

  • url 및 view 함수
# accounts/urls.py
path('profile/<int:pk>/',views.profile, name='profile'),

# accounts/views.py
@login_required
@require_GET
def profile(request, pk):
    User = get_user_model()
    person = User.objects.get(pk=pk)
    context = {
        'person' : person,
    }
    return render(request, 'accounts/profile.html', context)
  • profile 템플릿 작성
  • 팔로잉 수 / 작성한 게시글 / 좋아요한 게시글
{% extends 'base.html' %}

{% block content %}
  <h1>{{person.username}} 님의 프로필</h1>
  <div>
    <div>
        팔로잉 : {{person.followings.all|length}} / 팔로워 : {{person.followers.all|length}}
    </div>
    {% if request.user != person %}
      <div>
        <form action="{% url 'accounts:follow' person.pk %}" method = "POST">
            {% csrf_token %}
            {% if request.user in person.followers.all %}
              <input type="submit" value="Unfollow">
            {% else %}
              <input type="submit" value="Follow">
            {% endif %}
        </form>
      </div>
    {% endif %}
  </div>

  <hr>

  <h2>{{person.username}}'s 게시글</h2>
  {% for movie in person.movie_set.all  %}
    <div>
      <a href="{% url 'movies:detail' movie.pk %}">{{movie.title}}</a>
    </div>
  {% endfor %}

   
  <h2>{{person.username}}'s 좋아요한 게시글</h2>
  {% for movie in person.like_movies.all %}
    <div>
      <a href="{% url 'movies:detail' movie.pk %}">{{movie.title}}</a>
    </div>
  {% endfor %}

  <hr>

  <a href="{% url 'movies:index' %}">back</a>
{% endblock content %}
  • Profile 템플릿으로 이동할 수 있는 하이퍼링크 작성
# base.html
<!--마이 페이지-->
<a href="{% url 'accounts:profile' user.pk %}">마이 페이지</a>

Follow

모델관계 설정

class User(AbstractUser):
    followings = models.ManyToManyField('self', symmetrical=False, related_name='followers')

구현

  • url 및 view 함수 작성
# accounts/urls.py
path('<int:user_pk>/follow/',views.follow, name='follow'),

# accounts/views.py
@require_POST
def follow(request, user_pk):
    if request.user.is_authenticated:
        User = get_user_model()
        person = User.objects.get(pk=user_pk)
        if person != request.user:
            if person.followers.filter(pk=request.user.pk).exists():
                person.followers.remove(request.user)
            else:
                person.followers.add(request.user)
        return redirect('accounts:profile', person.pk)
    else:
        return redirect('accounts:login')

0개의 댓글