Search

Where to put business logic?

Intro

Django와 DRF로 프로젝트를 하다보면 business logic을 어디에 둘지 고민하게 됩니다.
공식 문서에 따르면 objects에 대한 row-level 기능 추가를 위해서는 모델에 method를 정의하라고 합니다.
Define custom methods on a model to add custom “row-level” functionality to your objects. ... This is a valuable technique for keeping business logic in one place – the model. — Docs
공식 문서에서 제안하는 방법이 널리 사용되는 Fat Models Skinny Views 패턴입니다.
하지만 Fat Models 패턴에는 몇 가지 두드러지는 단점이 있습니다.
프로젝트가 커질수록 모델은 Fat을 넘어 아주 거대한 코드 덩어리가 됩니다.
모델이 크고 중요할수록 많은 method를 가질 가능성이 높습니다.
Django 모델의 가장 중요한 역할은 코드베이스와 데이터베이스를 연결하는 것입니다.
그런데 모델에서 코드베이스의 핵심인 business logic까지 관리하게 되면 모델의 책임이 너무 커집니다.
또 다음과 같은 상황에서는 어떤 모델에서 해당 logic을 관리할지 굉장히 모호합니다:
1) 다수의 모델과 연관된 경우
2) 어떤 모델과도 연관되지 않은 경우
이번 글에서는 어떻게 business logic을 관리하는 것이 좋을지 얘기해보겠습니다.
그리고 오픈소스 패키지인 DRF-Service-Layer을 만들게 된 배경에 대해서 설명하겠습니다.

Where to put business logic?

Django View(a.k.a. Controller)

DRF에서 request와 response가 처리되는 흐름은 다음과 같습니다:
각 요소들의 역할에 대해서 되짚어봅시다:
1.
Serializer
요청과 응답의 데이터 입출력 과정에서 데이터 포맷을 변환합니다.
예를 들어 DRF에서는 POST 요청 body의 JSON 데이터를 Python object로 변환합니다.
2.
View
요청과 응답의 대부분 과정을 통제하며 중요한 로직을 처리합니다.
3.
Model
코드베이스와 데이터베이스를 연결하며 ORM을 지원합니다.
View는 model을 통해 데이터베이스에 접근합니다.
4.
Database
실제 데이터를 관리하는 저장소입니다.
View와 달리 serializer, model, database의 테두리에 점선 표시를 한 이유는 해당 요소들이 요청과 응답의 처리에 관여하지 않을 수 있기 때문입니다.
예를 들어 어떤 요청은 serializer를 거치지 않고 응답할 수도 있고, 또 어떤 요청은 데이터베이스에 접근하지 않을 수도 있습니다.
요청과 응답을 처리하는데 반드시 필요한 것은 오직 view 뿐입니다.
Django는 독자적으로 MTV 패턴을 따르기 때문에 요청과 응답을 처리하는 요소를 view라고 부르지만 MVC 패턴에 대응되는 view의 실제 이름은 Controller입니다.
우리는 controller라는 이름에 주목할 필요가 있습니다.
Controller를 말 그대로 데이트의 흐름과 로직을 통제(control)합니다.
요청을 어떻게 처리할 지 결정하고, 각 요소 사이의 데이터를 전달하는 등 요청과 응답의 모든 것을 통제합니다.
View에서 요청과 응답에 필요한 대부분의 작업을 처리할 수 있고, 또 view에서 처리하는 것이 쉽습니다.
그렇기 때문에 쉽게 범하는 실수가 모든 코드를 view에 추가하는 것입니다.
하지만 view가 커질수록 유지보수가 힘들고 중복되는 코드가 많아지며 가독성이 떨어집니다.
확장성이 낮은 구조이기 때문에 대안으로 Fat Models Skinny Views 패턴이 많이 채택되고 있습니다.
다만 앞서 얘기했듯이 Fat Models Skinny Views 패턴 역시 완벽한 해결책이 되지 못합니다.

Service layer

