본문 바로가기
개발/웹

Django 커머스 보일러플레이트 - (2) 장고/DRF 모델, 시리얼라이저, 뷰

by pandatta 2023. 5. 1.

Django 커머스 보일러플레이트 시리즈

(1) 프로젝트 요구사항과 다이어그램 모델링
(2) 장고/DRF 모델, 시리얼라이저, 뷰
(3) 유닛 테스트, drf-yasg API 문서화
(4) Nginx 웹 서버와 도커 컴포즈, AWS ECS 배포
(5) GitHub Actions CI/CD

안녕하세요, 판다타입니다.

한동안 파이썬 웹 프레임워크 삼대장 플라스크(Flask), 장고(Django), 패스트API(FastAPI) 중, 회사에서는 사용하지 않는 장고를 이용해 e-커머스(e-commerce) 제작에 사용할 수 있는 보일러플레이트(boilerplate)를 만들어보았습니다. 진행한지는 꽤 되어서 한꺼번에 몰아서 작성하다보니 자세한 설명을 하기는 어렵고... 여러분의 장고 프로젝트 작성에 도움이 될까 하여 간단하게 몇 가지를 정리해보았습니다. 자세한 소스코드는 Djarf GitHub 저장소를 참고해주시고, 궁금한 점은 댓글이나 메일로 문의해주세요.

프로젝트 구성 (DRF 공식 튜토리얼 참고)

# 가상환경 생성
cd djarf  # djarf = 프로젝트 이름
python -m venv venv
source venv/bin/activate

# 라이브러리 설치
python -m pip install --upgrade pip
python -m pip install django djangorestframework drf-yasg gunicorn mysqlclient

# 프로젝트와 앱 시작
django-admin startproject djarf
python manage.py startapp common  # 프로젝트 전체 사용자 관리를 위한 공통 앱
python manage.py startapp commerce  # 커머스 앱

프로젝트 전체 세팅 (장고 공식 로깅 문서 참고)

# djarf/settings.py
...
SECRET_KEY = (
    "django-insecure-z*tyg8=y3sb4gw@pbsocjd^w_d)9lm_opsd*8vg3(gu!vpp)re"
)
DEBUG = True
ALLOWED_HOSTS = []
if os.environ.get("DJARF_PROD"):  # DJARF_PROD 환경변수가 있는 production 환경의 경우
    SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]  # 암호화 키를 환경변수에 숨김
    DEBUG = False  # Django DEBUG 모드를 해제해 에러 메시지를 숨김
    ALLOWED_HOSTS = ["*"]  # 모든 접속 IP를 대상으로 앱 접근을 허용
    STATIC_ROOT = os.path.join(BASE_DIR, "static")  # 정적 파일들에 대한 접근 허용(CORS)
...
INSTALLED_APPS = [
    ...
    "rest_framework",
    "drf_yasg",
    "common",
    "commerce",
]
...
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
    }
}
if os.environ.get("DJARF_PROD"):  # Production 환경의 경우
    DATABASES = {  # 데이터베이스로 AWS RDS에 있는 MySQL DB를 사용
        "default": {
            "ENGINE": "django.db.backends.mysql",
            "NAME": "djarf",
            "USER": "admin",
            "PASSWORD": os.environ["DB_PASSWORD"],  # 환경변수에 숨긴 접속 패스워드
            "HOST": os.environ["DB_HOST"],  # 환경변수에 숨긴 RDS 엔드포인트
            "PORT": "3306",
            "OPTIONS": {"init_command": "SET sql_mode='STRICT_TRANS_TABLES'"},
        }
    }
...
AUTH_USER_MODEL = "common.User"  # common 앱 User 모델을 기본 유저 모델로 사용
...
REST_FRAMEWORK = {  # DRF API 응답결과를 페이지네이션
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 10,
}
...
LOGGING = {  # 장고 기본 로깅
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "main": {
            "()": "django.utils.log.ServerFormatter",
            "format": (  # 로깅 포맷을 정의
                "{asctime}\t{levelname}\t{process}\t{thread}"
                "\t{module}\t{funcName}\t{message}"
            ),
            "style": "{",
        },
    },
    "handlers": {
        "console": {
            "level": "INFO",
            "class": "logging.StreamHandler",
            "formatter": "main",
        },
        "file": {  # 로그 결과를 저장
            "level": "INFO",
            "class": "logging.FileHandler",
            "formatter": "main",
            "filename": BASE_DIR / "logs" / f"{date.today()}.log",
        },
    },
    "loggers": {
        "django": {
            "handlers": ["console", "file"],
            "level": "INFO",
        },
        "commerce": {  # 앱에 따라 다른 로깅 형식 사용 가능
            "handlers": ["console", "file"],
            "level": "INFO",
        },
    },
}​

