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
'개발 > 웹' 카테고리의 다른 글
Django 커머스 보일러플레이트 - (4) Nginx 웹 서버와 도커 컴포즈, AWS ECS 배포 (0) | 2023.05.03 |
---|---|
Django 커머스 보일러플레이트 - (3) 유닛 테스트, drf-yasg API 문서화 (0) | 2023.05.02 |
Django 커머스 보일러플레이트 - (1) 프로젝트 요구사항과 다이어그램 모델링 (0) | 2023.04.30 |
Flask Microservice 구축 - GitHub Actions로 CI/CD 시스템 구축 (2) | 2023.03.01 |
Flask Microservice 구축 - Zappa로 AWS Lambda에 Flask Docker 띄우기 (0) | 2023.02.25 |
댓글