REAL Python – Django] – “Django SQL 최적화하기 feat. prefetch_related”

REAL Python – Django] – “Django SQL 최적화하기 feat. prefetch_related”

2월 5, 2024

Django ORM

작년 초, 저는 학교에서 만난 소중한 동료들과 의미 있는 프로젝트인 Crescendo 를 진행했습니다. 스터디그룹을 모집하고, 활동까지 할 수 있는 간단한 웹 기반 서비스 프로젝트였죠. 해당 프로젝트의 백엔드는 DjangoDRF 로 구현되어 있었는데, Django ORM 을 적절히 사용했는지, 비효율적인 쿼리를 날리는 부분은 없었는지 등에 대해서 알아보고 이를 최적화하는 과정을 공유하고자 합니다.

서비스 링크는 https://www.crescendo-study.site/ 입니다.

Case 1. 쓸데없이 메모리에 객체를 올린 경우, 불필요한 쿼리 발생

첫 번째 사례는 쓸데없이 메모리에 객체를 올린 경우입니다. 이게 무슨 경우냐 하면..

위와 같은 API 구현에서 발생한 문제였습니다. 스터디그룹에는 스터디장이라고 불리는 리더가 “이 과제를 수행하세요!” 라는 의미의 “과제 요청” 을 게시할 수 있는데, 이 과제의 조회는 “스터디그룹의 멤버인 경우 상세 조회를”, “멤버가 아닌 경우 제목만” 조회할 수 있도록 구현되어 있었죠.

@extend_schema(tags=["스터디그룹 과제 등록 관리 API"])
class AssignmentRequestAPISet(viewsets.ModelViewSet):
    """
    스터디그룹 과제 관리 API

    - 과제의 목록을 조회하는 것은 로그인하지 않은 사람, 멤버인 사람에 따라 다릅니다.
        - 멤버가 아닌 사람은 과제의 제목만 조회할 수 있습니다.
        - 멤버인 사람은 과제의 제목, 내용의 일부를 말줄임한 형태로 조회할 수 있습니다.
    - 과제를 생성하는 것은 스터디그룹의 리더만 가능합니다.
    - 과제를 상세 조회하는 것은 스터디그룹의 멤버만 가능합니다.
    - 과제를 수정, 삭제하는 것은 스터디그룹의 리더만 가능합니다.
    """
    
    # 생략..
    
    def list(self, request, *args, **kwargs) -> Response:
        """
        - 멤버가 아닌 사람은 과제의 제목만 조회할 수 있습니다.
        - 멤버인 사람은 과제의 제목, 내용을 조회할 수 있습니다.
        """
        queryset = self.filter_queryset(self.get_queryset())
        truncate = int(request.query_params.get("truncate", 20))

        page = self.paginate_queryset(queryset)
        if self._check_user_is_member(request):
            serializer = self.get_serializer(page, many=True)
            for data in serializer.data:
                data["content"] = self._get_truncate_content(data["content"], truncate)
            return self.get_paginated_response(serializer.data)
        else:
            serializer = self.get_serializer(page, many=True)
            for data in serializer.data:
                data["content"] = self._get_empty_content(data["content"])
            return self.get_paginated_response(serializer.data)
            

저는 DRF 를 사용하고 있었으므로 “list 메서드에서 이걸 걸러주면 되겠다!” 라는 생각을 가지고 위와 같은 코드를 작성했습니다. if 분기로 시작되는 곳에 현재 request 를 던져주고, 멤버가 맞는지 아닌지를 판단하고 있네요. (25번 줄)

if self._check_user_is_member(request):

그러면 위 메서드는 어떻게 구현되어 있을까요?

    def _check_user_is_member(self, request) -> bool:
        """
        멤버인지 확인합니다.
        """
        studygroup_uuid = self.kwargs.get("studygroup_uuid")
        studygroup = StudyGroup.objects.get(uuid=studygroup_uuid)
        users = [member.user for member in studygroup.members.all()]
        if request.user in users:
            return True
        return False
        

한줄한줄 살펴봅시다.

  1. requestkwargs 로 스터디그룹의 uuid 를 얻어옵니다.
  2. 받아온 uuid 로, 스터디그룹 객체를 하나 가져옵니다.
  3. 스터디그룹 객체 안에서 모든 member 를 불러온 다음, request.usermembers 안에 포함되어 있는지 확인합니다.
  4. 만약 포함되어 있다면 True 를, 그렇지 않다면 False 를 반환합니다.

