[DRF] 게시판 프로젝트 게시글 관련 기능 구현하기

mia·2024년 8월 12일
0

DRF 기반으로 게시판 서비스를 만들어보자❗ 3탄

필요한 기능

게시글 관련 기능

  • 게시글 생성
  • 게시글 1개 가져오기/게시글 목록 가져오기(개수 제한)
  • 게시글 수정
  • 게시글 삭제
  • 게시글에 좋아요 기능
  • 게시글 필터링(내가 작성한 글/좋아요 누른 글)
  • 게시글 각 기능마다 권한 설정

기본 게시글

우선 게시글 관련 기능을 담을 posts 앱부터 만들고, settings에 추가한다.

$ py manage.py startapp posts

모델

게시글 모델에 필요한 필드를 정의했다.

저자
저자 프로필
제목
카테고리
본문
이미지
좋아요를 누른 사람들
게시글이 올라간 시간

이를 바탕으로 Post 모델을 추가해줬다.

from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from users.models import Profile

# Create your models here.
class Post(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE,)
    profile = models.ForeignKey(Profile, on_delete=models.CASCADE, blank=True)
    title = models.CharField(max_length=128)
    category = models.CharField(max_length=128)
    body = models.TextField()
    image = models.ImageField(upload_to='post/', default='default.png')
    likes = models.ManyToManyField(User)
    published_date = models.DateTimeField(default=timezone.now)
  • models.ManyToManyField
    • 좋아요의 경우, 한 유저가 여러 게시글에 좋아요를 누를 수 있고 여러 유저가 한 게시글에 좋아요를 누를 수 있다.
    • 여러 유저가 여러 게시글과 관계를 가질 수 있으므로 다대다(N:N) 관계가 성립한다.

모델을 작성했으므로 마이그레이션해본다.

엄 ㅋ

서로를 참조하는 관계(ForeignKey, OneToOneField, ManyToManyField 등)에서 발생한다.

Post 모델에서는 authorlikes 필드가 User 모델을 참조하고 있다.
이 때문에 Post 모델 내에서도 post.author.username과 같은 방식으로 author 필드를 통해 연결된 User의 데이터를 불러올 수 있다.

하지만 User 입장에서는 post라는 이름을 모르기 때문에, 역(user.post.title 등)은 바로 성립되지 않는다.
대신 이런 방식을 사용하면 역관계에서도 데이터에 접근이 가능해진다.

user = User.objects.get(pk=1)
posts = user.post_set.all()

이렇게 하면 특정 유저(pk=1)가 작성한 모든 글을 posts에 담아 볼 수 있다.
이때 post_set 대신 사용하는 것이 related_name이다.

author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')

user = User.objects.get(pk=1)
posts = user.posts.all()

이렇게 하면 앞서 작업한 것과 동일하게 유저가 작성한 글들을 확인할 수 있다.

이런 작업은 필수는 아니지만, 위 모델처럼 두 개 이상의 필드가 같은 모델을 참조하고 있는 경우에는 지정해 주어야 한다.
authorlikes 필드 둘 다 related_name을 정해주지 않은 상태로 User에서 역으로 참조하려 한다면, user.post_set.all()이 되면서 둘이 구분되지 않아 에러가 발생하는 것이다.

다른 모델을 참조하는 필드에 related_name을 지정해서 models.py를 다시 작성해 보자.

class Post(models.Model):
    author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
    profile = models.ForeignKey(Profile, on_delete=models.CASCADE, blank=True)
    title = models.CharField(max_length=128)
    category = models.CharField(max_length=128)
    body = models.TextField()
    image = models.ImageField(upload_to='post/', default='default.png')
    likes = models.ManyToManyField(User, related_name='like_posts', blank=True)
    published_date = models.DateTimeField(default=timezone.now)

이제 다시 마이그레이션하면 에러가 발생하지 않는다.


시리얼라이저

게시글의 데이터 중 실제로 유저가 입력하는 것은 제목, 카테고리, 본문, 이미지 정도이다.
나머지 데이터는 코드가 알아서 채워주거나(저자, 날짜) 처음에는 빈칸으로 두게 된다(좋아요).

이런 경우, 유저가 입력하는 데이터를 받아 검증하고 Django 데이터로 변환하여 저장하는 시리얼라이저와 해당 게시글의 모든 데이터를 역으로 JSON으로 변환하여 전달해야 하는 시리얼라이저를 구분해야 한다.

PostSerializer와 PostCreateSerializer를 나눠서 작성한다.

from rest_framework import serializers

from users.serializers import ProfileSerializer
from .models import Post

class PostSerializer(serializers.ModelSerializer):
    profile = ProfileSerializer(read_only=True)

    class Meta:
        model = Post
        fields = ("pk", "profile", "title", "body", "image", "published_date", "likes")

class PostCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ("title", "category", "body", "image")
  • PostSerializer 내 profile 필드를 따로 정의한다.
    • 시리얼라이저 내에 또 다른 시리얼라이저를 이중으로 연결하는 nested serializer 구조
    • 저자의 실제 프로필 정보를 볼 수 있게 하기 위해 추가한다.
    • 작성하지 않으면 profile 필드에는 profile의 pk 값만 나타나게 된다.