프로젝트 전체 URL 구성 (CORS 해소를 위한 static 경로 설정 글 by stresszero 참고)

# djarf/urls.py
from django.conf import settings
from django.contrib import admin
from django.urls import include, path, re_path
from django.views.static import serve
from rest_framework.permissions import AllowAny

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api-auth/", include("rest_framework.urls")),  # 로그인을 위한 DRF 기본 앱
    path("common/", include("common.urls")),
    path("commerce/", include("commerce.urls")),
    re_path(  # CORS 해소를 위해 media와 static이 저장되는 경로를 장고에게 알려줌
        r"^media/(?P<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT}
    ),
    re_path(
        r"^static/(?P<path>.*)$", serve, {"document_root": settings.STATIC_ROOT}
    ),
]

모델: 데이터베이스에 실제 저장되는 데이터

# common/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser

class AbstractModel(models.Model):  # 모든 모델에 생성시간과 수정시간을 기본으로 설정
    """Abstract model for all models inheritance."""
    created = models.DateTimeField(auto_now_add=True, db_index=True)
    updated = models.DateTimeField(auto_now=True, db_index=True)

    class Meta:
        abstract = True

class User(AbstractUser, AbstractModel):  # 장고 기본 유저 모델을 확장
    """User model for all applications."""

    class Meta:
        get_latest_by = "created"
        ordering = ["created"]​
# commerce/models.py
from django.db import models
from common.models import AbstractModel

class Cart(AbstractModel):
    customer = models.ForeignKey(  # common.User가 고객 외부키, User에서도 carts로 참고 가능
        "common.User", on_delete=models.CASCADE, related_name="carts"
    )
    product = models.ForeignKey(
        "Product", on_delete=models.CASCADE, related_name="carts"
    )
    quantity = models.PositiveSmallIntegerField(default=1)  # 고객이 상품을 몇 개 담았는지

    def __str__(self):  # 문서에 표시되는 형식
        return ", ".join(
            [str(self.customer), str(self.product), str(self.created)]
        )

    class Meta:
        get_latest_by = "created"
        ordering = ["created"]
        indexes = [models.Index(fields=["customer", "product"])]  # 검색 속도를 위한 인덱싱
        constraints = [  # 한 고객 당 한 상품에 대한 관계는 하나만 존재할 수 있도록 제약
            models.UniqueConstraint(
                fields=["customer", "product"],
                name="cart_unique_customer_product",
            )
        ]

class Category(AbstractModel):
    ...

class Order(AbstractModel):
    ...

class Order2Product(AbstractModel):
    ...

class Product(AbstractModel):
    vendor = models.ForeignKey(
        "common.User", on_delete=models.CASCADE, related_name="products"
    )
    category = models.ForeignKey(
        "Category", on_delete=models.CASCADE, related_name="products"
    )
    tags = models.ManyToManyField("Tag", related_name="products", blank=True)  # 여러 상품이 여러 태그를 가짐
    title = models.CharField(max_length=100)
    price = models.PositiveIntegerField(db_index=True)
    description = models.TextField(default="", blank=True)

    def __str__(self):
        return self.title

    class Meta:
        get_latest_by = "created"
        ordering = ["-created"]

