본문 바로가기
프로그래밍/Django

Django에서 비즈니스 로직 분리하기: 유지보수 가능한 코드의 비결

by 우주를놀라게하자 2025. 4. 13.
반응형
SMALL

 

"모든 개발의 첫 걸음은 아름다운 View 함수로 시작되지만, 결국에는 카오스가 된다" - 어느 지친 Django 개발자

 

Django 프로젝트를 개발하다 보면, View 함수가 점점 커지고 복잡해지는 현상을 경험해 보셨을 겁니다. 처음에는 10줄이었던 함수가 어느새 100줄이 되어있고, 비즈니스 로직, 데이터베이스 로직, 응답 처리 로직이 모두 한 군데 뒤섞여 있습니다. 이런 코드는 테스트하기 어렵고, 디버깅은 더 어렵습니다. 그리고 새로운 개발자가 이 코드를 마주했을 때? 그들의 눈에서 희망의 빛이 사라지는 것이 보일 겁니다.😭😭

(참고로 저또한 그랬습니다...)

이 글에서는 Django 애플리케이션에서 비즈니스 로직을 효과적으로 분리하는 방법과 그 이점에 대해 알아보겠습니다. 코드의 관심사를 명확히 분리하여, 더 깔끔하고, 테스트하기 쉽고, 유지보수하기 쉬운 애플리케이션을 만드는 여정을 함께 시작해봅시다.

 

아래 예시 코드는 모드 아래 GitHub 주소에서 볼 수 있어요!!ㅎㅎ

 

GITHUB


배경지식

비즈니스 로직 분리를 논하기 전에, Django의 MVT(Model-View-Template) 패턴을 잠시 상기해보겠습니다:

Model: 데이터 구조와 데이터베이스 상호작용을 담당
View: HTTP 요청을 처리하고 응답을 반환
Template: 사용자에게 보여지는 HTML 렌더링을 담당
그런데 이 구조에서는 "비즈니스 로직"의 위치가 명확하지 않습니다. 많은 Django 개발자들이 View에 모든 로직을 넣는 실수를 하게 됩니다. 이는 마치 주방에서 모든 요리 도구를 한 서랍에 넣는 것과 같습니다. 언젠가는 그 서랍을 열 때마다 아비규환이 펼쳐질 것입니다.

 

그렇다면 대체 비즈니스 로직이 뭘까요?


비즈니스 로직이란 무엇인가?

 

비즈니스 로직은 애플리케이션의 핵심 규칙과 계산을 담당하는 코드입니다. 이는 DB와 직접적인 관련이 없는 순수한 작업들을 포함합니다:

 

  • 포인트 계산 알고리즘
  • 할인율 계산
  • 조건에 따른 값 검증 및 변환
  • 복잡한 정렬 및 필터링 로직
  • 외부 API 호출 및 데이터 처리
  • 비즈니스 규칙에 따른 데이터 검증

💡 팁: 만약 그 코드가 데이터베이스 쿼리를 포함하지 않으면서, 애플리케이션의 핵심 규칙을 구현한다면, 그것은 아마도 비즈니스 로직입니다!

 


비즈니스 로직 분리의 장점

비즈니스 로직을 분리하는 것은 단순히 코드를 깔끔하게 정리하는 것 이상의 가치가 있습니다:

테스트 용이성: 독립적인 비즈니스 로직은 단위 테스트가 훨씬 쉽습니다. DB나 HTTP 요청 없이도 로직을 테스트할 수 있습니다.
재사용성: 분리된 비즈니스 로직은 다른 View나 심지어 다른 애플리케이션에서도 쉽게 재사용할 수 있습니다.
유지보수성: 코드의 역할이 명확히 분리되어 있어서 변경이 필요할 때 영향 범위를 예측하기 쉽습니다.
가독성: 각 코드 블록의 책임이 명확해져 새로운 개발자도 코드를 쉽게 이해할 수 있습니다.


Django 비즈니스 로직구성 4가지 방법

실무에서 Django를 직접 다루기 전까진 View에 비즈니스 로직을 넣는게 전부라고만 생각했습니다.

하지만 실제 실무에서 Django를 다루다보면 MTV 패턴 안에서 다양하게 비즈니스 로직을 처리할 수 있고 각 서버의 성격에 따라서 처리하는 방법이 다름을 알 수 있었습니다.

이 장에선 제가 실무에서 적용했던 방법들을 정리하려고 합니다.

아래는 비즈니스 로직을 처리하는 대표적인 방법 4가지입니다.

Fat Models 모델 내부에서 비즈니스 로직을 처리합니다.
View + Form + Serializer 일반적으로 생각하는 방식으로 모든 비즈니스 로직을 View에서 처리합니다.
Services Layer 비즈니스 로직을 처리할 service.py를 생성하여 처리합니다.
QuerySets + Manager ORM을 통해서 비즈니스 로직을 처리합니다.

