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관계를 매칭해 준다.