뷰 & 퍼미션

게시판은 CRUD 기능이 모두 있으므로 편하게 Viewset을 사용할 수 있다.

주의할 점

  • 게시글을 생성할 때 유저가 입력하지 않는 저자 정보(저자 이름, 프로필)가 추가되도록 한다.
  • 게시글을 조회하는 것은 회원가입도 하지 않은 사람들(AllowAny)까지 할 수 있게 하고, 게시글을 생성하는 것은 인증된 유저(Authenticated), 게시글을 수정/삭제하는 것은 해당 글의 저자(Author)만 가능하도록 한다.

권한을 설정하기 위해 posts 앱 내에 permissions.py 파일을 작성한다.

from rest_framework import permissions

class CustomReadOnly(permissions.BasePermission):
    def has_permission(self, request, view):
        if request.method == 'GET':
            return True
        return request.user.is_authenticated
    
    def has_object_permission(self, request, view, obj):
        if request.method in permissions.SAFE_METHODS:
            return True
        return obj.author == request.user
  • has_permission() : 각 객체별 권한(수정/삭제)뿐만 아니라 전체 객체에 대한 권한(목록 조회/생성)도 포함해야 하므로 함께 정의한다.

이제 뷰를 작성한다.

from rest_framework import viewsets

from users.models import Profile
from .models import Post
from .permissions import CustomReadOnly
from .serializers import PostSerializer, PostCreateSerializer

# Create your views here.
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    permission_classes = [CustomReadOnly]

    def get_serializer_class(self):
        if self.action == 'list' or 'retrieve':
            return PostSerializer
        return PostCreateSerializer
    
    def perform_create(self, serializer):
        profile = Profile.objects.get(user=self.request.user)
        serializer.save(author=self.request.user, profile=profile)
  • queryset : Post.objects.all()을 사용해 모든 Post 객체를 가져온다.
  • 앞서 정의한 CustomReadOnly 권한 클래스를 사용해 접근 제어를 정의한다.
  • get_serializer_class : 요청의 액션에 따라 다른 시리얼라이저를 리턴하는 메소드
    • list 또는 retrieve 액션인 경우 PostSerializer 사용
    • 그 외의 경우 PostCreateSerializer 사용

URL

Viewset을 사용했으니 Router도 설정해준다.
posts/urls.py를 작성한다.

from django.urls import path
from rest_framework import routers

from .views import PostViewSet

router = routers.SimpleRouter()
router.register('posts', PostViewSet)

urlpatterns = router.urls

프로젝트 폴더의 urls.py에도 추가해준다.
라우터가 이미 posts라는 이름을 설정해 주었기 때문의 별도의 경로 이름을 설정하지 않아도 된다.

urlpatterns = [
    path('admin/', admin.site.urls),
    path('users/', include('users.urls')),
    path('', include('posts.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

실행

runserver하고 /posts/ 에 들어가면 뜨는 화면

이제 Insomnia에서 API 테스트를 해보자.
게시글 생성을 위해서는 /posts/ 주소로 multipart 타입으로 POST 요청을 보내면 된다.
헤더에 Authorization - Token 토큰 값을 추가한다.

게시글이 잘 생성됐다.

전체 목록 조회 기능을 확인해보자.
대충 게시글을 하나 더 만들어준다.

/posts/에 새로운 GET 요청을 보내보면 목록이 잘 보인다.

PUT 요청으로 /posts/1/ 주소로 해당 게시글의 저자인 유저의 토큰을 넣어서 수정 요청을 보내봤다.
multipart로 image 필드를 추가하고, 타입을 File로 설정해서 미리 저장해둔 이미지 파일을 업로드했다.

이미지가 Django에 잘 저장되었는지 확인해보자.

잘 보인다✌️😉


필터링

게시글을 필터링해서 보여주는 기능을 구현해보자.

www.abc.com/posts?category=11&event=1
이런 링크처럼 ?로 시작해 &로 구분되는 쿼리를 URL에 넣으면 게시글들이 필터링되어 결과로 전달된다.

먼저 공식 필터링 패키지를 설치한다.

$ pip install django-filter

settings.py에 앱을 등록하고, REST_FRAMEWORK 옵션에도 'DEFAULT_FILTER_BACKENDS' 내용을 추가하여 해당 프로젝트의 기본 필터링 도구를 설정해준다.
이 옵션을 사용하면 이후에 django-filter 모듈을 뷰와 같은 코드에서 직접 불러오지 않아도 잘 적용되지만, 프로젝트 전역에 적용되므로 주의한다.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'rest_framework.authtoken',
    'users',
    'corsheaders',
    'posts',
    'django_filters',
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.TokenAuthentication',
    ],
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
    ],
}

다음은 뷰를 수정하면 된다.
뷰마다 filter_backend를 설정한다.

from django_filters.rest_framework import DjangoFilterBackend
# view마다 필터 설정할 때 사용(settings.py에 이미 등록해서 상관없음)

from rest_framework import viewsets

