Union Project - Code - 2주차

김기훈·2026년 1월 12일

부트캠프 프로젝트

목록 보기
32/39
post-thumbnail

[2026.01.12] review create api 구현

[2026.01.13] review create api 다듬기

  • exception_handler 작업

[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):
        # 별점 유효성 검사 (예: 1~5점)
        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:
    """
    리뷰 생성 비즈니스 로직
    """
    # 500대신 404 띄우기
    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):
        # 1. 입력 데이터 검증 
        serializer = ReviewCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        
        # 2. 서비스 레이어 호출
        data = serializer.validated_data
        review = create_review(
            user=request.user,
            game_id=game_id,
            content=data['content'],
            rating=data['rating']
        )
        
        # 3. 응답 반환
        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):
        # 1. 입력 데이터 검증
        serializer = ReviewCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        # 2. 유저 타입 캐스팅 (Type Hinting)
        user = cast(User, request.user)

        # 3. 서비스 레이어 호출
        review = create_review(
            author=user,
            game_id=game_id,
            validated_data=serializer.validated_data,
        )

        # 4. 응답 반환
        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 ✅


exception_handler


이전에 사용한 핸들러

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) 처리해라
# apps/core/authentication.py
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):
            # 인증 실패 시 에러를 내지 않고 None 반환 -> 비회원으로 간주됨
            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):
        # 1. 서비스 레이어를 통해 QuerySet 가져오기
        queryset = get_review_list(game_id=game_id)

        # 2. 페이지네이션 객체 생성(APIView는 수동 호출 필요)
        paginator = ReviewPageNumberPagination()

        # 3. 쿼리셋을 현재 페이지에 맞게 자르기
        page = paginator.paginate_queryset(queryset, request, view=self)

        # 4. 직렬화 (Serializer)
        if page is not None:
            serializer = ReviewListSerializer(page, many=True)
            # 5. 페이지네이션된 최종 응답 반환
            return paginator.get_paginated_response(serializer.data)

        # 만약 페이지네이션 설정이 꼬여서 page가 None이면 일반 리스트 반환 (예비책)
        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 쿼리를 날림
      • ex. 1번째부터 20번째까지만 가져오기
  • serializer = ReviewListSerializer(page, many=True)
    • 파이썬 객체를 프론트엔드가 이해할 수 있는 JSON 데이터로 변환
    • page 변수에는 파이썬 객체(Review 모델 인스턴스)들이 담겨 있음
      • 이를 JSON(dict 형태)으로 바꿈
    • 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,       # True면 하트 채우기, False면 비우기
                "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:
        # [Case A] 없어서 새로 생성됨 -> 좋아요 등록
        review.like_count += 1
        is_liked = True
    else:
        # [Case B] 이미 있어서 가져옴 -> 좋아요 취소
        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)
	
    # 1
    if update_fields:
        question.save(update_fields=update_fields)
        
	# 2
    review.save()
    
    # 3. 굳이 최적화가 필요하다면 (updated_at을 수동으로 갱신해줘야 함)
    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


내 코드

# view
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 = "유효하지 않은 수정 요청입니다."

        # 1. 수정할 데이터 검증
        serializer = ReviewUpdateSerializer(data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)

        # 2. 유저 타입 캐스팅 (Type Hinting)
        user = cast(User, request.user)

        # 3. 서비스 레이어 호출 (존재 여부 및 권한 검증 포함)
        review = update_review(
            user=user,
            review_id=review_id,
            validated_data=serializer.validated_data,
        )

        # 4. 수정된 데이터 반환
        return Response(
            ReviewUpdateSerializer(review).data,
            status=status.HTTP_200_OK,
        )

  
  
# selector
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:
        # 1. 리뷰 조회
        review = Review.objects.select_for_update().get(id=review_id)  # type: ignore
    except Review.DoesNotExist:
        # 2. 존재 여부 판단 -> 실패 시 404 예외 발생
        raise ReviewNotFound()

    # 3. 작성자 본인 확인 -> 실패 시 403 예외 발생
    if review.user != user:
        raise NotReviewAuthor()

    return review

  
# service 
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] = []

    # 1. Selector 호출 (문제가 있다면 여기서 예외가 발생하여 중단)
    review = check_and_get_review_for_update(review_id=review_id, user=user)

    # 2. 데이터 업데이트
    for field in ("content", "rating"):
        if field in validated_data:
            setattr(review, field, validated_data[field])
            update_fields.append(field)

    # 3. 저장
    review.save()

    return review
  

"like"(post/delete)

현재 코드

# view
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(  # type: ignore
            user=user, review_id=review_id
        )

        return Response(
            {
                "message": "성공적으로 반영되었습니다.",
                "is_liked": is_liked,  # True면 하트 채우기, False면 비우기
                "like_count": total_likes,  # 숫자를 이 값으로 덮어씌우기
            },
            status=status.HTTP_200_OK,
        )

# service
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]:
    # 1. 리뷰 가져오기 (select_for_update로 동시성 제어 - 락 걸기)
    try:
        review = Review.objects.select_for_update().get(id=review_id)  # type: ignore
    except Review.DoesNotExist:
        raise ReviewNotFound()

    # 2. 중간 테이블(ReviewLike) 확인
    like_obj, created = ReviewLike.objects.get_or_create(user=user, review=review)  # type: ignore

    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

수정된 코드

# view 
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,
        )

