2026/02/23 Blog - 9

김기훈·2026년 2월 23일

TIL

목록 보기
149/194
post-thumbnail

백엔드 개발

태그

# models.py
class Post(TimeStampedModel):
	
    ...
    
    # Tag와의 M:N 관계 (Through 설정)
    tags = models.ManyToManyField(
        "tags.Tag", through="tags.PostTag", related_name="posts"
    )
  • through="tags.PostTag" 옵션

    • Django가 자동으로 숨겨진 중간 테이블을 만들도록 내버려 두지 않고
    • tags 앱에 정의된 PostTag라는 모델을 연결 테이블로 직접 명시해서 사용
  • 기존 기능에 태그 추가

class PostListSerializer(serializers.ModelSerializer):
	...
    tags = serializers.SlugRelatedField(
        many=True, read_only=True, slug_field="name"
    )

    class Meta:
        model = Post
        fields = [
					...
 		           "tags",
        ]

# service
def get_global_posts() -> QuerySet[Post]:
    """
    모든 사용자의 공개된 포스트 목록을 가져옵니다. (전체 피드용)
    """
    return (
        Post.objects.filter(
            is_temp=False,  # 임시 저장글은 제외합니다.
            visibility=Post.Visibility.PUBLIC,  # 전체 공개만 필터링
            deleted_at__isnull=True,  # 삭제되지 않은 글만 필터링합니다.
        )
        .select_related("user")
        .prefetch_related("tags")
        .annotate(likes_count=Count("likes", distinct=True))
        .order_by("-created_at")
    )
  • 태그 개수 기능 추가

# service
def get_tags_with_post_counts() -> QuerySet[Tag]:
    """태그 목록과 각 태그별 유효한 게시글 수를 반환하는 서비스 로직입니다."""

    # 1. 집계(Count)할 조건을 명확히 설정합니다.
    valid_post_count = Count(
        "posts",  # Tag 관점에서 연관된 Post의 related_name
        filter=Q(
            posts__is_temp=False,
            posts__deleted_at__isnull=True,
            posts__visibility=Post.Visibility.PUBLIC,
        ),
        distinct=True,  # 중복 카운팅을 방지합니다.
    )

    # 2. Tag 쿼리셋을 만듭니다.
    return (
        Tag.objects.annotate(
            post_count=valid_post_count
        )  # 각 태그마다 'post_count'라는 가상 컬럼을 붙여 개수를 계산합니다.
        .filter(post_count__gt=0)  # 게시글이 0개인 껍데기 태그는 필터링합니다.
        .order_by(
            "-post_count", "name"
        )  # 게시글이 많은 태그부터 내림차순 정렬하고, 개수가 같으면 이름순으로 정렬합니다.
    )

# serializer
class TagStatSerializer(serializers.ModelSerializer):
    """홈 화면 태그 클라우드용 시리얼라이저"""

    # annotate를 통해 동적으로 생성된 필드이므로 read_only=True로 선언
    post_count = serializers.IntegerField(read_only=True)

    class Meta:
        model = Tag
        fields = ["id", "name", "post_count"]

# view 
class TagListAPIView(APIView):
    """홈 화면 등에 쓰일 전체 태그 목록 및 통계를 제공합니다."""

    # 홈 화면 데이터이므로 로그인하지 않은 유저(비회원)도 볼 수 있게 AllowAny로 설정합니다.
    permission_classes = [AllowAny]

    @extend_schema(tags=["태그"], summary="전체 태그 및 게시글 갯수 통계 조회")
    def get(self, request):
        # 1. 서비스 레이어 호출
        tags = get_tags_with_post_counts()

        # 2. 시리얼라이저를 통해 JSON 형태로 변환
        serializer = TagStatSerializer(tags, many=True)

        return Response(serializer.data)


댓글

작성

  • service

def create_comment(*, post_id: int, user: User, validated_data: dict) -> Comment:
    """댓글을 생성하는 서비스 로직입니다."""

    # 1. 대상 게시글이 존재하는지, 삭제되지 않았는지 검증
    post = Post.objects.filter(id=post_id, deleted_at__isnull=True).first()

    if not post:
        raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)

    # 2. 댓글 생성 및 반환
    comment = Comment.objects.create(
        post=post, user=user, content=validated_data["content"]
    )

    return comment
  • serializer

class CommentCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = Comment
        fields = ["content"]
  • view

class CommentAPIView(APIView):
    """게시글의 댓글 작성을 담당하는 View입니다."""

    # 로그인한 유저만 댓글을 작성할 수 있도록 설정
    permission_classes = [IsAuthenticated]

    @extend_schema(tags=["댓글"], summary="댓글 작성", request=CommentCreateSerializer)
    def post(self, request: Request, post_id: int):
        # 1. 입력 데이터 검증
        serializer = CommentCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        # 2. User 타입 캐스팅 (기존 코드 컨벤션 적용)
        user = cast(User, request.user)

        # 3. 서비스 레이어 호출
        create_comment(
            post_id=post_id, user=user, validated_data=serializer.validated_data
        )

        # 4. 성공 응답 반환
        return Response(
            {"message": "댓글이 성공적으로 작성되었습니다."},
            status=status.HTTP_201_CREATED,
        )

조회

  • service

def get_post_comments(*, post_id: int) -> QuerySet[Comment]:
    """특정 게시글의 댓글 목록을 조회하는 서비스 로직입니다."""

    # 1. 대상 게시글이 유효한지 검증(존재여부 확인)
    post_exists = Post.objects.filter(id=post_id, deleted_at__isnull=True).exists()

    if not post_exists:
        raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)

    # 2. 댓글 목록 조회 및 반환
    comments = (
        Comment.objects.filter(post_id=post_id)
        .select_related("user")
        .order_by("created_at")
    )

    return comments
  • serializer

class CommentListSerializer(serializers.ModelSerializer):
    """댓글 목록 조회를 위한 시리얼라이저입니다."""

    author_nickname = serializers.CharField(source="user.nickname", read_only=True)

    class Meta:
        model = Comment
        fields = [
            "id",
            "author_nickname",
            "content",
            "created_at",
        ]
  • view

    @extend_schema(tags=["댓글"], summary="게시글 댓글 목록 조회")
    def get(self, request: Request, post_id: int):
        """GET 요청 시 특정 게시글의 댓글 목록을 페이지네이션하여 반환합니다."""

        # 1. 서비스 레이어를 호출
        comments = get_post_comments(post_id=post_id)

        # 2. 페이지네이션 객체를 생성
        paginator = self.pagination_class()

        # 3. 받아온 쿼리셋을 현재 request의 쿼리 파라미터(예: ?page=1)에 맞게 자름
        page = paginator.paginate_queryset(comments, request, view=self)

        if page is not None:
            serializer = CommentListSerializer(page, many=True)
            return paginator.get_paginated_response(serializer.data)

        return Response(CommentListSerializer(comments, many=True).data)

시리즈

  • service

  • serializer

  • view


profile
안녕하세요.

0개의 댓글