본문 바로가기

문돌이 존버/Django 스터디

DRF 게시글 생성 및 조회수 중복 방지(쿠키 설정)

반응형

DRF(Django-rest-framework)를 통해 게시글을 생성하는 것 자체는 어렵지 않습니다. 하지만 게시글을 조회하는 수를 무한정 증가시키지 않으려면 쿠키를 통해 중복을 방지해야 합니다. 물론, 쿠키를 계속 삭제하면 조회수 증가가 되겠죠.(그건 고려하지 않겠습니다 ㅠ)

# views.py
from django.shortcuts import render, get_object_or_404

from .serializers import *
from .models import *

from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework import status

# 권한이 있는(jwt 토큰을 가진 사용자) 사람은 생성, 수정, 삭제 가능 <-> 일반 사용자(혹은 로그인 X)는 보기만 가능
from rest_framework.permissions import IsAuthenticatedOrReadOnly

# 쿠키 설정에 사용될 시간 관련 라이브러리
import datetime
from django.utils import timezone

# 트랜잭션
from django.db import transaction

# 페이지네이션
from rest_framework.pagination import PageNumberPagination
class BasicPagination(PageNumberPagination):
    page_size = 10
    page_size_query_param = 'page_size'

views.py 코드가 조금 길어져 라이브러리를 임포트하는 부분은 따로 적었습니다. DRF에서 제공하는 Viewsets를 사용했습니다. 관련 내용은 공식 홈페이지를 참고해주세요. Viewsets를 사용한 이유를 간단히 말하자면, 함수가 여러 개인 하나의 클래스만으로 urls.py 부분을 여러 개 설정할 수 있는데요. 예시 화면은 아래와 같습니다. 

<출처: 장고 공식 홈페이지>
<출처: 장고 공식 홈페이지>

이중에서도 저는 ModelViewSet을 사용했습니다. GenericAPIView를 상속하고, 믹스인 클래스를 통해 여러 메서드를 사용할 수 있기 때문입니다. 

<출처: 장고 공식 홈페이지>

다음은 페이지네이션 설정을 살펴보겠습니다. 페이지네이션은 자동으로 페이지를 설정해주는 기능인데, 한 페이지에 100개의 게시글을 보여줄 순 없겠죠. 100개의 게시글이 있고, page_size=10이면 한 페이지에 10개의 게시글을 보여줄 것입니다. 

각종 다양한 파라미터는 공식 홈페이지에서 참고를 하시면 되겠습니다. 제가 추가한 page_size_query_paㅇram 같은 경우, 클라이언트 측에서 request로 page_size를 정할 수 쿼리문의 파라미터입니다. 10개씩 보기, 20개씩 보기 등에 해당하는 것 같네요. 

다음은 트랜잭션에 관한 설정입니다. 은행 송금이나 포인트 지급 등 DB 액션 중 중간에 끊어져서는 안되는 경우가 있죠. 문제가 생길 것 같으면 아예 액션을 수행하지 않던지, 문제가 없다면 처음부터 끝까지 액션을 완성하던지 해야 합니다. 원자(Atom)는 쪼갤 수 없는 마지막 입자(논란이 있을 수 있지만 여기선 무시합니다)로 해당 DB 관련 액션을 원자성을 지닌다고 표현하며 좀 더 구체적으로는 트랜잭션(transaction)이라고 칭합니다. 트랜잭션의 모든 연산은 한 번에 완료해야 하며, "A는 DB에 저장되었는데, B는 저장이 안되었어" 라는 상황 자체가 존재할 수 없습니다.

아래 코드는 한 외국 사이트에서 찾은 DRF에서 트랜잭션을 사용하는 예시입니다. 

# decorator 사용하기

from django.db import transaction

@transaction.atomic
def create_category(name, products):
    category = Category.objects.create(name=name)
    product_api.add_products_to_category(category, products)
    activate_category(category)
# context manager 사용하기

from django.db import transaction

def create_category(name, products):
    with transaction.atomic():
        category = Category.objects.create(name=name)
        product_api.add_products_to_category(category, products)
        activate_category(category)

아래 코드에서 보겠지만 저는 두 번째 방법인 context manager를 사용했습니다. 

본격적으로 ModelViewSet 오버라이딩 코드를 살펴보겠습니다. 일단 전체 게시글 목록을 볼 수 있는 list() 함수와 게시글 1개를 자세히 볼 수 있는 retrieve() 함수를 만들었습니다. 

