최적화 진행
Post
다른 대안
소프트 딜리트 관리 대안 (커스텀 매니저 활용)
- 현재 방식
- 삭제된 글을 제외해야 하는 뷰나 서비스에서
- 매번
deleted_at__isnull=True 조건을 수동으로 붙여줘야 할 가능성이 있음
- 대안
- 장고의
models.Manager를 상속받아 ActivePostManager를 만들고
- 기본 매니저(objects)가 항상
deleted_at__isnull=True인 쿼리셋만 반환하도록 재정의
- 삭제된 글은
all_objects = models.Manager() 같은
- 별도 매니저로만 접근하게 하면 휴먼 에러를 막을 수 있음
태그 관계 설계의 대안 (비정형 데이터 활용)
- 현재 방식
- Tag 테이블과 PostTag 중간 테이블을 생성하여 정통적인 RDBMS의 M:N 관계를 구현
- 대안
- PostgreSQL을 사용 중이라면
ArrayField(models.CharField())를 사용하거나
- 범용적인
JSONField를 사용하여 태그 문자열 배열을 하나의 컬럼에 직접 저장 가능
- 태그별 통계가 매우 중요하지 않다면
- 중간 테이블을 없애 조인 비용을 줄이고 쓰기 속도를 극대화 가능
좋아요 카운트 관리 방식의 대안 (역정규화)
- 현재 방식
- get_global_posts 등에서
Count("likes")를 사용하여
- 매번 DB 조인을 통해 좋아요 개수를 동적으로 계산
- 대안
- 트래픽이 커질 경우 쿼리 부하가 심해질 수 있음
- Post 모델 자체에 like_count라는 정수형 컬럼을 추가하고
- 좋아요가 생성/삭제될 때마다 장고의 F 객체(F('like_count') + 1)를 사용해
- 값을 업데이트(역정규화)하는 방식으로 읽기 성능을 극대화 가능
검색(Search) 구현의 대안 (전문 검색 엔진 도입)
- 현재 방식
- RDBMS의 LIKE %keyword% 연산(장고의
icontains)을 사용
- 대안
- 데이터가 많아지면
icontains는 인덱스를 타지 못해 성능이 급격히 저하
- PostgreSQL의
SearchVector를 활용한 Full-Text Search를 도입하거나,
ElasticSearch 같은 외부 전문 검색 엔진을 도입하여
- 형태소 분석 및 초고속 검색을 지원하도록 개선 가능
개선사항
구조 및 가독성 개선: 뷰(View)의 중복 코드 제거 ✅
- 이슈
- post_api.py의 PostAPIView와 MyPostAPIView를 보면
- 쿼리 파라미터(series, tag, search)를 파싱하고
- 페이징 객체(paginator)를 다루는 코드가 완전히 중복
- 개선 가이드
- DRF의 GenericAPIView 또는 ViewSet을 상속받도록 리팩토링하고
- django-filter 라이브러리를 도입하면
- 파라미터 파싱 및 페이징 코드를 직접 작성할 필요 없이
- 한두 줄의 클래스 속성 선언으로 가독성을 대폭 향상시킬 수 있음
성능 및 로직 개선: 자동 요약(Summary) 기능의 한계 ✅
- 이슈
- create_post에서 요약이 없을 때
content[:150]으로 단순 문자열 자르기를 시도함
- 만약 content가 HTML 태그를 포함한 에디터 데이터라면
- 중간에
<div> 같은 태그가 잘리면서 프론트엔드에서 렌더링 레이아웃이 깨질 위험이 있음
- 개선 가이드
- 장고의 내장 유틸리티인
django.utils.html.strip_tags를 사용하여
- 본문에서 순수 텍스트만 추출한 뒤 150자로 자르도록 수정하는 것을 권장
안정성 개선: get_or_create의 예외 처리 부족 ✅
- 이슈
add_post_like 서비스에서
Like.objects.get_or_create(post=post, user=user)를 호출
- 만약 아주 짧은 순간에 동일한 유저가 여러 번 클릭을 보내면(동시성 이슈)
- DB의
UniqueConstraint에 의해 장고에서 IntegrityError가 발생하며
- 서버가 500 에러를 뱉을 수 있음
- 해결 가이드
- 예외를 안전하게 잡아 클라이언트에게 적절한 400번대 응답을 주도록 처리해야 함
from django.db import IntegrityError
try:
Like.objects.get_or_create(post=post, user=user)
except IntegrityError:
pass
쿼리 최적화: is_liked_by_user 서브쿼리 위치 조정
- 이슈
- get_post_detail에서는 Exists를 통해 좋아요 여부를 확인하고 있음
- 하지만 피드 목록(get_global_posts 등)을 조회할 때는
- 유저가 각 게시글에 좋아요를 눌렀는지 알 수 있는 값이 내려가지 않음
- 이로 인해 프론트엔드에서 목록 화면의 하트 아이콘을 칠해야 할지 말아야 할지 판단하기 어려움
- 해결 가이드
- 목록 조회 서비스 함수에도 인증된 유저가 요청을 보낸 경우
Exists(is_liked_subquery) 조건을 annotate에 추가하여
- 목록에서도 자신의 좋아요 여부를 파악할 수 있도록 프론트엔드-백엔드 간의 데이터 규격을 맞추가
다른 대안
작성자 권한 검증 방식의 대안 (DRF Permission 클래스 활용)
- 현재 방식
- 서비스 레이어 내부에서 if comment.user != user: 조건문을 통해 직접 에러를 발생시킴
- 대안
- 비즈니스 로직에 도달하기 전, 뷰(View) 단계에서 DRF의
- 커스텀 퍼미션(BasePermission 상속)인
IsOwnerOrReadOnly 같은
- 클래스를 만들어
permission_classes에 선언하는 방법 존재
- 이렇게 하면 권한 체크 로직을 View 계층의 앞단으로 분리 가능
댓글 구조화 대안 (계층형/대댓글 구조)
- 현재 방식
- 모든 댓글이 게시글 하나에만 종속되는 1차원적인 '평면(Flat) 구조'
- 대안
- 모델에 자기 자신을 참조하는
parent = models.ForeignKey('self', null=True) 필드를 추가
django-mptt 라이브러리를 도입하여
- '대댓글(Nested Comments)' 기능을 지원하는 트리(Tree) 구조로 확장 가능
개선 사항
성능 오버헤드 개선: 불필요한 트랜잭션(@transaction.atomic) 제거
- 이슈
- update_comment_service.py와 delete_comment_service.py 상단에
- @transaction.atomic 데코레이터가 선언되어 있음
- 이 로직들은 단순히 단일 레코드 하나를 save() 하거나 delete() 하는 작업만 수행
- 장고는 단일 쿼리에 대해 기본적으로 Auto-commit 속성을 통한 원자성을 보장하므로
- 별도의 트랜잭션 블록을 생성하는 것은 DB 연결 자원을 미세하게 낭비하는 오버헤드가 될 수 있음
- 해결 가이드
- 다중 쿼리(여러 테이블을 동시에 수정)가 아니므로
- 해당 파일들에서 @transaction.atomic 데코레이터를 제거하여 성능을 최적화하는 것을 권장
논리적 무결성 개선: 뷰(View)의 퍼미션(Permission) 설정 수정
- 이슈
- 수정과 삭제를 담당하는 CommentManageAPIView의
permission_classes가 [IsAuthenticatedOrReadOnly]로 설정되어 있음
- 이 엔드포인트는 PUT과 DELETE 메서드만 존재하며 읽기(GET) 메서드가 없음
- 즉, 비로그인 유저가 읽기 용도로 접근할 일이 없는 API
- 해결 가이드
- 비로그인 유저의 잘못된 요청이 서비스 레이어까지 도달하여 불필요한 연산을 발생시키지 않도록
CommentManageAPIView의 권한을
permission_classes = [IsAuthenticated]로 변경하여
- 진입 단계에서부터 401 Unauthorized 에러로 튕겨내야 함
데이터 보존 정책: 댓글 물리 삭제(Hard Delete)의 한계
- 이슈
- 게시글(Post) 시스템은
deleted_at을 활용한 소프트 딜리트(휴지통)를 구현하여
- 실수로 삭제한 데이터를 복구할 수 있는 반면
- 댓글 삭제 서비스는
comment.delete()를 호출하여
- DB에서 데이터를 영구히 날려버리고(Hard Delete) 있음
- 해결 가이드
- 사용자 경험의 일관성을 맞추거나 악의적인 댓글 작성 후 삭제(증거 인멸)를 방어하기 위해
- Comment 모델에도 deleted_at 필드를 추가하여
- 소프트 딜리트 방식으로 리팩토링하는 것을 권장
Tag
다른 대안
다대다(M:N) 관계 설계의 대안 (PostgreSQL ArrayField 도입)
- 현재 방식
- Tag, PostTag, Post 3개의 테이블을 쪼개어 정규화하고, 조인(JOIN)을 통해 관계를 형성
- 대안
- 만약 PostgreSQL을 사용한다면 RDBMS의 조인 비용을 아예 없애기 위해
- Post 모델에
tags = ArrayField(models.CharField(...))를 선언하여
- 태그들을 문자열 배열 형태로 한 컬럼에 직접 밀어 넣는 반정규화 방식을 취할 수 있음
- 읽기 속도는 극한으로 빨라지지만, 특정 태그의 이름을 일괄 변경할 때 다소 번거로울 수 있음
태그 카운트(집계) 로직의 대안 (역정규화 방식)
- 현재 방식
- API가 호출될 때마다 서비스 함수에서 동적으로 GROUP BY와 COUNT 쿼리를 실행하여 개수를 계산
- 대안
- 태그와 게시글이 수십만 개로 늘어나면 매번 카운트를 세는 것이 무거워짐
- Tag 모델에
post_count라는 정수형 컬럼을 추가하고
- 글에 태그가 달리거나 삭제될 때마다 post_count의 숫자를 +1, -1 업데이트해 두는 방식
- 이렇게 하면 조회 API에서 단순히 값을 읽어오기만 하면 되므로 응답 속도가 획기적으로 개선
개선 사항
데이터 무결성 개선: 휴지통(Soft Delete) 게시글 제외 처리
- 이슈
tag_count_service.py에서 태그 수를 집계할 때, 단순히 연결된 글의 개수만 세게 되면
- 사용자가 휴지통에 버린 글(deleted_at__isnull=False)이나 비공개 글까지
- 집계에 포함될 우려가 높음
- 이러면 화면에는 "Django 태그 (5개)"라고 뜨는데
- 클릭해 보면 공개된 글이 3개밖에 없는 데이터 불일치가 발생함
- 개선 가이드
- 집계 쿼리 내부에 filter 조건을 명시하여 '살아있는 공개 글'만 카운트하고
- 카운트가 0인 태그는 목록에서 제외하는 로직을 추가해야 함
from django.db.models import Count, Q
tags_with_count = Tag.objects.annotate(
valid_post_count=Count(
'posts',
filter=Q(posts__deleted_at__isnull=True, posts__visibility='PUBLIC')
)
).filter(valid_post_count__gt=0)
성능 최적화: 통계 API 캐싱(Caching) 적용
- 이슈
- 태그 클라우드(목록 및 통계)는 블로그 방문자들이 메인 페이지에 접속할 때마다
- 매우 빈번하게 호출되지만, 매초마다 극적으로 숫자가 변하는 데이터는 아님
- 매 요청마다 DB에서 무거운 GROUP BY 연산을 수행하는 것은 서버 리소스 낭비
- 개선 가이드
- Redis를 활용하여 tag_api.py 혹은 서비스 로직에 장고 캐시를 적용하는 것을 권장
from django.core.cache import cache
def get_tag_counts():
tags = cache.get("global_tag_counts")
if not tags:
tags = list(Tag.objects.annotate(...))
cache.set("global_tag_counts", tags, timeout=3600)
return tags
품질 향상: 문자열 정규화 (소문자/여백 처리)
- 이슈
- 사용자들은 태그를 입력할 때 "Python", "python", " python " 등 제각각으로 입력
- 별도의 정규화 과정이 없다면 DB에 이 세 가지가 전혀 다른 태그로 생성되어 통계와 검색 결과가 파편화됨
- 개선 가이드
- 태그를 생성하거나 등록하는 서비스 로직의 앞단(또는 시리얼라이저)에서
- 입력된 태그 문자열의 양옆 공백을 자르고(strip())
- 모두 소문자로 강제 변환(lower())하는 전처리 파이프라인을 추가
Series
다른 대안
중복 검증 로직의 대안 (App-Level Validation 활용)
- 현재 방식
- DB 에러(IntegrityError)를 try-except로 직접 잡아냄
- 대안
- DRF의 Serializer 내부에
validators = [UniqueTogetherValidator(queryset=Series.objects.all(), fields=['user', 'name'])]를 선언하여
- 뷰(View)의
is_valid() 단계에서 미리 중복을 걸러내는 방식
- 이렇게 하면 서비스 레이어에 에러 처리 코드를 두지 않아도 되어 코드가 더 깔끔해질 수 있음
삭제 처리 성능 최적화 대안 (Queryset 직접 삭제)
- 현재 방식
- 삭제할 때 filter(...).first()로 객체를 DB에서 메모리로 한 번 불러온(SELECT) 뒤, series.delete()(DELETE)를 수행합니다.
- 대안
- 객체를 굳이 메모리로 가져올 필요 없이
deleted_count, _ = Series.objects.filter(id=series_id, user=user).delete()
- 한 줄로 처리할 수 있음
deleted_count가 0이면 존재하지 않거나 남의 것이므로 에러를 발생시키면 됨
- 불필요한 SELECT 쿼리 1회를 아낄 수 있는 고성능 튜닝 기법
개선 사항
사용자 경험(UX) 개선: 시리즈 삭제 시의 사이드 이펙트(부작용) 핸들링
- 이슈
- 현재 코드는 시리즈를 데이터베이스에서 영구 삭제(Hard Delete)
- Post 모델에서 시리즈를
FK로 참조하며 on_delete=models.SET_NULL이 설정되어 있다면,
- 시리즈 삭제 시 해당 시리즈에 속했던 게시글들은 소속을 잃고 흩어지게 됨
- 해결 가이드
- 유저가 실수로 시리즈를 삭제하는 것을 방지하기 위해 삭제 전
- "이 시리즈에 포함된 N개의 게시글이 소속을 잃게 됩니다. 그래도 삭제하시겠습니까?"
- 같은 프롬프트를 띄울 수 있도록, 삭제 API 응답이나 별도 API를 통해
- '시리즈에 종속된 게시글의 개수'를 미리 체크해 주는 로직을 추가하는 것을 권장
- 이슈
- SeriesAPIView의 get 메서드에는 현재 페이징 처리가 되어있지 않음
- 만약 유저가 블로그를 오래 운영하여 1,000개의 시리즈를 만들었다면
- API 호출 한 번에 1,000개의 데이터가 한꺼번에 직렬화되어 내려가므로
- 서버 부하와 네트워크 지연이 발생
- 해결 가이드
- 다른 View(예: PostAPIView)에서 사용한 것처럼
pagination_class = SeriesPageNumberPagination 등을 도입하여
- 한 번에 20~30개씩 잘라서 응답하도록 개선하는 것이 대규모 트래픽 대비에 좋음
논리적 무결성: 반환 타입(Return Type) 일관성 유지
- 이슈
- SeriesListSerializer를 보면
- 응답에 id, name, created_at, updated_at을 포함하고 있음
- 하지만 시리즈 생성(POST)과 수정(PUT) API에서는 시리얼라이저를 재사용하지 않고
{"id": series.id, "message": ...} 형태의 임의의 딕셔너리를 반환하고 있음
- 해결 가이드
- 프론트엔드 상태 관리를 용이하게 하기 위해 생성/수정 성공 시에도
- 저장된 객체의 전체 정보(특히 업데이트된 시간 등)를 내려주는 것이 REST API 표준임
return Response(
SeriesListSerializer(updated_series).data,
status=status.HTTP_200_OK
)
도입
Post
뷰(View)의 중복 코드 제거
class PostAPIView(APIView):
"""포스트 등록 및 전체 목록 조회를 담당합니다."""
....
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
)
tag_name = request.query_params.get("tag")
search_keyword = request.query_params.get("search")
posts = get_global_posts(
series_id=series_id, tag_name=tag_name, search_keyword=search_keyword
)
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)
...
class MyPostAPIView(APIView):
"""내 블로그(공개글만) 조회를 담당합니다."""
...
def get(self, request: Request):
user = cast(User, request.user)
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
)
tag_name = request.query_params.get("tag")
search_keyword = request.query_params.get("search")
posts = get_my_published_posts(
user=user,
series_id=series_id,
tag_name=tag_name,
search_keyword=search_keyword,
)
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)
from rest_framework.response import Response
class PostListMixin:
"""게시글 목록 조회 시 반복되는 필터링 및 페이지네이션 로직을 분리한 믹스인 클래스입니다."""
def get_filter_params(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
tag_name = request.query_params.get("tag")
search_keyword = request.query_params.get("search")
return {
"series_id": series_id,
"tag_name": tag_name,
"search_keyword": search_keyword,
}
def get_paginated_response(self, queryset, serializer_class, request, context=None):
"""쿼리셋에 페이지네이션을 적용하고 DRF 규격에 맞는 Response 객체를 반환합니다."""
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request, view=self)
if page is not None:
serializer = serializer_class(page, many=True, context=context)
return paginator.get_paginated_response(serializer.data)
serializer = serializer_class(queryset, many=True, context=context)
return Response(serializer.data)
class PostAPIView(APIView, PostListMixin):
"""포스트 등록 및 전체 목록 조회를 담당합니다."""
permission_classes = [IsAuthenticatedOrReadOnly]
pagination_class = PostPageNumberPagination
@extend_schema(...)
def get(self, request: Request):
filter_params = self.get_filter_params(request)
posts = get_global_posts(**filter_params)
return self.get_paginated_response(
queryset=posts,
serializer_class=PostListSerializer,
request=request
)
...
class MyPostAPIView(APIView, PostListMixin):
"""내 블로그(공개글만) 조회를 담당합니다."""
permission_classes = [IsAuthenticated]
pagination_class = PostPageNumberPagination
@extend_schema(...)
def get(self, request: Request):
user = cast(User, request.user)
filter_params = self.get_filter_params(request)
posts = get_my_published_posts(user=user, **filter_params)
return self.get_paginated_response(
queryset=posts,
serializer_class=PostListSerializer,
request=request
)
...
class MyTempAPIView(APIView):
...
def get(self, request: Request):
...
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 MyTempAPIView(APIView, PostListMixin):
...
def get(self, request: Request):
...
return self.get_paginated_response(
queryset=posts,
serializer_class=PostListSerializer,
request=request
)
class TrashAPIView(APIView):
...
def get(self, request: Request):
...
paginator = self.pagination_class()
page = paginator.paginate_queryset(posts, request, view=self)
if page is not None:
serializer = PostListSerializer(
page, many=True, context={"request": request}
)
return paginator.get_paginated_response(serializer.data)
serializer = PostListSerializer(posts, many=True, context={"request": request})
return Response(serializer.data)
——————————————————————————————————————[비교]—————————————————————————————————————————
class TrashAPIView(APIView, PostListMixin):
...
def get(self, request: Request):
...
return self.get_paginated_response(
queryset=posts,
serializer_class=PostListSerializer,
request=request,
context={"request": request}
)
——————————————————————————————————————[비교]—————————————————————————————————————————
성능 및 로직 개선: 자동 요약(Summary) 기능의 한계
def create_post(*, author: User, validated_data: dict[str, Any]):
...
content = validated_data["content"]
summary = validated_data.get("summary") or content[:150]
...
——————————————————————————————————————[비교]—————————————————————————————————————————
from django.utils.html import strip_tags
def create_post(*, author: User, validated_data: dict[str, Any]):
...
content = validated_data["content"]
summary = validated_data.get("summary") or strip_tags(content)[:150]
...
안정성 개선: get_or_create의 예외 처리 부족
def add_post_like(*, post_id: int, user: User) -> None:
...
Like.objects.get_or_create(post=post, user=user)
——————————————————————————————————————[비교]—————————————————————————————————————————
def add_post_like(*, post_id: int, user: User) -> None:
...
try:
Like.objects.get_or_create(post=post, user=user)
except IntegrityError:
pass