2026/03/05 Blog - 15

김기훈·2026년 3월 5일

TIL

목록 보기
155/194
post-thumbnail

코딩테스트(4949)


기능

프로필

  • 이미지처리 필요(일단 보류)


휴지통

  • 추가 기능

    • 휴지통 비우기

      • 30일 보관
      • 삭제후에 30일이 경과되면 주기적으로 영구삭제 진행
    • 휴지통 일괄 삭제


service

def get_trashed_posts(*, user: User) -> QuerySet[Post]:
    """사용자의 삭제된 게시글(휴지통) 목록을 조회합니다."""
    # 본인이 작성한 글 중, deleted_at 필드가 비어있지 않은(삭제된) 데이터만 가져옴(내림차순 정렬)
    return Post.objects.filter(
        user=user,
        deleted_at__isnull=False
    ).order_by("-deleted_at")


def get_trashed_post_detail(*, post_id: int, user: User) -> Post:
    """휴지통 내 특정 게시글의 상세 내용을 조회합니다."""
    # 삭제된 상태의 글인지 명확히 확인하기 위해 deleted_at__isnull=False 조건을 줌
    post = Post.objects.filter(
        id=post_id,
        user=user,
        deleted_at__isnull=False
    ).first()

    if not post:
        raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)

    return post


def restore_trashed_post(*, post_id: int, user: User) -> Post:
    """휴지통에 있는 게시글을 원래 상태로 복구합니다."""
    # 복구할 대상을 찾음
    post = get_trashed_post_detail(post_id=post_id, user=user)

    # deleted_at 필드를 None으로 초기화하여 Soft Delete 상태를 해제
    post.deleted_at = None
    post.save(update_fields=["deleted_at"])

    return post


def hard_delete_post(*, post_id: int, user: User) -> None:
    """휴지통에 있는 게시글을 DB에서 영구 삭제합니다."""
    # 영구 삭제할 대상을 찾음
    post = get_trashed_post_detail(post_id=post_id, user=user)

    # DB에서 완전히 물리적 삭제를 진행
    post.delete()

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 drf_spectacular.utils import extend_schema
from typing import cast

from apps.post.services.trash.post_trash_service import get_trashed_posts, get_trashed_post_detail, \
    restore_trashed_post, hard_delete_post
from apps.user.models import User
from apps.post.serializers.post_list import PostListSerializer
from apps.post.serializers.post_detail import PostDetailSerializer
from apps.core.pagination import PostPageNumberPagination



class TrashListAPIView(APIView):
    """휴지통 목록 조회를 담당합니다."""
    permission_classes = [IsAuthenticated]
    pagination_class = PostPageNumberPagination

    @extend_schema(tags=["휴지통"], summary="휴지통 목록 조회")
    def get(self, request: Request):
        # 1. 요청한 유저 객체를 가져옴
        user = cast(User, request.user)
        # 2. 서비스 레이어를 호출
        posts = get_trashed_posts(user=user)

        # 3. 페이지네이션을 적용하여 응답
        paginator = self.pagination_class()
        page = paginator.paginate_queryset(posts, request, view=self)
        if page is not None:
            return paginator.get_paginated_response(PostListSerializer(page, many=True).data)
        return Response(PostListSerializer(posts, many=True).data)


