[2026.01.12] review create api 구현
[2026.01.13] review create api 다듬기
[2026.01.14] review list api 구현
[2026.01.15] review update api 구현
- "좋아요"기능 구현 (post/delete)
- 스웨거 설정 수정
[2026.01.16] review delete api 구현
2026.01.12 ✅
review create api
serializers.py
- game_id와 user_id는 URL과 토큰에서 가져오므로 fiels에서 제외
from rest_framework import serializers
from .models import Review
class ReviewCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Review
fields = ['content', 'rating']
def validate_rating(self, value):
if not (1 <= value <= 5):
raise serializers.ValidationError("별점은 1에서 5 사이의 정수여야 합니다.")
return value
service
from django.db import transaction
from .models import Review, Game
def create_review(user, game_id: int, content: str, rating: int) -> Review:
"""
리뷰 생성 비즈니스 로직
"""
game = Game.objects.get(pk=game_id)
review = Review.objects.create(
user=user,
game=game,
content=content,
rating=rating
)
return review
from django.db import transaction
from django.shortcuts import get_object_or_404
from typing import Any
from apps.community.models.reviews import Review
from apps.game.models.game import Game
from apps.user.models.user import User
def create_review(*, author: User, game_id: int, validated_data: dict[str, Any]) -> Review:
"""
리뷰 생성 비즈니스 로직
"""
game = get_object_or_404(Game, pk=game_id)
with transaction.atomic():
review = Review.objects.create(
user=author,
game=game,
content=validated_data["content"],
rating=validated_data["rating"],
)
return review
from django.db import transaction
from django.shortcuts import get_object_or_404
from typing import Any
from apps.community.models.reviews import Review
from apps.game.models.game import Game
from apps.user.models.user import User
@transaction.atomic
def create_review(*, author: User, game_id: int, validated_data: dict[str, Any]) -> Review:
"""
리뷰 생성 비즈니스 로직
"""
game = get_object_or_404(Game, pk=game_id)
review = Review.objects.create(
user=author,
game=game,
content=validated_data["content"],
rating=validated_data["rating"],
)
return review
에러 핸들링
game = Game.objects.get(pk=game_id)
- game_id가 DB에 없다면 Game.DoesNotExist 예외가 발생
game = get_object_or_404(Game, pk=game_id)
- 데이터가 없으면 즉시 HTTP 404 Not Found 응답을 발생
함수 호출 안정성 향상
* 문법을 추가함으로써
- 변경 전
create_review(user, 1, "내용", 5)처럼 호출하지만
- 변경 후
create_review(author=user, ...) 키워드를 명시하여 순서 실수 버그를 차단함
views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from .serializers import ReviewCreateSerializer
from .services import create_review
class GameReviewCreateView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, game_id):
serializer = ReviewCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
review = create_review(
user=request.user,
game_id=game_id,
content=data['content'],
rating=data['rating']
)
return Response(
{"id": review.id, "message": "리뷰가 등록되었습니다."},
status=status.HTTP_201_CREATED
)
from typing import cast
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from apps.user.models.user import User
from apps.community.services.review_create_service import create_review
from .serializers import ReviewCreateSerializer
class GameReviewCreateView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request, game_id):
serializer = ReviewCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = cast(User, request.user)
review = create_review(
author=user,
game_id=game_id,
validated_data=serializer.validated_data,
)
return Response(
{"id": review.id, "message": "리뷰가 등록되었습니다."},
status=status.HTTP_201_CREATED
)
user = cast(User, request.user)
- cast를 사용해 "이건 확실히 User 객체야"라고 알려줌
데이터 전달 방식 수정
- 이전에는
content=data['content'], rating=data['rating'] 처럼
- 변경 후에는 '검증된 데이터 뭉치(validated_data)'를 그대로 서비스에 던짐
- 나중에 필드가 추가되어도 view코드는 수정 할 필요가 없음
2026.01.13 ✅
이전에 사용한 핸들러
import logging
from typing import Any, Optional
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.views import exception_handler
from apps.core.exceptions.exception_messages import EMS
from apps.core.response.response_message import MagicException, resolve_message
logger = logging.getLogger(__name__)
def custom_exception_handler(
exc: Exception,
context: dict[str, Any],
) -> Optional[Response]:
response = exception_handler(exc, context)
"""
1. DRF 기본 예외 처리기 호출(DRF가 기본적으로 제공하는 exception_handler를 호출)
- 이를 통해 인증 실패(401), 권한 없음(403), 찾을 수 없음(404) 등의 표준 예외를
- 1차적으로 처리하여 response 객체를 받아옴
"""
if response is None:
logger.error(f"[System Error] {exc}", exc_info=True)
return Response(EMS.E500_INTERNAL_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
"""
2. 시스템 에러(500) 처리 (response가 None인 경우)
- exception_handler가 None을 반환했다는 것은
- DRF가 처리하지 못한 예외(일반적인 Python 에러, DB 에러 등)가 발생했다는 뜻임
- 이때는 서버 로그에 에러를 기록하고, 클라이언트에게는 미리 정의된
- '내부 서버 오류(E500)' 메시지를 500 상태 코드와 함께 반환함
"""
view = context.get("view")
request = context.get("request")
if isinstance(exc, MagicException):
message = resolve_message(exc.message_code, request, exc.variables)
"""
3. 커스텀 예외(MagicException) 처리
- MagicException은 개발자가 의도적으로 발생시킨 비즈니스 로직 에러
- message_code를 통해 다국어 처리 등이 적용된 실제 메시지 문자열을 가져옴
"""
detail_key = "error_detail" if str(exc.status_code).startswith(("4", "5")) else "detail"
"""
3-1. 상태 코드가 4xx, 5xx 에러인 경우 키 값을 'error_detail'로
- 그 외(성공 메시지 등)는 'detail'로 설정합니다.
"""
return Response({detail_key: message}, status=exc.status_code)
"""
3-2. 최종적으로 { "error_detail": "에러 내용" } 형태의 응답을 반환
"""
if isinstance(exc, ValidationError):
message = getattr(view, "validation_error_message", "유효하지 않은 요청입니다.")
"""
4. 유효성 검사 실패(ValidationError, 400) 처리
- ViewSet이나 View에 'validation_error_message' 속성이 있다면
- 그 메시지를 쓰고, 없으면 기본 메시지를 사용
"""
response.data = {
"error_detail": message,
"errors": exc.detail,
}
return response
"""
4-1. 400 에러의 경우 구조를 변경
- error_detail: 사용자에게 보여줄 간단한 안내 메시지 (예: "입력값이 잘못되었습니다.")
- errors: 실제 필드별 에러 상세 내용 (예: {"email": ["형식이 올바르지 않습니다."]})
"""
if isinstance(response.data, dict) and "detail" in response.data:
response.data = {"error_detail": str(response.data["detail"])}
"""
5. 기타 DRF 표준 에러(401, 403, 404 등) 키 통일
- DRF는 기본적으로 에러 메시지를 {"detail": "..."} 키에 담아 보냄
- 클라이언트(프론트엔드)가 일관된 키("error_detail")로 메시지를 파싱할 수 있도록
- 키 이름을 "detail"에서 "error_detail"로 변경
"""
return response
새로 추가한 핸들러
- 401은 다른 앱에서도 동일한 멘트를 출력하기 때문에 여기서 정의했음
import logging
from typing import Any, Optional
from rest_framework import status
from rest_framework.exceptions import (
ValidationError,
NotAuthenticated,
AuthenticationFailed,
)
from rest_framework.response import Response
from rest_framework.views import exception_handler
logger = logging.getLogger("django")
def custom_exception_handler(
exc: Exception, context: dict[str, Any]
) -> Optional[Response]:
""" 1. 핸들러 호출 """
response = exception_handler(exc, context)
""" 2. 시스템 에러 (500) """
if response is None:
logger.error(f"[System Error] {exc}", exc_info=True)
return Response(
{"error_detail": "서버 내부 오류가 발생했습니다.", "code": "server_error"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
""" 3. 에러 메시지 포맷 통일 (Detail -> Error Detail) """
if isinstance(response.data, dict):
""" 401 인증 에러 (로그인 안 함) """
if isinstance(exc, (NotAuthenticated, AuthenticationFailed)):
response.data = {"error_detail": "로그인이 필요한 서비스입니다."}
""" 유효성 검사 실패 (400) """
if isinstance(exc, ValidationError):
view = context.get("view")
""" 뷰에 설정된 메시지 or 기본 메시지 가져오기 """
message = getattr(
view, "validation_error_message", "유효하지 않은 데이터입니다."
)
response.data = {"error_detail": message, "errors": response.data}
""" 그 외 모든 에러 (403, 404, 409 등) """
else:
""" 'detail' 키가 있으면 'error_detail'로 이름표 바꿔달기 """
if "detail" in response.data:
response.data = {"error_detail": str(response.data["detail"])}
""" 커스텀 예외에 code가 있다면 추가 """
if hasattr(exc, "default_code"):
response.data["code"] = exc.default_code
return response
2026.01.14 ✅
review list api
serializer
from rest_framework import serializers
from apps.community.models.reviews import Review
from apps.community.serializers.common.author_serializer import AuthorSerializer
class ReviewListSerializer(serializers.ModelSerializer):
author = AuthorSerializer(read_only=True)
class Meta:
model = Review
fields = [
'id',
'author',
'content',
'rating',
'view_count',
'like_count',
"created_at",
]
view
- 리뷰 등록은 로그인 권한이 필요했지만 리뷰 조회는 아무런 권한이 필요없기 때문에 각 메서드 별
방법 1
initialize_request 메서드 오버라이딩
- 요청이 들어오자마자 가장 먼저 실행됨
- GET 요청이면 인증 클래스 목록을 비워버리는 방식
- GET 요청일 때만 '인증 절차(Authenticator)' 자체를 비활성화
단점
- 이 방식을 사용하면 GET 요청 시 유효한 토큰을 보낸 정상 회원도
- '비회원(AnonymousUser)' 취급을 받음
- 단순조회는 괜찮지만 내가 쓴 리뷰에 수정/삭제 버튼을 보여주기 위해서는 사용X
class GameReviewView(APIView):
permission_classes = [IsAuthenticatedOrReadOnly]
validation_error_message = "이 필드는 필수 항목입니다."
def initialize_request(self, request, *args, **kwargs):
"""
1. 만료/오류 토큰 무시 로직
- 요청이 들어오면 가장 먼저 실행되는 메서드
- 만약 GET 요청이라면, 인증 클래스(authentication_classes)를 빈 리스트로 만듬
- 이렇게 하면 DRF는 토큰 검사를 아예 수행하지 않으므로,
- 만료된 토큰을 달고 와도 에러(401)가 나지 않고 AnonymousUser로 처리됨
"""
if request.method == "GET":
self.authentication_classes = []
return super().initialize_request(request, *args, **kwargs)
방법2
"유연한 인증 처리"(SimpleJWT기준)
- 인증을 시도하되,
- 실패하면(만료/오류) 에러를 내지 말고 조용히 비회원(Anonymous) 처리해라
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework.exceptions import AuthenticationFailed, InvalidToken
class SoftJWTAuthentication(JWTAuthentication):
"""
토큰이 유효하면 유저 인증 처리.
토큰이 만료되거나 틀려도 401 에러를 내지 않고, 그냥 비회원(None)으로 처리하여 넘김.
"""
def authenticate(self, request):
try:
return super().authenticate(request)
except (AuthenticationFailed, InvalidToken):
return None
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
class GameReviewView(APIView):
authentication_classes = [SoftJWTAuthentication]
"""
1. 커스텀 인증 클래스 적용
- 유효한 토큰: request.user = User
- 만료된 토큰: request.user = AnonymousUser (에러 X)
"""
permission_classes = [IsAuthenticatedOrReadOnly]
"""
2. 권한 설정
- POST: request.user가 로그인 상태여야 통과
- GET: 누구나 통과
"""
def get(self, request, game_id):
"""
- 여기서 request.user가 살아있음
- Serializer에 context={'request': request}를 넘겨서
- is_owner 필드를 계산할 수 있게 됨
"""
pass
방법 3
IsAuthenticatedOrReadOnly 사용
- "조회는 누구나, 등록은 회원만"을 구현하는데에 DRF가 제공하는 기본 클래스
- 이전 코드에서는 GET 요청이라도 "토큰을 보냈는데 그게 가짜라도"
- 지금 코드는 GET 요청이라도 "토큰을 보냈는데 그게 가짜라면" 401에러를 뱉음
{
"count": 152, // 전체 리뷰 개수 (100개가 아님)
"next": "http://.../?page=3", // 다음 페이지 주소
"previous": "http://.../?page=1", // 이전 페이지 주소
"results": [ ... ] // 위에서 직렬화한 데이터 10개
}
class ReviewAPIView(APIView):
permission_classes = [IsAuthenticatedOrReadOnly]
validation_error_message = "이 필드는 필수 항목입니다."
def get(self, request, game_id):
queryset = get_review_list(game_id=game_id)
paginator = ReviewPageNumberPagination()
page = paginator.paginate_queryset(queryset, request, view=self)
if page is not None:
serializer = ReviewListSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
serializer = ReviewListSerializer(queryset, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
queryset = get_review_list(game_id=game_id)
- 데이터베이스에서 데이터를 가져올 준비
- 이 시점에서 실제로 DB에서 데이터를 가져오는게 아닌 명령서를 가지고 있다 정도임
paginator = ReviewPageNumberPagination()
- APIView는 GenericView와 다르게 페이지네이션을 자동으로 해주지 않음
- 직접 ReviewPageNumberPagination 클래스의 인스턴스를 생성해야 함
- page_size=10 : "한 페이지에 10개씩 보여준다"
page = paginator.paginate_queryset(queryset, request, view=self)
- 전체 데이터 중 현재 페이지에 해당하는 부분만 잘라냄
- request.query_params를 확인해서 클라이언트가 몇 페이지를 원하는지(?page=2) 파악
- 전체 queryset에 LIMIT과 OFFSET을 적용하여 실제 DB 쿼리를 날림
serializer = ReviewListSerializer(page, many=True)
- 파이썬 객체를 프론트엔드가 이해할 수 있는 JSON 데이터로 변환
- page 변수에는 파이썬 객체(Review 모델 인스턴스)들이 담겨 있음
many=True
- 우리가 변환하려는 대상이 '하나'가 아니라 '리스트(여러 개)'임을 알려줌
- author 정보 같은 외래 키 데이터도 이때 시리얼라이저 설정에 따라 함께 변환됨
- return paginator.get_paginated_response(serializer.data)
- 변환된 데이터에 네비게이션 정보(메타데이터)를 붙여서 최종 응답을 보냄
"Like"기능(toggle)
view
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from apps.community.services.review_like_service import create_review_like
class ReviewLikeAPIView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request, review_id: int) -> Response:
is_liked, total_likes = create_review_like(
user=request.user,
review_id=review_id
)
return Response(
{
"message": "성공적으로 반영되었습니다.",
"is_liked": is_liked,
"like_count": total_likes,
},
status=status.HTTP_200_OK,
)
service
from django.db import transaction
from django.shortcuts import get_object_or_404
from apps.community.models.reviews import Review
from apps.community.models.review_like import ReviewLike
from apps.user.models.user import User
from django.http import Http404
@transaction.atomic
def create_review_like(user: User, review_id: int) -> tuple[bool, int]:
"""
1. 리뷰 가져오기 (select_for_update로 동시성 제어 - 락 걸기)
- 락을 걸지 않으면 동시에 100명이 눌렀을 때 숫자가 1만 올라갈 수 있음(동시성 문제)
"""
try:
review = Review.objects.select_for_update().get(id=review_id)
except Review.DoesNotExist:
"""서비스 레이어에서 직접 404를 띄우거나 예외를 던짐"""
raise Http404
"""
2. 중간 테이블(ReviewLike) 확인
- get_or_create: (객체, 생성여부_bool) 반환
"""
like_obj, created = ReviewLike.objects.get_or_create(
user=user,
review=review
)
if created:
review.like_count += 1
is_liked = True
else:
like_obj.delete()
review.like_count -= 1
is_liked = False
""" 3. 변경된 like_count 저장 """
review.save()
""" 4. 결과 반환 (현재 상태, 갱신된 총 개수) """
return is_liked, review.like_count
2026.01.15 ✅
수정 로직
setattr
for key, value in validated_data.items():
setattr(review, key, value)
- ex. validated_data: {"content": "수정된 내용", "rating": 5}
반복문 (for ... in ...)
- validated_data 딕셔너리에 있는 모든 키(key)와 값(value)을 하나씩 꺼냄
setattr(obj, key, value)
- obj: 속성을 바꿀 대상 객체 (review)
- key: 바꿀 속성의 이름 문자열 ("content", "rating")
- value: 새로 저장할 값 ("수정된 내용", 5)
- 기능: review.content = "수정된 내용"과 완전히 동일한 동작을 수행합니다.
update_fields
update_fields: list[str] = []
for field in ("content", "rating"):
if field in validated_data:
setattr(review, field, validated_data[field])
update_fields.append(field)
if update_fields:
question.save(update_fields=update_fields)
review.save()
if update_fields:
review.updated_at = timezone.now()
update_fields.append("updated_at")
review.save(update_fields=update_fields)
return question
- 위의 코드와 거의 동일한 기능
update_fields 옵션 사용시
- Django가 변경된 컬럼만 수정하기 때문에 DB부하를 줄일 수 있음
save(update_fields=...) 사용시
- 자동 갱신화를 사용하기 위해서는 review.save() 사용하는게 좋음
review update api
내 코드
from typing import cast
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from apps.community.serializers.review.review_update import ReviewUpdateSerializer
from apps.community.services.review.review_update_service import update_review
from apps.user.models.user import User
class ReviewUpdateAPIView(APIView):
permission_classes = [IsAuthenticated]
validation_error_message = "이 필드는 필수 항목입니다."
@extend_schema(
tags=["리뷰"],
summary="리뷰 수정 API",
request=ReviewUpdateSerializer,
responses=ReviewUpdateSerializer,
)
def patch(self, request, review_id):
self.validation_error_message = "유효하지 않은 수정 요청입니다."
serializer = ReviewUpdateSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
user = cast(User, request.user)
review = update_review(
user=user,
review_id=review_id,
validated_data=serializer.validated_data,
)
return Response(
ReviewUpdateSerializer(review).data,
status=status.HTTP_200_OK,
)
from apps.community.models.reviews import Review
from apps.community.exceptions.review_exceptions import ReviewNotFound, NotReviewAuthor
from apps.user.models.user import User
def check_and_get_review_for_update(review_id: int, user: User) -> Review:
"""
리뷰 조회 및 권한 검증 Selector
존재하지 않거나 권한이 없으면 예외를 발생시킴
"""
try:
review = Review.objects.select_for_update().get(id=review_id)
except Review.DoesNotExist:
raise ReviewNotFound()
if review.user != user:
raise NotReviewAuthor()
return review
from django.db import transaction
from typing import Any
from apps.community.models.reviews import Review
from apps.user.models.user import User
from apps.community.selectors.review_selector import check_and_get_review_for_update
@transaction.atomic
def update_review(
*, user: User, review_id: int, validated_data: dict[str, Any]
) -> Review:
"""
Selector를 통해 검증된 리뷰 객체를 가져와서
실제 필드 업데이트와 저장을 수행합니다.
"""
update_fields: list[str] = []
review = check_and_get_review_for_update(review_id=review_id, user=user)
for field in ("content", "rating"):
if field in validated_data:
setattr(review, field, validated_data[field])
update_fields.append(field)
review.save()
return review
"like"(post/delete)
현재 코드
from rest_framework.views import APIView
from rest_framework.response import Response
from typing import cast
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from apps.user.models.user import User
from apps.community.services.review_like_service import create_review_like
class ReviewLikeAPIView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request, review_id: int) -> Response:
user = cast(User, request.user)
is_liked, total_likes = create_review_like(
user=user, review_id=review_id
)
return Response(
{
"message": "성공적으로 반영되었습니다.",
"is_liked": is_liked,
"like_count": total_likes,
},
status=status.HTTP_200_OK,
)
from django.db import transaction
from apps.community.exceptions.review_exceptions import ReviewNotFound
from apps.community.models.reviews import Review
from apps.community.models.review_like import ReviewLike
from apps.user.models.user import User
@transaction.atomic
def create_review_like(user: User, review_id: int) -> tuple[bool, int]:
try:
review = Review.objects.select_for_update().get(id=review_id)
except Review.DoesNotExist:
raise ReviewNotFound()
like_obj, created = ReviewLike.objects.get_or_create(user=user, review=review)
if created:
review.like_count += 1
is_liked = True
else:
like_obj.delete()
review.like_count -= 1
is_liked = False
review.save()
return is_liked, review.like_count
수정된 코드
from typing import cast
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from apps.community.services.review_like_service import (
add_review_like,
remove_review_like,
)
from apps.user.models.user import User
class ReviewLikeAPIView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request: Request, review_id: int) -> Response:
user = cast(User, request.user)
total_likes = add_review_like(user=user, review_id=review_id)
return Response(
{
"message": "좋아요를 눌렀습니다.",
"is_liked": True,
"like_count": total_likes,
},
status=status.HTTP_201_CREATED,
)
def delete(self, request: Request, review_id: int) -> Response:
user = cast(User, request.user)
total_likes = remove_review_like(user=user, review_id=review_id)
return Response(
{
"message": "좋아요를 취소했습니다.",
"is_liked": False,
"like_count": total_likes,
},
status=status.HTTP_200_OK,
)
from django.db import transaction
from apps.community.exceptions.review_exceptions import ReviewNotFound
from apps.community.models.reviews import Review
from apps.community.models.review_like import ReviewLike
from apps.user.models.user import User
@transaction.atomic
def add_review_like(user: User, review_id: int) -> int:
"""
좋아요 생성 (POST)
"""
try:
review = Review.objects.select_for_update().get(id=review_id)
except Review.DoesNotExist:
raise ReviewNotFound()
like_obj, created = ReviewLike.objects.get_or_create(user=user, review=review)
if created:
review.like_count += 1
review.save(update_fields=["like_count"])
"""
- `review.save(update_fields=["like_count"])`
- Django의 save()는 기본적으로 모든 필드를 UPDATE
- update_fields를 지정하면 딱 필요한 컬럼만 건드림
"""
return review.like_count
@transaction.atomic
def remove_review_like(user: User, review_id: int) -> int:
"""
좋아요 삭제 (DELETE)
"""
try:
review = Review.objects.select_for_update().get(id=review_id)
except Review.DoesNotExist:
raise ReviewNotFound()
deleted_count, _ = ReviewLike.objects.filter(user=user, review=review).delete()
if deleted_count > 0:
review.like_count -= 1
review.save(update_fields=["like_count"])
"""
- `review.save(update_fields=["like_count"])`
- Django의 save()는 기본적으로 모든 필드를 UPDATE
- update_fields를 지정하면 딱 필요한 컬럼만 건드림
"""
return review.like_count
deleted_count, _ = ReviewLike.objects.filter(user=user, review=review).delete()
- 해당 유저가 해당 리뷰에 남긴 좋아요 기록을 찾아서, 그 즉시 삭제하고, 몇 개가 지워졌는지 알려달라
ReviewLike.objects
- ReviewLike 테이블에 접근하기 위한 매니저
.filter(user=user, review=review)
- 조건에 맞는 데이터 찾기
- user: 요청을 보낸 유저 / review: 좋아요를 취소하려는 리뷰
delete()
- delete() 메서드는 실행 후 항상 튜플(Tuple) 형태의 값을 반환
- (총삭제개수, { '앱이름.모델명': 삭제_개수, ... })
- ex
- ReviewLike 데이터 1개가 삭제
(1, {'community.ReviewLike': 1})
Swagger update
현재 상황
- 스웨거에 yaml 정적 파일을 연결했기 때문에
- 구현한 코드들을 Django가 자동으로 스웨거에서 수정해주지 않음
- 처음 세팅할때 두개 동시 작동 불가인 줄 알았으나
- 두개가 동시에 작동 가능하다는것을 조교님을 통해 인지 수정할 생각이다.
현재 코드
from django.conf import settings
from django.conf.urls.static import static
from django.urls import URLPattern, URLResolver, include, path
from drf_spectacular.views import (
SpectacularRedocView,
SpectacularSwaggerView,
)
...
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if "debug_toolbar" in settings.INSTALLED_APPS:
urlpatterns += [path("__debug__/", include("debug_toolbar.urls"))]
if "drf_spectacular" in settings.INSTALLED_APPS:
urlpatterns += [
"""
1. YAML이 아닌 코드를 읽고 자동으로 만드는 세팅
path("api/schema", SpectacularAPIView.as_view(), name="schema"),
path("api/schema/swagger-ui", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
"""
path(
"api/schema/swagger-ui",
SpectacularSwaggerView.as_view(url="/static/swagger.yaml"),
name="swagger-ui",
),
path(
"api/schema/redoc",
SpectacularRedocView.as_view(url_name="schema"),
name="redoc",
),
]
수정 코드
SPECTACULAR_SETTINGS = {
...
"urls": [
{url: "/static/swagger.yaml", name: "1. 기획서 (Static YAML)"},
{url: "/api/schema", name: "2. 실제 구현 코드 (Live Schema)"}
],
"presets": [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
]
}""",
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
"SECURITY": [
{
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
}
}
],
}
...
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if "debug_toolbar" in settings.INSTALLED_APPS:
urlpatterns += [path("__debug__/", include("debug_toolbar.urls"))]
if "drf_spectacular" in settings.INSTALLED_APPS:
urlpatterns += [
path("api/schema", SpectacularAPIView.as_view(), name="schema"),
path(
"api/schema/swagger-ui",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path(
"api/schema/redoc",
SpectacularRedocView.as_view(url_name="schema"),
name="redoc",
),
]
2026.01.16 ✅
코드 수정
review create
404 에러의 출력 메세지를 커스텀하기
- 리뷰 등록 api에서 발생하는 404에러메세지를 내가 원하는 멘트로 변경하기 위함
from django.db import transaction
from django.shortcuts import get_object_or_404
from typing import Any
from apps.community.models.reviews import Review
from apps.game.models.game import Game
from apps.user.models.user import User
@transaction.atomic
def create_review(
*, author: User, game_id: int, validated_data: dict[str, Any]
) -> Review:
"""
리뷰 생성 비즈니스 로직
"""
game = get_object_or_404(Game, pk=game_id)
review = Review.objects.create(
user=author,
game=game,
content=validated_data["content"],
rating=validated_data["rating"],
)
return review
이후
- 404에러메세지를 exception을 이용하여 원하는 멘트로 변경
class GameNotFound(APIException):
status_code = status.HTTP_404_NOT_FOUND
default_detail = "존재하지 않는 게임입니다."
default_code = "game_not_found"
def create_review(
*, author: User, game_id: int, validated_data: dict[str, Any]
) -> Review:
"""
리뷰 생성 비즈니스 로직
"""
try:
game = Game.objects.get(pk=game_id)
except Game.DoesNotExist:
raise GameNotFound()
review = Review.objects.create(
user=author,
game=game,
content=validated_data["content"],
rating=validated_data["rating"],
)
return review
review update
리뷰 수정api에 연결된 시리얼라이즈 변경
장점
- ReviewCreateSerializer
rating 필드에 대한 커스텀 에러 메시지(1~5 사이 정수)가 정의되어 있음
- 이를 재사용함으로써 수정 API에서도 동일한 에러 메시지 사용 가능
- "특정 컬럼의 수정은 불가능" 같은 조건이 존재하면 분리해야함
def patch(self, request, review_id):
self.validation_error_message = "유효하지 않은 수정 요청입니다."
serializer = ReviewUpdateSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
class ReviewUpdateAPIView(APIView):
permission_classes = [IsAuthenticated]
validation_error_message = "이 필드는 필수 항목입니다."
@extend_schema(
tags=["리뷰"],
summary="리뷰 수정 API",
request=ReviewCreateSerializer,
responses=ReviewCreateSerializer,
)
def patch(self, request, review_id):
self.validation_error_message = "유효하지 않은 수정 요청입니다."
serializer = ReviewCreateSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
user = cast(User, request.user)
review = update_review(
user=user,
review_id=review_id,
validated_data=serializer.validated_data,
)
return Response(
ReviewCreateSerializer(review).data,
status=status.HTTP_200_OK,
)
review delete api
구현 코드
from apps.community.models.reviews import Review
from apps.community.exceptions.review_exceptions import ReviewNotFound, NotReviewAuthor
from apps.user.models.user import User
def check_and_get_review_for_update(review_id: int, user: User) -> Review:
"""
리뷰 조회 및 권한 검증 Selector
존재하지 않거나 권한이 없으면 예외를 발생시킴
"""
try:
review = Review.objects.select_for_update().get(id=review_id)
except Review.DoesNotExist:
raise ReviewNotFound()
if review.user != user:
raise NotReviewAuthor()
return review
from apps.community.selectors.review_selector import check_and_get_review_for_update
from apps.user.models.user import User
from django.db import transaction
@transaction.atomic
def delete_review(review_id: int, user: User) -> None:
review = check_and_get_review_for_update(review_id=review_id, user=user)
review.is_deleted = True
review.save(update_fields=["is_deleted"])
@extend_schema(tags=["리뷰"], summary="리뷰 삭제 API")
def delete(self, request, review_id):
delete_review(review_id=review_id, user=cast(User, request.user))
return Response(
{"message": "리뷰가 삭제되었습니다."}, status=status.HTTP_204_NO_CONTENT
)
멘토링
model
review = models.ForeignKey(...)
- Django가 자동으로 review_id에 인덱스를 생성
indexes = [models.Index(fields=['review'])]
- 기본 인덱스 외에 동일한 컬럼에 인덱스를 하나 더 만듦
indexes = [models.Index(fields=['review', 'created_at'])]
- '특정 리뷰의 댓글을 작성일순으로 조회'할 때 성능 최적화
from django.db import models
from apps.core.models import TimeStampedModel
from apps.community.models.reviews import Review
from apps.user.models.user import User
class Comment(TimeStampedModel):
"""
리뷰 댓글 저장 테이블
"""
review = models.ForeignKey(
Review,
on_delete=models.CASCADE,
related_name="comments",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="written_comments",
)
content = models.TextField(verbose_name="댓글내용")
is_deleted = models.BooleanField(default=False, verbose_name="삭제 여부")
class Meta:
db_table = "comments"
from django.db import models
from apps.core.models import TimeStampedModel
from apps.community.models.reviews import Review
from apps.user.models.user import User
class ReviewComment(TimeStampedModel):
"""
리뷰 댓글 저장 테이블
"""
review = models.ForeignKey(
Review,
on_delete=models.CASCADE,
related_name="comments",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="written_comments",
)
content = models.TextField(verbose_name="댓글내용")
is_deleted = models.BooleanField(default=False, verbose_name="삭제 여부")
class Meta:
db_table = "comments"
indexes = [
models.Index(fields=['review']),
]
코드 중복
중복 코드는 어디에 적어놓고 불러와서 사용하자
from django.db import transaction
from apps.community.exceptions.review_exceptions import ReviewNotFound
from apps.community.models.reviews import Review
from apps.community.models.review_like import ReviewLike
from apps.user.models.user import User
@transaction.atomic
def add_review_like(user: User, review_id: int) -> int:
"""
좋아요 생성 (POST)
"""
try:
review = Review.objects.select_for_update().get(id=review_id)
except Review.DoesNotExist:
raise ReviewNotFound()
like_obj, created = ReviewLike.objects.get_or_create(user=user, review=review)
if created:
review.like_count += 1
review.save(update_fields=["like_count"])
return review.like_count
@transaction.atomic
def remove_review_like(user: User, review_id: int) -> int:
"""
좋아요 삭제 (DELETE)
"""
try:
review = Review.objects.select_for_update().get(id=review_id)
except Review.DoesNotExist:
raise ReviewNotFound()
deleted_count, _ = ReviewLike.objects.filter(user=user, review=review).delete()
if deleted_count > 0:
review.like_count -= 1
review.save(update_fields=["like_count"])
return review.like_count
from django.db import transaction
from apps.community.exceptions.review_exceptions import ReviewNotFound
from apps.community.models.reviews import Review
from apps.community.models.review_like import ReviewLike
from apps.user.models.user import User
def _get_review_with_lock(review_id: int) -> Review:
"""
리뷰를 Lock과 함께 조회하고, 없으면 예외를 발생시킴
"""
try:
return Review.objects.select_for_update().get(id=review_id)
except Review.DoesNotExist:
raise ReviewNotFound()
@transaction.atomic
def add_review_like(user: User, review_id: int) -> int:
"""
좋아요 생성 (POST)
"""
review = _get_review_with_lock(review_id)
like_obj, created = ReviewLike.objects.get_or_create(user=user, review=review)
if created:
review.like_count += 1
review.save(update_fields=["like_count"])
return review.like_count
@transaction.atomic
def remove_review_like(user: User, review_id: int) -> int:
"""
좋아요 삭제 (DELETE)
"""
review = _get_review_with_lock(review_id)
deleted_count, _ = ReviewLike.objects.filter(user=user, review=review).delete()
if deleted_count > 0:
review.like_count -= 1
review.save(update_fields=["like_count"])
return review.like_count