그러면요. 실제로 데이터베이스에는 어떤 쿼리가 날아갈까요? 요청을 날려봅시다.

curl -X 'GET' \
  'http://127.0.0.1:8000/api/v1/studygroup/studies/b20442cd-dad6-4f7e-8104-8944878c4a3c/assignments/?truncate=20' \
  -H 'accept: application/json'

생성된 쿼리는 아래와 같습니다.

(0.001) SELECT "studygroup_assignmentrequest"."id", "studygroup_assignmentrequest"."created_at", "studygroup_assignmentrequest"."updated_at", "studygroup_assignmentrequest"."studygroup_id", "studygroup_assignmentrequest"."author_id", "studygroup_assignmentrequest"."title", "studygroup_assignmentrequest"."content" FROM "studygroup_assignmentrequest" INNER JOIN "studygroup_studygroup" ON ("studygroup_assignmentrequest"."studygroup_id" = "studygroup_studygroup"."id") WHERE "studygroup_studygroup"."uuid" = 'b20442cddad64f7e81048944878c4a3c' ORDER BY "studygroup_assignmentrequest"."created_at" DESC LIMIT 6; args=('b20442cddad64f7e81048944878c4a3c',); alias=default
(0.000) SELECT "studygroup_studygroup"."id", "studygroup_studygroup"."created_at", "studygroup_studygroup"."updated_at", "studygroup_studygroup"."uuid", "studygroup_studygroup"."name", "studygroup_studygroup"."member_limit", "studygroup_studygroup"."start_date", "studygroup_studygroup"."end_date", "studygroup_studygroup"."head_image", "studygroup_studygroup"."title", "studygroup_studygroup"."content", "studygroup_studygroup"."deadline" FROM "studygroup_studygroup" WHERE "studygroup_studygroup"."uuid" = 'b20442cddad64f7e81048944878c4a3c' LIMIT 21; args=('b20442cddad64f7e81048944878c4a3c',); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."studygroup_id" = 32; args=(32,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 2 LIMIT 21; args=(2,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 3 LIMIT 21; args=(3,); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default

복잡해 보이지만 중복된 부분이 많습니다. 그 중, 메모리에 올렸기 때문에 발생한 SQL 에 대해서 살펴봅시다.

    def _check_user_is_member(self, request) -> bool:
        """
        멤버인지 확인합니다.
        """
        
        studygroup_uuid = self.kwargs.get("studygroup_uuid")
        studygroup = StudyGroup.objects.get(uuid=studygroup_uuid)
        users = [member.user for member in studygroup.members.all()]
        if request.user in users:
            return True
        return False

첫째, Django QuerySetLazy 하게 동작합니다. 정말 필요할 때까지 미루다가, 진짜 필요한 순간에 데이터베이스에 쿼리를 날린다는 거죠. 위의 코드를 보면 6번 줄, studygroup = StudyGroup.objects.get(uuid=studygroup_uuid) 는 그 즉시 데이터베이스에 쿼리를 합니다. 아래의 SQL을요:

(0.000) SELECT "studygroup_studygroup"."id", "studygroup_studygroup"."created_at", "studygroup_studygroup"."updated_at", "studygroup_studygroup"."uuid", "studygroup_studygroup"."name", "studygroup_studygroup"."member_limit", "studygroup_studygroup"."start_date", "studygroup_studygroup"."end_date", "studygroup_studygroup"."head_image", "studygroup_studygroup"."title", "studygroup_studygroup"."content", "studygroup_studygroup"."deadline" FROM "studygroup_studygroup" WHERE "studygroup_studygroup"."uuid" = 'b20442cddad64f7e81048944878c4a3c' LIMIT 21; args=('b20442cddad64f7e81048944878c4a3c',); alias=default

둘째, 같은 원리로 위 파이썬 코드의 users = [member.user for member in studygroup.members.all()] 또한 여러 개의 쿼리를 날립니다. 리스트를 만들기 위해, 모든 스터디그룹 멤버를 가져온다는 뜻이죠. 결국 아래의 중복된 SQL 들을 여러 개 만들어냅니다. 게다가 member.userDjangoAUTH_USER_MODEL 을 가져오고 있으므로, 스터디그룹 멤버가 많아지면 많아질수록 더더욱 많은 데이터베이스 쿼리를 날리게 되죠.

이러한 상황에서는 굳이 studygroup, users 와 같이 즉시 평가되는 쿼리셋 코드를 작성할 필요가 없습니다. 아래처럼 바꾸면 되겠습니다.

    def _check_user_is_member(self, request) -> bool:
        """
        멤버인지 확인합니다.
        """
        studygroup_uuid = self.kwargs.get("studygroup_uuid")
        if request.user.is_authenticated:
            return StudyGroupMember.objects.filter(
                studygroup__uuid=studygroup_uuid, user=request.user
            ).exists()
        return False

이제는 return 이후에서만 쿼리셋이 평가되고, 굳이 많은 스터디그룹 멤버들을 메모리에 올릴 필요도 – 검색 전 스터디그룹을 특정해 가져올 필요도 없습니다.

(0.001) SELECT "studygroup_assignmentrequest"."id", "studygroup_assignmentrequest"."created_at", "studygroup_assignmentrequest"."updated_at", "studygroup_assignmentrequest"."studygroup_id", "studygroup_assignmentrequest"."author_id", "studygroup_assignmentrequest"."title", "studygroup_assignmentrequest"."content" FROM "studygroup_assignmentrequest" INNER JOIN "studygroup_studygroup" ON ("studygroup_assignmentrequest"."studygroup_id" = "studygroup_studygroup"."id") WHERE "studygroup_studygroup"."uuid" = 'b20442cddad64f7e81048944878c4a3c' ORDER BY "studygroup_assignmentrequest"."created_at" DESC LIMIT 6; args=('b20442cddad64f7e81048944878c4a3c',); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default

로그인하지 않은 경우, 쿼리가 16개에서 11개로 줄었습니다! 나름 좋은 시작입니다.

Case 2. 객체가 N개면, N개의 쿼리가 발생

(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" = 29 LIMIT 21; args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" = 1 LIMIT 21; args=(1,); alias=default

하지만 여전히 반복되는 쿼리가 보이네요. SELECT account_user, SELECT studygroup_studygroupmember.id 가 쌍으로 세 번씩 반복됩니다. 왜인지는 모르겠지만, 세 번이나 반복해서 따로따로 스터디그룹 멤버와 DjangoAUTH_USER_MODEL 에 해당하는 User 객체를 찾는 쿼리를 날리고 있습니다.

어, 대체 어디서 반복되는 걸까요? 저는 Django DEBUG Toolbar 로 대체 어디서 이 쿼리가 만들어지는 건지 살펴봤습니다.

아, 보아하니 serializer 에서 발생하고 있네요. 그럼 AssignmentRequest 모델을 직렬화하는 시리얼라이저는 어떻게 구현되어 있는지를 확인해 봐야 되겠어요.

class AssignmentReadSerializer(serializers.ModelSerializer[AssignmentRequest]):
    """
    스터디그룹의 과제를 조회하기 위한 serializer 입니다.
    """

    author = ProfileSerializer(
        source="author.user",
        read_only=True,
    )

    class Meta:
        model = AssignmentRequest
        fields = [
            "id",
            "author",
            "title",
            "content",
            "created_at",
            "updated_at",
        ]

어, 뭔가 보여요. 분명히 반복되는 쿼리들은 “스터디그룹 멤버, DjangoAUTH_USER_MODEL” 과 관련된 것이었습니다. 그리고 위의 author 필드를 보면, ProfileSerializer 가 각 사용자마다 외래 키로 연결된 user 객체를 가져오고 있네요. 그러고 보니 알겠어요. Django DEBUG Toolbar 를 살펴보면, 가장 밑바닥에서 쿼리가 어떻게 만들어지는지를 확인할 수 있습니다.

instance 는 무엇을 의미할까요? 실제 디버그에 찍어보겠습니다.

와우, 역시나 모델 객체였습니다. 모델 객체 안에서 atts (필드) 를 가져오려고 시도하는 코드였던 거네요.

  • DRFserializerget_representation 을 통해서 필드들을 python 의 원시 타입으로 변환하려고 시도합니다.
  • 우리는 스터디그룹의 목록을 조회하기 위해서(정확히는 DRF 가) AssignmentReadSerializer 생성 시 many=True 옵션을 사용합니다.
  • many=True 를 사용하면, DRF 는 내부적으로 ListSerializer 를 생성토록 합니다.
  • 자동 생성된 ListSerializer 는, 내부적으로 자신의 모든 객체들(현재 경우에서는, AssignmentReqeustSerializer 객체들, 모델 객첵 아님에 유의) 을 순회하며 해당 객체들의 to_representation() 을 호출합니다.
  • 그러면 어떻게 되죠? 당연히 하나의 serializer 에 대해 또 to_representation 이 수행되죠.
  • .. 그러면 하나의 AssignmentRequestSerializerpython 원시 타입으로 바꾸려고 시도할 거고,
  • 그 안에 있는 ProfileSerializer 를 직렬화하기 위해서 또 to_representation 이 수행됩니다.

결국엔, DRF 가 내부적으로 many=True 옵션을 줘서 ProfileSerializer 를 냅다 만들어버리고, DRFListSerizlier 안에 만들어진 객체들인 ProfileSerialzer 들을 순회하며 직렬화를 하려고 시도하는데 – 그때 ProfileSerializer 가 포함되어 있으므로 또 직렬화를 시도할 거고, 직렬화를 하려면 author 객체, author 객체와 연관된 user 객체가 모두 필요하므로 순회를 돌며 필요할 때마다 스터디그룹 멤버, 그에 연결된 사용자 객체를 가져오려는 쿼리를 날려버렸던 겁니다.

마찬가지로, 스터디그룹의 사용자가 이백 명이면 이백 개 이상의 쿼리를 날려버리는 대환장 파티가 일어나는 겁니다.

그러면요, 어쩌면. 어쩌면 미리 불러올 수는 없을까요? 물론 Lazy 하게 동작하는 게 쓸데없이 쿼리를 날리지 않는 것에 도움은 되지만, 적어도 이 상황에서는 예외잖아요.

이를 위해서 prefetch_related 가 등장합니다. prefetch_related 는 딱 우리를 위한 상황에 알맞게 만들어졌습니다. 미리 author.user 에 대한 쿼리를 날려서, 객체가 N개면 N개의 쿼리가 발생하는 상황 자체를 없애 버리는 거죠. 아래와 같이 코드를 수정합니다.

@extend_schema(tags=["스터디그룹 과제 등록 관리 API"])
class AssignmentRequestAPISet(viewsets.ModelViewSet):
    """
    스터디그룹 과제 관리 API

    - 과제의 목록을 조회하는 것은 로그인하지 않은 사람, 멤버인 사람에 따라 다릅니다.
        - 멤버가 아닌 사람은 과제의 제목만 조회할 수 있습니다.
        - 멤버인 사람은 과제의 제목, 내용의 일부를 말줄임한 형태로 조회할 수 있습니다.
    - 과제를 생성하는 것은 스터디그룹의 리더만 가능합니다.
    - 과제를 상세 조회하는 것은 스터디그룹의 멤버만 가능합니다.
    - 과제를 수정, 삭제하는 것은 스터디그룹의 리더만 가능합니다.
    """

    lookup_url_kwarg = "assignment_id"
    queryset = AssignmentRequest.objects.all().prefetch_related("author__user")
    
    # 생략..

그리고 쿼리를 날려 볼까요? 16개였던 쿼리가, 3개로 줄어들었습니다. prefetch_related 를 사용했기 때문에, 아래와 같이 WHERE - IN 이 사용되었네요. 이 경우, 모든 스터디그룹 과제의 저자가 같은 사람이므로 하나의 사람에 대해서만 쿼리가 수행되었습니다.

(0.001) SELECT "studygroup_assignmentrequest"."id", "studygroup_assignmentrequest"."created_at", "studygroup_assignmentrequest"."updated_at", "studygroup_assignmentrequest"."studygroup_id", "studygroup_assignmentrequest"."author_id", "studygroup_assignmentrequest"."title", "studygroup_assignmentrequest"."content" FROM "studygroup_assignmentrequest" INNER JOIN "studygroup_studygroup" ON ("studygroup_assignmentrequest"."studygroup_id" = "studygroup_studygroup"."id") WHERE "studygroup_studygroup"."uuid" = 'b20442cddad64f7e81048944878c4a3c' ORDER BY "studygroup_assignmentrequest"."created_at" DESC LIMIT 6; args=('b20442cddad64f7e81048944878c4a3c',); alias=default
(0.000) SELECT "studygroup_studygroupmember"."id", "studygroup_studygroupmember"."created_at", "studygroup_studygroupmember"."updated_at", "studygroup_studygroupmember"."user_id", "studygroup_studygroupmember"."is_leader", "studygroup_studygroupmember"."studygroup_id" FROM "studygroup_studygroupmember" WHERE "studygroup_studygroupmember"."id" IN (29); args=(29,); alias=default
(0.000) SELECT "accounts_user"."id", "accounts_user"."password", "accounts_user"."last_login", "accounts_user"."is_superuser", "accounts_user"."created_at", "accounts_user"."updated_at", "accounts_user"."uuid", "accounts_user"."email", "accounts_user"."username", "accounts_user"."is_admin" FROM "accounts_user" WHERE "accounts_user"."id" IN (1); args=(1,); alias=default

좋아요, 뿌듯합니다. 16개의 쿼리를 3개로 줄였어요.

물론, 이는 로그인하지 않은 경우에 대해서만 해당됩니다. 로그인한 경우, Django 에서 세션을 찾기 위해 날리는 쿼리와 Permission 관련 쿼리 등 쿼리가 복잡해질 여지가 많이 있습니다. 그 부분들도 고쳐봐야겠어요.

Case 3. 마찬가지로 list comprehension 을 조심하자

class StudyGroupMemberRequestCreateSerializer(
    serializers.ModelSerializer[StudyGroupMemberRequest]
):
    """
    스터디그룹 가입 요청을 생성하기 위한 serializer 입니다.
    """
    class Meta:
        model = StudyGroupMemberRequest
        fields = [
            "request_message",
        ]

    def validate(self, attrs: OrderedDict[str, Any]) -> OrderedDict[str, Any]:
        """
        1. 스터디그룹의 모집이 마감되었는지 확인합니다.
        2. 이미 스터디그룹에 가입되어 있는지 확인합니다.
        3. 이미 스터디그룹 가입 요청을 보냈는지 확인합니다.
        """
        uuid = self.__dict__["_context"]["view"].kwargs["studygroup_uuid"]
        studygroup = StudyGroup.objects.get(uuid=uuid)
        if studygroup.is_closed:
            raise serializers.ValidationError(
                "The studygroup is already closed. You can't join it."
            )
        members = [member.user for member in studygroup.members.all()]
        request_user = self.__dict__["_context"]["request"].user
        if request_user in members:
            raise serializers.ValidationError(
                "You are already a member of this studygroup."
            )
        if StudyGroupMemberRequest.objects.filter(
            studygroup=studygroup, user=request_user
        ).exists():
            raise serializers.ValidationError(
                "You have already sent a request to join this studygroup."
            )
        return attrs

위의 코드를 보면.. 26번줄이 왜 문제가 되는지 알 수 있을 겁니다.

        request_user = self.__dict__["_context"]["request"].user
        if StudyGroupMember.objects.filter(
            studygroup=studygroup, user=request_user
        ).exists():
            raise serializers.ValidationError(
                "You are already a member of this studygroup."
            )
            

이렇게 필터를 걸어 검증을 하는 편이 낫죠. 위에서 언급했듯이, 메모리에 올리면 스터디그룹의 멤버 수만큼 – 그리고 멤버마다 user 을 가져오는 쿼리를 또 날리기 때문입니다.

요약

구현에만 집중하고 나니, 위처럼 사소하지만 치명적인 최적화가 되어있지 않은 경우를 많이 보게 되었습니다. 어쩌면 아직 고치지 않은 부분에도, 이미 고쳐진 부분에도 데이터베이스와 컴퓨터를 잡아먹는 비효율적인 코드가 그 모습을 감추고 있지는 않을까 하는 두려움도 느낍니다.

하지만 이번 기회로 테스트 코드를 작성했기 때문에, 그나마 테스토스테론 넘치는 변경을 할 수 있는 좋은 기회가 된 것 같기도 합니다. :) 테스트 코드를 잘 작성해야겠습니다.

배포도 완료되었습니다. :) 역시 맨 처음에 배포 설정, 코드 컨벤션 설정, 테스트 설정을 하니 정말 편하네요. 커밋 후 메인 브랜치에 머지되면, 자동으로 배포를 해 줍니다.

2 Comments

  1. 익명 2024-02-15 at 6:30 오후 - Reply

    글 너무 잘 쓰시는 것 같습니다. 몰입해서 정말 재밌게 봤습니다!

    • Goddessana_ 2024-02-15 at 6:52 오후 - Reply

      안녕하세요. 잘 읽어 주셔서 감사합니다.🔥

Leave A Comment

Avada Programmer

Hello! We are a group of skilled developers and programmers.

Hello! We are a group of skilled developers and programmers.

We have experience in working with different platforms, systems, and devices to create products that are compatible and accessible.