코드 작업
포스트 시리즈 지정
- post 생성 api에서 시리얼라이저와 서비스로직에 series 데이터를 저장하는 처리 추가 필요
- 시리얼라이저에서 시리즈를 입력받도록 추가하고
- 서비스 로직에서 해당 시리즈가 현재 포스트를 작성하는 유저의 소유인지 검증하는 로직을 추가
- 생성과 업데이트 전체에서 주체를 검사하는 로직을 추가함
serializer
class PostCreateSerializer(serializers.ModelSerializer):
...
series = serializers.PrimaryKeyRelatedField(
queryset=Series.objects.all(),
required=False,
allow_null=True
)
class Meta:
model = Post
fields = [
...
"series",
]
...
service
def create_post(*, author: User, validated_data: dict[str, Any]):
"""
게시글을 생성하고 태그를 대량(Bulk)으로 처리하여 최적화한 로직입니다.
"""
tags_names = validated_data.pop("tags", [])
series = validated_data.pop("series", None)
if series and series.user != author:
raise BaseCustomException(ErrorMessage.SERIES_PERMISSION_DENIED)
content = validated_data["content"]
summary = validated_data.get("summary") or content[:150]
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):
post = Post.objects.filter(id=post_id, user=user, deleted_at__isnull=True).first()
if not post:
raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)
if "series" in validated_data:
series = validated_data.get("series")
if series and series.user != user:
raise BaseCustomException(ErrorMessage.SERIES_PERMISSION_DENIED)
tag_names = validated_data.pop("tags", None)
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:
"""기존 시리즈의 이름을 변경하는 서비스 로직입니다."""
series = Series.objects.filter(id=series_id, user=user).first()
if not series:
raise BaseCustomException(ErrorMessage.SERIES_SERVICE_PERMISSION)
try:
series.name = name
series.save(update_fields=["name"])
return series
except IntegrityError:
raise BaseCustomException(ErrorMessage.SERIES_ALREADY_EXISTS)
view
def put(self, request: Request, series_id: int):
serializer = SeriesCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = cast(User, request.user)
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:
"""본인의 시리즈를 삭제하는 서비스 로직입니다."""
series = Series.objects.filter(id=series_id, user=user).first()
if not series:
raise BaseCustomException(ErrorMessage.SERIES_SERVICE_PERMISSION)
series.delete()
view
@extend_schema(tags=["시리즈"], summary="시리즈 삭제")
def delete(self, request: Request, series_id: int):
user = cast(User, request.user)
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()
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",
"series_name",
]
——————————————————————————————————————[비교]—————————————————————————————————————————
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,
)
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):
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
posts = get_global_posts(series_id=series_id)
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)
이론
- 클라이언트(프론트엔드)와 서버 간에 "ID(숫자)를 주고받으면
- 백엔드에서 알아서 실제 데이터베이스 객체(인스턴스)로 변환해 주는 마법 같은 번역기" 역할
요청을 받을 때 (Deserialization)
- 프론트엔드에서 {"series": 5} 처럼 시리즈의 ID(PK) 값만 숫자로 보내면
- 이 필드가 데이터베이스를 뒤져서 "5번 시리즈 객체"를 찾아옵니다.
- (만약 5번 시리즈가 DB에 없다면 자동으로 에러를 뱉어냅니다.)
응답을 보낼 때 (Serialization)
- 반대로 백엔드에서 프론트엔드로 데이터를 줄 때는 무거운 전체 객체 정보 대신
- 깔끔하게 ID 숫자(5) 로 변환해서 보내줍니다.
예시
series = serializers.PrimaryKeyRelatedField(
queryset=Series.objects.all(),
required=False,
allow_null=True
)
이거 사용 안하면?
- view / service에서 아래와 같은 코드를 추가해야 함
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)
...