ISSUE/Django

[ISSUE] ManytoMany 관계를 가진 두 모델 Bulk Create 작업

조별하 2023. 5. 6. 17:01

[22.11.26]

🍃 ManytoMany 관계를 가진 두 모델 Bulk Create 작업

✔️ 현재 위와 같이 등록된 항목에 대해 태그를 추가하는 기능을 개발 구현 중이다.

  • 등록된 항목은 Scrap Parsing을 통해 특정 사이트에 관련한 url, 썸네일, 제목 등을 저장하여 접근할 수 있게 구현한 현황 화면이다.
  • 특정 사이트를 선택하여 bulk(다중)로 태그를 등록할 수 있게 스크립트 구현
  • 선택한 각 Site와 Tag Model이 ManytoMany 관계로 데이터 모델링

 

1. 모델 코드

📌 Tag Model

class Site(models.Model):
    """ 항목에 관한 데이터 모델 """

    title = models.CharField(verbose_name='타이틀', max_length=100)
    user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='유저')
    url = models.CharField(verbose_name='url', max_length=2000, null=True)
    thumbnail_url = models.CharField(verbose_name='썸네일주소', max_length=2000)
    host_name = models.CharField(verbose_name='호스트명', max_length=500)
    content = models.TextField(verbose_name='컨텐츠')

    # category choices
    CATEGORY_CHOICES = [(1, 'python'), (2, 'django'), (3, 'javascript'), (4, 'orm'), (5, 'mysql'), (6, 'drf'), (7, 'docker'), (8, 'os'), (9, 'aws'), (10, 'html'), (11, 'css'), (12, 'git'), (13, 'linux')]

    category = models.IntegerField(verbose_name='카테고리', choices=CATEGORY_CHOICES)
    favorite = models.BooleanField(verbose_name='즐겨찾기', default=False)
    video = models.BooleanField(verbose_name='비디오', default=False)
    created_at = models.DateTimeField(verbose_name='생성일', auto_now_add=True)
    updated_at = models.DateTimeField(verbose_name='갱신일', auto_now=True)

    def __str__(self):
        return f"{self.title} ({self.get_category_display()})"

    class Meta:
        verbose_name = '항목'
        verbose_name_plural = '항목 목록'

📌 Tag Model

class Tag(models.Model):
    """ 웹 항목 태그 목록 모델  """
    name = models.CharField(verbose_name='이름', max_length=20)
    site = models.ManyToManyField(Site, verbose_name='리스트')

    def __str__(self):
        return f"{self.name}"

    class Meta:
        verbose_name = '태그'
        verbose_name_plural = '태그 목록'

 

2. js에서 선택한 항목의 id값과 입력된 Tag값 전달

function setFechData(method, body){
    /* Fetch data 셋팅 */

    let csrftoken   = getCookie('csrftoken');

    const data = {
        method: method,
        headers: {
            'content-type': 'application/json',
            'X-CSRFToken' : csrftoken,        
        },
        body: JSON.stringify(body)
    }

    return data
}

function bulkTag() {
    /* 벌크 태그 이벤트 */

    const data = setFechData("POST",{
        pk_ids: selected_articles,
        tags: added_tags,
        user: "User Id" 
    })

    fetch(`/api/sites/tags`, data)
        .then(response => {
            let status = response.status
        })
        .then(() => getSiteList())
        .then(() => changeSelected())
        .catch(error   => console.log(error)
}

✔️ ‘저장’ 버튼 Click

1. bulkTag() 실행 및 fetch에 담아 전송할 data 할당

2. data는 setFechData() 함수를 이용

  • 다른 api요청 시에도 재사용할 수 있도록 공통함수 작성
  • method는 새로 저장되는 api이기 때문에 POST로 요청

3. pk_ids에는 선택한 항목의 pk(id) 값을 배열 형식으로 할당 ex) [1, 2]

4. tags에는 선택한 항목에 등록할 태그 명을 배열 형식으로 할당 ex) [’python’, ‘django’]

