ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Django + Rest Framework 에서 Service Layer를 구성하는 방법
    💻 프로그래밍/Django 2021. 2. 13. 09:17

    안녕하세요! 지난 포스팅에서는 비즈니스 로직을 넣는 위치에 대해 정리해봤는데요! 이번에는 그중 Service Layer를 통해 비즈니스 로직을 정리하는 과정 등에 대해 정리해보려고 합니다.

     

    1. Service Layer 란? 🤔


    간단히 말하자면 비즈니스 로직을 Service로 분리하고, 이런 비즈니스 로직들이 Class(클래스)Function(함수)으로 이루어진 형태(?)입니다.

     

    예를 들어 쇼핑과 관련된 API를 호출할 때, 장바구니에 물건을 담거나, 장바구니에 담긴 아이템들의 카테고리를 나눠 카운트를 한다거나 하는 모든 것들을 비즈니스 로직이라고 할 수 있습니다. 그리고 이런 비즈니스 로직을 View, Model이 아닌 Service라는 곳으로 위치시킨걸 Service Layer라고 합니다.

     

    Service Layer에 비즈니스 로직을 넣게되면 얻는 몇 가지 이점들이 있습니다.

     

    첫 번째, View, Model 등에서 비즈니스 로직을 분리해 각각의 위치에 맞는 역할을 할 수 있게 합니다. View에서는 사용자의 action에 따른 동작들을 하고, 이러한 동작들은 Service에서 모두 확인할 수 있습니다. 또한, serializer, form 등에서 비즈니스 로직을 제거해 data의 유효성 검증만 할 수 있습니다.

     

    두 번째, 비즈니스 로직을 분리하는 거 자체에서 비즈니스를 좀 더 쉽게 이해할 수 있게 해 줍니다. 보통 view나 serializer에서 생각한 비즈니스 로직을 넣게 된다면, 초기 개발단계나 작은 프로젝트에서는 큰 문제가 없을 수 있겠지만, 비즈니스가 점점 커지게 되면 그 자체로 히스토리와 서비스 파악이 어려울 것입니다. 친절한 주석이 없다면, 중간에 합류한 개발자에게는 더 이해하기 어려운 로직이 되겠죠 ㅎㅎ

     

    세 번째, 테스트 케이스 작성이 좀 더 용이해집니다. 비즈니스 로직 자체를 분리했기 때문에 Unit Test를 좀 더 쉽게 할 수 있습니다. 비즈니스 로직이 view에 있는 경우 API 호출 테스트로는 그 비즈니스 로직이 잘 동작해서 response가 200으로 떨어진 건지에 대한 검증을 하기 어렵습니다. (물론 가능은 하겠지만, 뭔가 직관적이지 않고 비즈니스 로직을 테스트한다는 느낌이 아닌...?)

     

    자, 이제 그럼 예제를 통해 Service Layer를 구성해 보겠습니다. 예제는 실제로 제가 토이프로젝트로 만들었던 API를 refectoring 해보겠습니다.

     

    2. Rectoring을 통해 Service Layer를 나눠보자! 🚀


    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
     
    class MoodViewSet(mixins.CreateModelMixin,
                      mixins.ListModelMixin,
                      GenericViewSet):
        """
            - Mood (기분) 생성
            endpoint : /moods/
        """
     
        queryset = Mood.objects.all()
        serializer_class = MoodSerializer
        permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsUserMoodGroup)
        pagination_class = CustomCursorPagination
     
        def perform_create(self, serializer) -> dict:
            """
                - show_summary_group_list 가 empty list 이면 전체 공개
            """
     
            today = timezone.now()
            user = self.request.user
            mood_group_create_list = []
            show_summary_group_list = self.request.data.get('group_list', [])  # 기분 설명(summary) 보여줄 그룹
     
            user_mood_count = UserMood.objects.filter(
                user=user,
                created__date=today.date(),
                mood_group=None
            ).count()
     
            # 오늘 기분 생성
            if user_mood_count < MOOD_LIMITED_COUNT:
     
                # 현재 속한 그룹 리스트 가져옴
                mood_group_ids = list(UserMoodGroup.objects.filter(
                    user=user
                ).values_list('mood_group_id', flat=True))
     
                mood = serializer.save()
     
                # 그룹과 별개로, 개인 기분(mood) 생성 - 그룹이 없을수도 있어서, mood_group=None 을 기본으로 생성
                UserMood.objects.create(
                    mood=mood,
                    user=self.request.user,
                    mood_group=None
                )
     
                # 내가속한 그룹에 기분을 저장
                for mood_group_id in mood_group_ids:
                    do_show_summary = False
     
                    if mood_group_id in show_summary_group_list:
                        do_show_summary = True
     
                    mood_group_create_list.append(
                        UserMood(
                            do_show_summary=do_show_summary,
                            mood_group_id=mood_group_id,
                            user=user,
                            mood=mood
                        )
                    )
     
                if mood_group_create_list:
                    UserMood.objects.bulk_create(mood_group_create_list)
     
            else:
                err_data = {
                    'err_code''limited',
                    'description''You have exceeded 100 moods.'
                }
                return err_data
     
            return self.get_serializer(instance=mood).data
     
        def create(self, request, *args, **kwargs) -> Response:
            serializer = self.get_serializer(data=request.data)
            serializer.is_valid(raise_exception=True)
            my_mode = self.perform_create(serializer)
     
            return Response(my_mode, status=status.HTTP_201_CREATED)
     
    cs

    일단 기본적인 비즈니스 로직(?)에 대해서 설명하자면 MoodViewSet()은 내 기분(Mood)을 저장하거나(POST) 볼 수있는(GET) API 입니다. 기분 상태와, 기분에 대한 설명을 입력받아 내가 속한 그룹에 그 기분을 보여줄 수 있는 로직입니다.

    1. 기분, 설명, 기분을 보여줄 그룹 pk를 입력받는다
    2. 기분(Mood)을 생성한다. (validation 통과하면)
    3. 생성된 기분을 UserMood()를 통해 Group과 Mood를 연결해준다.
    4. 생성된 기분을 reponse로 전달한다.

    요약하자면 이런 비즈니스 로직을 갖고 있는 API입니다.

     

    일단 View에 비즈니스 로직이 이렇게 있을때, 처음 이 API를 본 개발자가 한눈에 이 API 뭘 하는 API인지 어떤 행동(비즈니스)을 하는 API인지 파악하기 힘듭니다. 물론 위 예제의 경우 간단하지만, 좀 더 복잡할 경우 이해하기 훨씬 힘들 것입니다.

     

    Service Layer를 만들기 위해 다음과 같은 과정을 거칠 것입니다.

    1. 비즈니스 로직 view에서 걷어낸다.
    2. 걷어낸 비즈니스 로직을 service.py 에 class로 만들어 class의 함수로 만든다.
    3. view에서는 service를 통해 생성할 데이터의 DTO(Data Transfer Object)를 생성한다.
    4. viewe에서 DTO를 넘기면서 service.py 있는 class를 호출해서 동작을 실행한다.

    위 과정대로 refectoring을 진행한 코드를 보겠습니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
     
    class MoodViewSet(mixins.CreateModelMixin,
                      mixins.ListModelMixin,
                      GenericViewSet):
        """
            - Mood (기분) 생성
            endpoint : /moods/
        """
     
        queryset = Mood.objects.all()
        serializer_class = MoodSerializer
        permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsUserMoodGroup)
        pagination_class = CustomCursorPagination
     
        def perform_create(self, serializer) -> dict:
            """
                - show_summary_group_list 가 empty list 이면 전체 공개
            """
     
            mood_manage_service = MoodManageService(self.request)
            mood = self._build_mood_from_validated_data(self.request.data)
     
            try:
                response_data = mood_manage_service.update_my_mood(mood)
            except LimitTodayMood:
                raise ServiceUnavailable
     
            return response_data
     
        def create(self, request, *args, **kwargs) -> Response:
            serializer = self.get_serializer(data=request.data)
            serializer.is_valid(raise_exception=True)
            my_mode = self.perform_create(serializer)
     
            return Response(my_mode, status=status.HTTP_201_CREATED)
     
        @staticmethod
        def _build_mood_from_validated_data(data):
            serializer = MoodSerializer(data=data)
            serializer.is_valid(raise_exception=True)
            data = serializer.validated_data
     
            return MoodDto(
                status=data["status"],
                simple_summary=data["simple_summary"],
            )
     
    cs

    View만 봤을 때 refectoring 전보다 훨씬 깔끔하고 이 API가 뭘 하는 API인지 직관적으로 알 수 있습니다. (저만 그런가요 Hoxy?ㅋㅋ)

     

    mood_manage_serive()라는 클래스를 통해 인스턴스를 생성하고, update_my_mood() 라는 동작을 실행합니다. 음, 이 API는 기분(mood)을 업데이트하는 API 군요!! ㅋㅋㅋ 특이한 점은 MoodDto를 만들어서 update_my_mood()의 파라미터로 전달합니다.

     

    이 부분도 좀 특별한 점이라고  생각하는데요. 일반적으로 serializer.save()를 통해 바로 데이터를 저장하는데, Service Layer를 통할 때는 데이터를 바로 저장하는 게 아닌 DTO를 전달합니다. 그리고 이 데이터를 실제 저장하는 건 service에서 실행합니다.

     

    그럼 service에 비즈니스 로직이 어떻게 구현되어 있는지 확인해 보겠습니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
     
    @dataclass
    class MoodDto:
        status: int # 기분(상태)
        simple_summary: str # 기분 설명
     
     
    class MoodManageService(object):
     
        def __init__(self, request):
            self.user = request.user
            self.show_summary_group_ids = request.data.get('group_list', [])  # 기분 설명(summary) 보여줄 그룹
     
        def update_my_mood(self, mood: MoodDto) -> dict:
            self.check_today_mood_limit()
     
            mood_group_create_list = []
     
            new_mood = Mood.objects.create(
                status=mood.status,
                simple_summary=mood.simple_summary,
                is_day_last=False,
            )
     
            # 그룹과 별개로, 개인 기분(mood) 생성 - 그룹이 없을수도 있어서, mood_group=None 을 기본으로 생성
            UserMood.objects.create(
                mood_id=new_mood.id,
                user_id=self.user.id,
                mood_group=None
            )
     
            # 현재 속한 그룹 리스트 가져옴
            mood_group_ids = list(UserMoodGroup.objects.filter(
                user_id=self.user.id
            ).values_list('mood_group_id', flat=True))
     
            # 내가속한 그룹에 기분을 저장
            for mood_group_id in mood_group_ids:
                mood_group_create_list.append(
                    UserMood(
                        do_show_summary=True if mood_group_id in self.show_summary_group_ids else False,
                        mood_group_id=mood_group_id,
                        user_id=self.user.id,
                        mood_id=new_mood.id
                    )
                )
     
            if mood_group_create_list:
                UserMood.objects.bulk_create(mood_group_create_list)
     
            return MoodSerializer(instance=new_mood).data
     
        def check_today_mood_limit(self):
            today = timezone.now()
     
            user_mood_count = UserMood.objects.filter(
                user_id=self.user.id,
                created__date=today.date(),
                mood_group=None
            ).count()
     
            if user_mood_count > MOOD_LIMITED_COUNT:
                raise LimitTodayMood
     
    cs

    정확히 refectoring 전 view에 있던 비즈니스 로직만 툭 떼서 MoodManageService()에 함수로 재정의 했습니다. 이렇게 Service Layer로 비즈니스 로직을 분리하게 되면 API에서 무슨 동작을 하는지 한눈에 알 수 있고, 그 동작에 대한 로직을 알고 싶을 때는 service에서 확인하면 됩니다.

     

    Test Case를 만들 때도 Service Layer로 분리하기 전에는 API 호출에 대한 응답만 테스트할 수 있었습니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    @pytest.mark.urls(urls='urls')
    @pytest.mark.django_db
    @pytest.mark.parametrize(
        'mood_status',
        [Mood.BAD, Mood.GOOD, Mood.BEST, Mood.MOPE, Mood.SOSO, Mood.WORST]
    )
    def test_today_mood_create(rf, client, user_context, mood_status, mock_is_authenticated):
        user = user_context.init.create_user()
     
        # 그룹 생성
        mood_group, user_mood_group = user_context.init.create_groups(
            user=user,
            title='5boon',
            summary='5boon 팀원들과의 기분 공유'
        )
     
        data = {
            "status": mood_status,
            "simple_summary""테스트 기분",
            "group_list": [mood_group.id]
        }
     
        url = reverse(viewname="moods:today_mood")
        response = pytest_request(rf,
                                  method='post',
                                  url=url,
                                  user=user,
                                  data=data)
     
        assert response.status_code == status.HTTP_201_CREATED
        assert list(response.data.keys()) == MOOD_FIELDS_LIST
     
    cs

    위 테스트 코드가 refectiong 전 API를 테스트하는 코드인데, 실제로 비즈니스 로직을 테스트한다기보다 API 호출을 테스트하는 케이스라고 볼 수 있습니다. 물론, 실제 저장된 data를 체크하고 response 객체를 확인해보면 내부적인 비즈니스 로직이 동작 한지는 알 수 있겠지만 뭔가 딱 그 비즈니스 로직을 테스트한다고 보기는 어려운 것 같습니다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
     
    @dataclass
    class TestRequest:
        user: User
        data: dict
     
        def __init__(self, user, data):
            self.user = user
            self.data = data
     
     
    @pytest.mark.django_db
    @pytest.mark.parametrize(
        'mood_status',
        [Mood.BAD, Mood.GOOD, Mood.BEST, Mood.MOPE, Mood.SOSO, Mood.WORST]
    )
    def test_update_my_mood(rf, client, user_context, mock_is_authenticated, mood_status):
        user = user_context.init.create_user()
     
        request = TestRequest(user, {'group_list': []})
        mood_manage_service = MoodManageService(request)
        mood = MoodDto(
            status=mood_status,
            simple_summary='test',
        )
     
        data = mood_manage_service.update_my_mood(mood)
        assert data.keys() == UPDATE_MY_MOOD_DATA
     
    cs

    위 코드는 Serive Layer로 분리한 비즈니스 로직을 테스트하는 테스트 케이스입니다. 이렇게 Service Layer로 비즈니스 로직을 분리하게 되면 Unit Test를 좀 더 쉽게 할 수 있습니다.

     

    3. Service Layer가 그래서 최고인가? 👍


    Service Layer 최고에요! 최고에요?

    꼭 그렇다고 볼 수는 없는 것 같습니다. 정말 간단한 프로젝트나 MSA(Microservices Architecture) 혹은 진짜 겁나 복잡한 비즈니스 로직은 Serivce Layer로 분리하기보다는 View, Serializer, Form, Model(Fat Model)등을 통해서 비즈니스 로직을 구현하는 것도 좋은 방법이라고 생각합니다.

     

    다만, 이 글을 통해서 Service Layer를 알아본 이유는 Django의 MVC패턴 안에서 비즈니스 로직을 어떻게 분리하고 효율적으로  사용할 수 있는지에 대해 알아보기 위함이었습니다.

     

    그리고 개인적을 DTO 까지는 조금 오버인 것 같기도 하고... 그냥 serializer.save() 하고 그 데이터를 넘겨주는 게 좀 더 나아 보이기도 합니다. (이건 예제가 좀 그런 것 같기도 하고..) 사실 DTO라는 개념을 Django에서 사용하는 게 좋은 방법인지는 잘 모르겠습니다.😅

     

    4. 마치며


    개인적으로 실제 토이 프로젝트에서 사용했던 코드들을 Service Layerrefectoring 하면서 비즈니스 로직을 분리하는 과정과 그 필요성에 대해 알 수 있었던 것 같습니다.

     

    여러분도 한번 Service Layer로 비즈니스 로직을 분리하는 작업을 해보시는 게 어떨까요? 나름 재밌습니다 ㅋㅋ 그럼 오늘 하루도 즐거운 하루 보내시길 바라요~:D

     

    * 참고

    breadcrumbscollector.tech/how-to-implement-a-service-layer-in-django-rest-framework/

    댓글

운동하는 개발자 JAY-JI