from users.models import Profile
from .models import Post
from .permissions import CustomReadOnly
from .serializers import PostSerializer, PostCreateSerializer

# Create your views here.
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    permission_classes = [CustomReadOnly]
    
    # 추가된 부분
    filter_backends = [DjangoFilterBackend]
    filterset_fields = ['author', 'likes']

    def get_serializer_class(self):
        if self.action == 'list' or 'retrieve':
            return PostSerializer
        return PostCreateSerializer
    
    def perform_create(self, serializer):
        profile = Profile.objects.get(user=self.request.user)
        serializer.save(author=self.request.user, profile=profile)

filter_backendsDjangoFilterBackend로 설정하고,
filterset_fields['author', 'likes']로 설정하면 필터링 설정이 끝난다.

이제 브라우저에 접속해서 확인해보자.
/posts/로 접속하니 우측 상단에 Filters 버튼이 생겨 있다.

눌러보면 이런 창이 뜬다.

testuser3을 선택하면 주소가 http://127.0.0.1:8000/posts/?author=4 로 바뀌고 필터링이 적용된다.


페이징

Pagination

게시글 전체 조회 페이지를 여러 페이지로 나누거나,
한 번에 모든 글을 가져오기 부담스러운 경우 한 번의 API 요청으로 가져올 수 있는 데이터 수를 제한할 수 있는 기능이다.

따로 작업할 필요는 없고, settings.pyREST_FRAMEWORK에 내용을 추가하면 된다.

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.TokenAuthentication',
    ],
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
    ],
    'DEFAULT_PAGINATION_CLASS':
    'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE':
    3,
}

PageNumberPagination 외 여러 종류의 Pagination 기능은 아래 링크 참조
https://www.django-rest-framework.org/api-guide/pagination/


Insomnia로 GET 요청을 보내보면 count, next, previous가 나타난다.
count는 전체 게시글의 수,
next, previous는 각각 다음, 이전 페이지의 URL로 현재는 null로 표시되어 더 이상의 페이지가 없다는 것을 의미한다.

페이징으로 페이지를 나누면 데이터는 results 내에 들어가게 된다.
페이징을 적용하기 전에는 바로 데이터에 접근할 수 있었지만, 지금은 results 안에 들어 있기 때문에 프론트엔드에서 데이터를 가져가는 과정이 달라질 수 있다는 점을 유의해야 한다.


좋아요

좋아요 기능은 오직 likes 필드에만 영향을 준다.
간단한 GET 요청 하나로 처리할 수 있으므로, posts/views.py에 함수형 뷰로 작성해 보도록 하자.

from django_filters.rest_framework import DjangoFilterBackend

from rest_framework import viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from users.models import Profile
from .models import Post
from .permissions import CustomReadOnly
from .serializers import PostSerializer, PostCreateSerializer

@api_view(['GET'])
@permission_classes([IsAuthenticated])
def like_post(request, pk):
    post = get_object_or_404(Post, pk=pk)
    if request.user in post.likes.all():
        post.likes.remove(request.user)
    else:
        post.likes.add(request.user)
    return Response({'status': 'ok'})
  • 데코레이터로 GET 요청을 받는 함수형 뷰라는 설정, 권한이 필요하다는 설정을 해 준다.
    • 좋아요를 누르는 권한은 회원가입한 유저라면 모두 가능하게 해야 하므로, IsAuthenticated로 설정한다.
  • post.likes.all() 내에 request.user가 있으면 request.user를 지우고 없으면 추가한다. 즉 좋아요를 한 번 누르면 추가하고 두 번 누르면 삭제한다.

아까 만든 likes 필드는 ManyToMany 다대다 관계로, 실행했을 때 []라는 리스트 형태로 반환되었다.
즉 리스트 형태로 유저 데이터를 담고 있을 수 있다.
likes 리스트에 담겨 있는 유저들의 목록을 확인해 좋아요를 누른 것인지, 한 번 더 눌러 취소한 것인지 처리하는 뷰를 구현할 수 있다.


다음은 URL을 설정한다.
Viewset의 Router가 있기 때문에 이에 path를 추가해 주는 방식으로 작성한다.

from django.urls import path
from rest_framework import routers

from .views import PostViewSet, like_post

router = routers.SimpleRouter()
router.register('posts', PostViewSet)

urlpatterns = router.urls + [
    path('like/<int:pk>/', like_post, name='like_post')
]

이제 Insomnia로 좋아요 기능을 확인해보자.
헤더에 5번 유저의 토큰을 넣어서, /like/1/에 GET 요청을 보내봤다.

좋아요가 잘 찍혔는지 확인하기 위해 1번 게시글을 조회했다.
likes에 5번 유저가 추가된 것을 확인할 수 있다.


좋아요 취소 기능을 확인하기 위해 /like/1/ 주소로 다시 5번 유저의 토큰을 넣어서 GET 요청을 보낸 뒤 1번 게시글을 다시 조회해보면

비어있는 likes 리스트와 함께 좋아요가 취소된 것을 확인할 수 있다.

profile
바보

0개의 댓글