Fat Models란?

 

Fat model이란 말 그대로 모델에 비즈니스 로직을 넣어 크게 만든다는 뜻입니다.

MVC의 기본 설계 패턴은 Fat Model, Thin Controller이다. Model에 기능을 모아두고, Controller은 중개역할만 하는거다.

이 경우, 코드의 중복이 줄어들고, 객체지향적으로 코드를 설계하기 편하다는 장점이 있다.

코드를 보면서 이해해봅시다.

# products/views.py (기본 골격)
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework import viewsets
from rest_framework.serializers import Serializer
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST
from rest_framework.exceptions import ValidationError

from api_server.api.versioned.v1.products.serializers import ProductSerializer
from products.models import Product


class ProductsViewSet(viewsets.ReadOnlyModelViewSet):
    permission_classes = [AllowAny, ]
    serializer_class = ProductSerializer
    queryset = Product.objects.all()

    @extend_schema(
        parameters=[
            OpenApiParameter(name='discount_code', description='할인 코드', required=True, type=str)
        ]
    )
    def fatmodel(self, request, *args, **kwargs):
        discount_code = request.query_params.get('discount_code')
        try:
            # 모델 메서드 호출
            discounted_products = Product.get_discounted_products(discount_code)
            return Response(discounted_products, status=HTTP_200_OK)

        except ValidationError as e:
            return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST)
# products/models.py (Fat Model 접근법)
# products/models.py (Fat Model 패턴)
from django.db import models
from django.core.exceptions import ValidationError
from decimal import Decimal


class DiscountCode(models.Model):
    code = models.CharField(max_length=20, unique=True)
    percentage = models.DecimalField(max_digits=5, decimal_places=2)
    is_active = models.BooleanField(default=True)
    expires_at = models.DateTimeField(null=True, blank=True)

    def __str__(self):
        return self.code


class Product(models.Model):
    name = models.CharField(max_length=100)
    description = models.TextField()
    price = models.DecimalField(max_digits=10, decimal_places=2)
    stock = models.PositiveIntegerField(default=0)

    def __str__(self):
        return self.name

    def apply_discount(self, discount_code_str):
        """제품에 할인 적용 (Fat Model 패턴)"""
        try:
            discount = DiscountCode.objects.get(code=discount_code_str, is_active=True)

            # 할인 코드 유효성 검증
            if discount.expires_at and timezone.now() > discount.expires_at:
                raise ValidationError("할인 코드가 만료되었습니다.")

            # 할인 계산
            discount_amount = self.price * (discount.percentage / Decimal('100'))
            discounted_price = self.price - discount_amount

            return {
                'product': self,
                'original_price': self.price,
                'discount_code': discount_code_str,
                'discount_percentage': discount.percentage,
                'discounted_price': discounted_price
            }

        except DiscountCode.DoesNotExist:
            raise ValidationError("유효하지 않은 할인 코드입니다.")

    @classmethod
    def get_discounted_products(cls, discount_code_str):
        """모든 제품에 할인 적용 (Fat Model 패턴)"""
        try:
            # 할인 코드 유효성 검증
            discount = DiscountCode.objects.get(code=discount_code_str, is_active=True)
            if discount.expires_at and timezone.now() > discount.expires_at:
                raise ValidationError("할인 코드가 만료되었습니다.")

            # 모든 제품에 할인 적용
            products = cls.objects.all()
            result = []

            for product in products:
                discount_amount = product.price * (discount.percentage / Decimal('100'))
                discounted_price = product.price - discount_amount

                result.append({
                    'id': product.id,
                    'name': product.name,
                    'original_price': product.price,
                    'discounted_price': discounted_price
                })

            return result

        except DiscountCode.DoesNotExist:
            raise ValidationError("유효하지 않은 할인 코드입니다.")

 

Fat Model 패턴의 장점:

  • 로직이 모델과 가까이 있어 응집성이 높습니다
  • 뷰가 간단해집니다
  • Django의 철학과 일치합니다

Fat Model 패턴의 단점:

  • 모델이 너무 커지고 복잡해질 수 있습니다
  • 여러 모델에 걸친 로직 처리가 어려울 수 있습니다
  • 테스트가 복잡해질 수 있습니다

Serializer 패턴 (DRF 활용)

 

같은 로직을 한번 Serializer에서 처리해볼게요!

 

    @extend_schema(
        parameters=[
            OpenApiParameter(name='discount_code', description='할인 코드', required=True, type=str)
        ]
    )
    def serializer_pattern(self, request, *args, **kwargs):
        """Serializer 패턴을 사용한 할인 로직"""
        discount_code = request.query_params.get('discount_code')

        if not discount_code:
            return Response(
                {"error": "할인 코드를 제공해주세요."},
                status=HTTP_400_BAD_REQUEST
            )

        from rest_framework import serializers
        try:
            products = self.get_queryset()
            serializer = DiscountedProductSerializer(
                products,
                many=True,
                context={'discount_code': discount_code}
            )
            return Response(serializer.data, status=HTTP_200_OK)

        except serializers.ValidationError as e:
            return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST)

 

