2026/03/03 Blog - 13

김기훈·2026년 3월 3일

TIL

목록 보기
153/194
post-thumbnail

코딩테스트(30802)


코드 작업


포스트 시리즈 지정

  • post 생성 api에서 시리얼라이저와 서비스로직에 series 데이터를 저장하는 처리 추가 필요
    • 시리얼라이저에서 시리즈를 입력받도록 추가하고
    • 서비스 로직에서 해당 시리즈가 현재 포스트를 작성하는 유저의 소유인지 검증하는 로직을 추가
    • 생성과 업데이트 전체에서 주체를 검사하는 로직을 추가함

serializer


class PostCreateSerializer(serializers.ModelSerializer):
	
    ...
    
    series = serializers.PrimaryKeyRelatedField(
        queryset=Series.objects.all(),  # 존재하는 모든 시리즈 중에서 찾도록 쿼리셋 지정
        required=False,  # 시리즈 지정은 필수가 아니므로 False
        allow_null=True  # 빈 값도 허용하여 시리즈 없이도 글을 쓸 수 있게 함
    )

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

service

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

    # 2. 클라이언트가 전달한 시리즈 객체를 뽑음 (없으면 None)
    series = validated_data.pop("series", None)

    # 전달받은 시리즈가 있다면, 그 시리즈를 만든 사람(user)이 현재 글 작성자(author)와 일치하는지 확인
    if series and series.user != author:
        raise BaseCustomException(ErrorMessage.SERIES_PERMISSION_DENIED)

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

    # 4. 게시글을 먼저 생성합니다.
    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),
        series=series,
    )
    
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)

    # # series 객체를 가져옵니다. (없을 경우 None)
    if "series" in validated_data:
        series = validated_data.get("series")
        
        # 수정하려는 시리즈가 존재하는데, 그 시리즈의 주인이 현재 수정을 요청한 유저가 아니라면 차단합니다.
        if series and series.user != user:
            # 타인의 시리즈를 도용하려는 시도이므로 예외를 발생시킵니다.
            raise BaseCustomException(ErrorMessage.SERIES_PERMISSION_DENIED)

    # 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()

시리즈 Update

service

def update_series(*, series_id: int, user: User, name: str) -> Series:
    """기존 시리즈의 이름을 변경하는 서비스 로직입니다."""

    # 1. 전달받은 series_id와 user 정보로 내 시리즈가 맞는지 데이터베이스에서 조회합니다.
    series = Series.objects.filter(id=series_id, user=user).first()

    # 2. 조회된 시리즈가 없다면 (남의 것이거나 없는 ID일 경우) 에러를 발생시킵니다.
    if not series:
        raise BaseCustomException(ErrorMessage.SERIES_SERVICE_PERMISSION)

    try:
        # 3. 모델 객체의 이름을 새로운 이름으로 변경
        series.name = name

        # 4. 변경된 이름만 데이터베이스에 업데이트
        series.save(update_fields=["name"])

        return series

    # 6. 만약 변경하려는 이름이 이미 내가 가진 다른 시리즈 이름과 겹친다면 (UniqueConstraint 위반)
    except IntegrityError:
        raise BaseCustomException(ErrorMessage.SERIES_ALREADY_EXISTS)
        

view

    def put(self, request: Request, series_id: int):
        # 1. 입력데이터 검증
        serializer = SeriesCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        # 2. User 타입 지정
        user = cast(User, request.user)

        # 3. 서비스 레이어 호출
        updated_series = update_series(
            series_id=series_id,
            user=user,
            name=serializer.validated_data["name"]
        )

        return Response(
            {"id": updated_series.id, "message": "시리즈가 성공적으로 수정되었습니다."},
            status=status.HTTP_200_OK,
        )

시리즈 Delete

service

def delete_series(*, series_id: int, user: User) -> None:
    """본인의 시리즈를 삭제하는 서비스 로직입니다."""

    # 1. 전달받은 series_id와 user 정보로 삭제할 대상 시리즈를 조회합니다.
    series = Series.objects.filter(id=series_id, user=user).first()

    # 2. 대상 시리즈가 없다면 에러를 발생시켜 비정상적인 삭제 요청을 차단합니다.
    if not series:
        raise BaseCustomException(ErrorMessage.SERIES_SERVICE_PERMISSION)

    # 3. 권한이 확인되었으므로 시리즈를 데이터베이스에서 완전히 삭제(Hard Delete)합니다.
    series.delete()

