저장하고자 하는 의류들의 데이터를 서버에 잘 저장하였다면, 저장된 데이터를 사용자에게 잘 보여주는 것 역시 중요할 것이다. 저번 포스팅까지 ItemForm.tsx 컴포넌트를 구현하면서 저장하는 add 페이지와 details 페이지에서의 데이터 수정 과정에 대해서 다루어보았다.
이제 실제 의류들의 목록을 유저에게 보여주는 페이지를 구상하기 위해(이를 앞으로 Store 페이지라고 하겠다) 코드를 작성하기 전 고려해야 할 부분들에 대해서 생각해보았다.
이번 포스팅은 고려했던 사항들 중 데이터에 대한 페이지네이션을 구현할 때 기존 offset 방식이 아닌 cursor 개념으로 접근하려 하였는지에 대해서 다뤄보고자 한다.
일정 수량의 데이터를 서버로부터 가져올 때
흔한 커뮤니티의 게시판을 생각해보면, 아래에 페이지내이션이 있고, 우리는 원하는 페이지로 이동하면서 게시글을 확인한다. 한 페이지에 몇 개의 게시글을 표시할지는 개발자의 의도이겠지만, 적어도 대부분의 개발자들은 처음부터 모든 게시글을 서버로부터 가져오려고 하진 않는다. 게시글의 개수가 그냥 예를 들어서 백만개만 되어도 모든 유저에게 처음 게시판을 접근 할 때 모든 게시글을 가져오게 하면 너무나 비효율적이기 때문이다.
1페이지에 있다고 가정해보자. 유저가 바라보는 화면에는 전체 게시글이 얼마나 있을지는 모르지만 적어도 1페이지 내 게시글을 보여야 한다. 그렇기 때문에 1페이지 내 렌더될 데이터들은 서버에서 가지고 와야 한다. 이를 돌려말하자면 1페이지에서 머물 때는 1페이지에 해당하는 데이터만 서버에서 받아와 렌더하면 되는 것이다. 2페이지로 이동하면 해당 페이지에 해당하는 게시글만을 가져오면 되고, 5페이지이던지 7페이지이던지 해당 페이지에 속한 게시글을 서버로부터 가져오면 되는 것이다.
이러한 개념에서 Pagination 은 전체 데이터를 쪼갠다는 부분에서 의미가 큰데, 보통 Pagination 방식에는 offset-based-pagination 과 cursor-based-pagination 2가지의 방식을 자주 활용한다.
offset 기반의 페이지네이션을 구현하기 위해서는 쿼리미터로 offset 과 limit 을 서버에 전달해주어야 한다. sql 에서 limit 은 출력할 행의 갯수이며, offset 은 데이터의 초기 위치값이다.
예를 들어서 총 100개의 데이터가 있을 때, offset = 0, limit = 10 이라고 한다면 id 0~10 까지의 데이터를 요청하는 것이며, offset = 10, limit = 10 의 경우 10~20 까지의 데이터를 요청하는 것이다. sql 에서는 전체 데이터 중 첫 시작점인 offset 위치를 스캔하면서 찾은 후, limit 만큼의 데이터를 전달한다. 전체 데이터의 개수를 기준으로 가져오기 때문에, 손쉽게 범위 데이터를 받아올 수 있다는 점에서 사용하기 편리하다.
지금 프로젝트는 시퀄라이즈를 통해서 sql 을 컨트롤하기에, 시퀄라이즈에서 offset 방식을 사용하는 방식은 다음과 같다.
// 10개의 인스턴스를 불러온다
Project.findAll({ limit: 10 });
// 8 인스턴스를 skip 한다
Project.findAll({ offset: 8 });
// 5 인스턴스를 skip 하고 다음 5 인스턴스를 불러온다
Project.findAll({ offset: 5, limit: 5 });
실제 API 를 작성할 때 query parameter 를 통해 offset 과 limit 을 전달받으면, 이를 위 조건에 넣어주어서 데이터를 응답 받으면 된다.
router.get("/clothes/store/", isLoggedIn, async (req, res, next) => {
try {
let where = { UserId: req.user.id };
const userClothes = await Cloth.findAll({
where,
// query 로 부터 limit 과 offset 수치를 가져온다. 숫자로는 변경해주자
limit: parseInt(req.query.limit, 10),
offset: parseInt(req.query.offset, 10),
// 검색할 순서를 정해준다. 생성일자 순으로 해주자.
order: [["createdAt", "DESC"]],
include: [
{
model: Image,
attributes: ["id", "ClothId", "src"],
},
],
});
// 데이터 가공 로직
// 생략
} catch (error) {
console.error(error);
next(error);
}
});
limit 이 고정이라면 그냥 10 같은 숫자로 고정해주면 된다. query 로는 offset 만 추가해줘도 상관없다. 이렇듯 간단하게 API 를 작성할 수 있다.
offset 방식은 이전부터 많이 사용했던 방식이며, 특히 게시판 내 페이지내이션 버튼과 연계시키는 것이 용이하다.
limit 이 동일하다는 가정하에(한 페이지 내 게시판 갯수가 같다면) 각 페이지에 해당하는 데이터를 가져오도록 query 를 작성하는 것은 간단하다. 단순한 곱셈을 통해 해결할 수 있기 때문이다. limit 은 고정이며 변경되는 사항은 offset 이다.
페이지 번호 | offset | 불러올 데이터 id |
1 | (1-1) * limit (10개라 가정하자) = 0 | 1~10 |
2 | (2-1) * limit = 10 | 11~20 |
3 | (3-1) * limit = 20 | 21~30 |
4 | (4-1) * limit = 30 | 31~40 |
페이지번호에 limit 을 곱해주는 것으로 offset 를 쉽게 설정해줄 수 있으며, 번호식 페이지내이션의 가장 큰 장점이자 핵심인 페이지 점프를 손쉽게 구현해줄 수 있다. 사용자는 항상 1번부터 2번 3번 순서대로 게시글을 살펴보지 않는다. 1번을 보다가 4번 페이지를 가고 다음 페이지로 넘어가기도 한다. 이러한 점프를 offset 은 쉽게 해결해준다. 페이지 번호에 따라 offset 이 자동으로 설정이 되기 때문이다
편한 방식이지만 많은 개발자들이 추천하지 않으려 하는 이유
offset 방식은 편하게 페이지내이션을 할 수 있도록 도와주지만, 몇가지 한계점을 가지기도 한다.
1. offset 을 통한 skip 해야할 데이터의 범위가 늘어날 수록, 성능이 저하된다
100000 개의 데이터가 저장되어있다고 가정해보자. 처음 offset 이 0 이고 limit 이 10 이라면 skip 할 부분이 없으니 바로 10개의 데이터를 전송하면 된다. 이제 offset 은 점점 늘어나고 어느새 50000 정도까지 늘어났다고 가정해보자. 그냥 바로 50000 부터 10개를 가져올 것 같은 기대와 달리, offset 방식은 index 를 찾아가는 방식이 아니기 때문에, 50000 번째를 찾을 때 까지 데이터를 순회해야 한다. 자료구조 중 가장 편하게 사용하는 배열을 생각해보자. 100개의 인자가 들어있는 배열 속에서 'dog' 이라는 인자를 찾기 위해서는 맨 처음부터 차례대로 순회를 해서 'dog' 을 찾는다. 만일 'dog' 이 100번째에 있다면 전체 배열을 순회해야지만 찾을 수 있게 된다.
offset 역시 마찬가지이다. 따라서 offset 을 통해 skip 해야하는 범위가 늘어나면 늘어날 수록 전체 데이터를 순회해야 하는 비중이 커지기 때문에, 당연히 성능이 저하된다.
실제 cursor 방식(뒤에서 언급) 과 비교하여 다루는 데이터가 증가할 수록 지연되는 시간이 선형 비례함을 알 수 있다. (다만 이 이미지가 실제 측정된 시간인지는 확실치 않다. 그래서 선형 비례라고 표현하는게 무리일 수 있지만, 중요한점은 skip 하는 데이터가 늘어날 수록 시간이 지연된다는 사실은 명백하다)
만일 다루는 데이터가 방대한 사이트를 다루어야 할 경우 offset 방식을 도입하기 전 한번 더 고민을 해볼 필요가 있겠다.
2. 실시간 데이터 변경에 따른 다음 페이지의 데이터 소실 및 중복 현상
여러 유저가 같이 사용하는 한 커뮤니티 사이트라고 가정해보자.
게시글을 살펴보기 위해 현재 나는 2페이지를 클릭하였고, offset 을 통해 2페이지에 대한 데이터를 가져올 수 있었다.
이때 순간 게시글 주인이 2페이지 내 자신의 게시글 3개를 지워버렸다고 가정해보자. 아직 새로고침을 하지 않았기에 현재 나의 2페이지에는 지운 3개의 게시글이 존재한다. 이제 2페이지 게시글의 목록을 확인하고 내가 원하는 게시글이 없어서 다음 3페이지로 이동한다. 이렇게 됬을 경우 3페이지에는 유저가 원래 의도한 게시글들이 보이게 될까?
만일 게시글 3개를 삭제하지 않았을 때 3페이지에서 보일 게시글의 id 가 21~30 이라고 생각해보자. 21번부터 30번까지의 게시글은 아직 내가 눈으로 확인하지 못한 게시글이다. 의도대로라면 21번부터 30번까지의 게시글을 3페이지에서 읽을 수 있어야 한다.
하지만 2페이지에서 머무는 동안 3개의 게시글이 2페이지에서 실제 삭제되었고, 그렇기 때문에 3페이지로 이동할 시 3개의 게시글이 2페이지로 이동하고 실제 내가 눈으로 확인하는 게시글은 24~33 번의 게시글을 눈으로 확인하게 된다. 원래라면 확인할 수 있었던 21,22,23 번의 게시글을 확인하지 못하는(Skiped) 상황이 발생한 것이다.
이렇게 다른 유저와의 상호작용으로 데이터가 실시간으로 변화함에 따라 실 유저가 확인하게 되는 데이터가 소실되거나 중복되는 현상이 offset 방식에서는 발생하게 된다. 데이터가 중복되는 현상은 반대로 누군가 게시글을 추가했을 상황이다. 다만 이 상황은 유저 경험에서 볼 때 충분히 인식할만한 상황이기에 skiped 된 상황보다는 좀 덜 불편하다 생각한다. (어찌되었던 게시글을 확인한것이니깐)
이러한 2가지 이유로 offset 방식 대신 cursor 방식을 선호하는 개발자들이 늘어나기 시작했다.
Cursor 방식을 통해 이러한 단점 극복하기
한 메타의 개발자가 페이지내이션에 대해서 한 말이다
Cursor-based pagination is the most efficient method of paging and should always be used where possible.
cursor 방식이 가장 효율적이며 가능하다면 항상 cursor 방식을 사용하라는 조언이다. 위에서 미리 그래프로 확인할 수 있었지만 cursor 방식은 데이터의 양과는 관계없이, 고유한 index 를 찾아(객체에서 key 를 찾아가는 과정으로 이해하면 편할것이다), 그 이후부터 limit 만큼의 데이터를 전달하게 된다. 즉 전체 데이터를 순회할 필요 없이 바로 고유 index 를 찾기 때문에 효율적이다.
cursor 방식을 도입해서 시퀄라이즈에서 API 를 작성해보면 다음과 같다
router.get("/clothes/store/", isLoggedIn, async (req, res, next) => {
try {
let where = { UserId: req.user.id };
if (req.query.categori) {
where.categori = req.query.categori;
}
// cursor 가 존재할 경우, where.id 를 cursor 로 설정해준다. 즉 cursor 이하로 해준다.
// Op.lt 는 이하를 나타내는데, 이러는 이유는 내림차순으로 정렬했기 때문이다. 20, 19, 18 ...
if (parseInt(req.query.lastId, 10)) {
where.id = { [Op.lt]: parseInt(req.query.lastId, 10) };
}
const userClothes = await Cloth.findAll({
where,
// 9개를 가져오는 것은 고정한다
limit: 9,
order: [["createdAt", "DESC"]],
include: [
{
model: Image,
attributes: ["id", "ClothId", "src"],
},
],
});
// 생략
} catch (error) {
console.error(error);
next(error);
}
});
클라이언트 단에서 cursor 를 query 로 가져온 다음, 이를 where.id 를 통해서 가져올 데이터 범위를 설정할 수 있다. 이후 limit 만큼의 데이터를 전달해주면 된다.
클라이언트 단에서 cursor 를 아는 방법은 간단한데, 서버에서 데이터를 전송해줄 때, id 값을 같이 전송해주면 된다. 예를 들어 10개의 데이터가 전송된다고 가정하면
[{id: 1, data: ...}, {id:2, data:...}, ... {id: 10, data: ...}]
이렇게 데이터를 받게 되면, 다음 cursor 는 10이 된다. 이 10을 query parameter 를 통해 서버로 전달해주면 된다. 뭔가 꼬리물기 식처럼 데이터를 가져올 수 있게 된다.
이러한 cursor 방식에서는 중간에 데이터가 변경이 되더라도, cursor 가 그대로 index 를 가리키고 있기 때문에, 가리킨 index 다음 limit 만큼의 데이터를 그대로 가져오게 된다. 앞선 예제에서 2페이지 내 데이터가 삭제되었다고 한들, 이미 cursor 는 20 이라는 index 를 가리키고 있으니 그대로 21~30 의 게시글을 가져오게 되는 것이다. 데이터가 추가되는 경우도 마찬가지이다.
모든 단점이 해결된 듯 하지만, 앞서서 꼬리물기 식으로 데이터를 가져온다고 표현했는데, 이러한 특징때문에 실제 게시판 내 페이지 점프를 구현해야 하는 경우에는 cursor 방식은 한계가 있다. 데이터를 가져오는 것이 cursor index 에 의존하기 때문이다.
만일 2페이지에 접근하여 데이터를 가져온다면 cursor 는 바로 3페이지를 가리키고 있을 것이다. 그렇다면 나머지 5페이지 6페이지 등으로의 이동은 불가능하다는 뜻이다. cursor 가 5페이지를 가리키지 않기 때문이다. 따라서 번호가 있는 페이지내이션에는 적합하지 않다. 반면 무한 스크롤과 같이 순차적으로 데이터를 가져오는 경우라면 적합하다 할 수 있겠다.
프로젝트 때 선택한 방식은?
결과적으로 나는 cursor 방식으로 하기로 선택하였다. 그리고 이 선택은 반쯤은 후회하고 있다! ㅜㅜ..
솔직하게 말하자면 당시에는 큰 고민을 하지 않았었다. 왜냐하면 무한스크롤 방식으로 데이터를 로드해야 했기 때문이다. 무한 스크롤 하니깐 바로 cursor 방식이 떠올라서 별다른 고민하지 않고 API 를 cursor 방식으로 작성했었던 것 같다.
프로젝트의 기본 방향은 웹앱이였기 때문에 데스크탑과 모바일일 때의 목록 표현 방식을 다르게 가져가려고 하였다. 모바일에서 Table 구조로 표현하는 것은 화면 크기상 적합하지 않다고 생각했다. 머리속에 떠오른 방식은 인스타그램이었고, 인스타그램식으로 board 형태로 표현하는 것이 맞다고 생각하였고, 데이터의 로드 방식은 무한 스크롤 페이지내이션 방식으로 결정하였다.
반면 데스크탑의 경우 Table 을 구현하여 좀 더 의류 데이터를 손쉽게 탐색 할 수 있도록 하고 싶었다. 모니터는 모바일 화면에 비해서 큰 폭을 가지고 있었고, 굳이 무한스크롤을 적용할 필요를 느끼지 못했다. 페이지 점프를 통해서 좀 더 자유롭게 데이터 탐색을 할 수 있는것이 더 최선이라 생각했다.
결국 모바일은 무한스크롤, 데스크탑은 테이블 페이지내이션으로 결론을 짓고 cursor 방식을 도입하려고 하다보니, cursor 방식으로는 페이지 점핑을 할 수 없다는 것을 깨닫게 되었다. (이때라도 API 하나 더 생성해서 offset 방식을 도입할 걸 싶었다..)
만약 지금 다시 코드를 작성한다면 그냥 offset 방식의 API 를 하나 더 구현한 뒤, 모바일 환경과 데스크탑 환경에서의 API 를 구별하여 디바이스 타입에 따라 다른 방식으로 받아오도록 할 것 같다. 다만 이 때는 어떻게서든 페이지내이션 점핑을 cursor 로 구현하려고 하였고(마치 noSQL 에서 offset Pagination 을 구현하려는 시도), 당시 생각했던 이유로는 다음과 같다
- 프로젝트 Store 페이지 내에서 바로 데이터가 삭제가 가능하다. 즉 데이터 변화가 있으니 offset 보다 cursor 를 활용해보자
- 데이터가 많아지면 많아질수록 cursor 가 더 유리하니, 되도록이면 cursor 방식으로 해보자
이러한 이유로 어거지(?)로라도 한번 cursor 방식으로 구현해보고자 했다
어거지 방식!
다음 포스팅 때 설명하겠지만, Store 페이지는 전체 의류의 갯수와, 총 가격, 가장 우세한 카테고리 정보를 보여주어야 한다. 따라서 처음 페이지에 접근할 때 getServerSideProps 를 통해 전체 데이터의 가공된 데이터를 가져와 미리 next 서버단에서 페이지를 생성하게 된다. 이 때의 전체 데이터를 다루는 서버 API 는 다음과 같다 (생략해서 표현하겠다)
router.get("/clothes/", isLoggedIn, async (req, res, next) => {
try {
const clothes = await Cloth.findAll({
where: { UserId: req.user.id },
order: [["createdAt", "DESC"]],
include: [
{
model: Image,
attributes: ["id", "ClothId", "src"],
},
],
});
// 생략
const result = {
// 생략
// 이 부분이 바로 저장된 의류의 전체 id 가 담긴 배열이다
idArray: clothes.map((v) => {
return { id: v.id, categori: v.categori };
}),
};
res.status(200).json(result);
} catch (error) {
console.error(error);
next(error);
}
});
어차피 전체 데이터를 한번 순회해야 한다는 점을 이용해서, 전체 id Array 를 받아온다. 이 배열을 통해 테이블의 번호 페이지내이션을 설정해 줄 것이다
받아온 id Array 는 post Reducer 의 상태값으로 저장이 된다.
// reducer/post
case t.LOAD_ITEMS_SUCCESS: {
draft.loadItemsLoding = false;
draft.loadItemsDone = true;
draft.loadItemsError = false;
// indexArray 에 저장이 된다
draft.indexArray = action.data.idArray;
draft.userItems = action.data;
break;
}
이 배열을 기반으로 antd 의 페이지내이션 컴포넌트를 구현해줄 것이다. Store 페이지로 이동하여 useSelector 를 통해 indexArray 를 가져오도록 하자
const store = ({ device }: StoreProps) => {
const dispatch = useDispatch();
const { userItems, indexArray, deleteItemDone, loadItemsLoding, deleteItemLoding } = useSelector((state: rootReducerType) => state.post);
const [windowWidth, setWindowWidth] = useState(device);
// 생략
return (
<SkeletonStore loadItemsLoading={loadItemsLoding} deleteItemLoding={deleteItemLoding} windowWidth={device}>
<PageLayout>
// 생략
<ItemsStoreSection>
// 생략
{windowWidth === 'desktop' ? (
<div>
<Pagination current={current} onChange={pageChange} total={itemsIdArray?.length} defaultPageSize={9} itemRender={itemRender} aria-label='페이지네이션 입니다' />
</div>
) : null}
</ItemsStoreSection>
// 생략
</PageLayout>
</SkeletonStore>
);
}
페이지내이션 부분 total 부분에 indexArray 의 길이를 넣어준다. 이로서 저장된 전체 의류에 대한 페이지 번호가 생성이 되게 된다.
이제 페이지 번호를 누르면 해당되는 cursor 와 함께 API 로 전송되어 데이터를 가져와야 한다. 이러한 작업을 해주는 함수는 다음과 같다
const store = ({ device }: StoreProps) => {
const dispatch = useDispatch();
const { userItems, indexArray, deleteItemDone, loadItemsLoding, deleteItemLoding } = useSelector((state: rootReducerType) => state.post);
const [current, setCurrent] = useState(1);
const [windowWidth, setWindowWidth] = useState(device);
// 페이지가 변하면 함수가 다시 실행된다.
// indexArray 를 수정해줄 것이다.
let itemsIdArray = indexArray;
// 페이지를 클릭하게 되면 current 가 변하게 된다. 1페이지는 1, 2페이지는 2 이런식으로
// 이를 활용해서 전체 배열 내 cursor 로 할 index 를 설정해준다. 2페이지라면 pageIndex 는 8이 된다
let pageIndex = (current - 1) * 9 - 1;
// 8은 0보다 크기 때문에, indexArray[8] 번을 cursor 로 하겠다는 의미이다.
// indexArray 는 규칙성이 보장되지 않은 고유 index 번호이다.
let lastId = pageIndex >= 0 ? itemsIdArray[pageIndex].id : 0;
// query parameter 를 통해 lastId 를 전달한다
const { data, error, isLoading, mutate } = useSWR(`${backUrl}/posts/clothes/store?lastId=${lastId}&categori=${categoriName}&deviceType=${windowWidth}`, mutateFetcher);
const pageChange: PaginationProps['onChange'] = page => {
// 페이지가 변함과 동시에 setState 로 인해 store 함수가 다시 실행된다.
setCurrent(page);
};
return ()
}
페이지를 변화시키면 상태값도 변화되기에 다시 함수가 실행이 된다. 이 때 reducer 의 상태값 indexArray 에는 규칙성이 없는 의류들의 고유한 id 값이 들어있다. 이 id 값들은 고유하지만, 배열 내 index 를 통해 cursor 가 될 id 를 골라내는 것은 가능하다.
즉, limit 가 9일 떄, 2페이지로 가는 cursor 는 indexArray[8] 이며, 3페이지로 가는 cursor 는 indexArray[17] 이다. 다시 언급하자면 indexArray는 전체 의류 데이터의 id 값을 가지는 배열이다.
이런식으로 lastId 를 가져온 뒤, 이를 query parameter 로 넘겨서 API 에 요청하면 이에 해당하는 데이터를 가져오게 된다. 즉 1번 페이지를 보다가 갑자기 3번을 가던 4번을 가던, pageIndex 의 계산에 따라 해당 페이지로의 cursor 를 얻어 lastId 에 넣어 줄 수 있게 되었다. 어거지 방식으로 페이지 점프를 구현했다.
위는 데스크탑의 환경이고 모바일에서는 서버 API 에서 다음 cursor id 를 이미 전송해주기 때문에 무리없이 cursor 방식으로 무한 스크롤 방식을 구현할 수 있었다.
router.get("/clothes/store/", isLoggedIn, async (req, res, next) => {
try {
let where = { UserId: req.user.id };
if (parseInt(req.query.lastId, 10)) {
where.id = { [Op.lt]: parseInt(req.query.lastId, 10) };
}
const userClothes = await Cloth.findAll({
where,
limit: 9,
order: [["createdAt", "DESC"]],
include: [
{
model: Image,
attributes: ["id", "ClothId", "src"],
},
],
});
if (userClothes.length === 0) {
return res.status(200).json({ items: userClothes });
}
if (userClothes.length > 0) {
// cursor id 를 가져온다
let nextCursor = userClothes[userClothes.length - 1].dataValues.id;
let userClothesData = userClothes.map((item) => item.dataValues);
// 객체에 담아서 전달해주자
let userClothesWithCursor = {
items: userClothesData,
nextCursor: nextCursor,
};
if (req.query.deviceType === "desktop") {
return res.status(200).json(userClothes);
// 모바일 에서는 userClothesWithCursor 데이터를 전송한다
} else if (req.query.deviceType === "phone") {
return res.status(200).json(userClothesWithCursor);
}
}
} catch (error) {
console.error(error);
next(error);
}
});
다음 포스팅들에서 SWR 과 데이터를 삭제 할 경우 Reducer 상태를 어떻게 수정해야할지 다루기 때문에 데이터 삭제 설명은 미루겠지만, 페이지내이션에 한해서 설명을 덫붙이자면, 만일 특정 id 를 가지는 의류가 삭제가 되면, 그 특정 id 를 indexArray 에서 삭제를 하면, cursor 를 조정할 수 있다.
나의 판단 미스
처음에는 cursor 방식으로 어떻게해서든 페이지 점프를 구현할 수 있어서 다행이라 생각했는데, 추후 SWR 을 통해서 데이터를 요청하는 과정이 지나고 다시 판단했을 때, 지금처럼 cursor 방식을 모바일 및 데스크탑에서도 유지한 것은 판단 미스라 생각이 들었다.
우선, 데이터량이 많아질 경우 indexArray 를 상태값으로 저장해놓는다는 것이 너무 비효율적이고, 나중에는 불가능할수도 있겠다고 생각이 들었다. 전체 의류 데이터를 가져오는 것을 생각할 수 없어서 페이지내이션에 따라 limit 만큼 데이터를 요청하고 받아오는 것인데, 아무리 id 값만 들어있다고 한들 전체 데이터의 id 를 서버로부터 요청하는것은 문제가 있다고 생각이 든다.
당시에도 이런 생각이 들지 않았던 것은 아닌데, 그때 생각으로는 한 유저가 집안의 의류를 저장해봤자 수가 크지 않을꺼라는 생각이 있었고 그래서 이렇게 추진하려고 하였다. 하지만 이를 반대로 생각해보면 데이터 양이 얼마 되지 않는다면 그냥 offset 방식을 사용해도 응답 속도에 큰 차이가 없을 것이라 생각이 든다.
두번재로, 사용하는 유저가 인지하지 못하는 데이터 삭제는 존재하지 않는다는 점이다. 지금 프로젝트의 특징은 철저하게 개인의 저장소와 같은 개념이다. 즉 다른 유저와 어떠한 정보도 공유하지 않는다. 따라서 자신이 데이터를 삭제하지 않는한 데이터는 유지된다. 즉 데이터의 소실이나 중복은 유저가 의도하지 않는한 일어나지 않는다. 거기에 실제 데이터를 삭제할 시 indexArray 에서 삭제된 id 도 같이 삭제하여 pagination 의 번호를 최신화 해주었는데, 이 때문에 다음 페이지로 갈 경우 데이터의 소실이 발생하게 된다. cursor 의 장점을 사용하려고 했던 것이 오히려 cursor 의 장점을 해쳐버렸다
반드시 이게 더 좋다 라는것은 없는것 같다
지금까지 cursor 방식을 구현한 방식과 그 이유, 그리고 그것이 왜 나의 판단 미스였는지 살펴보았다.
다음에 다시 비슷한 상황에서 구현하라고 한다면 offset 방식도 적절하게 고려하면서 더 고민해야겠다는 교휸을 얻은 경험이었다.
그럼에도 이 기회를 통해 페이지내이션의 offset & cursor 방식에 대해 좀 더 공부를 해볼 수 있었던 것 같고, 실제 클라이언트에서 cursor 로 페이지내이션 점프를 활용하기 위해서 계속 고민했던 과정이 손해는 아니라고 생각한다. 상태 변화로 인한 함수 재실행을 활용해서 페이지내이션을 최신화 하는 과정 등등 그냥 offset 을 적용했다면 해볼 수 없는 경험들이었다 생각한다.
어떤 기술이던지간에 그 기술을 사용해야하는 프로젝트를 정확하게 해석하는것이 먼저라는 것을 명심해야겠다.!
참고문헌
https://uxdesign.cc/why-facebook-says-cursor-pagination-is-the-greatest-d6b98d86b6c0
'Practice' 카테고리의 다른 글
[Closet] SWR Mutate 를 통한 캐시 갱신 (0) | 2023.05.06 |
---|---|
[Closet]Sequelize custom method 을 활용한 데이터 수정 (0) | 2023.04.30 |
[Closet]React-Hook-Form의 Reset (1) | 2023.04.30 |
[Closet]이미지 파일을 Multer 와 Vision AI로 처리해보자 (0) | 2023.04.30 |
[Closet] 이미지 업로드 시 Drag and Drop을 활용해보기 (0) | 2023.04.29 |