5. fetch함수를 이용하여 /api/sites/tags api에 data 담아 전송

 

3. api url 설정

from pocket.views import (
    # api_view
    SiteBulkAPIView
    **SiteTagsAPIView,** 
)

api_patterns = [
    path('sites/bulk', SiteBulkAPIView.as_view()), 
    **path('sites/tags', SiteTagsAPIView.as_view()),** 
]

urlpatterns = [
    # api
    **path('api/', include(api_patterns)),**
]
  • 화면 이동이 아닌 api 관련은 include 하여 api_patterns로 연결되게 설정
  • js에서 전송한 request를 SiteTagsAPIView 뷰단으로 이동할 수 있게 맴핑

 

4. view 코드 작성

class SiteTagsAPIView(APIView):
    """
    벌크 항목 태그 api
    """

        # 2_b
    def get_list(self):

        pk_ids: list = self.request.data.get('pk_ids')

        return get_list_or_404(Site, id__in=pk_ids)

        # 2_a
    def validate_ids(self):

        pk_ids: list = self.request.data.get('pk_ids')

        for id in pk_ids:
            get_object_or_404(Site,id=id)

        return self.get_list()

        # 1
    def post(self, request):
        """
        Site 태그 추가
        """ 
                # 2,3
        sites = validate_ids()
        tags  = self.request.data.get('tags')

        with transaction.atomic():
                '''트랜젝션 시작'''

                        # 4_a
            bulk_tags   = [Tag(name=tag) for tag in tags]
                        # 4_b
            created_tags = Tag.objects.bulk_create(bulk_tags)

                        # 5
            [tag.site.add(site.id) for tag in created_tags for site in sites]

        return Response({'msg':'Add Tag successfully'}, status=status.HTTP_200_OK)

1. js에서 요청한 method와 동일하게 함수명을 post로 지정

2. 전달받은 pk(id)가 담긴 배열을 이용하여 Site list를 조회

  • validate_ids() 호출 후 존재하는 id값들인 경우 get_list() 호출
  • get_list()를 호출하여 get_list_or_404(Site, id__in=pk_ids) 와 같이 pk_ids값을 포함한 Site들 조회 (단, 항목이 존재하지 않을 경우 404 에러 반환)

3. 전달받은 태그가 담긴 배열 변수 할당

4. 태그 생성을 위한 Tag 객체 생성

  • list comprehesion을 이용하여 Tag가 담긴 배열 생성
  • bulk_create() 함수를 이용하여 이전 단계에서 담아둔 Tag객체를 매개변수로하여 create_tags 할당

5. 생성된 Tags 리스트와 Sites 리스트 ManytoMany 매칭

  • list comprehesion을 이용하여 Tag 모델의 site 칼럼에 전달받는 site들을 넣어주는 작업 진행
bulk_create()를 사용한 이유

✔️ 벌크 작업 시 일반 create로 작업을 하게 되면 하나하나 create를 할 때마다 DB에 connection을 요청하기 때문에 오래 걸리기도 하고 과부하가 걸린다는 단점이 있다.

✔️ 그래서 Django에서 제공하는 bulk_create()를 사용한 것이다. 이 함수는 DB에 connection을 한 번만 요청하여 생성을 한 번에 처리해 주는 함수이기 때문에 효율적인 면에서 좋다.

5. Issue1

