프로그래밍/Python

📄 페이지네이션 누락: 대량 데이터 조회 시 성능 저하

Tiboong 2026. 2. 13. 12:07

🚨 이런 상황, 겪어보셨나요?

"상품 목록 API가 처음에는 빨랐는데, 데이터가 쌓이니까 10초 넘게 걸립니다."

"모바일 앱에서 주문 내역을 불러오면 앱이 멈춰버려요."

"관리자 페이지에서 회원 목록을 열면 브라우저가 뻗습니다."

"API 응답이 갑자기 50MB가 넘어가면서 서버 트래픽 비용이 폭발했어요."

 

Django REST Framework(DRF)로 API를 만들 때, 페이지네이션(Pagination) 설정을 빠뜨리면 서비스 초기에는 문제가 없다가 데이터가 쌓이면서 갑자기 서비스 전체가 마비되는 상황이 발생합니다.

 

특히 이 문제가 위험한 이유는, 개발 단계에서 테스트 데이터가 적을 때는 전혀 느끼지 못하다가 실제 운영 환경에서 데이터가 수만~수십만 건으로 늘어나면 폭탄처럼 터진다는 점입니다.


🔥 실제 상황: 느려지는 서비스의 비밀

한 스타트업에서 전자상거래 API를 운영하고 있었습니다. 런칭 초기 상품 수는 200개. API 응답 속도는 0.1초로 쾌적했습니다.

6개월 후, 입점 업체가 늘어나면서 상품 수가 50,000개를 돌파했습니다. 그런데 갑자기 고객들의 불만이 쏟아지기 시작했습니다.

 

"앱이 너무 느려요."

"상품 목록이 안 뜹니다."

"로딩 중에 앱이 꺼져요."

 

서버 로그를 확인해보니 상품 목록 API 하나가 응답 시간 12초, 응답 크기 45MB를 기록하고 있었습니다. 원인은 단 하나, 페이지네이션 없이 전체 상품 50,000개를 한 번에 JSON으로 내려보내고 있던 것이었습니다.

데이터베이스에서 5만 건을 한꺼번에 조회하고, Serializer가 5만 개 객체를 직렬화하고, 45MB짜리 JSON 응답을 네트워크로 전송하는 과정에서 서버 CPU, 메모리, 네트워크 대역폭이 모두 압박을 받고 있었습니다.


🎯 페이지네이션이 뭔데, 왜 이렇게 중요할까?

도서관 비유로 이해하기

페이지네이션을 이해하려면 도서관의 책 검색 시스템을 떠올려보세요.

페이지네이션이 없는 API는 "파이썬 관련 책을 알려주세요"라고 물었을 때 도서관에 있는 파이썬 책 5,000권을 전부 카트에 실어서 한꺼번에 가져다주는 것과 같습니다. 책을 실은 카트가 복도를 막고, 다른 이용자는 지나갈 수도 없고, 카트를 끌고 오는 사서도 지쳐 쓰러집니다.

반면, 페이지네이션이 있는 API는 "1페이지: 1번~20번 책입니다. 다음 페이지를 보시겠습니까?" 라고 안내하는 것과 같습니다. 한 번에 20권만 가져오니까 빠르고, 필요한 만큼만 추가로 요청하면 됩니다.

이것이 바로 페이지네이션의 핵심입니다. 전체 데이터를 한 번에 보내지 않고, 적절한 크기로 나누어 필요한 만큼만 전달하는 것이죠.

페이지네이션이 없으면 어떤 일이 벌어질까?

데이터가 늘어날수록 문제는 기하급수적으로 심각해집니다.

상품 수 응답 시간 응답 크기 서버 메모리

100개 0.05초 90KB 10MB
1,000개 0.3초 900KB 80MB
10,000개 3초 9MB 600MB
50,000개 12초 45MB 2.5GB
100,000개 30초+ 90MB+ 5GB+ (OOM 위험)

100개일 때는 전혀 문제가 없지만, 10만 개가 되면 서버가 Out of Memory(OOM) 에러로 죽을 수 있습니다. 페이지네이션 하나를 빠뜨렸을 뿐인데, 서비스 전체가 마비되는 것입니다.


