2026/02/19 Blog - 5

김기훈·2026년 2월 19일

TIL

목록 보기
145/194
post-thumbnail

기능 추가

포스트 공개 비공개 처리


모델 수정

class Post(TimeStampedModel):
    class Visibility(models.TextChoices):
        PUBLIC = "PUBLIC", "전체 공개"
        PRIVATE = "PRIVATE", "비공개"

	...

    # 공개 범위 설정
    visibility = models.CharField(
        max_length=10,
        choices=Visibility.choices,
        default=Visibility.PUBLIC,  # 기본값: 전체 공개
        help_text="게시글의 공개 범위를 설정합니다."
    )

조회 서비스 조건 추가

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

시리얼라이저 컬럼 추가

class PostCreateSerializer(serializers.ModelSerializer):

	...

    class Meta:
        model = Post
        fields = [
            "title",
            "content",
            "thumbnail",
            "summary",
            "is_temp",
            "tags",
            "visibility",
        ]

서비스 수정

def create_post(*, author: User, validated_data: dict[str, Any]):
    """
    게시글을 생성하고 태그를 대량(Bulk)으로 처리하여 최적화한 로직입니다.
    """
    # 1. 입력 데이터에서 태그 목록을 분리
    tags_names = validated_data.pop("tags", [])

    # 2. 요약(summary)이 없을 경우 본문에서 앞부분을 추출하여 저장
    content = validated_data["content"]
    summary = validated_data.get("summary") or content[:150]

    # 3. 게시글을 먼저 생성
    post = Post.objects.create(
        user=author,
        title=validated_data["title"],
        content=content,
        summary=summary,
        thumbnail=validated_data.get("thumbnail"),
        is_temp=validated_data.get("is_temp", False),
        visibility=validated_data.get("visibility", Post.Visibility.PUBLIC),
    )

디자인 추가


코드 분석

  • 연휴동안 했던 작업들을 분석해 봅시다!


포스트 작성 및 저장 플로우 (Create)

  • [백엔드: post/services/post_create_service.py]

    • 전달받은 is_tempvisibility 값을 그대로 데이터베이스에 저장
def create_post(*, author: User, validated_data: dict[str, Any]):
	...
    post = Post.objects.create(
        user=author,
        title=validated_data["title"],
        content=content,
        summary=summary,
        # 프론트에서 넘겨준 is_temp 값을 저장 (기본값 False)
        is_temp=validated_data.get("is_temp", False),
        # 프론트에서 넘겨준 공개 범위 값을 저장 (기본값 PUBLIC)
        visibility=validated_data.get("visibility", Post.Visibility.PUBLIC),
    )
	...
  • [프론트엔드: templates/post/write.html]

    • 사용자가 클릭한 버튼에 따라 isTemp 변수 값이 true 또는 false로 API에 전달
// 발행하기 버튼 클릭 시 -> isTemp = false
document.getElementById('postForm').addEventListener('submit', function(e) {
    e.preventDefault(); 
    savePost(false); 
});

// 임시저장 버튼 클릭 시 -> isTemp = true
document.getElementById('tempSaveBtn').addEventListener('click', function() {
    savePost(true);
});

// fetch API 요청 본문
body: JSON.stringify({
    title: title,
    content: content,
    tags: tags,
    is_temp: isTemp,        // 핵심 1: 임시저장 여부
    visibility: visibility  // 핵심 2: 공개 범위 (PUBLIC / PRIVATE)
})

목록 조회 플로우 (Read)

  • 저장된 데이터는 is_tempvisibility의 조합에 따라 3개의 각기 다른 페이지에 노출

  • 전체 피드 (Global Feed)

    • 조건: 정식 발행되었고(is_temp=False), 전체 공개인 글(visibility=PUBLIC)
    • 플로우: global_list.htmlPostAPIView.get()get_global_posts()
def get_global_posts() -> QuerySet[Post]:
    return Post.objects.filter(
        is_temp=False,                     # 임시저장글 제외
        visibility=Post.Visibility.PUBLIC, # 전체 공개만 필터링! (비공개 차단)
        deleted_at__isnull=True,
    ).select_related("user").order_by("-created_at")

  • 내 블로그

    • 조건: 내가 작성했고, 정식 발행된 글(is_temp=False). (공개/비공개 무관)
    • 플로우: my_list.htmlMyPostAPIView.get()get_my_published_posts()
