목차
1. 백엔드에서 페이지네이션 기능을 구현하는 이유는 무엇인가?
2. Django 에서 페이지네이션 종류는 몇가지 인가 ?
3. ModelViewSet <-> React 상품 전체 리스트 들고오기
4. 백엔드에서 페이지 네이션 Link를 들고와서 그걸 프론트에서 보여주는 것과
백엔드에서 상품 전체 리스트를 들고와서, 그걸 프론트에서 페이징 해주는 것의 차이는 무엇인가?
1. 백엔드에서 페이지네이션 기능을 구현하는 이유는 무엇인가?
데이터베이스 스키마 구조가 간단?하다면
상품과 같이, 많은 양의 데이터를 가지고 있는 경우에
서버에 부하를 줄 수 있기 때문입니다.
페이지 네이션을 통해서, 한 페이지에 볼 수 있는 상품의 갯수를 정하고
filtering 을 통해, 사용자가 원하는 데이터를 보내준다면
UI/UX 관점으로도 좋습니다
2. Django 에서 페이지네이션 가능한 기능들이 얼마나 있는가 ? 3종류정도 있었습니다.
(https://velog.io/@jewon119/Django-%EA%B8%B0%EC%B4%88-ListView)
1. 내가 직접 페이지네이션 만들기 or 장고 자체 페이지 네이션 쓰기
2. ListView 자체 기능(Django Template 를 써야하기 때문에, 선택하지 못했다. list 해줄 html 을 따로 설정하지 않으면, product_list.html을 디폴트 값으로 설정해주는 클래스이다. 저는 리액트에서 라우팅 설정을 통해서 자유롭게 api 상품 데이터를 들고 오고 싶기 때문에, 선택하지 않았습니다. 리액트에서도 설정 가능한지는 정확히 해보지 않았기에 모르겠네요 ^^;!! ModelViewSet 이 자체적으로 커스텀도 괜찮고, 페이지네이션 구현도 보다 자유롭고 쉬워서 그걸로 결정했네요. )
3. ModelViewSet 을 이용해서, 장고 ViewSet 의 기능과, 페이지 네이션 기능을 둘다 사용할 수 있어서 좋았습니다.
일반적으로 APIView 보다는 ViewSet 이 좋다는 의견이 많습니다. 사용해보신분들은 알겠죠 ?
저는 ListView 와 같은 함수를 통해서, return render(request, "rooms/home.html", context) HTML 을 반환하는 것이 아니라
return Response(serializer.data, status=status.HTTP_200_OK) 시리얼라이저 데이터를 반환해서, 프론트로 가져오도록 하였습니다.
from rest_framework import status, permissions, viewsets
from rest_framework.decorators import action
from rest_framework import viewsets
from rest_framework.permissions import (
IsAdminUser,
IsAuthenticated,
IsAuthenticatedOrReadOnly,
AllowAny,
)
from rest_framework.pagination import PageNumberPagination
class CustomPagination(PageNumberPagination):
page_size = 6
page_size_query_param = "page_size"
max_page_size = 1000
def get_paginated_response(self, data):
return Response(
{
"links": {
"next": self.get_next_link(),
"previous": self.get_previous_link(),
},
"count": self.page.paginator.count,
"page_size": self.page_size,
"results": data,
}
)
class ProductsViewSet(viewsets.ModelViewSet):
queryset = Product.objects.filter(status=Product.Status.ACTIVE).select_related(
"category"
)
serializer_class = ProductListSerializer
pagination_class = CustomPagination
permission_classes = [IsAuthenticatedOrReadOnly]
@action(detail=True, methods=["get"], permission_classes=[AllowAny])
def products_list(self, request, *args, **kwargs):
products = Product.objects.all().order_by("-created_at")
products = self.filter_queryset(products)
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, status=status.HTTP_200_OK)
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
def create_product(self, request):
serializer = ProductCreateSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
ViewSet 을 이용하면, 6가지의 뷰셋 자체 HTTP 메소드 기능들을
사용할 수 있습니다.
https://www.django-rest-framework.org/api-guide/viewsets/#modelviewset
class UserViewSet(viewsets.ViewSet):
"""
Example empty viewset demonstrating the standard
actions that will be handled by a router class.
If you're using format suffixes, make sure to also include
the `format=None` keyword argument for each action.
"""
def list(self, request): # GET 전체 리스트 반환
pass
def create(self, request): # POST 새로운 객체(게시글, 유저) 생성
pass
def retrieve(self, request, pk=None): # GET 특정 객체에 대한 세부 정보 반환
pass
def update(self, request, pk=None): # PUT 전체 정보 업데이트
pass
def partial_update(self, request, pk=None): # PATCH 일부 정보 업데이트
pass
def destroy(self, request, pk=None): # DELETE 특정 객체 삭제
pass
나만의 시리얼라이저, 나만의 permissions 클래스, 특정 비즈니스 로직을 적용하고 싶다면
커스터마이징하여 나만의 비즈니스 로직을 구현할 수도 있습니다.
그런것이 없다면, 있는 그대로 6가지 메서드를 바로~! 사용할 수도 있습니다.
dispatch (특정 목적을 보내거나 구현하는 것) 하는 동안에, 뷰셋의 6가지 속성을 이용할 수도 있습니다. (공식문서 참조)
permissions 클래스에 대한 로직을 위와 같이 구현할 수도 있다고 하네요.
class ProductsViewSet(viewsets.ModelViewSet):
queryset = Product.objects.filter(status=Product.Status.ACTIVE).select_related(
"category"
)
serializer_class = ProductListSerializer
pagination_class = CustomPagination
permission_classes = [IsAuthenticatedOrReadOnly]
@action(detail=True, methods=["get"], permission_classes=[AllowAny])
def products_list(self, request, *args, **kwargs):
products = Product.objects.all().order_by("-created_at")
products = self.filter_queryset(products)
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, status=status.HTTP_200_OK)
제가 작성한 상품 뷰셋 클래스를, 호출해본 결과
serializer.data 는 다음과 같이 나왔습니다.
{
"links": {
"next": "http://127.0.0.1:8000/api/v1/products/?page=2",
"previous": null
},
"count": 100,
"page_size": 6,
"results": [
{
"id": 366,
"user_info": {
"id": 1,
"email": "ksw4060@kakao.com",
"username": null,
"nickname": "ksw4060"
},
"product_like_cnt": 0,
"category": {
"id": 1,
"name": "미분류"
},
"name": "후드티 클럽 플리스 기모 BV2973",
"price": 30900,
"status": "active",
"description": "design`/`디자인`/`디자인색상저렴한가격 빠른배송 만족합니다`|`material`/`소재`/`기모가 들어있어서 따뜻하고 편하네요`|`practicability`/`착용감`/`안감에 먼지가 너무 많아서`|`warmth`/`보온성`/`기모가 확실히 따뜻하네요`|`line`/`라인`/`핏도 예쁘고 편안하다고 합니다`|`quality`/`품질`/`품질좋고",
"photo": "http://127.0.0.1:8000/media/mall/product/photo/2024/01/04/367_i7jS9uG.jpg",
"thumbnail_img1": null,
"thumbnail_img2": null,
"thumbnail_img3": null,
"thumbnail_img4": null,
"thumbnail_img5": null,
"thumbnail_img6": null,
"manufacturer": "",
"created_at": "2024-01-04T12:56:26.396746+09:00",
"updated_at": "2024-01-04T23:04:44.552054+09:00",
"user": 1,
"likes": []
},
{
"id": 365,
"user_info": {
"id": 1,
"email": "ksw4060@kakao.com",
"username": null,
"nickname": "ksw4060"
},
"product_like_cnt": 0,
"category": {
"id": 1,
"name": "미분류"
},
"name": "GAP 갭 남성 로고 기모 후드집업 P332513800",
"price": 26300,
"status": "active",
"description": "total`/`만족도`/`좋은 가격에 득템 한 것 같습니다`|`price`/`가격`/`가볍게 걸치는 정도로 저렴하게 잘 산 것 같습니다",
"photo": "http://127.0.0.1:8000/media/mall/product/photo/2024/01/04/366_jQEEtLO.jpg",
"thumbnail_img1": null,
"thumbnail_img2": null,
"thumbnail_img3": null,
"thumbnail_img4": null,
"thumbnail_img5": null,
"thumbnail_img6": null,
"manufacturer": "",
"created_at": "2024-01-04T12:56:25.995410+09:00",
"updated_at": "2024-01-04T12:56:26.393911+09:00",
"user": 1,
"likes": []
},
{
"id": 364,
"user_info": {
"id": 1,
"email": "ksw4060@kakao.com",
"username": null,
"nickname": "ksw4060"
},
"product_like_cnt": 0,
"category": {
"id": 1,
"name": "미분류"
},
"name": "나이키 남성 NSW 클럽 기모 후드집업 BV2645-063",
"price": 47600,
"status": "active",
"description": "design`/`디자인`/`맨투맨위에 입으니 오버핏으로 예쁘게 맞아요`|`material`/`재질`/`기모가 들어있어서 따뜻합니다`|`practicability`/`착용감`/`기모가 얇게 들어 있어 오히려 입는데 너무 편합니다`|`warmth`/`보온성`/`기모 안감으로 따뜻합니다`|`quality`/`품질`/`품질은좋음`|`line`/`라인`/`핏도예쁘고만족해요",
"photo": "http://127.0.0.1:8000/media/mall/product/photo/2024/01/04/365_Xkgof7u.jpg",
"thumbnail_img1": null,
"thumbnail_img2": null,
"thumbnail_img3": null,
"thumbnail_img4": null,
"thumbnail_img5": null,
"thumbnail_img6": null,
"manufacturer": "",
"created_at": "2024-01-04T12:56:25.585582+09:00",
"updated_at": "2024-01-04T12:56:25.992610+09:00",
"user": 1,
"likes": []
},
.... 이하 363개의 상품 리스트
}
3. ModelViewSet 을 통해서, 프론트에서 나타내보기
다음과 같은 결과(serializer.data)를 프론트(리액트)에서 response라는 변수로 받아주었고
import React, { useState, useEffect } from "react";
import { Col, NavLink } from "react-bootstrap";
import Product from "../Posts/index.js"; // Product 컴포넌트를 가져옵니다.
const backend_base_url = "http://127.0.0.1:8000";
const list_api_url = `${backend_base_url}/api/v1/products/`;
const ProductsList = () => {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [paginationLinks, setPaginationLinks] = useState(null);
const [totalProductsCount, setTotalProductsCount] = useState(0);
useEffect(() => {
const fetchProducts = async () => {
try {
const response = await fetch(`${list_api_url}?page=${currentPage}`);
if (response.ok) {
const response_json = await response.json();
setProducts(response_json.results);
setPaginationLinks(response_json.links); // 페이지네이션을 위한 이전/다음 페이지 URL 설정.
setTotalProductsCount(response_json.count); // 전체 상품 개수 설정.
setTotalPages(
Math.ceil(totalProductsCount / response_json.page_size)
); // 전체 페이지 수 설정. Math.ceil은 올림 함수.
setLoading(false);
// 각 상품의 이미지 URL 출력
response_json.results.forEach((product) => {
console.log(`${product.photo}`);
});
} else {
console.error("상품 목록을 불러오는 데 실패했습니다.");
}
} catch (error) {
console.error("데이터를 불러오는 도중 에러가 발생했습니다:", error);
}
};
fetchProducts();
}, [currentPage, totalPages, totalProductsCount]);
if (loading) {
return <div>loading...</div>;
}
const handleFirstPageClick = () => {
setCurrentPage(1); // 현재 페이지를 1로 설정
setPaginationLinks({ next: true }); // paginationLinks.next 값을 변경
};
return (
<div className="text-center mx-auto">
<h1>상품 목록</h1>
<p>상품 개수 :{totalProductsCount}</p>
<div className="row mx-auto" style={{ maxWidth: "1200px" }}>
{products.map((product) => (
<Col
key={product.id}
className="col-sm-12 col-md-6 col-xl-4 p-1 m-3 mx-auto "
style={{ maxWidth: "310px" }}
>
<NavLink to={`/product/${product.id}`} className="text-center">
<Product
id={product.id}
title={product.name}
category={product.category.name}
description={product.description}
price={`${product.price} 원`}
imageUrl={`${product.photo}`}
/>
</NavLink>
</Col>
))}
</div>
<div className="text-center mx-auto w-50">
<div className="text-center mx-auto mb-5">
{/* 페이지네이션 컴포넌트를 추가하고 페이지 번호에 따라 setCurrentPage를 호출하세요 */}
<button
onClick={handleFirstPageClick}
className="btn btn-outline-dark"
>
맨 앞
</button>
<button
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="btn btn-outline-dark"
>
이전
</button>
<span>
[ {currentPage} / {totalPages} ]
</span>
<button
onClick={() => setCurrentPage(currentPage + 1)}
disabled={!paginationLinks.next}
className="btn btn-outline-dark"
>
다음
</button>
<button
onClick={() => setCurrentPage(totalPages)}
className="btn btn-outline-dark"
>
맨 끝
</button>
</div>
</div>
</div>
);
};
export default ProductsList;
<참고 사항>
리액트 문법을 사용하고 있지만, 아직 jsx 에 대한 강의 내용을 듣지 않고
혼자서 페이지네이션 , 인덱스 페이지, 상세 페이지를 만들어봤기 때문에
부족한 면이 많습니다. 양해 부탁드립니다.
const backend_base_url = "http://127.0.0.1:8000";
const list_api_url = `${backend_base_url}/api/v1/products/`;
const response = await fetch(`${list_api_url}?page=${currentPage}`);
여기서 백엔드 상품 리스트 url을 받고, 쿼리스트링 문법으로
페이지네이션 페이지를 이동할 때마다, 백엔드 페이지네이션 url 을 들고올 수 있도록 해주었습니다.
currentPage 는 현재 1로 저장되어 있고, 페이지를 클릭할 때마다 바뀝니다.
useEffect 는 데이터를 가져오거나, 가져온 데이터를 렌더링해줄 변수값들의
상태를 저장해주는 등 부수효과(side effect)를 위해 쓰입니다.
https://ko.legacy.reactjs.org/docs/hooks-effect.html
자세한 내용은, 리액트 문서를 참고해주시길 바랍니다.
{
"links": {
"next": "http://127.0.0.1:8000/api/v1/products/?page=2",
"previous": null
},
"count": 100,
"page_size": 6,
"results": [
{
"id": 366,
"user_info": {
"id": 1,
"email": "아몰랑@kakao.com",
"username": null,
"nickname": "아몰랑"
},
"product_like_cnt": 0,
"category": {
"id": 1,
"name": "미분류"
},
"name": "후드티 클럽 플리스 기모 BV2973",
"price": 30900,
"status": "active",
"description": "design`/`디자인`/`디자인색상저렴한가격 빠른배송 만족합니다`|`material`/`소재`/`기모가 들어있어서 따뜻하고 편하네요`|`practicability`/`착용감`/`안감에 먼지가 너무 많아서`|`warmth`/`보온성`/`기모가 확실히 따뜻하네요`|`line`/`라인`/`핏도 예쁘고 편안하다고 합니다`|`quality`/`품질`/`품질좋고",
"photo": "http://127.0.0.1:8000/media/mall/product/photo/2024/01/04/367_i7jS9uG.jpg",
"thumbnail_img1": null,
"thumbnail_img2": null,
"thumbnail_img3": null,
"thumbnail_img4": null,
"thumbnail_img5": null,
"thumbnail_img6": null,
"manufacturer": "",
"created_at": "2024-01-04T12:56:26.396746+09:00",
"updated_at": "2024-01-04T23:04:44.552054+09:00",
"user": 1,
"likes": []
},
{
"id": 365,
"user_info": {
"id": 1,
"email": "아몰랑@kakao.com",
"username": null,
"nickname": "아몰랑"
},
"product_like_cnt": 0,
"category": {
"id": 1,
"name": "미분류"
},
"name": "GAP 갭 남성 로고 기모 후드집업 P332513800",
"price": 26300,
"status": "active",
"description": "total`/`만족도`/`좋은 가격에 득템 한 것 같습니다`|`price`/`가격`/`가볍게 걸치는 정도로 저렴하게 잘 산 것 같습니다",
"photo": "http://127.0.0.1:8000/media/mall/product/photo/2024/01/04/366_jQEEtLO.jpg",
"thumbnail_img1": null,
"thumbnail_img2": null,
"thumbnail_img3": null,
"thumbnail_img4": null,
"thumbnail_img5": null,
"thumbnail_img6": null,
"manufacturer": "",
"created_at": "2024-01-04T12:56:25.995410+09:00",
"updated_at": "2024-01-04T12:56:26.393911+09:00",
"user": 1,
"likes": []
},
{
"id": 364,
"user_info": {
"id": 1,
"email": "ksw4060@kakao.com",
"username": null,
"nickname": "ksw4060"
},
"product_like_cnt": 0,
"category": {
"id": 1,
"name": "미분류"
},
"name": "나이키 남성 NSW 클럽 기모 후드집업 BV2645-063",
"price": 47600,
"status": "active",
"description": "design`/`디자인`/`맨투맨위에 입으니 오버핏으로 예쁘게 맞아요`|`material`/`재질`/`기모가 들어있어서 따뜻합니다`|`practicability`/`착용감`/`기모가 얇게 들어 있어 오히려 입는데 너무 편합니다`|`warmth`/`보온성`/`기모 안감으로 따뜻합니다`|`quality`/`품질`/`품질은좋음`|`line`/`라인`/`핏도예쁘고만족해요",
"photo": "http://127.0.0.1:8000/media/mall/product/photo/2024/01/04/365_Xkgof7u.jpg",
"thumbnail_img1": null,
"thumbnail_img2": null,
"thumbnail_img3": null,
"thumbnail_img4": null,
"thumbnail_img5": null,
"thumbnail_img6": null,
"manufacturer": "",
"created_at": "2024-01-04T12:56:25.585582+09:00",
"updated_at": "2024-01-04T12:56:25.992610+09:00",
"user": 1,
"likes": []
},
.... 이하 363개의 상품 리스트
}
아까 받아왔던 serializer.data 를 response 로 받아주고
const response_json = await response.json(); json 화 해준 다음에
response_json.results 를 하면 상품 전체 데이터가 되고
response_json.count 를 하면, 상품 전체 갯수가 반환됩니다.
response_json.links 를 하면, 이전 페이지와 다음 페이지 url 을 파악할 수 있습니다.
setTotalPages(
Math.ceil(totalProductsCount / response_json.page_size)
); // 전체 페이지 수 설정. Math.ceil은 올림 함수.
전체 페이지 수를, 메인 페이지에서 보여줄 의도이기 때문에
따로 State 변수로 설정해주었습니다.
<div className="text-center mx-auto mb-5">
{/* 페이지네이션 컴포넌트를 추가하고 페이지 번호에 따라 setCurrentPage를 호출하세요 */}
<button
onClick={handleFirstPageClick}
className="btn btn-outline-dark"
>
맨 앞
</button>
<button
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="btn btn-outline-dark"
>
이전
</button>
<span>
[ {currentPage} / {totalPages} ]
</span>
<button
onClick={() => setCurrentPage(currentPage + 1)}
disabled={!paginationLinks.next}
className="btn btn-outline-dark"
>
다음
</button>
<button
onClick={() => setCurrentPage(totalPages)}
className="btn btn-outline-dark"
>
맨 끝
</button>
</div>
다음과 같이 페이지 네이션 구조를 설정해두었습니다.
1. 맨 앞 버튼은, 클릭을 했을 때 handleFirstPageClick 함수를 호출하여
const handleFirstPageClick = () => {
setCurrentPage(1); // 현재 페이지를 1로 설정
setPaginationLinks({ next: true }); // paginationLinks.next 값을 변경
};
currentPage 를 1로 설정해주었고
2. 이전 페이지와, 다음 페이지 버튼은 클릭 했을 때 currentPage 를 1 빼거나, 1을 더하도록 했습니다
3. 맨 끝 버튼을 클릭 했을 시, setCurrentPage(totalPages)
마지막 페이지를 currentPage로 설정하도록 해주었습니다.
disabled={currentPage === 1}
disabled 속성은, 다음 조건이 만족될때 활성화 됩니다.
<button
onClick={() => setCurrentPage(currentPage + 1)}
disabled={!paginationLinks.next}
className="btn btn-outline-dark"
>
다음
</button>
다음 버튼은, next 버튼이 없을때 비활성화 된다는 의미입니다.
4. 백엔드에서 페이지 네이션 Link를 들고와서 그걸 프론트에서 보여주는 것과
백엔드에서 상품 전체 리스트를 들고와서, 그걸 프론트에서 페이징 해주는 것의 차이는 무엇인가?
- 백엔드에서 페이지네이션을 해주면, 페이지 링크에 맞게 n 개씩만
서버에 요청하게 됩니다.
- 백엔드에서 페이징을 해주지 않고, 전체 리스트를 반환한다면
프론트에서 페이지네이션을 구현해도 , 상품 전체를 서버에 요청해야 하는 것입니다.
결국엔, 무신사 , 쿠팡, 대형 블로그나 뉴스 아티글 등등
1만개, 10만개, 100만개가 넘는 데이터를
백엔드 서버에서 페이징 해주지 않고 , 모두를 들고와서
프론트 단에서만 페이징을 해준다는 것은
서버를 과부하 하게 만들 수 있습니다.
현재 제가 사이드로 진행하는 프로젝트가
쇼핑몰 프로젝트이기 때문에 , 다양한 기능 구현을 시도중에 있습니다 .
앞으로도 관련 자료들에 대해 정리하고, 블로깅 할게요 ^^
2024년 새해복 많이 받으세요
'Library&Framework > Django' 카테고리의 다른 글
파이썬/장고로 결제 시작하기 2~3주차(Feat. 아임포트) (1) | 2023.12.07 |
---|---|
파이썬/장고로 결제 시작하기 1주차(Feat. 아임포트) (0) | 2023.11.29 |
파이썬/장고로 결제 시작하기 (Feat. 아임포트) (3) | 2023.11.29 |
게시글 댓글을 생성하는 함수에서 왜 인자로 article_id 를 꼭 넣어줘야 할까 ?? (0) | 2023.09.20 |
[django 기초] models 모듈 안에, User 클래스와 AbstractUser는 어떤 점이 다를까?? (0) | 2023.04.25 |