📊 비즈니스 영향: 페이지네이션 누락이 가져오는 실질적 피해

1. 서버 비용 폭발

불필요하게 큰 응답을 반복적으로 보내면 네트워크 트래픽 비용이 급증합니다. AWS 기준으로 데이터 전송 비용은 GB당 약 $0.09입니다. 50,000명의 사용자가 45MB 응답을 하루에 한 번씩 받으면 하루 트래픽이 약 2.25TB, 월 비용이 **약 $6,000(800만 원)**이 추가됩니다.

페이지네이션을 적용해서 한 페이지당 900KB로 줄이면? 같은 사용자 수라도 트래픽이 98% 감소합니다.

2. 사용자 이탈

구글의 연구에 따르면 모바일 페이지 로딩이 3초를 넘으면 53%의 사용자가 이탈합니다. 12초짜리 API 응답은 사실상 "사용자를 쫓아내는 것"과 같습니다. 특히 모바일 환경에서 45MB 응답을 받으려면 LTE 환경에서도 상당한 시간이 걸리고, 데이터 요금까지 잡아먹습니다.

3. 연쇄 장애

하나의 느린 API가 서버 자원을 독점하면, 같은 서버에서 실행되는 다른 API도 느려집니다. 상품 목록 API가 서버 메모리를 2.5GB씩 잡아먹으면, 결제 API와 로그인 API까지 영향을 받습니다. 하나의 API 문제가 서비스 전체 장애로 번지는 것이죠.


🔍 DRF 페이지네이션의 3가지 방식 비교

Django REST Framework는 세 가지 페이지네이션 방식을 제공합니다. 각각의 특성과 적합한 상황이 다르므로 서비스에 맞는 방식을 선택해야 합니다.

1. PageNumberPagination - "책의 페이지 번호"

가장 직관적인 방식입니다. 우리가 책을 읽을 때 "37페이지를 펴세요"라고 하는 것과 같습니다.

# settings.py
REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
}

 

요청 예시: GET /api/products/?page=3

{
    "count": 50000,
    "next": "<http://api.example.com/products/?page=4>",
    "previous": "<http://api.example.com/products/?page=2>",
    "results": [
        {"id": 41, "name": "상품 41"},
        {"id": 42, "name": "상품 42"},
        ...
    ]
}

 

장점: 사용자가 "3페이지"처럼 특정 페이지로 바로 이동할 수 있습니다. 전체 페이지 수를 알 수 있어서 "1 2 3 ... 100" 같은 페이지 네비게이션을 만들기 좋습니다.

 

단점: 전체 건수를 세기 위해 COUNT(*) 쿼리를 실행합니다. 데이터가 수십만 건 이상이면 이 COUNT 쿼리 자체가 느려질 수 있습니다. 또한 데이터가 실시간으로 추가/삭제되면 페이지 경계가 밀리면서 같은 항목이 중복으로 보이거나 누락될 수 있습니다.

 

적합한 경우: 관리자 페이지, 검색 결과처럼 전체 건수 표시가 필요하고 데이터 변동이 적은 경우.


2. LimitOffsetPagination - "몇 번째부터 몇 개 주세요"

SQL의 LIMIT과 OFFSET을 그대로 사용하는 방식입니다. "20번째부터 10개 주세요"처럼 시작 지점과 개수를 지정합니다.

# settings.py
REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
    'DEFAULT_LIMIT': 20,
    'MAX_LIMIT': 100,
}

 

요청 예시: GET /api/products/?limit=20&offset=40

{
    "count": 50000,
    "next": "<http://api.example.com/products/?limit=20&offset=60>",
    "previous": "<http://api.example.com/products/?limit=20&offset=20>",
    "results": [...]
}

 

장점: PageNumber 방식보다 유연합니다. "25번째부터 15개"처럼 자유롭게 범위를 지정할 수 있습니다.

 

단점: PageNumber와 같은 문제점을 공유합니다. COUNT 쿼리 비용이 발생하고, OFFSET 값이 커질수록 성능이 급격히 저하됩니다. OFFSET 90000이면 데이터베이스가 앞의 9만 건을 건너뛰어야 하므로, 사실상 9만 건을 읽고 버리는 셈입니다.

 