Internal Server Error: /api/sites/tags
Traceback (most recent call last):
  File "/Users/cjy/Lecture/Devket/env-devket/lib/python3.10/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/Users/cjy/Lecture/Devket/env-devket/lib/python3.10/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/Users/cjy/Lecture/Devket/env-devket/lib/python3.10/site-packages/django/views/decorators/csrf.py", line 54, in wrapped_view
    return view_func(*args, **kwargs)
  File "/Users/cjy/Lecture/Devket/env-devket/lib/python3.10/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/Users/cjy/Lecture/Devket/env-devket/lib/python3.10/site-packages/rest_framework/views.py", line 509, in dispatch
    response = self.handle_exception(exc)
  File "/Users/cjy/Lecture/Devket/env-devket/lib/python3.10/site-packages/rest_framework/views.py", line 469, in handle_exception
    self.raise_uncaught_exception(exc)
  File "/Users/cjy/Lecture/Devket/env-devket/lib/python3.10/site-packages/rest_framework/views.py", line 480, in raise_uncaught_exception
    raise exc
  File "/Users/cjy/Lecture/Devket/env-devket/lib/python3.10/site-packages/rest_framework/views.py", line 506, in dispatch
    response = handler(request, *args, **kwargs)
  File "/Users/cjy/Lecture/Devket/pocket/decorator.py", line 18, in exec_func
    return func(self, request, sites=sites)
  File "/Users/cjy/Lecture/Devket/pocket/views.py", line 189, in post
    [tag.site.add(site.id) for tag in created_tags for site in sites]
  File "/Users/cjy/Lecture/Devket/pocket/views.py", line 189, in <listcomp>
    [tag.site.add(site.id) for tag in created_tags for site in sites]
  File "/Users/cjy/Lecture/Devket/env-devket/lib/python3.10/site-packages/django/db/models/fields/related_descriptors.py", line 536, in __get__
    return self.related_manager_cls(instance)
  File "/Users/cjy/Lecture/Devket/env-devket/lib/python3.10/site-packages/django/db/models/fields/related_descriptors.py", line 851, in __init__
    raise ValueError('"%r" needs to have a value for field "%s" before '
**ValueError: "<Tag: 자바>" needs to have a value for field "id" before this many-to-many relationship can be used.**

✔️ 위와 동일하게 코드를 작성하여 이벤트를 실행시키면 제일 에러가 발생한다.

  • Error 내용
  • Tag라는 객체는 many-to-many 작업을 하기 위해 id 필드가 필요하다.라는 내용이 담겨 있다.
bulk_create() 사용으로 에러가 발생한 이유

✔️ 여기서 에러를 생각보다 오래 잡지 못했던 이유가 작업을 하면서 당연히 bulk_create() 함수를 사용하면

✔️ 리턴되는 객체가 Tag를 생성하였으니 그 생성한 id와 pk값을 가지고 있다고 판단하고 위와 같이 코드를 작성하니 id값이 존재하지 않다고 에러가 발생한 것이다.

✔️ 결론적으로 bulk_create() 함수는 여러 생성 작업만 가지고는 효율적이지만 재사용성 면에서는 좋지 않다는 것이다. 그 이유에 Tag를 Many-to-Many로 추가해야 하는 상황에서 후처리 작업을 하지 못한다.

6. 해결 방법

class SiteTagsAPIView(APIView):
    """
    벌크 항목 태그 api
    """

    def get_list(self):

        pk_ids: list = self.request.data.get('pk_ids')

        return get_list_or_404(Site, id__in=pk_ids)


    def validate_ids(self):

        pk_ids: list = self.request.data.get('pk_ids')

        for id in pk_ids:
            get_object_or_404(Site,id=id)

        return self.get_list()

    def post(self, request, **kwards):
        """
        Site 태그 추가
        """ 
        sites          = validate_ids()
        tags           = self.request.data.get('tags')

        with transaction.atomic():
            '''트랜젝션 시작'''

            created_tags   = [Tag.objects.get_or_create(name=tag)[0] for tag in tags]

            [tag.site.add(site.id) for tag in created_tags for site in sites]

        return Response({'msg':'Updated successfully'}, status=status.HTTP_200_OK)
  • Tag 객체가 담긴 리스트를 bulk_create()로 생성해 주는 로직에서 list comprehesion을 이용하여 Tag를 생성하며 반환되는 Tag객체를 배열에 할당해 준다.
  • 할당해 준 Tags 리스트와 Sites를 반복문을 통해서 tag.site.add(site.id)하여 Many-to-Many관계를 매칭해 준다.