Business logic을 올바르게 관리하기 위해서는 별도의 layer가 필요합니다.
그리고 해당 계층 역시 view의 통제를 받아 데이터를 처리하고 로직을 수행할 것입니다.
일반적으로 이런 계층을 service layer라고 부릅니다.
Django의 아키텍쳐 패턴에 service layer를 추가하면 다음과 같습니다:
Service layer를 분리했을 때의 가장 큰 장점은 view가 가벼워진다는 것입니다.
Business logic을 service에서 대신 처리하기 때문에 view은 본래의 역할에 집중할 수 있습니다.
이는 view를 유지보수 할 때 큰 장점이 됩니다.
Service layer를 구현하는 가장 쉬운 방법은 모듈로 분리하여 service class를 만들고 method로 business logic을 구현하는 것입니다.
# services.py class FooService: def any_business_method(self): # busienss logic return ...
Python
복사
그리고 이를 view 안에서 호출하면 됩니다.
# views.py class FooView(generics.GenericAPIView): def get(self, request, *args, **kargs): # ... FooService().any_business_method() return Response(...)
Python
복사
View와 service layer가 간단하게 분리되었습니다.
하지만 위처럼 단조로운 구현으로 프로젝트를 진행하면 불편한 점이 몇 가지 있습니다.
먼저 함수에 필요한 arguments가 많은 경우 이를 일일이 전달해야 합니다.
def get(self, request, *args, **kargs): # ... FooService().any_business_method( request.user, kwagrs["pk"], request.query_params.get("query"), request.META.get("HTTP_X_CUSTOMHEADER"), ) return Response(...)
Python
복사
그리고 각 함수마다 arguments가 중복되는 경우가 자주 발생합니다.
def get(self, request, *args, **kargs): # ... foo_service = FooService() foo_service.any_business_method( request.user, kwagrs["pk"], request.query_params.get("query"), request.META.get("HTTP_X_CUSTOMHEADER"), ) foo_service.any_business_method_2( request.user, kwagrs["pk"], request.query_params.get("query"), request.META.get("HTTP_X_CUSTOMHEADER"), ) return Response(...)
Python
복사
불편함을 줄이기 위해 request를 service에 전달하여 직접 사용하게 할 수도 있습니다.
하지만 이는 service가 view의 역할까지 대신할 수 있기 때문에 view와 service의 역할 경계가 모호해집니다.
Service에 필요한 데이터를 view로부터 효율적으로 넘겨받을 수 있는 대안이 필요합니다.

DTO; Data Transfer Object

이를 위해 view에서 DTO를 만들어 service layer에 전달하겠습니다.
DTO는 dataclass를 이용하면 쉽게 구현할 수 있습니다:
from dataclasses import dataclass from typing import Optional @dataclass Class FooDTO: user: User pk: int query: Optional[str] = None header_data: Optional[str] = None
Python
복사
이를 View에 적용하면 다음과 같습니다:
def get(self, request, *args, **kargs): # ... dto = FooDTO( user=request.user, pk=kwagrs["pk"], query=request.query_params.get("query"), header_data=request.META.get("HTTP_X_CUSTOMHEADER"), ) foo_service = FooService() foo_service.any_business_method(dto) foo_service.any_business_method_2(dto) return Response(...)
Python
복사
하지만 아직도 아쉬운 부분이 있습니다.
위의 코드를 보면 get() 안에서 dto를 만들어서 service layer에 전달하고 있는데, dto를 생성하는 것 자체는 요청-응답 로직과 큰 연관이 없습니다.
이를 view의 메인 로직과 따로 분리하고 view가 동작할 때 자동으로 service layer에 dto가 전달되면 훨씬 좋을 것입니다.
또 serializer가 validation 과정에서 business logic을 수행하는 경우가 있습니다.
따라서 serializer에서도 service layer에 접근할 수 있어야합니다.
이런 모든 고민을 담아 결국 service layer 구현을 돕는 패키지를 만들었습니다.

DRF-Service-Layer

Usage

