Union Project - Code - 4주차

김기훈·2026년 1월 30일

부트캠프 프로젝트

목록 보기
37/39
post-thumbnail

[2026.01.26] preference 작업 시작

[2026.01.27]

[2026.01.28]

[2026.01.29]

[2026.01.30]


2026.01.26 ✅

preference 준비

모델

# genre 모델

class Genre(models.Model):
    Genre = models.CharField(max_length=255)
    Genre_ko = models.CharField(max_length=255)

    class Meta:
        db_table = "genre"

# 저장 예시

| id | Genre | Genre_ko |
| :--- | :--- | :--- |
| 1 | RPG | 롤플레잉 |
| 2 | FPS | 슈팅 |
| 3 | Simulation | 시뮬레이션 |
| 4 | Strategy | 전략 |

serializer

비교 항목ModelSerializer (비추천)Serializer (추천)
입력 데이터 형태[{"genre": 1}, {"genre": 2}]{"genre_ids": [1, 2]}
매핑 방식1 JSON 객체 = 1 DB Row1 JSON 필드(List) = N DB Rows
검증 로직개별 객체 단위로 수행 (쿼리 N번 발생 가능)리스트 전체를 한 번에 검증 (id__in 사용 가능)
생성 로직기본적으로 save()를 N번 호출 (느림)bulk_create로 한 번에 저장하기 쉬움
사용 목적일반적인 CRUD (생성, 수정, 조회)특수 목적의 액션 (일괄 등록, 로그인, 이메일 발송 등)

class UserPreferenceSerializer(serializers.Serializer):
    genre_ids = serializers.ListField(
        child=serializers.IntegerField(),
        write_only=True
    )
  • serializers.ModelSerializer 를 사용하지 않는 이유

    • ModelSerializer 는 "DB 테이블 모양 그대로 데이터를 받고 싶을 때" 사용
    • 입력받는 데이터의 형태(List)와 저장되는 모델의 형태(Single Row)가 1:1로 매칭되지 않기 때문
  • 불편한 이유

    • Preference 모델은 한 줄에 하나의 장르만 저장 (User 1명 - Genre 1개)
    • 하지만 프론트엔드에서 보내고 싶은 데이터는 장르 ID들의 리스트 ([1, 2, 3])
# ModelSerializer를 쓴다면 프론트엔드가 보내야 하는 데이터 형태
[
    {"genre": 1},
    {"genre": 2},
    {"genre": 3}
]

——————————————————————————————————————[비교]—————————————————————————————————————————
# 일반 Serializer를 썼을 때 프론트엔드 데이터 형태

{
    "genre_ids": [1, 2, 3]
}

스크립트

장르데이터 넣기

from apps.game.models.genre import Genre

# 1. 생성할 장르 데이터 리스트 (영어, 한글)
genre_data = [
    ("RPG", "롤플레잉"),
    ("FPS", "FPS"),
    ("Action", "액션"),
    ("Adventure", "어드벤처"),
    ("Simulation", "시뮬레이션"),
    ("Strategy", "전략"),
    ("Sports", "스포츠"),
    ("Puzzle", "퍼즐"),
    ("MOBA", "MOBA"),
    ("Racing", "레이싱"),
    ("Fighting", "격투"),
    ("Shooting", "슈팅"),
    ("Horror", "공포"),
    ("Rhythm", "리듬"),
    ("Card", "카드"),
]

# 2. 객체 생성 (이미 존재하는지 확인 후 생성)
created_count = 0
for en_name, ko_name in genre_data:
    # get_or_create: 있으면 가져오고, 없으면 생성함
    obj, created = Genre.objects.get_or_create(
        Genre=en_name,
        defaults={"Genre_ko": ko_name}
    )
    if created:
        created_count += 1

print(f"✅ 총 {created_count}개의 새로운 장르가 등록되었습니다.")
print("현재 등록된 장르 목록:")
for g in Genre.objects.all():
    print(f"- ID: {g.id} | {g.Genre} ({g.Genre_ko})")