적합한 경우: 외부 시스템과의 연동에서 유연한 범위 지정이 필요한 경우. 단, 대량 데이터에서는 OFFSET 상한선을 반드시 설정해야 합니다.


3. CursorPagination - "이 다음 것부터 보여주세요"

가장 성능이 좋은 방식입니다. 마지막으로 본 항목을 기준으로 "그 다음 것"을 가져옵니다. 책에 **북마크를 끼워두고 "여기서부터 계속 읽기"**하는 것과 같습니다.

# pagination.py
from rest_framework.pagination import CursorPagination

class ProductCursorPagination(CursorPagination):
    page_size = 20
    ordering = '-created_at'
    cursor_query_param = 'cursor'

 

요청 예시: GET /api/products/?cursor=cD0yMDI1LTAxLTE1

{
    "next": "<http://api.example.com/products/?cursor=cD0yMDI1LTAxLTE2>",
    "previous": "<http://api.example.com/products/?cursor=cD0yMDI1LTAxLTE0>",
    "results": [...]
}

 

장점: COUNT 쿼리가 필요 없어서 데이터가 아무리 많아도 일정한 속도를 유지합니다. 100만 건이든 1억 건이든 응답 시간이 동일합니다. 또한 실시간 데이터 추가/삭제에도 중복이나 누락이 발생하지 않습니다.

 

단점: "5페이지로 이동"같은 랜덤 접근이 불가능합니다. 오직 "다음" 또는 "이전"으로만 이동할 수 있습니다. 전체 건수도 알 수 없습니다.

 

적합한 경우: 무한 스크롤, 모바일 앱 피드, SNS 타임라인처럼 순차적으로 탐색하는 경우. 대량 데이터에서 가장 추천하는 방식입니다.


⚡ 방식별 성능 비교

세 가지 방식의 실제 성능 차이를 10만 건 데이터 기준으로 비교해보면 차이가 극명합니다.

항목 PageNumber LimitOffset Cursor

첫 페이지 조회 0.15초 0.15초 0.05초
중간 페이지(5000페이지) 2.3초 2.3초 0.05초
마지막 페이지 4.5초 4.5초 0.05초
COUNT 쿼리 필요 O O X
전체 건수 제공 O O X
랜덤 페이지 접근 O O X
실시간 데이터 정합성 낮음 낮음 높음

 

PageNumber와 LimitOffset은 뒤로 갈수록 느려지는 반면, Cursor는 항상 일정한 속도를 유지합니다. 이것이 대량 데이터에서 CursorPagination이 압도적으로 유리한 이유입니다.


🛠️ 실전 적용: 상황별 페이지네이션 설정

1단계: 프로젝트 전체 기본 설정

가장 먼저 해야 할 일은 프로젝트 전체에 기본 페이지네이션을 설정하는 것입니다. 이렇게 하면 개별 뷰에서 설정을 빠뜨려도 최소한의 안전장치가 됩니다.

# settings.py
REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
}

이 한 줄이면 모든 ListAPIView에 자동으로 페이지네이션이 적용됩니다. "일단 기본 설정부터 하자"는 원칙입니다.


2단계: 뷰별 커스텀 페이지네이션

서비스에 따라 다른 페이지 크기가 필요할 수 있습니다. 예를 들어 상품 목록은 20개씩, 리뷰는 10개씩, 알림은 50개씩 보여주고 싶다면 각 뷰에서 개별 설정을 합니다.

# pagination.py
from rest_framework.pagination import PageNumberPagination, CursorPagination

class StandardPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = 'page_size'
    max_page_size = 100  # 클라이언트가 요청할 수 있는 최대 크기 제한

class SmallPagination(PageNumberPagination):
    page_size = 10
    max_page_size = 50

class FeedCursorPagination(CursorPagination):
    page_size = 20
    ordering = '-created_at'
# views.py
from rest_framework.generics import ListAPIView
from .pagination import StandardPagination, SmallPagination, FeedCursorPagination

