Django: 게시판 만들기-글 관리
- 1. Frontend(React)와 연동하기
- 2. 게시글 기능 정리
- 3. 게시글 모델 만들기 & 마이그레이션
- 4. 게시글 기능 만들기
- 5. 필터링 기능 만들기
- 6. 페이징 기능
- 7. 좋아요 기능
- 8. 댓글 기능 만들기
1. Frontend(React)와 연동하기
- Backend 뿐만 아니라 Frontend의 처리 과정도 이해하여야 Frontend 개발자와 소통이 원활해짐
1.1 CORS 오류
- CORS (Cross-Origin Resource Sharing, 교차 출처 자원 공유)
- Backend와 Frontend의 연동 과정에서 가장 빈번하고 가장 처음에 발생하는 오류
- 엄밀하게는 오류가 아니라 개발 결과물을 안전하게 지켜주기 위한 것
- 정책의 형태로 존재
- 정책을 위반하려 할 때마다 오류를 발생시킴
- 서로 다른 출처끼리 자원을 공유하는 것의 의미는?
- 같은 출처란 http://127.0.0.1:8000, http://127.0.0.1:8000/users/ 와 같이 포트번호까지 동일한 경우를 의미
- React의 경우 http://127.0.0.1:3000 이므로 호스트 주소는 같으나 포트번호가 달라서 서로 다른 출처가 됨
- React에서 다른 출처인 Django로부터 데이터(리소스)를 가져오려는 시도가 SOP(Same Origin Policy)에 의해 차단되는 것
- CORS는 이러한 상황에 대한 예외조항
- 서버에서 CORS 정책을 준수하도록 설정해두면
- SOP의 예외 조항인 CORS 정책을 준수하여 다른 출처끼리도 자원의 공유가 가능하게 됨
- CORS는 프론트엔드에서 발생하는 오류이지만 백엔드에서 정책을 지켜야 하는 문제임
CORS 해결을 위한 방법
pip install django-cors-headers
#//file: "board/settings.py" 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', ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # 순서가 중요함 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] CORS_ORIGIN_ALLOW_ALL = True CORS_ALLOW_CREDENTIALS = True
- 이제 백엔드에서 할 일은 없음(Django에서 모두 처리함)
2. 게시글 기능 정리
- 게시글 생성
- 게시글 1개 가져오기
- 게시글 목록 가져오기(가져오는 개수 제한하기)
- 게시글 수정하기
- 게시글 삭제하기
- 게시글 좋아요 기능
- 게시글 필터링(좋아요 누른 글/내가 작성한 글)
- 게시글 각 기능마다 권한 설정
3. 게시글 모델 만들기 & 마이그레이션
앱 만들기
python manage.py startapp posts
settings.py 설정
#//file: "board/settings.py" 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', ]
Models
- 모델의 필드 구성
- 저자
- 저자 프로필
- 제목
- 카테고리
- 본문
- 이미지 → 이미지가 없을 때는 default.png 파일 표시
- 좋아요 누른 사람들 → 다대다(ManyToMany)
- 글이 올라간 시간
#//file: "posts/models.py" from django.db import models from django.contrib.auth.models import User from django.utils import timezone from users.models import Profile 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)
- 모델의 필드 구성
마이그레이션
python manage.py makemigrations python manage.py migrate
- 마이그레이션 시 오류 발생
- author, likes가 모두 User를 참조하는 중 → 오류 발생
- 관련 내용
- relation_name에서의 오류 → 참조관계에서의 오류
- 저자 → Users 모델을 ForeignKey로 참조 중(author와 연관) → post.author.username과 같이 참조 가능
User 모델에서는 post라는 이름을 모름 → user.post.title과 같이 참조 불가능
# 이런 방식으로는 역관계에서도 데이터에 접근 가능 user = User.objects.get(pk=1) posts = user.post_set.all()
- 여기서 post_set 대신 사용하는 것이 related_name
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='author')
- 위와 같이 이름을 지정하고
user = User.objects.get(pk=1) posts = user.posts.all()
-이렇게 하면 유저가 작성한 글들을 확인할 수 있음
- relation_name에서의 오류 → 참조관계에서의 오류
- 위의 오류에서는 author, likes가 모두 User를 참조하는 중
→ 둘 다 related_name을 지정하지 않고 역관계로 User에서 author, likes에 참조하려고 하므로
→ user.post_set.all()이 되어 어떤 것을 잠조해야 하는지 구분할 수 없음
→ 오류 발생
#//file: "posts/models.py" from django.db import models from django.contrib.auth.models import User from django.utils import timezone from users.models import Profile 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)
- author, likes가 모두 User를 참조하는 중 → 오류 발생
- 마이그레이션
python manage.py makemigrations python manage.py migrate
4. 게시글 기능 만들기
- Serializers
- PostSerializer()
- 해당 게시글에 대한 모든 정보를 JSON으로 변환하여 전달하는 역할을 수행
- profile 필드를 작성하지 않으면 profile 필드에는 profile의 PK 값만 나타나므로 ProfileSerializer를 포함하도록 함
- 이런 형태를 Nested Serializer라고 함
- PostCreateSerializer()
- 게시물을 등록할 때 유저는 제목, 카테고리, 본문, 이미지 등만 입력함
- 나머지는 시스템이 알아서 채워주거나 빈칸으로 남겨둠
- 시리얼라이저는 유저가 입력한 데이터를 검증하고 Django 데이터로 변환하여 저장하게 하는 역할을 수행
#//file: "posts/serializers.py 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()
Views(CRUD) + 권한
- 게시글은 모든 CRUD 기능이 포함되어 있으므로 ViewSet을 사용하여 쉽게 적용 가능함
- 단, 각기 다른 시리얼라이저를 적재적소에 활용하도록 코드를 작성해야 함
- 게시글 생성 시 유저가 입력해 주지 않는 저자 정보를 같이 넣을 수 있도록 함
- 요구되는 권한
- 게시글의 조회: 모든 사람
- 게시글의 생성: 인증된 유저만 가능
- 게시글의 수정/삭제: 해당 글의 작성자만 가능
- User에서의 CustomReadOnly와 다른 점
- 각 객체별 권한 뿐만 아니라 전체 객체에 대한 권한도 포함해야 함(목록 조회/생성)
- has_permission()을 함께 구현
- 각 객체별 권한 뿐만 아니라 전체 객체에 대한 권한도 포함해야 함(목록 조회/생성)
#//file: "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
#//file: "posts/views.py" from rest_framework import viewsets from users.models import Profile from .models import Post from .permissions import CustomReadOnly from .serializers import PostSerializer, PostCreateSerializer 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)
- URL
- ViewSet을 사용하면 라우터가 함께 따라옴
- 라우터에 등록된 url을 활용함
#//file: "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
- ViewSet을 사용하면 라우터가 함께 따라옴
- 프로젝트의 urls.py에는 라우터가 이미 posts를 설정해줌
posts를 설정하면 실제 주소는 localhost:8000/posts/posts/와 같이 중복됨
#//file: "board/urls.py" from django.contrib import admin from django.urls import path, include from django.conf import settings from django.conf.urls.static import static urlpatterns = [ path('admin/', admin.site.urls), path('users/', include('users.urls')), path('', include('posts.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
실행
python manage.py runserver
5. 필터링 기능 만들기
- 필터링 기능: 게시글 전체를 가져올 때 조건을 걸어 가져오도록 하는 기능
- Django에서 이미 지원하는 기능임
View 등의 코드에서 따로 호출하지 않아도 프로젝트 전역에 적용됨
pip install django-filter
settings.py 설정
#//file: "board/settings.py" 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', ], }
Views
#//file: "posts/views.py" from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets from users.models import Profile from .models import Post from .permissions import CustomReadOnly from .serializers import PostSerializer, PostCreateSerializer 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)
6. 페이징 기능
- Pagination
- 게시글 전체 조회 페이지를 여러 페이지로 나누는 기능
- 한 번에 모든 글을 가져오기 부담스러울 경우 한 번의 API 요청으로 가져울 수 있는 데이터의 수를 제한하는 기능
- 별다른 작업은 필요없음 → settings.py의 REST_FRAMEWORK에 관련 기능을 추가하기만 하면 됨
- 단, 결과 데이터는 results에 들어가서 프론트엔드에 전달되므로 프론트엔드에서는 데이터를 꺼내가는 과정이 추가로 요구됨
#//file: "board/settings.py" 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, }
7. 좋아요 기능
- Views
- 좋아요 기능은 오직 likes 필드에만 영향을 주므로 간단한 GET 요청 하나로 처리 가능
- 요구되는 설정
- 데코레이터로 GET 요청을 받는 함수형 뷰라는 설정
- 권한이 필요하다는 설정
- 좋아요를 누르는 권한은 회원가입을 한 유저라면 모두 가능하므로 IsAuthenticated로 설정
- 처리 내용
- post.likes.all() 내에 request.user가 있으면 request.user 삭제
- post.likes.all() 내에 request.user가 없으면 request.user 추가
#//file: "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 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) @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'})
URL
#//file: "posts/urls.py" 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') ]
8. 댓글 기능 만들기
- 댓글 기능 정리
- 댓글 생성
- 댓글 1개 가져오기
- 댓글 목록 가져오기
- 댓글 수정하기
- 댓글 삭제하기
- 게시글을 가져올 때 댓글도 가져오게 만들기
- 댓글 모델 만들기
- 댓글 모델에 필요한 필드들
- 작성자, 작성자 프로필, 게시글, 내용
- 댓글의 경우 게시글과 밀접한 연관이 있음 → 따로 모델을 만들 필요는 없음
Foreign Key로 유저, 프로필, 포스트와 연결됨 + 댓글 내용 텍스트만 추가
#//file: "posts/models.py" from django.db import models from django.contrib.auth.models import User from django.utils import timezone from users.models import Profile class Post(models.Model): author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='author') 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) class Comment(models.Model): author = models.ForeignKey(User, on_delete=models.CASCADE) profile = models.ForeignKey(Profile, on_delete=models.CASCADE) post = models.ForeignKey(Post, related_name='comments', on_delete=models.CASCADE) text = models.TextField()
- 댓글 모델에 필요한 필드들
마이그레이션
python manage.py makemigrations python manage.py migrate
- Serializers
- 댓글을 작성할 때, 가져올 때 각각 다른 시리얼라이저가 필요함 → 게시글 시리얼라이저와 비슷
- 게시글에서도 댓글을 불러올 수 있어야 함 → Nested Serializer 개념 활용(작성해 놓은 댓글 시리얼라이저를 게시글 시리얼라이저에 넣어주기)
게시글 시리얼라이저에 댓글 시리얼라이저가 포함됨 → 댓글 시리얼라이저가 더 위에 선언되어야 함
#//file: "posts/serializers.py" from rest_framework import serializers from users.serializers import ProfileSerializer from .models import Post, Comment class CommentSerializer(serializers.ModelSerializer): profile = ProfileSerializer(read_only=True) class Meta: model = Comment fields = ("pk", "profile", "post", "text") class CommentCreateSerializer(serializers.ModelSerializer): class Meta: model = Comment fields = ("post", "text") class PostSerializer(serializers.ModelSerializer): profile = ProfileSerializer(read_only=True) comments = CommentSerializer(many=True, read_only=True) class Meta: model = Post fields = ("pk", "profile", "title", "body", "image", "published_date", "likes", "comments") class PostCreateSerializer(serializers.ModelSerializer): image = serializers.ImageField(use_url=True, required=False) class Meta: model = Post fields = ("title", "category", "body", "image")
Views
- ViewSet 사용
- 댓글에 필요한 권한은 게시글과 동일
- 댓글 보기: 모두
- 댓글 작성: 유저만
- 댓글 수정/삭제: 해당 댓글 작성자만 → CustomReadOnly 활용
#//file: "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 rest_framework import generics, status from users.models import Profile from .models import Post, Comment from .permissions import CustomReadOnly from .serializers import PostSerializer, PostCreateSerializer, CommentSerializer, CommentCreateSerializer 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) @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'}) class CommentViewSet(viewsets.ModelViewSet): queryset = Comment.objects.all() permission_classes = [CustomReadOnly] def get_serializer_class(self): if self.action == 'list' or 'retrieve': return CommentSerializer return CommentCreateSerializer def perform_create(self, serializer): profile = Profile.objects.get(user=self.request.user) serializer.save(author=self.request.user, profile=profile)
URL
#//file: "posts/urls.py" from django.urls import path from django.urls import path from rest_framework import routers from .views import PostViewSet, like_post, CommentViewSet router = routers.SimpleRouter() router.register('posts', PostViewSet) router.register('comments', CommentViewSet) urlpatterns = router.urls + [ path('like/<int:pk>/', like_post, name='like_post') ]
실행
python manage.py runserver