최적화 진행
http://127.0.0.1:8000/silk/
Post(전체 글 조회)
# 수정 전
200 GET
/api/v1/post/
23ms overall
5ms on queries
6 queries
# 수정 후
200 GET
/api/v1/post/
23ms overall
5ms on queries
3 queries
쿼리 분석
- 페이지네이션 COUNT 쿼리
- PostAPIView.get() 내부의
paginator.paginate_queryset(posts, request, view=self)에서
- 전체 페이지 수를 계산하기 위해
SELECT COUNT(*) 쿼리가 1회 발생
- Post 및 User 메인 데이터 조회 쿼리
get_global_posts() 서비스 함수에서
.select_related("user")와 .annotate(likes_count=...)가 결합되어
- 실제 포스트 목록과 유저 정보를 가져오는 메인 쿼리가 1회 발생
- Tag Prefetch 쿼리
.prefetch_related("tags")로 인해 게시글과 연결된 태그들을 가져오기 위해
- 별도의
SELECT ... IN (...) 쿼리가 1회 발생
- 작성자 등급 계산용 COUNT 쿼리
PostListSerializer의 get_author_grade_image() 내부에서
Post.objects.filter(...).count()가 실행됨
__init__에서 딕셔너리로 캐싱 처리를 했지만
- 첫 번째 게시글의 작성자에 대해서는 무조건 1회의 쿼리가 발생
- Series 참조 (N+1 문제) 쿼리 1
-PostListSerializer에는 source="series.name"을 통해 시리즈의 이름을
- 참조하는 필드가 있음
- 하지만
get_global_posts()의 select_related에는
"series"가 빠져있어서 Series 정보를 가져오기 위해 추가 쿼리가 발생
- Series 참조 (N+1 문제) 쿼리 2
- 한 페이지에 노출된 게시글 중
- 서로 다른 series를 가진 게시글이 2개 이상일 경우 위 5번의 쿼리가 반복되어 발생
- 또는 API 호출 시 Django의 Session/Auth User를 조회하는 쿼리일 수 있으나
- 시리즈 N+1 문제로 인한 누적일 확률이 가장 높습니다.
수정
- 시리얼라이저의 작성자 카운트 쿼리를 서브쿼리(Subquery)로 메인 쿼리에 밀어 넣고
- Series에 대한 N+1 문제를 select_related로 해결
...
return (
qs.select_related("user")
.prefetch_related("tags")
.annotate(likes_count=Count("likes", distinct=True))
.order_by("-created_at")
)
——————————————————————————————————————[비교]—————————————————————————————————————————
author_posts_count = (
Post.objects.filter(
user_id=OuterRef("user_id"),
deleted_at__isnull=True
)
.values("user_id")
.annotate(count=Count("id"))
.values("count")
)
...
return (
qs.select_related("user", "series")
.prefetch_related("tags")
.annotate(
likes_count=Count("likes", distinct=True),
author_total_posts=Subquery(author_posts_count, output_field=IntegerField())
)
.order_by("-created_at")
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._author_counts = {}
def get_author_grade_image(self, obj):
user_id = obj.user_id
if user_id not in self._author_counts:
self._author_counts[user_id] = Post.objects.filter(
user_id=user_id,
deleted_at__isnull=True
).count()
total_count = self._author_counts[user_id]
for grade in GRADE_SETTINGS:
if total_count >= grade["min"]:
return grade["imgUrl"]
return GRADE_SETTINGS[-1]["imgUrl"]
——————————————————————————————————————[비교]—————————————————————————————————————————
def get_author_grade_image(self, obj):
total_count = getattr(obj, "author_total_posts", 0)
for grade in GRADE_SETTINGS:
if total_count >= grade["min"]:
return grade["imgUrl"]
return GRADE_SETTINGS[-1]["imgUrl"]
결과

코드 수정에 따른 이점
- 응답 속도(Latency) 대폭 개선
- 웹 서버(Django)와 데이터베이스(DB)가 통신하는 횟수 자체가 6번에서 3번으로 줄어듬
- 네트워크 통신은 비용이 매우 큰 작업이므로
- 쿼리 횟수를 줄이는 것만으로도 API 응답 속도가 비약적으로 빨라짐
- 메모리 및 CPU 절약
- 파이썬 코드(시리얼라이저) 내부에서 루프를 돌며 데이터를 계산하고 조립하는 것보다
- 데이터베이스 엔진 자체에서 데이터를 다듬어서 보내주는 것이 훨씬 빠르고 효율적
- 책임의 분리 (Thin Serializer)
- 데이터를 '조회'하는 책임은 Service(또는 QuerySet) 계층이
- 데이터를 화면에 맞게 '포장(직렬화)'하는 책임은 Serializer가 가지도록 역할을 명확화
- 따라서, 유지보수가 훨씬 쉬워짐
- 진짜 N+1 문제의 근본적인 해결 (압도적인 성능 향상)
- 이전 방식의 한계
- 시리얼라이저의
__init__에 딕셔너리를 둬서 '동일 유저'의 중복 쿼리만 막았을 뿐
- 피드에 10명의 각기 다른 유저가 쓴 글이 있다면 결국 총 글 개수를 알기 위해
- 수정된 방식의 이점
author_total_posts=Subquery(...)를 통해 DB에 "메인 쿼리를 실행할 때
- 하위 작업 지시서(author_posts_count)를 통해 각 작성자의 총 글 개수도
- 한방에 계산해서 가져와!"라고 지시하게 되었음
- 따라서 유저가 10명이든 100명이든 단 1번의 쿼리로 모든 데이터를 가져오게 되어
- 서버 메모리 절약 (캐싱 불필요)
- 이전에는 시리얼라이저가 동작할 때마다 파이썬 메모리에
self._author_counts = {}라는 임시 저장소(딕셔너리)를 만들고
- 일일이 데이터를 넣고 빼는 작업이 필요했음
- 하지만 이제는 DB 자체에서 계산이 완료된 결과(
author_total_posts)를
- 객체에 붙여서 반환해 주기 때문에, 시리얼라이저가 무거운 딕셔너리 캐시를
- 들고 있을 필요가 없어 메모리 사용량이 크게 절약됨
__init__사용 이유
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._author_counts = {}
def get_author_grade_image(self, obj):
user_id = obj.user_id
if user_id not in self._author_counts:
self._author_counts[user_id] = Post.objects.filter(
user_id=user_id,
deleted_at__isnull=True
).count()
total_count = self._author_counts[user_id]
for grade in GRADE_SETTINGS:
if total_count >= grade["min"]:
return grade["imgUrl"]
return GRADE_SETTINGS[-1]["imgUrl"]
def __init__가 하는 핵심 역할: "기억 저장소(캐시) 준비하기"
__init__은 생성자(Constructor)라고 부르며
- 클래스가 객체로 생성될 때(즉, 시리얼라이저가 작동을 시작할 때)
- 이 코드에서
__init__이 하는 가장 중요한 역할은
self._author_counts = {} 라는 빈 딕셔너리를 만드는 것
- 한 번 DB에서 계산해 온 유저의 글 개수는 이 딕셔너리에 적어두고
- 다음번에는 DB에 가지 말고 여기서 바로 꺼내 쓰려는 목적으로 만들었음
- 이 코드는 같은 유저의 글이 여러 개일 때 발생하는 중복 쿼리를 막을 수 있음
가장 큰 문제
- 만약 게시판 목록에 10명 각기 다른 유저가 쓴 글 10개가 있다면
- 이 시리얼라이저는
self._author_counts에 저장된 정보가 없기 때문에
- 10명의 유저 글 개수를 알기 위해 결국 DB에 10번의 쿼리를 날리게 됨
- 동일 유저 중복만 막을 뿐, 다른 유저라면 매번 쿼리가 발생함
Post(내 글 조회)
# 수정 전
/api/v1/post/my/
28ms overall
6ms on queries
14 queries
# 수정 후(2026/03/21)
/api/v1/post/my/
17ms overall
4ms on queries
5 queries
# 수정 후(2026/03/22)
200 GET
/api/v1/post/my/
31ms overall
4ms on queries
4 queries
서비스 수정
- 어제 쿼리최적화를 진행하면서 캐싱방법을 사용했는데 전체글 조회 쿼리 최적화를 진행하면서
- 캐싱방식을 삭제했더니 오류 발생
- 내 글 목록에서는 무조건 총 게시글 수가 0개로 인식되어 가장 낮은 등급의 이미지만 노출
...
return (
qs.select_related("user")
.prefetch_related("tags")
.annotate(likes_count=Count("likes", distinct=True))
.order_by("-created_at")
)
——————————————————————————————————————[비교]—————————————————————————————————————————
author_posts_count = (
Post.objects.filter(
user_id=OuterRef("user_id"),
deleted_at__isnull=True
)
.values("user_id")
.annotate(count=Count("id"))
.values("count")
)
...
return (
qs.select_related("user", "series")
.prefetch_related("tags")
.annotate(
likes_count=Count("likes", distinct=True),
author_total_posts=Subquery(author_posts_count, output_field=IntegerField())
)
.order_by("-created_at")
)
Post(임시글 목록)
# 수정 전
/api/v1/post/my/temp/
30ms overall
3ms on queries
7 queries
# 수정 후
/api/v1/post/my/temp/
28ms overall
2ms on queries
4 queries
불필요한 로직 제거 및 N+1방지
- 좋아요 집계(Count("likes")) 연산 제거 및 series 추가
return (
Post.objects.filter(
user=user,
is_temp=True,
deleted_at__isnull=True,
)
.prefetch_related("tags")
.annotate(likes_count=Count("likes", distinct=True))
.order_by("-created_at")
)
——————————————————————————————————————[비교]—————————————————————————————————————————
qs = Post.objects.filter(
user=user,
is_temp=True,
deleted_at__isnull=True,
)
return (
qs.select_related("user", "series")
.prefetch_related("tags")
.order_by("-created_at")
)
결과

Post(휴지통 목록)
# 수정 전
/api/v1/post/my/trash/
31ms overall
7ms on queries
21 queries
# 수정 후
/api/v1/post/my/trash/
17ms overall
3ms on queries
4 queries
수정
return (
Post.objects.filter(user=user, deleted_at__isnull=False)
.annotate(likes_count=Count("likes", distinct=True))
.order_by("-deleted_at")
)
——————————————————————————————————————[비교]—————————————————————————————————————————
qs = Post.objects.filter(
user=user,
deleted_at__isnull=False,
)
return (
qs.select_related("user", "series")
.prefetch_related("tags")
.order_by("-deleted_at")
)
원인
- 수정 전의 코드는 게시글 목록만 먼저 가져온 뒤
- 시리얼라이저(Serializer)가 JSON 데이터를 만들면서 필요한 추가 정보를
- 그때그때 DB에 물어보는 구조
- ex. 휴지통에 10개의 글이 있었다고 가정
- 메인 쿼리 (1개): "휴지통에 있는 게시글 10개 다 가져와!"
- 시리즈 조회 (최대 10개)
- 1번 글 시리얼라이징 중... "어? 1번 글의 시리즈 이름(series.name)이 필요하네?
- DB야, 1번 글 시리즈 정보 줘!" ➡️ 2번 글... 3번 글... (반복)
- 태그 조회 (최대 10개)
- 1번 글 시리얼라이징 중... "어? 1번 글의 태그 목록(tags)도 필요하네?
- DB야, 1번 글 태그 줘!" ➡️ (반복)
수정 후
- select_related와 prefetch_related는
- "이따가 시리얼라이저가 분명히 물어볼 테니까, 미리 한 번에 다 챙겨와!"
- 라고 DB에 미리 지시하는 역할
- 사용자/세션 확인 쿼리 (1개)
- Django가 API 요청을 보낸 사람이 누군지(현재 로그인한 유저 정보) 확인하기 위해 발생
- 페이지네이션 COUNT 쿼리 (1개)
- 전체 휴지통 게시글이 몇 개인지
- 몇 페이지까지 있는지 계산하기 위한
SELECT COUNT(*) 쿼리
- 메인 데이터 조회 쿼리 (1개)
- select_related("user", "series")가 작동한 쿼리
- "게시글 가져올 때, 작성자(user) 정보랑 시리즈(series) 정보도
- JOIN(결합)해서 하나의 표로 한 번에 가져와!"
- 이 단 1번의 쿼리로 시리얼라이저가 유저와 시리즈를 찾기 위해 쏘던
- 태그 정보 조회 쿼리 (1개)
- prefetch_related("tags")가 작동한 쿼리
- 다대다(M:N) 관계인 태그는 JOIN으로 가져오기 어렵기 때문에
- Django가 조회된 게시글들의 ID를 모아서
- "이 게시글들(IN (...))에 달린 태그 다 가져와!" 라며
- 딱 1번의 추가 쿼리만 날려 태그들을 미리 싹 쓸어옴
결과

Post(상세조회)
# 수정 전
/api/v1/post/104/
20ms overall
3ms on queries
6 queries
# 수정 후
/api/v1/post/104/
21ms overall
5ms on queries
3 queries
원인
- 현재 로그인한 유저(Auth) 조회 쿼리 (1개)
- API 요청 시 Django가 Token/Session을 확인하여 사용자 정보를 가져오는 필수 쿼리
- 게시글 메인 데이터 조회 쿼리 (1개)
- get_post_detail의
- .select_related("user")와 .annotate(likes_count=...)가 실행되는 쿼리
- 태그(Tag) Prefetch 쿼리 (1개)
- prefetch_related("tags")로 인해 태그 데이터를 가져오는 쿼리
[낭비 1] 시리즈(Series) N+1 조회 쿼리 (1개)
- PostDetailSerializer에 series.name을 요구하는 필드가 있지만
- get_post_detail의 select_related에는 "series"가 빠져있어 DB에 다시 요청을 보냄
[낭비 2] 작성자의 총 게시글 수 계산 쿼리 (1개)
- 시리얼라이저의 get_author_grade_image 내부에서
- Post.objects.filter(...).count()가 실행되어 추가 쿼리가 발생
[낭비 3] 좋아요 여부(is_liked) 확인 쿼리 (1개)
- 시리얼라이저의 get_is_liked 내부에서
- obj.likes.filter(...).exists()가 실행되면서 또다시 DB와 통신
해결
service(get_post_detail)
- 서비스 함수가 현재 접속한 user 객체도 받도록 수정하여 is_liked 여부를 DB단에서 미리 확인
return (
Post.objects.select_related("user")
.filter(id=post_id, deleted_at__isnull=True)
.prefetch_related("tags")
.annotate(likes_count=Count("likes", distinct=True))
.first()
)
——————————————————————————————————————[비교]—————————————————————————————————————————
author_posts_count = (
Post.objects.filter(
user_id=OuterRef("user_id"),
deleted_at__isnull=True
)
.values("user_id")
.annotate(count=Count("id"))
.values("count")
)
qs = (
Post.objects.filter(id=post_id, deleted_at__isnull=True)
.select_related("user", "series")
.prefetch_related("tags")
.annotate(
likes_count=Count("likes", distinct=True),
author_total_posts=Subquery(author_posts_count, output_field=IntegerField())
)
)
if user and user.is_authenticated:
is_liked_subquery = Like.objects.filter(
post_id=OuterRef("id"),
user=user
)
qs = qs.annotate(is_liked_by_user=Exists(is_liked_subquery))
return qs.first()
view
- get_post_detail을 호출할 때 request.user를 같이 넘겨주기
class PostDetailAPIView(APIView):
permission_classes = [IsAuthenticatedOrReadOnly]
@extend_schema(tags=["포스트"], summary="게시글 상세 조회")
def get(self, request: Request, post_id: int):
post = get_post_detail(post_id)
if not post:
raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)
return Response(PostDetailSerializer(post, context={"request": request}).data)
——————————————————————————————————————[비교]—————————————————————————————————————————
class PostDetailAPIView(APIView):
permission_classes = [IsAuthenticatedOrReadOnly]
@extend_schema(tags=["포스트"], summary="게시글 상세 조회")
def get(self, request: Request, post_id: int):
post = get_post_detail(post_id, user=request.user)
if not post:
raise BaseCustomException(ErrorMessage.POST_NOT_FOUND)
return Response(PostDetailSerializer(post, context={"request": request}).data)
serializer
- 시리얼라이저 내부의 연산을 서브쿼리 결과값 매핑으로 교체
def get_author_grade_image(self, obj):
total_count = Post.objects.filter(
user=obj.user, deleted_at__isnull=True
).count()
for grade in GRADE_SETTINGS:
if total_count >= grade["min"]:
return grade[
"imgUrl"
]
return GRADE_SETTINGS[-1]["imgUrl"]
def get_is_liked(self, obj) -> bool:
request = self.context.get("request")
if request and request.user.is_authenticated:
return obj.likes.filter(user=request.user).exists()
return False
——————————————————————————————————————[비교]—————————————————————————————————————————
def get_author_grade_image(self, obj):
total_count = getattr(obj, "author_total_posts", 0)
for grade in GRADE_SETTINGS:
if total_count >= grade["min"]:
return grade["imgUrl"]
return GRADE_SETTINGS[-1]["imgUrl"]
def get_is_liked(self, obj) -> bool:
return getattr(obj, "is_liked_by_user", False)
결과