문제 해결의 요구조건은 다음 두 가지입니다:
1.
DTO를 생성하는 코드를 view의 메인 로직과 분리한다.
2.
Serializer에서도 service layer에 대한 접근을 쉽게 할 수 있어야한다.
패키지의 사용 방법을 알아보며 위 문제들을 어떻게 해결하였는지에 대해 얘기하겠습니다.
먼저 다음과 같이 DTO를 선언해줍니다:
# services.py from dataclasses import dataclass from typing import Optional @dataclass class OrderDTO: user_id: int order_id: int sort: Optional[str] = None
Python
복사
그리고 DRF-Service-Layer의 GenericServiceAPIView를 상속받아 view를 만들어줍니다.
그 다음 View 안에 dto라는 property를 만들어 service layer에 전달할 DTO를 구현합니다:
# views.py from drf_service_layer.views import GenericServiceAPIView class OrderAPIView(GenericServiceAPIView): @property def dto(self) -> OrderDTO: return OrderDTO( user_id=self.request.user.id, order_id=self.kwagrs['order_id'], sort=self.request.query_params.get("sort"), )
Python
복사
이제 service class를 구현합니다:
# services.py from drf_service_layer.services import Service class OrderService(Service): def any_business_method(self): self.dto: OrderDTO user_id = self.dto.user_id order_id = self.dto.order_id sort = self.dto.sort # business logic goes here.
Python
복사
Service class 안에서는 self.dto를 통해 view에서 전달받은 DTO를 사용할 수 있습니다.
생성한 service layer를 view에 연결하겠습니다.
Service layer를 연결하는 방법은 view에 serializer_class를 정의하듯이 service_class를 정의하면 됩니다:
# views.py class OrderAPIView(GenericServiceAPIView): service_class = OrderService # new @property def dto(self) -> OrderDTO: # ...
Python
복사
이제 view에서 다음과 같이 self.service를 통해 service의 메소드를 호출할 수 있습니다:
# views.py class OrderAPIView(GenericServiceAPIView): service_class = OrderService @property def dto(self) -> OrderDTO: # ... def get(self, request, *args, **kwargs): # new # ... self.service.any_business_method() return Response(...)
Python
복사
그리고 이 때 serializer_class에 정의한 serializer에도 context로 service가 전달됩니다.
따라서 serializer 안에서 다음과 같이 service를 사용할 수 있습니다:
# serializers.py class FooSerializer(serializers.ModelSerializer): # ... def get_foo(self, foo): # ... self.context["service"].any_business_method() return ...
Python
복사
만약 serializer에서도 self.service를 통해 service를 사용하고 싶다면, @service_layer 데코레이터를 붙여주면 됩니다:
# serializers.py from drf_service_layer.services import service_layer @service_layer(OrderService, OrderDTO) class FooSerializer(serializers.ModelSerializer): # ... def get_foo(self, foo): # ... self.service.any_business_method() return ...
Python
복사
@service_layer 데코레이터를 붙였을 때 또 다른 장점은 명시적으로 해당 serializer에서 사용하고 있는 service와 DTO를 나타낼 수 있다는 점입니다.

Explanation

정리하자면 service를 만들어 view 안에 service_class로 선언하면 view와 service layer가 쉽게 연결됩니다.
또 serializer를 사용하고 있다면 serializer의 context로 service layer가 전달됩니다.
그리고 dto property를 선언해주면 view가 instantiate 되는 과정에서 연결된 service layer에 자동으로 DTO를 전달합니다.
View의 메인 로직에서 직접 DTO 생성해서 전달할 필요가 없습니다.
Service layer를 분리했을 때 좋은 점은 view와 business logic의 결합도(coupling)가 낮아집니다.
따라서 view와 service layer의 재사용성이 늘어나고 유연하게 확장이 가능합니다.
또 각 layer 별 책임과 역할이 명확해집니다.
View의 역할이 한정되기 때문에 가독성이 좋아지고 유지보수가 쉬워지며 전체적인 생산성이 증가합니다.
다만 초기에 service layer와 dto의 개념 및 구조를 익히는 것이 어려울 수도 있습니다.

Outro

Business logic을 어디에 둘 것인지에 대한 얘기부터 직접 만든 패키지에 대한 소개까지 해보았습니다.
아직 만든지 얼마되지 않은 패키지이기 때문에 여러가지 부족한 점이 있을 수 있습니다.
따라서 많은 django 개발자들의 관심과 조언이 필요합니다.
DRF-Service-Layer의 구현은 매우 단순하게 이루어져 있습니다.
DRF의 life cycle만 알고 있다면 누구나 쉽게 이해할 수 있습니다.
적극적인 기여와 피드백은 언제든지 환영합니다.