class DiscountedProductSerializer(serializers.ModelSerializer):
    original_price = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True)
    discounted_price = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True)

    class Meta:
        model = Product
        fields = ['id', 'name', 'original_price', 'discounted_price']

    def to_representation(self, instance):
        """직렬화 중 할인 적용"""
        data = super().to_representation(instance)

        # context에서 할인 코드 가져오기
        discount_code_str = self.context.get('discount_code')

        if not discount_code_str:
            return data

        try:
            # 할인 코드 유효성 검증
            discount = DiscountCode.objects.get(code=discount_code_str, is_active=True)

            from django.utils import timezone
            if discount.expires_at and timezone.now() > discount.expires_at:
                raise serializers.ValidationError("할인 코드가 만료되었습니다.")

            # 할인 계산
            original_price = instance.price
            discount_amount = original_price * (discount.percentage / Decimal('100'))
            discounted_price = original_price - discount_amount

            # 결과 업데이트
            data['original_price'] = str(original_price)
            data['discounted_price'] = str(discounted_price)

            return data

        except DiscountCode.DoesNotExist:
            raise serializers.ValidationError("유효하지 않은 할인 코드입니다.")

 

Serializer 패턴의 장점:

  • DRF와 잘 통합됩니다
  • 입력 유효성 검증과 출력 형식을 함께 처리할 수 있습니다
  • API 응답을 직접 제어할 수 있습니다

Serializer 패턴의 단점:

  • DRF에 의존적입니다
  • 복잡한 비즈니스 로직은 Serializer를 과도하게 복잡하게 만들 수 있습니다
  • 모델과 Serializer 간에 책임 경계가 모호해질 수 있습니다

Services Layer 패턴

 

같은 로직을 한번 Services Layer를 생성해서 처리해볼게요!

# products/services.py (Services Layer 패턴)
from django.utils import timezone
from django.core.exceptions import ValidationError
from decimal import Decimal

from products.models import Product, DiscountCode

class DiscountService:
    @staticmethod
    def validate_discount_code(discount_code_str):
        """할인 코드 유효성 검증"""
        try:
            discount = DiscountCode.objects.get(code=discount_code_str, is_active=True)
            
            if discount.expires_at and timezone.now() > discount.expires_at:
                raise ValidationError("할인 코드가 만료되었습니다.")
            
            return discount
        
        except DiscountCode.DoesNotExist:
            raise ValidationError("유효하지 않은 할인 코드입니다.")
    
    @staticmethod
    def calculate_discount(product, discount_percentage):
        """제품 할인 계산"""
        discount_amount = product.price * (discount_percentage / Decimal('100'))
        return product.price - discount_amount
    
    @staticmethod
    def get_discounted_products(discount_code_str):
        """모든 제품에 할인 적용"""
        # 할인 코드 유효성 검증
        discount = DiscountService.validate_discount_code(discount_code_str)
        
        # 모든 제품에 할인 적용
        products = Product.objects.all()
        result = []
        
        for product in products:
            discounted_price = DiscountService.calculate_discount(
                product, discount.percentage
            )
            
            result.append({
                'id': product.id,
                'name': product.name,
                'original_price': product.price,
                'discounted_price': discounted_price
            })
        
        return result

 

    @extend_schema(
        parameters=[
            OpenApiParameter(name='discount_code', description='할인 코드', required=True, type=str)
        ]
    )
    def service_pattern(self, request, *args, **kwargs):
        """Service Layer 패턴을 사용한 할인 로직"""
        discount_code = request.query_params.get('discount_code')

        if not discount_code:
            return Response(
                {"error": "할인 코드를 제공해주세요."},
                status=HTTP_400_BAD_REQUEST
            )

        try:
            # 서비스 레이어 호출
            discounted_products = DiscountService.get_discounted_products(discount_code)
            return Response(discounted_products, status=HTTP_200_OK)

        except ValidationError as e:
            return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST)

 

Services Layer 패턴의 장점:

  • 비즈니스 로직이 명확하게 분리됩니다
  • 여러 모델에 걸친 복잡한 비즈니스 로직을 처리하기 쉽습니다
  • 단위 테스트가 용이합니다
  • 재사용성이 높습니다


Services Layer 패턴의 단점:

  • 추가적인 코드 레이어가 생깁니다
  • 어디까지 서비스 레이어에 두어야 할지 경계 설정이 어려울 수 있습니다

QuerySet + Manager 패턴