2026.01.27 ✅

스크립트

장르 태그 테이터 생성

  • 1단계: 기본 설정 및 장르 생성

from django.db import transaction
from apps.game.models.genre import Genre
from apps.preference.models.tag_category import TagCategory
from apps.preference.models.tag import Tag

def create_genres():
    print("🚀 [1/3] 장르 생성 시작...")
    genre_list = [
        ("RPG", "롤플레잉"), ("Action", "액션"), ("Adventure", "어드벤처"),
        ("FPS", "FPS (1인칭 슈팅)"), ("Simulation", "시뮬레이션"), ("Strategy", "전략"),
        ("Sports", "스포츠"), ("Racing", "레이싱"), ("Puzzle", "퍼즐"), ("Casual", "캐주얼"),
    ]
    for en, ko in genre_list:
        Genre.objects.get_or_create(Genre=en, defaults={'Genre_ko': ko})
    print("✅ 장르 생성 완료")

create_genres()
  • 2단계: 태그 카테고리 생성

def create_categories():
    print("🚀 [2/3] 카테고리 생성 시작...")
    data = {
        "플레이 방식": "게임의 진행 방식이나 인원 수와 관련된 태그",
        "분위기": "게임의 전반적인 분위기나 아트 스타일",
        "기능/특징": "기술적 지원이나 플랫폼 특징",
        "난이도": "게임의 난이도 관련 태그"
    }
    objs = {}
    for name, desc in data.items():
        obj, _ = TagCategory.objects.get_or_create(name=name, defaults={'description': desc})
        objs[name] = obj
    print("✅ 카테고리 생성 완료")
    return objs

cat_objs = create_categories()
  • 3단계: 태그 데이터 생성

def create_tags(cats):
    print("🚀 [3/3] 태그 생성 시작...")
    tags = [
        ("플레이 방식", "#싱글플레이", "혼자서 즐길 수 있는"),
        ("플레이 방식", "#멀티플레이", "여러 명과 함께하는"),
        ("플레이 방식", "#협동", "친구와 힘을 합쳐 클리어"),
        ("플레이 방식", "#PVP", "다른 유저와 경쟁하는"),
        ("분위기", "#힐링", "편안하고 따뜻한 분위기"),
        ("분위기", "#공포", "무섭고 긴장감 넘치는"),
        ("분위기", "#사이버펑크", "미래 지향적인 어두운 도시"),
        ("분위기", "#픽셀아트", "도트 그래픽 감성"),
        ("기능/특징", "#컨트롤러 지원", "게임패드 사용 가능"),
        ("기능/특징", "#얼리액세스", "앞서 해보기 게임"),
        ("기능/특징", "#VR", "가상현실 기기 필요"),
        ("난이도", "#로그라이크", "실패하면 처음부터, 높은 난이도"),
        ("난이도", "#소울라이크", "극악의 난이도와 성취감"),
    ]
    for c_name, t_name, label in tags:
        if c_name in cats:
            Tag.objects.get_or_create(category=cats[c_name], name=t_name, defaults={'label': label})
    print("✨ 모든 데이터 생성 완료!")

create_tags(cat_objs)

2026/01/28 ✅

커뮤니티 ✅

  • 댓글내용/리뷰내용 - 길이제한 두기
    • 리뷰 300자 댓글 150자
class ReviewCommentCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = ReviewComment
        fields = ["content"]
        extra_kwargs = {
            "content": {
                "max_length": 150,
            }
        }
class ReviewCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = Review
        fields = ["content", "rating"]
        extra_kwargs = {
            "rating": {
                "error_messages": {
                    "min_value": "별점은 1에서 5 사이의 정수여야 합니다.",
                    "max_value": "별점은 1에서 5 사이의 정수여야 합니다.",
                }
            },
            "content": {
                "max_length": 300,
            },
        }

