[2026.01.26] preference 작업 시작
[2026.01.27]
[2026.01.28]
[2026.01.29]
[2026.01.30]
2026.01.26 ✅
preference 준비
모델
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 Row | 1 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])
[
{"genre": 1},
{"genre": 2},
{"genre": 3}
]
——————————————————————————————————————[비교]—————————————————————————————————————————
{
"genre_ids": [1, 2, 3]
}
스크립트
장르데이터 넣기
from apps.game.models.genre import Genre
genre_data = [
("RPG", "롤플레잉"),
("FPS", "FPS"),
("Action", "액션"),
("Adventure", "어드벤처"),
("Simulation", "시뮬레이션"),
("Strategy", "전략"),
("Sports", "스포츠"),
("Puzzle", "퍼즐"),
("MOBA", "MOBA"),
("Racing", "레이싱"),
("Fighting", "격투"),
("Shooting", "슈팅"),
("Horror", "공포"),
("Rhythm", "리듬"),
("Card", "카드"),
]
created_count = 0
for en_name, ko_name in genre_data:
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 ✅
스크립트
장르 태그 테이터 생성
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()
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()
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 ✅
커뮤니티 ✅
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를 사용해서 너무 당연하게 사용했음
def check_and_get_review_for_update(review_id: int, user: User) -> Review:
try:
review = Review.objects.select_for_update().get(id=review_id)
except Review.DoesNotExist:
raise ReviewNotFound()
if review.user != user:
raise NotReviewAuthor()
return review
————————————————————————————————————————————————————————————————————————————————————————————————————————
@transaction.atomic
def update_review(
*, user: User, review_id: int, validated_data: dict[str, Any]
) -> Review:
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
————————————————————————————————————————————————————————————————————————————————————————————————————————
class ReviewUpdateAPIView(APIView):
permission_classes = [IsAuthenticated]
def patch(self, request, review_id):
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=review
- 원래 view에서 id만 service로 넘겨서 select_for_update().get(id) 호출했다.(1회)
- 변경후에 id를 넘기면 뷰에서 존재여부 확인할때 1번
- 서비스에서 Review.objects.get(id) 받은 아이디로 1번 호출 -> 총 2번
- 그래서 view에서 존재여부 확일할때 호출한 리뷰 인스턴스를 그냥 서비스로 던짐
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 = "유효하지 않은 수정 요청입니다."
review = self.get_review(request, review_id)
serializer = ReviewCreateSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
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):
review = self.get_review(request, review_id)
delete_review(review=review)
return Response(
{"message": "리뷰가 삭제되었습니다."}, status=status.HTTP_204_NO_CONTENT
)
def update_review(*, review: Review, validated_data: dict[str, Any]) -> Review:
"""
Selector를 통해 검증된 리뷰 객체를 가져와서
실제 필드 업데이트와 저장을 수행합니다.
"""
for field in ("content", "rating"):
if field in validated_data:
setattr(review, field, validated_data[field])
review.save()
return review
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