def get_my_published_posts(*, user: User) -> QuerySet[Post]:
    return Post.objects.filter(
        user=user,               # 내 글만
        is_temp=False,           # 발행된 글만 (visibility 조건이 없으므로 비공개 글도 함께 조회됨)
        deleted_at__isnull=True,
    ).select_related("user").order_by("-created_at")

  • 임시 저장함 (Temp Posts)

    • 조건: 내가 작성했고, 아직 작성 중인 글(is_temp=True)
    • 플로우: temp_list.htmlMyTempAPIView.get()get_my_temp_posts()
def get_my_temp_posts(*, user: User) -> QuerySet[Post]:
    return Post.objects.filter(
        user=user,               # 내 글만
        is_temp=True,            # 임시저장 상태인 글만
        deleted_at__isnull=True,
    ).order_by("-created_at")

임시 저장글 관리 플로우 (Manage: Publish / Delete)

  • 임시 저장함(temp_list.html)에서는 작성 중인 글을 정식으로 발행하거나 삭제할 수 있습니다.
    • 발행 (PATCH): 글의 is_temp 상태를 False로 바꿔줍니다.
    • 삭제 (DELETE): 글을 실제로 지우지 않고 deleted_at 필드에 시간을 기록합니다(Soft Delete)
# apps/post/services/post_manage_service.py

def restore_temp_post(post_id: int, user: User):
    """임시글을 공개글로 전환(발행)합니다."""
    post = Post.objects.filter(id=post_id, user=user, is_temp=True).first()
    # ... 예외 처리 생략 ...
    post.is_temp = False # 상태 변경
    post.save(update_fields=["is_temp"])

def soft_delete_post(post_id: int, user: User):
    """게시글을 삭제(Soft Delete) 처리합니다."""
    post = Post.objects.filter(id=post_id, user=user, deleted_at__isnull=True).first()
    # ... 예외 처리 생략 ...
    post.deleted_at = timezone.now() # 삭제된 시간 기록
    post.save(update_fields=["deleted_at"])

Post UD

update

@transaction.atomic
def update_post(*, post_id: int, user: User, validated_data: dict):
    # 1. 권한 확인 및 존재 여부 확인
    post = Post.objects.filter(id=post_id, user=user, deleted_at__isnull=True).first()

    # 게시글이 존재하지 않거나 권한이 없는 경우
    if not post:
        raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)

    # 2. 태그 데이터가 있다면 데이터에서 태그 목록을 추출
    tag_names = validated_data.pop("tags", None)

    # 3. 전달된 데이터로 게시글 필드를 업데이트
    for attr, value in validated_data.items():
        setattr(post, attr, value)

    # 요약이 비어있다면 본문에서 자동으로 추출
    if not validated_data.get("summary"):
        post.summary = post.content[:150]

    post.save()

    # 4. 태그가 전달되었다면 기존 관계를 끊고 새로 생성(최적화)
    if tag_names is not None:
        post.posttag_set.all().delete()  # 기존의 PostTag 중간 테이블 관계를 모두 삭제

        # 신규 태그 생성 및 연결
        existing_tags = Tag.objects.filter(name__in=tag_names)  # 이미 DB에 있는 태그를 조회
        existing_names = {t.name for t in existing_tags}  # 조회된 태그 이름들을 셋(Set)으로 만듬

        new_names = set(tag_names) - existing_names  # DB에 없는 새로운 태그 이름들만 골라냄
        if new_names:
        	# 새로 추가할 태그가 있다면 한 번에 생성
            Tag.objects.bulk_create([Tag(name=name) for name in new_names])
            
		# 전체 태그 객체들을 다시 가져옴
        all_tags = Tag.objects.filter(name__in=tag_names)
        
        # 게시글과 연결
        PostTag.objects.bulk_create([PostTag(post=post, tag=tag) for tag in all_tags])  

    return post
    @extend_schema(tags=["포스트"], summary="게시글 수정", request=PostCreateSerializer)
    def put(self, request: Request, post_id: int):
        serializer = PostCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        user = cast(User, request.user)

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

        return Response(PostCreateSerializer(updated_post).data, status=status.HTTP_200_OK)

delete

    def delete(self, request: Request, post_id: int):
        user = cast(User, request.user)

        delete_post(post_id=post_id, user=user)

        return Response(status=status.HTTP_204_NO_CONTENT)
def delete_post(post_id: int, user: User):
    # 1. 본인의 게시글 중 삭제되지 않은 글을 찾음
    post = Post.objects.filter(id=post_id, user=user, deleted_at__isnull=True).first()

    # 대상이 없거나 권한이 없는 경우
    if not post:
        raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)

    # 2. 삭제 일시를 현재 시간으로 설정하여 논리적 삭제 처리
    post.deleted_at = timezone.now() # deleted_at 필드에 현재 시각을 기록
    post.save(update_fields=["deleted_at"])

profile
안녕하세요.

0개의 댓글