Django의 QuerySet과 Manager를 활용하여 비즈니스 로직을 구현하는 방법입니다.

 

    def queryset_pattern(self, request, *args, **kwargs):
        """QuerySet + Manager 패턴을 사용한 할인 로직"""
        discount_code = request.query_params.get('discount_code')

        if not discount_code:
            return Response(
                {"error": "할인 코드를 제공해주세요."},
                status=HTTP_400_BAD_REQUEST
            )

        try:
            # Manager 메서드 호출
            products_with_discount = Product.objects.with_discount(discount_code)

            # 직접 응답 형식 구성
            result = [{
                'id': product.id,
                'name': product.name,
                'original_price': product.price,
                'discounted_price': product.discounted_price
            } for product in products_with_discount]

            return Response(result, status=HTTP_200_OK)

        except ValidationError as e:
            return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST)

 

class DiscountCode(models.Model):
    code = models.CharField(max_length=20, unique=True)
    percentage = models.DecimalField(max_digits=5, decimal_places=2)
    is_active = models.BooleanField(default=True)
    expires_at = models.DateTimeField(null=True, blank=True)

    def __str__(self):
        return self.code


class ProductQuerySet(models.QuerySet):
    def with_discount(self, discount_code_str):
        """할인이 적용된 제품 쿼리셋 반환"""
        try:
            # 할인 코드 유효성 검증
            discount = DiscountCode.objects.get(code=discount_code_str, is_active=True)

            if discount.expires_at and timezone.now() > discount.expires_at:
                raise ValidationError("할인 코드가 만료되었습니다.")

            # 제품 목록을 가져온 후 각 제품에 할인 적용
            products = list(self)
            result = []

            for product in products:
                discount_amount = product.price * (discount.percentage / Decimal('100'))
                discounted_price = product.price - discount_amount

                # 원본 제품 객체에 속성 추가
                product.discounted_price = discounted_price
                product.discount_percentage = discount.percentage
                result.append(product)

            return result

        except DiscountCode.DoesNotExist:
            raise ValidationError("유효하지 않은 할인 코드입니다.")


class ProductManager(models.Manager):
    def get_queryset(self):
        return ProductQuerySet(self.model, using=self._db)

    def with_discount(self, discount_code_str):
        """할인이 적용된 제품 목록 반환"""
        return self.get_queryset().with_discount(discount_code_str)


class Product(models.Model):
    name = models.CharField(max_length=100)
    description = models.TextField()
    price = models.DecimalField(max_digits=10, decimal_places=2)
    stock = models.PositiveIntegerField(default=0)

    # 기본 매니저 교체
    objects = ProductManager()

    def __str__(self):
        return self.name

 

QuerySet + Manager 패턴의 장점:

  • Django의 QuerySet 시스템과 자연스럽게 통합됩니다
  • 쿼리 최적화가 용이합니다
  • 비즈니스 로직이 데이터 접근과 함께 캡슐화됩니다

QuerySet + Manager 패턴의 단점:

  • 복잡한 비즈니스 로직에는 적합하지 않을 수 있습니다
  • 데이터베이스 쿼리와 관련 없는 로직은 어색하게 느껴질 수 있습니다

🚨 주의사항 & 한계점


각 패턴을 사용할 때 주의해야 할 점들:

Fat Model 패턴

  • 모델이 너무 비대해지면 유지보수가 어려워집니다

Serializer 패턴

  • API 중심 애플리케이션에만 적합합니다

Services Layer 패턴

  • 과도한 추상화로 코드 이해가 어려워질 수 있습니다
  • 서비스 간 의존성 관리가 복잡해질 수 있습니다

QuerySet + Manager 패턴

  • 데이터베이스 액세스와 관련 없는 로직에는 부적합합니다
  • QuerySet 체인이 복잡해지면 이해하기 어려울 수 있습니다

마무리 


이 글에서는 Django에서 비즈니스 로직을 구현하는 4가지 주요 패턴을 살펴보았습니다. 각 패턴은 각자의 장단점이 있습니다. 그렇다면 어떤 패턴을 활용해야할까요?

 

프로젝트의 특성과 요구사항에 따라 적절한 패턴을 선택해야 합니다.

어떤 패턴을 선택해야 할까?
소규모 프로젝트: Fat Model이나 Serializer 패턴이 간단하고 빠르게 구현할 수 있습니다.
중규모 프로젝트: QuerySet + Manager 패턴이 데이터 액세스와 비즈니스 로직을 적절히 분리하면서 Django의 패턴을 따릅니다.
대규모 프로젝트: Services Layer 패턴이 확장성과 유지보수성을 제공합니다.


가장 중요한 것은 프로젝트 전체에서 일관된 패턴을 사용하는 것입니다. 여러 패턴을 혼합하면 코드 베이스가 혼란스러워지고 유지보수가 어려워집니다.

반응형
LIST