class TrashDetailAPIView(APIView):
    """휴지통 내 게시글 상세 확인, 복구, 영구 삭제를 담당합니다."""
    permission_classes = [IsAuthenticated]

    @extend_schema(tags=["휴지통"], summary="휴지통 게시글 상세 조회")
    def get(self, request: Request, post_id: int):
        # 1. 서비스레이어 호출
        post = get_trashed_post_detail(post_id=post_id, user=cast(User, request.user))
        return Response(PostDetailSerializer(post, context={"request": request}).data)

    @extend_schema(tags=["휴지통"], summary="휴지통 게시글 복구")
    def patch(self, request: Request, post_id: int):
        # 1. 서비스 레이어를 호출
        restore_trashed_post(post_id=post_id, user=cast(User, request.user)
        return Response({"message": "게시글이 성공적으로 복구되었습니다."}, status=status.HTTP_200_OK)

    @extend_schema(tags=["휴지통"], summary="휴지통 게시글 영구 삭제")
    def delete(self, request: Request, post_id: int):
        # 1. 서비스 레이어를 호출
        hard_delete_post(post_id=post_id, user=cast(User, request.user))
        return Response(status=status.HTTP_204_NO_CONTENT)


휴지통에 있는 포스트 내용 미리보기

  • 제목을 클릭 가능한 링크로 변경하기

    • fetchTrashedPosts 함수 안에서 container.innerHTML = posts.map(...) 부분을 찾아
    • 제목이 들어가는 <td> 태그 안의 코드를 아래처럼 <a> 태그로 수정해
// 기존 코드: <div class="fw-bold text-dark">${post.title}</div>
// 👇 아래 코드로 변경하세요!

<td class="ps-4">
    <a href="#" onclick="event.preventDefault(); showTrashDetail(${post.id})" class="fw-bold text-dark text-decoration-none" style="cursor: pointer;">
        ${post.title} <i class="bi bi-box-arrow-up-right small text-muted ms-1"></i>
    </a>
</td>
  • 화면에 모달(미리보기 창) 뼈대 추가하기

    • {% block content %} 내부의 제일 하단
    • (즉, {% endblock %} 바로 위)에 아래의 부트스트랩 모달 HTML을 추가
<div class="modal fade" id="trashDetailModal" tabindex="-1" aria-hidden="true">
            <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
                <div class="modal-content rounded-4 border-0 shadow">
                    <div class="modal-header border-bottom-0 pb-0 pt-4 px-4">
                        <h5 class="modal-title fw-bold text-success" id="modalTitle">불러오는 중...</h5>
                        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                    </div>
                    <div class="modal-body p-4">
                        <div id="modalDate" class="text-muted small mb-4 pb-2 border-bottom"></div>
                        <div id="modalContent" class="text-dark" style="white-space: pre-wrap; line-height: 1.8;">내용을 불러오는 중입니다...</div>
                    </div>
                    <div class="modal-footer border-top-0 pt-0 pb-4 px-4">
                        <button type="button" class="btn btn-secondary rounded-pill px-4" data-bs-dismiss="modal">닫기</button>
                    </div>
                </div>
            </div>
        </div>

    </div>
</div>
{% endblock %}
  • 클릭 시 모달을 띄우는 자바스크립트 함수 추가하기

    • {% block extra_js %} 안쪽(기존 함수들 아래)에
      • 모달을 띄우고 데이터를 불러오는 showTrashDetail 함수를 추가
/**
     * 휴지통 게시글 제목을 클릭했을 때 모달을 띄워 상세 내용을 보여주는 함수입니다.
     */
    async function showTrashDetail(postId) {
        // 1. 부트스트랩 모달 객체를 가져와서 화면에 띄웁니다.
        const modalElement = document.getElementById('trashDetailModal');
        const modal = new bootstrap.Modal(modalElement);
        
        // 2. 데이터를 불러오기 전 로딩 상태로 텍스트를 초기화합니다.
        document.getElementById('modalTitle').innerText = '불러오는 중...';
        document.getElementById('modalDate').innerText = '';
        document.getElementById('modalContent').innerHTML = '<div class="text-center py-5"><div class="spinner-border text-success" role="status"></div></div>';
        
        modal.show();

        try {
            const token = localStorage.getItem('access_token');
            // 3. 백엔드 휴지통 상세 조회 API를 호출합니다. (이미 잘 만들어두신 TrashManageAPIView의 get 활용)
            const response = await fetch(`/api/v1/post/my/trash/${postId}/`, {
                headers: { 'Authorization': 'Bearer ' + token }
            });
            
            if (response.ok) {
                const data = await response.json();
                // 4. 받아온 데이터를 모달 창에 채워 넣습니다.
                document.getElementById('modalTitle').innerText = data.title;
                document.getElementById('modalDate').innerText = `삭제일: ${new Date(data.created_at).toLocaleDateString()}`;
                
                // 본문 내용 삽입 (휴지통이므로 에디터 없이 마크다운 원문을 텍스트로 보여줍니다)
                document.getElementById('modalContent').innerText = data.content || "내용이 없습니다.";
            } else {
                document.getElementById('modalContent').innerHTML = '<span class="text-danger">내용을 불러오지 못했습니다.</span>';
            }
        } catch (error) {
            console.error('Error:', error);
            document.getElementById('modalContent').innerHTML = '<span class="text-danger">서버 통신 오류가 발생했습니다.</span>';
        }
    }

태그 필터링

  • GET /posts/?tag=태그이름 형식의 Query Parameter를 받아 처리

service

  • 전체 글 조회

def get_global_posts(series_id: int | None = None) -> QuerySet[Post]<:
    """
    모든 사용자의 공개된 포스트 목록을 가져옵니다. (전체 피드 및 시리즈 목차용)
    """
    qs = Post.objects.filter(
        is_temp=False,
        visibility=Post.Visibility.PUBLIC,
        deleted_at__isnull=True,
    )

    # 시리즈 ID가 전달되었다면 해당 시리즈의 글만 필터링합니다.
    if series_id:
        qs = qs.filter(series_id=series_id)

    return (
        qs.select_related("user")
        .prefetch_related("tags")
        .annotate(likes_count=Count("likes", distinct=True))
        .order_by("-created_at")
    )
——————————————————————————————————————[비교]—————————————————————————————————————————
def get_global_posts(series_id: int | None = None, tag_name: str | None = None) -> QuerySet[Post]:
    """전체 피드 및 시리즈 목차, 태그 필터링용 포스트 목록을 가져옵니다."""
    
    # 1. 기본 필터링 조건 (임시글 제외, 공개글, 삭제되지 않은 글) 적용
    qs = Post.objects.filter(
        is_temp=False,
        visibility=Post.Visibility.PUBLIC,
        deleted_at__isnull=True,
    )

    # 2. 시리즈 ID가 전달되었다면 해당 시리즈의 글만 필터링 (기존 로직)
    if series_id:
        qs = qs.filter(series_id=series_id)

    # 3. [추가된 로직] 태그 이름이 전달되었다면, 연결된 태그의 이름이 일치하는 글만 필터링
    if tag_name:
        qs = qs.filter(tags__name=tag_name)

    # 4. N+1 문제 해결 및 좋아요 수 계산 후 생성일 기준 내림차순 정렬 반환
    return (
        qs.select_related("user")
        .prefetch_related("tags")
        .annotate(likes_count=Count("likes", distinct=True))
        .order_by("-created_at")
    )
  • 개인 글 조회

def get_my_published_posts(
    *, user: User, series_id: int | None = None
) -> QuerySet[Post]:
    """
    내가 작성한 글 중 공개된(발행된) 글만 가져옵니다. (내 블로그용)
    """
    qs = Post.objects.filter(
        user=user,
        is_temp=False,
        deleted_at__isnull=True,
    )

    # 전달받은 시리즈 아이디가 있다면 필터링 적용
    if series_id:
        qs = qs.filter(series_id=series_id)

    return (
        qs.select_related("user")
        .prefetch_related("tags")
        .annotate(likes_count=Count("likes", distinct=True))
        .order_by("-created_at")
    )
    
——————————————————————————————————————[비교]—————————————————————————————————————————
def get_my_published_posts(
    *, user: User, series_id: int | None = None, tag_name: str | None = None
) -> QuerySet[Post]:
    """내가 작성한 발행 글 중 조건에 맞는 글만 가져옵니다."""
    
    # 1. 내 글 중 임시저장 및 삭제되지 않은 글 필터링
    qs = Post.objects.filter(
        user=user,
        is_temp=False,
        deleted_at__isnull=True,
    )

    # 2. 시리즈 ID 필터링
    if series_id:
        qs = qs.filter(series_id=series_id)

    # 3. [추가된 로직] 태그 이름 필터링 추가
    if tag_name:
        qs = qs.filter(tags__name=tag_name)

    # 4. N+1 문제 해결 및 좋아요 수 계산 후 반환
    return (
        qs.select_related("user")
        .prefetch_related("tags")
        .annotate(likes_count=Count("likes", distinct=True))
        .order_by("-created_at")
    )

view

  • 클라이언트가 전달한 URL 파라미터(?tag=...)를 읽어서 Service 레이어에 넘겨줌
    def get(self, request: Request):
        # 1. URL에서 '?series=숫자' 값을 꺼내옵니다.
        series_id_str = request.query_params.get("series")
        series_id = (
            int(series_id_str) if series_id_str and series_id_str.isdigit() else None
        )

        # 2. 서비스 레이어 호출 시 series_id를 전달합니다.
        posts = get_global_posts(series_id=series_id)

        # 3. 페이지네이션 적용
        paginator = self.pagination_class()
        page = paginator.paginate_queryset(posts, request, view=self)

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

        return Response(PostListSerializer(posts, many=True).data)
        
——————————————————————————————————————[비교]—————————————————————————————————————————
    def get(self, request: Request):
        # 1. URL에서 '?series=숫자' 값을 꺼내옵니다.
        series_id_str = request.query_params.get("series")
        series_id = int(series_id_str) if series_id_str and series_id_str.isdigit() else None

        # 2. URL에서 '?tag=문자열' 값을 꺼내옵니다.
        tag_name = request.query_params.get("tag")

        # 3. 서비스 레이어 호출 시 series_id와 tag_name을 함께 전달합니다.
        posts = get_global_posts(series_id=series_id, tag_name=tag_name)

        # 4. 페이지네이션 적용 후 반환 
        paginator = self.pagination_class()
        page = paginator.paginate_queryset(posts, request, view=self)

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

        return Response(PostListSerializer(posts, many=True).data)
    def get(self, request: Request):
        # 1. User 타입 지정
        user = cast(User, request.user)

        # 2. URL에서 '?series=숫자' 파라미터 값을 꺼내옴
        series_id_str = request.query_params.get("series")
        series_id = (
            int(series_id_str) if series_id_str and series_id_str.isdigit() else None
        )

        # 3. 서비스 레이어 호출
        posts = get_my_published_posts(user=user, series_id=series_id)

        # 4. 페이지 네이션 적용
        paginator = self.pagination_class()
        page = paginator.paginate_queryset(posts, request, view=self)

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

        return Response(PostListSerializer(posts, many=True).data)
——————————————————————————————————————[비교]—————————————————————————————————————————
    def get(self, request: Request):
        # 1. User 타입 지정
        user = cast(User, request.user)

        # 2. URL 파라미터에서 series 값을 꺼내옴
        series_id_str = request.query_params.get("series")
        series_id = int(series_id_str) if series_id_str and series_id_str.isdigit() else None

        # 3. URL 파라미터에서 tag 값을 꺼내옵니다.
        tag_name = request.query_params.get("tag")

        # 4. 서비스 레이어 호출 시 시리즈와 태그 조건 전달
        posts = get_my_published_posts(user=user, series_id=series_id, tag_name=tag_name)

        # 5. 페이지 네이션 적용 및 응답
        paginator = self.pagination_class()
        page = paginator.paginate_queryset(posts, request, view=self)

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

        return Response(PostListSerializer(posts, many=True).data)

홈페이지 태그

  • 태그 개수 카운트

    • 현재 전체 블로그 글을 기준으로 태그개수를 카운트함
    • 로그인한 유저가 작성한 글을 기준으로 태그 개수 파악

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"
        )  # 게시글이 많은 태그부터 내림차순 정렬하고, 개수가 같으면 이름순으로 정렬
    )


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

    # 1. 태그 개수를 집계하기 위한 기본 필터 조건을 Q 객체로 정의합니다.
    filter_conditions = Q(
        posts__is_temp=False, # 임시 저장된 글은 제외
        posts__deleted_at__isnull=True, # 휴지통에 들어간(삭제된) 글 제외
        # 내 태그를 볼 때는 비공개 글도 포함할지 고민하다가 전체 공개 글만 세도록 함
        posts__visibility=Post.Visibility.PUBLIC
    )

    # 2. 만약 인자로 user 객체가 넘어왔고, 그 유저가 인증된 사용자라면
    if user and user.is_authenticated:
        # 조건에 '해당 유저가 작성한 글'이라는 조건을 AND(&=)로 추가 결합
        filter_conditions &= Q(posts__author=user)

    # 3. 위에서 만든 필터 조건을 바탕으로 카운트(개수 세기) 기준을 생성
    valid_post_count = Count(
        "posts",  # 태그 모델과 연결된 Post 모델을 참조
        filter=filter_conditions, # 위에서 만든 필터 조건을 적용하여 개수를 카운트
        distinct=True,  # 동일한 게시글이 여러 번 세어지는 것을 방지(중복 제거)
    )

    # 4. 최종적으로 조건이 반영된 태그 목록을 데이터베이스에서 가져와 반환
    return (
        Tag.objects.annotate( # 태그 객체에 새로운 가상의 필드를 추가
            post_count=valid_post_count # 위에서 만든 카운트 기준을 'post_count'라는 이름의 필드로 저장
        )
        .filter(post_count__gt=0)  # 게시글 수가 0개 초과(즉, 1개 이상)인 태그들만 남김
        .order_by( # 정렬 기준을 지정
            "-post_count", "name" # 첫 번째 기준: 개수 내림차순(많은 순), 두 번째 기준: 태그 이름 오름차순(가나다순)
        )
    )

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)
        
——————————————————————————————————————[비교]—————————————————————————————————————————
class TagListAPIView(APIView):
    # 비회원도 볼 수 있도록 권한을 모두에게 허용
    permission_classes = [AllowAny]

    @extend_schema(tags=["태그"], summary="전체 태그 및 게시글 갯수 통계 조회")
    def get(self, request):
        # 1. 서비스레이어 호출
        tags = get_tags_with_post_counts()
        
        # 2. 데이터를 JSON으로 변환(여러 개이므로 many=True)
        serializer = TagStatSerializer(tags, many=True)
        
        return Response(serializer.data)


class MyTagListAPIView(APIView):
    permission_classes = [IsAuthenticated]
    
    @extend_schema(tags=["태그"], summary="내가 작성한 글의 태그 통계 조회")
    def get(self, request): 
        # 1. 서비스레이어 호출
        tags = get_tags_with_post_counts(user=request.user)

        # 2. 가져온 내 태그 데이터를 JSON 형태로 변환
        serializer = TagStatSerializer(tags, many=True)

        return Response(serializer.data)

profile
안녕하세요.

0개의 댓글