class Review(AbstractModel):
    reviewer = models.ForeignKey(
        "common.User", on_delete=models.CASCADE, related_name="reviews"
    )
    product = models.ForeignKey(
        "Product", on_delete=models.CASCADE, related_name="reviews"
    )
    rating = models.FloatField(  # 1 ~ 5 사이에 0.5 간격으로 평점을 매길 수 있음
        db_index=True,
        choices=[(score / 2, score / 2) for score in range(1, 11)],
    )
    description = models.TextField(default="", blank=True)

    def __str__(self):
        return ", ".join(
            [str(self.reviewer), str(self.product), str(self.rating)]
        )

    class Meta:
        get_latest_by = "created"
        ordering = ["-created"]
        constraints = [
            models.UniqueConstraint(
                fields=["reviewer", "product"],
                name="review_unique_reviewer_product",
            )
        ]

class Tag(AbstractModel):
    ...

시리얼라이저: 데이터베이스에 저장되기 위해 정리해 보여줄 형식

# common/serializers.py
from rest_framework.serializers import HyperlinkedModelSerializer
from common.models import User

class UserAdminSerializer(HyperlinkedModelSerializer):  # 관리자를 위한 고객 정보
    class Meta:
        model = User
        fields = [
            "url",
            "id",
            "created",
            "updated",
            "username",
            "first_name",
            "last_name",
            "email",
            "password",
            # "groups",
            # "user_permissions",
            "is_staff",
            "is_active",
            "is_superuser",
            "last_login",
            "date_joined",
        ]
        read_only_fields = ["created", "updated", "last_login", "date_joined"]

    def create(self, validated_data: dict) -> User:
        """Creates User with hashed password."""
        user: User = super().create(validated_data)
        user.set_password(validated_data["password"])  # 패스워드 저장 시 해시값만 저장
        user.save()
        return user

    def update(self, instance: User, validated_data: dict) -> User:
        """Updates User with hashed password."""
        user: User = super().update(instance, validated_data)
        user.set_password(validated_data["password"])
        user.save()
        return user

class UserSerializer(UserAdminSerializer):  # 고객을 위해 공개된 고객 정보
    class Meta:
        model = User
        fields = [
            "url",
            "id",
            "created",
            "updated",
            "username",
            "first_name",
            "last_name",
            "email",
            "password",
            "last_login",
            "date_joined",
        ]
        read_only_fields = ["created", "updated", "last_login", "date_joined"]
        write_only_fields = ["password"]

 

# commerce/serializers.py
from django.db import transaction
from rest_framework.serializers import HyperlinkedModelSerializer, IntegerField
from commerce.models import *
from common.models import User

class UserSerializer(HyperlinkedModelSerializer):
    ...

class CartAdminSerializer(HyperlinkedModelSerializer):
    ...

class CartSerializer(CartAdminSerializer):
    ...

class CategorySerializer(HyperlinkedModelSerializer):
    ...

class OrderAdminSerializer(HyperlinkedModelSerializer):  # 관리자의 주문 정보 수정용
    class Meta:
        model = Order
        fields = [
            "url",
            "id",
            "created",
            "updated",
            "customer",
            "order2products",
        ]
        read_only_fields = ["created", "updated", "order2products"]

    def create(self, validated_data: dict) -> Order:
        """Creates Order creating Order2Products and deleting Cart.

        Creates Order from customer creating Order2Products from Cart items of \
            customer and deleting Cart, in an atomic transaction.
        """
        with transaction.atomic():  # 모든 처리는 하나의 트랜잭션 원자성 안에서
            order = super().create(validated_data)
            customer: User = validated_data["customer"]
            for cart in customer.carts.all():
                Order2Product.objects.create(  # 주문 정보를 갱신하면
                    order=order, product=cart.product, quantity=cart.quantity
                )
                cart.delete()  # 카트에 저장된 정보를 삭제
        return order


class OrderSerializer(OrderAdminSerializer):  # 고객의 자기 주문 정보 수정용
	class Meta:
        model = Order
        fields = [
            "url",
            "id",
            "created",
            "updated",
            "customer",
            "order2products",
        ]
        read_only_fields = ["created", "updated", "customer", "order2products"]

    # 주문 정보를 고객이 직접 변경할 때는 고객 정보를 따로 넣어줄 필요가 없음
    def create(self, validated_data: dict) -> Order:
        """Creates Order creating Order2Products and deleting Cart.

        Creates Order from customer creating Order2Products from Cart items of \
            customer and deleting Cart.
        Creates Order with request user as customer.
        """
        validated_data["customer"] = self.context["request"].user  # 직접 변경 요구했으니 요구자가 곧 주문고객
        return super().create(validated_data)