class ProductListView(ListAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    pagination_class = StandardPagination  # 20개씩

class ReviewListView(ListAPIView):
    queryset = Review.objects.all()
    serializer_class = ReviewSerializer
    pagination_class = SmallPagination  # 10개씩

class NotificationListView(ListAPIView):
    queryset = Notification.objects.filter(is_read=False)
    serializer_class = NotificationSerializer
    pagination_class = FeedCursorPagination  # 커서 기반, 20개씩

여기서 핵심은 max_page_size를 반드시 설정하는 것입니다. 이걸 빠뜨리면 악의적인 사용자가 ?page_size=1000000으로 요청해서 서버를 마비시킬 수 있습니다.


3단계: COUNT 쿼리 최적화

PageNumberPagination의 가장 큰 약점은 count 값을 계산하기 위해 매 요청마다 전체 건수를 세는 쿼리를 실행한다는 것입니다. 데이터가 많을수록 이 COUNT 쿼리가 느려집니다.

전체 건수가 꼭 필요하지 않다면, COUNT 쿼리를 생략하는 커스텀 페이지네이션을 만들 수 있습니다.

from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response

class FastPageNumberPagination(PageNumberPagination):
    page_size = 20

    def get_paginated_response(self, data):
        return Response({
            'next': self.get_next_link(),
            'previous': self.get_previous_link(),
            'results': data,
            # count를 제거하여 COUNT 쿼리 생략
        })

 

또는 전체 건수 대신 "다음 페이지가 있는지 여부"만 알려주는 방식도 효과적입니다.

class HasNextPagination(PageNumberPagination):
    page_size = 20

    def get_paginated_response(self, data):
        return Response({
            'has_next': self.page.has_next(),
            'has_previous': self.page.has_previous(),
            'results': data,
        })

 

이렇게 하면 COUNT 쿼리 없이도 "더 보기" 버튼을 구현할 수 있습니다.


🐛 자주 발생하는 실수와 디버깅

실수 1: ViewSet에서 페이지네이션이 안 먹히는 경우

list 액션이 아닌 커스텀 액션에서 QuerySet을 직접 반환하면 페이지네이션이 적용되지 않습니다.

# ❌ 잘못된 방법 - 페이지네이션 무시
class ProductViewSet(viewsets.ModelViewSet):
    @action(detail=False)
    def featured(self, request):
        products = Product.objects.filter(is_featured=True)
        serializer = self.get_serializer(products, many=True)
        return Response(serializer.data)  # 페이지네이션 없이 전체 반환!
# ✅ 올바른 방법 - 페이지네이션 적용
class ProductViewSet(viewsets.ModelViewSet):
    @action(detail=False)
    def featured(self, request):
        products = Product.objects.filter(is_featured=True)
        page = self.paginate_queryset(products)  # 페이지네이션 적용
        if page is not None:
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)
        serializer = self.get_serializer(products, many=True)
        return Response(serializer.data)

커스텀 액션에서는 self.paginate_queryset()을 명시적으로 호출해야 합니다. 이것을 빠뜨리는 것이 가장 흔한 실수입니다.


실수 2: APIView에서 페이지네이션 빠뜨리기

ListAPIView는 자동으로 페이지네이션이 적용되지만, 기본 APIView에서는 직접 처리해야 합니다.

# ❌ APIView에서 페이지네이션 없이 전체 반환
class ProductSearchView(APIView):
    def get(self, request):
        keyword = request.query_params.get('q', '')
        products = Product.objects.filter(name__icontains=keyword)
        serializer = ProductSerializer(products, many=True)
        return Response(serializer.data)  # 검색 결과가 만 건이면?

APIView를 사용할 때는 반드시 페이지네이션 로직을 직접 구현하거나, ListAPIView로 전환하는 것이 안전합니다.


실수 3: Serializer의 중첩 관계에서 폭발

페이지네이션을 적용했더라도, Serializer 안에서 관련 객체를 전부 가져오면 여전히 문제가 발생합니다.

# ❌ 위험 - 상품 하나당 리뷰 수백 개가 전부 포함
class ProductSerializer(serializers.ModelSerializer):
    reviews = ReviewSerializer(many=True)  # 제한 없이 전체 리뷰!

    class Meta:
        model = Product
        fields = ['id', 'name', 'price', 'reviews']

 