권한검증 ✅

  • 쿼리셋 조회 / 퍼미션으로 정의 / 뷰에서 그 퍼미션으로 검증

비교 항목표준 DRF 방식현재 구현 방식 (Service/Selector)
존재 여부 (404)View 단계 (get_object_or_404 등 사용)Selector 내부 (try-except 블록)
권한 검증 (403)Permission 클래스 (IsOwner 등)Selector 내부 (if review.user != user)
트랜잭션 락View에서 객체를 가져온 후 Service 진입 (락 시점 애매함)Selector에서 select_for_update()로 확실히 잠금
장점역할 분리가 명확함 (HTTP 응답과 비즈니스 로직 분리)재사용성이 높음 (API 외에 CLI, Task 등에서도 동일 검증 수행)
단점비즈니스 로직이 View와 Permission에 분산될 수 있음Selector가 너무 많은 책임(조회+권한+락)을 가질 수 있음

이전 코드

  • "좋아요"기능에서 select_for_update를 사용해서 너무 당연하게 사용했음
    • 일반 수정은 굳이 필요없음
# selector.py
def check_and_get_review_for_update(review_id: int, user: User) -> Review:
    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.py
@transaction.atomic
def update_review(
    *, user: User, review_id: int, validated_data: dict[str, Any]
) -> Review:

    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
    
————————————————————————————————————————————————————————————————————————————————————————————————————————
# view.py
class ReviewUpdateAPIView(APIView):
    permission_classes = [IsAuthenticated]

    def patch(self, request, review_id):

        # 1. 수정할 데이터 검증
        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=review
    • 원래 view에서 id만 service로 넘겨서 select_for_update().get(id) 호출했다.(1회)
    • 변경후에 id를 넘기면 뷰에서 존재여부 확인할때 1번
      • 서비스에서 Review.objects.get(id) 받은 아이디로 1번 호출 -> 총 2번
    • 그래서 view에서 존재여부 확일할때 호출한 리뷰 인스턴스를 그냥 서비스로 던짐
# views.py
class ReviewUpdateAPIView(APIView):
    permission_classes = [IsAuthenticated, IsReviewAuthor]
    validation_error_message = "이 필드는 필수 항목입니다."

    def get_review(self, request, review_id) -> Review:
        """
        리뷰 조회 및 권한 검증을 수행
        """
        try:
            review = Review.objects.get(id=review_id)
        except Review.DoesNotExist:
            raise ReviewNotFound()

        # 권한 검사
        self.check_object_permissions(request, review)

        return review
        
    def patch(self, request, review_id):
        self.validation_error_message = "유효하지 않은 수정 요청입니다."
        # 1. 조회 & 검증
        review = self.get_review(request, review_id)

        # 2. 데이터 검증
        serializer = ReviewCreateSerializer(data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)

        # 3. 서비스 호출
        updated_review = update_review(
            review=review, validated_data=serializer.validated_data
        )
        return Response(
            ReviewCreateSerializer(updated_review).data, status=status.HTTP_200_OK
        )

    @extend_schema(tags=["리뷰"], summary="리뷰 삭제 API")
    def delete(self, request, review_id):
        # 1. 조회 & 검증
        review = self.get_review(request, review_id)

        # 2. 서비스 호출
        delete_review(review=review)

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

# service.py
def update_review(*, review: Review, validated_data: dict[str, Any]) -> Review:
    """
    Selector를 통해 검증된 리뷰 객체를 가져와서
    실제 필드 업데이트와 저장을 수행합니다.
    """

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

    # 2. 저장
    review.save()

    return review

# permissions.py
class IsReviewAuthor(permissions.BasePermission):
    """
    작성자 본인만 수정/삭제 권한을 가짐
    """

    def has_object_permission(self, request, view, obj):
        if request.method in permissions.SAFE_METHODS:
            return True

        if obj.user != request.user:
            raise NotReviewAuthor()

        return True

profile
안녕하세요.

0개의 댓글