공부 요소
서브쿼리(Subquery / OuterRef)
지연 로딩(Lazy Loading)
- Django ORM은 기본적으로 '지연 로딩' 방식을 사용
- 즉, "진짜로 그 데이터를 달라고 하기 전까지는 굳이 DB에서 가져오지 않고 미뤄두는" 특성 존재
- Service (비즈니스 로직)
- Post.objects.all() 처럼 메인 데이터만 가져와서 시리얼라이저에게 넘김
- Serializer
- 데이터를 JSON으로 변환하려고 하나씩 뜯어봅니다.
- "어? series.name을 출력해야 하는데 데이터가 없네?
- DB야, 지금 시리즈 데이터 좀 줘!" (쿼리 발생)
- "어? 이번엔 tags 목록이 필요하네? DB야, 태그 데이터도 줘!" (쿼리 발생)
- "작성자의 총 게시글 수도 계산해야 하네? DB야, 카운트 세서 줘!" (쿼리 발생)
- 시리얼라이저가 JSON 변환을 하던 도중에 필요한 항목이 발견될 때마다 그때그때 DB에 요청을 함
즉시 로딩(Eager Loading)
- select_related, prefetch_related, annotate(Subquery) 등을
- 비즈니스 로직에 추가한 것은 Django에게 '즉시 로딩'을 강제한 것
- Service (비즈니스 로직)
- "이따 시리얼라이저가 시리즈, 태그, 총 게시글 수 다 물어볼 테니까
- 지금 한 번에 다 결합(JOIN)해서 가져와!"
- Serializer
- "우와, 내가 JSON 만들 때 필요한 데이터가 이미 메모리에 다 준비되어 있네?
- 그냥 꺼내서 포장만 해야지!" (DB 통신 0회)
서브쿼리(Subquery / OuterRef)
- Subquery (서브쿼리)
- 하나의 메인 SQL 쿼리문 안에 포함된 또 다른 하위 쿼리문
- 데이터베이스에서 복잡한 조건이나 계산을 수행하기 위해 사용
- Django ORM에서는
django.db.models.Subquery 클래스로 지원됨
- OuterRef (외부 참조)
- 서브쿼리 내부에서, 자신을 감싸고 있는 메인 쿼리(Outer Query)의
- 필드 값을 참조할 때 사용하는 Django ORM의 클래스
- SQL 이론상으로는 상관 서브쿼리(Correlated Subquery)를 구현할 때 필수적인 역할
서브쿼리와 상관 서브쿼리의 차이
- 일반 서브쿼리
- 메인 쿼리와 독립적으로 실행될 수 있음
- 서브쿼리가 먼저 한 번 실행되고, 그 결과를 메인 쿼리가 사용
- 상관 서브쿼리 (Correlated Subquery)
- 서브쿼리 내부에서 메인 쿼리의 컬럼을 참조
- 이 경우 메인 쿼리의 각 행(Row)이 평가될 때마다 서브쿼리가 반복적으로 실행되어야 함
- Django의 OuterRef가 바로 이 상관 서브쿼리를 만들기 위해 존재
예시
from django.db import models
class Category(models.Model):
name = models.CharField(max_length=100)
class Post(models.Model):
category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='posts')
title = models.CharField(max_length=200)
created_at = models.DateTimeField(auto_now_add=True)
Subquery와 OuterRef 활용 예제
- 이 데이터를 순회하면서 최신 포스트를 조회할 때, 단순 반복문을 쓰면 N+1 문제가 발생
- 이를 Subquery와 OuterRef로 단일 쿼리로 최적화 가능
from django.db.models import OuterRef, Subquery
from .models import Category, Post
def get_categories_with_newest_post():
"""
OuterRef('pk')는 메인 쿼리(Category)의 Primary Key(id)를 참조
즉, "현재 평가 중인 카테고리의 id와 동일한 카테고리를 가진 포스트"를 찾음
"""
newest_post_subquery = Post.objects.filter(
category=OuterRef('pk')
).order_by('-created_at')
"""
values('title')[:1]을 통해 가장 최신 포스트의 'title' 필드값 1개만 추출하여 반환
Subquery는 반드시 하나의 컬럼, 하나의 로우만 반환해야 에러가 나지 않음
"""
categories = Category.objects.annotate(
newest_post_title=Subquery(newest_post_subquery.values('title')[:1])
)
for category in categories:
print(f"카테고리: {category.name}, 최신 글 제목: {category.newest_post_title}")
return categories
중요 로직 해석
category=OuterRef('pk')
- 서브쿼리인 Post.objects 내부에서 필터링을 할 때
- 부모 쿼리인 Category의 pk 값을 가져와서 비교하겠다는 의미
.values('title')[:1]
- 서브쿼리는 테이블(다수의 행과 열)을 반환하면 안 되고 단일 값(Scalar 값)을 반환해야
- 따라서 특정 필드(values)를 지정하고 하나만(
[:1]) 잘라냄