20개 상품을 가져왔는데 각 상품에 리뷰가 500개씩 있다면? 실질적으로 20 × 500 = 10,000건의 데이터가 반환됩니다.

# ✅ 안전 - 중첩 데이터에 제한 적용
class ProductSerializer(serializers.ModelSerializer):
    reviews_count = serializers.IntegerField(source='reviews.count', read_only=True)
    recent_reviews = serializers.SerializerMethodField()

    class Meta:
        model = Product
        fields = ['id', 'name', 'price', 'reviews_count', 'recent_reviews']

    def get_recent_reviews(self, obj):
        recent = obj.reviews.order_by('-created_at')[:3]  # 최근 3개만!
        return ReviewSerializer(recent, many=True).data

 

중첩 관계의 데이터는 별도의 엔드포인트로 분리하고 거기에도 페이지네이션을 적용하는 것이 가장 좋은 설계입니다.


✅ 베스트 프랙티스 체크리스트

설계 단계

  • [ ] settings.py에 프로젝트 전체 기본 페이지네이션 설정했는가?
  • [ ] PAGE_SIZE를 서비스 특성에 맞게 설정했는가? (보통 10~50)
  • [ ] max_page_size를 설정하여 과도한 요청을 차단했는가?
  • [ ] 대량 데이터 API에는 CursorPagination을 고려했는가?

개발 단계

  • [ ] 모든 List 계열 엔드포인트에 페이지네이션이 적용되었는가?
  • [ ] 커스텀 액션(@action)에서도 paginate_queryset()을 호출했는가?
  • [ ] APIView에서 직접 QuerySet을 반환하는 곳은 없는가?
  • [ ] Serializer의 중첩 관계에서 데이터 제한을 설정했는가?
  • [ ] select_related(), prefetch_related()로 N+1 문제를 해결했는가?

운영 단계

  • [ ] API 응답 시간과 크기를 모니터링하고 있는가?
  • [ ] COUNT 쿼리가 병목이 되는 API는 없는가?
  • [ ] 비정상적으로 큰 page_size 요청을 로깅하고 있는가?
  • [ ] 데이터 증가 추세에 따른 성능 계획이 있는가?

🎯 결론: 페이지네이션은 선택이 아니라 필수입니다

페이지네이션 누락은 "시한폭탄" 같은 문제입니다. 지금 당장은 괜찮아 보여도, 데이터가 쌓이면 반드시 터집니다.

기억해야 할 핵심 원칙

  1. 기본 설정을 먼저 하세요: settings.py에 글로벌 페이지네이션을 설정하면, 최소한의 안전장치가 됩니다.
  2. 서비스 특성에 맞는 방식을 선택하세요: 관리자 페이지는 PageNumber, 모바일 피드는 Cursor, 외부 연동은 LimitOffset이 적합합니다.
  3. max_page_size는 반드시 설정하세요: 이것 하나로 악의적인 요청과 실수를 동시에 방지합니다.
  4. 중첩 관계를 조심하세요: 페이지네이션을 적용해도 Serializer 내부에서 데이터가 폭발할 수 있습니다.

서비스 규모별 추천

서비스 규모 추천 방식

MVP/초기 스타트업 PageNumberPagination + 글로벌 설정
성장기 스타트업 Cursor + PageNumber 혼합
중대형 서비스 CursorPagination + COUNT 최적화 + 캐싱

💼 Django API 성능 최적화 컨설팅

페이지네이션 설계부터 대량 데이터 처리 아키텍처까지, API 성능은 서비스의 핵심입니다.

이런 고민이 있으시다면:

  • API 응답이 점점 느려지는데 원인을 모르겠다면
  • 데이터가 늘어날수록 서버 비용이 급증한다면
  • 페이지네이션을 적용했는데도 여전히 느린 API가 있다면
  • 대량 데이터를 효율적으로 처리하는 아키텍처를 설계하고 싶다면

크몽에서 Django 전문 컨설팅을 제공합니다. 실제 운영 경험을 바탕으로, 여러분의 서비스에 맞는 최적의 API 아키텍처를 함께 설계해 드립니다.

👉 [크몽에서 Django 컨설팅 문의하기]