# service
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:
        # 1. 리뷰 가져오기 (Lock)
        review = Review.objects.select_for_update().get(id=review_id)  # type: ignore
    except Review.DoesNotExist:
        raise ReviewNotFound()

    # 2. 좋아요 생성 (get_or_create)
    like_obj, created = ReviewLike.objects.get_or_create(user=user, review=review)  # type: ignore

    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:
        # 1. 리뷰 가져오기 (Lock)
        review = Review.objects.select_for_update().get(id=review_id)  # type: ignore
    except Review.DoesNotExist:
        raise ReviewNotFound()

    # 2. 좋아요 삭제(deleted_count = 1(삭제)/0(유지))
    deleted_count, _ = ReviewLike.objects.filter(user=user, review=review).delete()  # type: ignore

    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가 자동으로 스웨거에서 수정해주지 않음
      • 처음 세팅할때 두개 동시 작동 불가인 줄 알았으나
        • 두개가 동시에 작동 가능하다는것을 조교님을 통해 인지 수정할 생각이다.

현재 코드

# config/urls.py
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",
            ),
        ]

수정 코드

# settings

# drf-spectacular 관련 설정
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",
            }
        }
    ],
}


# config/urls.py
	
    ...

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. 코드를 읽고 자동으로 스키마를 생성하는 뷰
            path("api/schema", SpectacularAPIView.as_view(), name="schema"),
            # 2. Swagger UI 설정 수정
            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(  # type: ignore
        user=author,
        game=game,
        content=validated_data["content"],
        rating=validated_data["rating"],
    )

    return review
  • 이후

    • 404에러메세지를 exception을 이용하여 원하는 멘트로 변경
# exception

# 404 - 게임 없음 예외
class GameNotFound(APIException):
    status_code = status.HTTP_404_NOT_FOUND
    default_detail = "존재하지 않는 게임입니다."
    default_code = "game_not_found"
    
    
# service
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:
        # 게임이 없을 경우 커스텀 404 예외 발생
        raise GameNotFound()

    review = Review.objects.create(  # type: ignore
        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 = "유효하지 않은 수정 요청입니다."

        # 1. 수정할 데이터 검증
        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 = "유효하지 않은 수정 요청입니다."

        # 1. 수정할 데이터 검증 (CreateSerializer 재사용)
        serializer = ReviewCreateSerializer(data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)

        # 2. 유저 타입 캐스팅 (Type Hinting)
        user = cast(User, request.user)

        # 3. 서비스 레이어 호출
        review = update_review(
            user=user,
            review_id=review_id,
            validated_data=serializer.validated_data,
        )

        # 4. 수정된 데이터 반환
        return Response(
            ReviewCreateSerializer(review).data,
            status=status.HTTP_200_OK,
        )

review delete api

구현 코드

# selector
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:
        # 1. 리뷰 조회
        review = Review.objects.select_for_update().get(id=review_id)  # type: ignore
    except Review.DoesNotExist:
        # 2. 존재 여부 판단 -> 실패 시 404 예외 발생
        raise ReviewNotFound()

    # 3. 작성자 본인 확인 -> 실패 시 403 예외 발생
    if review.user != user:
        raise NotReviewAuthor()

    return review

# service
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"])

# view
@extend_schema(tags=["리뷰"], summary="리뷰 삭제 API")
    def delete(self, request, review_id):

        # 1. 서비스 레이어 호출 (존재 여부 및 권한 검증 포함)
        delete_review(review_id=review_id, user=cast(User, request.user))

        return Response(
            {"message": "리뷰가 삭제되었습니다."}, status=status.HTTP_204_NO_CONTENT
        )

멘토링


model

  • comment 의 모델명을 ReviewComment로 변경

  • 댓글도 리뷰 기준으로 인덱스 걸기


  • 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:
        # 1. 리뷰 가져오기 (Lock)
        review = Review.objects.select_for_update().get(id=review_id)  # type: ignore
    except Review.DoesNotExist:
        raise ReviewNotFound()

    # 2. 좋아요 생성 (get_or_create)
    like_obj, created = ReviewLike.objects.get_or_create(user=user, review=review)  # type: ignore

    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:
        # 1. 리뷰 가져오기 (Lock)
        review = Review.objects.select_for_update().get(id=review_id)  # type: ignore
    except Review.DoesNotExist:
        raise ReviewNotFound()

    # 2. 좋아요 삭제(deleted_count = 1(삭제)/0(유지))
    deleted_count, _ = ReviewLike.objects.filter(user=user, review=review).delete()  # type: ignore

    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)  # type: ignore
    except Review.DoesNotExist:
        raise ReviewNotFound()


@transaction.atomic
def add_review_like(user: User, review_id: int) -> int:
    """
    좋아요 생성 (POST)
    """
    # 1. 공통 함수 호출 (Lock)
    review = _get_review_with_lock(review_id)

    # 2. 좋아요 생성 (get_or_create)
    like_obj, created = ReviewLike.objects.get_or_create(user=user, review=review)  # type: ignore

    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)
    """
    # 1. 공통 함수 호출 (Lock)
    review = _get_review_with_lock(review_id)

    # 2. 좋아요 삭제
    deleted_count, _ = ReviewLike.objects.filter(user=user, review=review).delete()  # type: ignore

    if deleted_count > 0:
        review.like_count -= 1
        review.save(update_fields=["like_count"])

    return review.like_count

profile
안녕하세요.

0개의 댓글