class Order2ProductSerializer(HyperlinkedModelSerializer):
    ...

class ProductAdminSerializer(HyperlinkedModelSerializer):
    ...

class ProductSerializer(ProductAdminSerializer):
    ...

class ReviewAdminSerializer(HyperlinkedModelSerializer):
    ...

class ReviewSerializer(ReviewAdminSerializer):
    ...

class TagSerializer(HyperlinkedModelSerializer):
    ...​

뷰: 시리얼화된 데이터를 실제로 보여줄 인터페이스

# common/views.py
import logging
from rest_framework.permissions import IsAdminUser
from rest_framework.viewsets import ModelViewSet
from common.models import User
from common.permissions import IsOwnerOrReadOnly
from common.serializers import UserSerializer, UserAdminSerializer

LOGGER = logging.getLogger(__name__)


class UserAdminViewSet(ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserAdminSerializer
    permission_classes = [IsAdminUser]


class UserViewSet(UserAdminViewSet):
    """User viewset for admin.

    Permission
    ----------  # 관리자와 달리 권한에 제한이 있음
    - Owner: Create / List / Retrieve / Update / Destroy
    - Others: ~~Create~~ / List / Retrieve / ~~Update~~ / ~~Destroy~~
    """
    serializer_class = UserSerializer
    permission_classes = [IsOwnerOrReadOnly]
# commerce/views.py
import logging
from django.contrib.auth.models import AnonymousUser
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from commerce.models import *
from commerce.permissions import *
from commerce.serializers import *
from common.models import User

LOGGER = logging.getLogger(__name__)

class UserViewSet(ReadOnlyModelViewSet):
    ...

class CartAdminViewSet(ModelViewSet):
    ...

class CartViewSet(CartAdminViewSet):
    ...

class CategoryViewSet(ModelViewSet):
    ...

class OrderAdminViewSet(ModelViewSet):
    ...

class OrderViewSet(OrderAdminViewSet):
    serializer_class = OrderSerializer
    permission_classes = [IsAdminUser | IsCustomer]  # "or"을 사용하면 안됨

    def get_queryset(self):  # 주문 생성시에는 고객 모델만 넣도록 하여 고객의 카트 데이터만 사용 
        """Overriding to pass User for request data to Order view."""
        if self.action == "create":
            return User.objects.all()
        return super().get_queryset()  # 그 외에는 주문 정보 시리얼라이저를 그대로 사용

    def list(self, request: Request) -> Response:
        """Lists Order of the customer who is request user."""
        if isinstance(request.user, AnonymousUser):  # 로그인하지 않았으면 주문 정보가 보이지 않음
            queryset = Order.objects.none()
        else:  # 로그인하면 내 주문 정보만 보임
            queryset = Order.objects.filter(customer=request.user)

        page = self.paginate_queryset(queryset)
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)

        serializer = self.get_serializer(queryset, many=True)
        return Response(serializer.data)


class Order2ProductAdminViewSet(ReadOnlyModelViewSet):
    ...

class Order2ProductViewSet(Order2ProductAdminViewSet):
    ...

class ProductAdminViewSet(ModelViewSet):
    ...

class ProductViewSet(ProductAdminViewSet):
    ...

class ReviewAdminViewSet(ModelViewSet):
    ...

class ReviewViewSet(ReviewAdminViewSet):
    ...

class TagViewSet(ModelViewSet):
    ...

URL: 뷰를 보여줄 URL 엔드포인트(endpoint)

# common/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from common.views import UserViewSet, UserAdminViewSet

router = DefaultRouter()
router.register("users", UserViewSet, basename="user")
router.register("users-admin", UserAdminViewSet, basename="user_admin")

urlpatterns = [
    path("", include(router.urls)),
]
# commerce/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from commerce.views import *

router = DefaultRouter()
router.register("users", UserViewSet, basename="commerce.user")
...

urlpatterns = [
    path("", include(router.urls)),
]

모델 마이그레이션: 모델 구조를 데이터베이스에 테이블로 생성

python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser

마이그레이션 확인

python manage.py runserver  # 127.0.0.1:8000

 

댓글