view

    @extend_schema(tags=["시리즈"], summary="시리즈 삭제")
    def delete(self, request: Request, series_id: int):
        # 1. User 타입 지정
        user = cast(User, request.user)

        # 2. 서비스 레이어 호출
        delete_series(series_id=series_id, user=user)

        return Response(status=status.HTTP_204_NO_CONTENT)

결과


디자인 도입

포스트 작성 / 수정에 시리즈 추가


코드 수정

게시글 상세 조회 시 시리즈 정보 넘겨주기

class PostDetailSerializer(serializers.ModelSerializer):
    author_nickname = serializers.CharField(source="user.nickname", read_only=True)
    tags = serializers.SlugRelatedField(many=True, read_only=True, slug_field="name") 
    likes_count = serializers.IntegerField(read_only=True)
    is_liked = serializers.SerializerMethodField()

    # [추가] 시리즈 ID와 시리즈 이름을 Post 모델의 외래키(series)에서 빼옴
    series_id = serializers.IntegerField(source="series.id", read_only=True)
    series_name = serializers.CharField(source="series.name", read_only=True)

    class Meta:
        model = Post
        fields = [
            "id",
            "title",
            "content",
            "thumbnail",
            "author_nickname",
            "created_at",
            "visibility",
            "tags",
            "likes_count",
            "is_liked",
            "series_id",   # [추가] fields에 등록
            "series_name", # [추가] fields에 등록
        ]

——————————————————————————————————————[비교]—————————————————————————————————————————
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_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(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)

이론

PrimaryKeyRelatedField

  • 클라이언트(프론트엔드)와 서버 간에 "ID(숫자)를 주고받으면
    • 백엔드에서 알아서 실제 데이터베이스 객체(인스턴스)로 변환해 주는 마법 같은 번역기" 역할
  • 요청을 받을 때 (Deserialization)

    • 프론트엔드에서 {"series": 5} 처럼 시리즈의 ID(PK) 값만 숫자로 보내면
      • 이 필드가 데이터베이스를 뒤져서 "5번 시리즈 객체"를 찾아옵니다.
      • (만약 5번 시리즈가 DB에 없다면 자동으로 에러를 뱉어냅니다.)
  • 응답을 보낼 때 (Serialization)

    • 반대로 백엔드에서 프론트엔드로 데이터를 줄 때는 무거운 전체 객체 정보 대신
    • 깔끔하게 ID 숫자(5) 로 변환해서 보내줍니다.
  • 예시

# 클라이언트가 숫자로 된 ID를 보내면, 이를 Series 모델의 객체로 변환해주는 필드입니다.
series = serializers.PrimaryKeyRelatedField(
    
    # 1. queryset: 클라이언트가 보낸 ID(예: 5)가 유효한지 검사할 '탐색 범위'를 지정합니다.
    # "Series 테이블에 존재하는 모든 데이터(all) 중에서 클라이언트가 보낸 ID와 일치하는 것을 찾아라"는 뜻입니다.
    queryset=Series.objects.all(),
    
    # 2. required: 글을 작성할 때 반드시 시리즈를 지정해야 하는지 묻는 옵션입니다.
    # 시리즈 없이 단독으로 포스트를 작성할 수도 있어야 하므로 False로 설정하는 것이 최선입니다.
    required=False,
    
    # 3. allow_null: 클라이언트가 값을 비워서(null) 보내는 것을 허용할지 묻는 옵션입니다.
    # '선택 안 함' 상태를 처리하기 위해 null을 허용하는 것이 좋은 설계입니다.
    allow_null=True
)
  • 이거 사용 안하면?

    • view / service에서 아래와 같은 코드를 추가해야 함
# [최선이 아닌 나쁜 예시] 수동으로 DB를 뒤져야 함
series_id = request.data.get('series_id')
if series_id:
    try:
        series = Series.objects.get(id=series_id) # 직접 찾기
    except Series.DoesNotExist:
        raise ValidationError("존재하지 않는 시리즈입니다.") # 직접 에러 처리

기본 세팅 해석

  • on_delete=models.SET_NULL, null=True
    • 시리즈를 삭제하더라도 그 안에 속했던 포스트들은 지워지지 않고 단지 '시리즈 없음' 상태로 안전하게 남게 됨
class Post(TimeStampedModel):
    class Visibility(models.TextChoices):
        PUBLIC = "PUBLIC", "전체 공개"
        PRIVATE = "PRIVATE", "비공개"

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="posts"
    )
    series = models.ForeignKey(
        Series, on_delete=models.SET_NULL, null=True, blank=True, related_name="posts"
    )
    series_order = models.PositiveSmallIntegerField(null=True, blank=True)

	...
profile
안녕하세요.

0개의 댓글