# views.py
class BoardAPIView(viewsets.ModelViewSet):
    queryset = Board_post.objects.all()
    serializer_class = BoardSerializer
    permission_classes = (IsAuthenticatedOrReadOnly, )

    pagination_class = BasicPagination

    def list(self, request):
        qs = self.get_queryset()
        page = self.paginate_queryset(qs)

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

        return Response(serializer.data, status=status.HTTP_200_OK)
    
    def retrieve(self, request, pk=None):
        instance = get_object_or_404(self.get_queryset(), pk=pk)
        # 당일날 밤 12시에 쿠키 초기화
        tomorrow = datetime.datetime.replace(timezone.datetime.now(), hour=23, minute=59, second=0)
        expires = datetime.datetime.strftime(tomorrow, "%a, %d-%b-%Y %H:%M:%S GMT")
        
        # response를 미리 받고 쿠키를 만들어야 한다
        serializer = self.get_serializer(instance)
        response = Response(serializer.data, status=status.HTTP_200_OK)
        
        # 쿠키 읽기 & 생성
        if request.COOKIES.get('hit') is not None: # 쿠키에 hit 값이 이미 있을 경우
            cookies = request.COOKIES.get('hit')
            cookies_list = cookies.split('|') # '|'는 다르게 설정 가능 ex) '.'
            if str(pk) not in cookies_list:
            response.set_cookie('hit', cookies+f'|{pk}', expires=expires) # 쿠키 생성
                with transaction.atomic(): # 모델 필드인 views에 1 추가
                    instance.views += 1
                    instance.save()
                    
        else: # 쿠키에 hit 값이 없을 경우(즉 현재 보는 게시글이 첫 게시글임)
            response.set_cookie('hit', pk, expires=expires)
            instance.views += 1
            instance.save()

        # views가 추가되면 해당 instance를 serializer에 표시
        serializer = self.get_serializer(instance)
        response = Response(serializer.data, status=status.HTTP_200_OK)

        return response

퍼미션(permission)을 적용할 때 주의할 점이 있는데요, 아래와 같이 반드시 "," 를 찍어줘야 합니다. 이유는 튜플이나 리스트를 사용하게 되어있기 때문이죠. 

permission_classes = (IsAuthenticatedOrReadOnly, ) 
# permission_classes = [IsAuthenticatedOrReadOnly]

이제 조회수 중복 방지를 위한 쿠키 설정을 설명해보겠습니다. 

1. 쿠키 만들기

set_cookie(name, value, max_age)

name: 쿠키 이름
value: 저장하는 값
max_age: 쿠키 유지 시간

2. 쿠키 데이터 읽기

request.COOKIES.get(cookie name)

아래는 print(request.COOKIES) 의 출력 결과입니다.

{'sessionid': '****************************', 'csrftoken': '***************************************************************', 'hit': '2'}

개발자 도구(F12)에서 아래 쿠키 정보를 확인해볼 수 있습니다. hit 인자 value 값에 '.'이 찍힌 이유는 위에서 주석으로 언급한 것과 연관되어 있습니다.(이것저것 시도해봤습니다)

cookies_list를 출력하면 아래와 같이 리스트로 나옵니다.

cookies_list = ['2.3', '3', '2', '4']

마지막으로 models.py 와 urls.py 코드를 공유하겠습니다. 자세한 설명은 생략하겠습니다. 

# models.py
from django.db import models
from django.conf import settings

class Category(models.Model):
    name = models.CharField(max_length=30)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return '%s' % (self.name)

class Board_post(models.Model):
    writer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True)
    category_id = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
    title = models.CharField(max_length=40, null=False, blank=False)
    body = models.TextField()
    views = models.IntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    class Meta:
        ordering = ['-created_at']
    
    def __str__(self):
        return '%s' % (self.id)
# urls.py
from django.urls import path
from . import views

urlpatterns = [
    path('board', views.BoardAPIView.as_view({'get': 'list'}), name='all_board'),
    path('board/<int:pk>', views.BoardAPIView.as_view({'get': 'retrieve'}), name='detail_board')
]

쿠키 참고 원문: Django-mysite만들기9 게시판 조회수 중복 증가 쿠키 처리 · 콩정의 개발 정리 블로그

728x90
반응형