MUI 같이 범용적으로 어디서든 사용될 수 있는 컴포넌트를 프로젝트 내부에서 직접 구현해서 사용하고자 했다. PaginationBar
라는 이름의 범용 컴포넌트를 구현했다.
꽤나 확장성있게 구조를 잡았다. 보여질 버튼의 개수를 prop으로 받아서 페이지 버튼이 5개씩 보이게 할 수도 있고 10개씩 보이게 할 수도 있다. 사용처에서 개발자 마음대로 UI 디자인에 맞게 사용하면 되도록 한 것이다.
게다가 해당 페이지에서 몇 개의 item 을 보여줄지도 정할 수 있다.
export interface PaginationBarProps extends HTMLAttributes<HTMLDivElement> {
visiblePageButtonLength: number; //몇 개의 페이지 버튼으로 이루어진 바를 만들것인가 결정
itemCountInPage: number; //한 페이지 안에서 몇 개의 item을 보여줄 것인지 결정
totalItemCount: number; //내부적으로 페이지를 계산하기 위해 item 총 개수를 넘겨줘야함
focusedPage: number; //현재 페이지
onClickPageButton: (pageNumber: number) => void; //페이지를 눌렀을 때의 핸들러
}
위 컴포넌트의 prop type 을 보면 이 컴포넌트를 어떻게 사용할지 감이 올 것이다. 실제 이 컴포넌트를 사용한 모습은 아래와 같다.
물론 마지막 페이지가 몇 페이지인지 등등의 계산 로직은 컴포넌트 내부로 분리돼있어서 사용자는 계산에 필요한 prop만 넘겨주면 아주 편리하게 사용할 수 있는 컴포넌트이다.
우선 이 컴포넌트를 사용하다가 허점을 발견했다. 일단 기본적으로 이 컴포넌트의 버튼을 누르면 현재 url에 query param으로 page=?
을 추가해서 페이지를 리다이렉트하는 방식으로 사용했다.
이 컴포넌트는 위처럼 url 변경 방식에 의존적이지 않은 컴포넌트로 설계했다. 그래서 page 정보가 url이 아닌 컴포넌트 내부 state로 갖고 있어도 이 컴포넌트를 활용할 수 있도록 구현했다.
url 변경 방식을 예로 들자면, 3번 페이지 버튼을 누르면 www.helloworld.com/list?page=3
이런 식으로 url이 변경이 되면서 해당 페이지에 있는 item 들을 API 요청을 통해 불러오는 방식이다.
이 정도로만 생각하고 넘어간다면 이 동작 방식이 매우 허술하다는 것을 눈치채지 못하고 버그를 양산했을 것이다.
해당 컴포넌트의 prop 중에 focusedPage
가 있다는 것을 알 수 있다. 이 컴포넌트를 사용하는 페이지에서 그럼 해당 prop 을 어떻게 넘겨줄 수 있을까? 당연히 url에 있는 query string 으로 부터 받아와서 넘겨주었다.
이-컴포넌트를-사용하는-페이지.tsx
...
const pageNumberParams = searchParam.get('page');
const pageNumber = isNumberString(pageNumberParams) ? Number(pageNumberParams) : 1;
...
return (
...
<PaginationBar
className={styles.pagination}
visiblePageButtonLength={PAGE_OPTION.TEMPLATE_BUTTON_LENGTH}
itemCountInPage={PAGE_OPTION.TEMPLATE_ITEM_SIZE}
totalItemCount={numberOfTemplates}
focusedPage={Number(pageNumber)}
onClickPageButton={handleClickPagination}
/>
...
)
그런데 이렇게 사용하는 방식으로 인해 생기는 문제가 있다.
만약에 사용자가 외부에서 url을 직접 조작해서 page=9999
와 같이 존재하지 않는 페이지를 불러올 때는 어떻게 대처할 수 있을까?
이런 식의 버그가 발생한다.
추가로, 만약에 페이지에서 item을 삭제할 수 있다고 해보자. 마지막 페이지에서 item을 삭제하다보면 해당 페이지에 표시할 item이 없어질 것이다. 그렇다면 새로 갱신된 마지막 페이지로 이동해야 할 것이다. 그런데 이 컴포넌트의 사용 방식으로는 따로 처리를 해주지 않는다면 해당 페이지에 그대로 머무르면서 보여줄 item이 없는 상태가 될 것이다. 이것도 버그이다.
이런 문제를 해결하기 위해서는 컴포넌트 내부에서 가장 마지막 페이지보다 더 큰 숫자의 페이지넘버가 들어왔을 때 마지막 페이지를 보여주는 방법으로 해결해줄 수 있다. (위 두 버그 둘 다 해결 가능)
PaginationBar.tsx
...
const [searchParams, setSearchParams] = useSearchParams();
...
useEffect(() => {
if (focusedPage > totalPageLength) {
setSearchParams({
tab: searchParams.get('tab') || FILTER.USER_PROFILE_TAB.REVIEWS,
page: String(totalPageLength),
});
window.scrollTo(0, 0);
}
}, [focusedPage, totalItemCount]);
이렇게 훅을 추가해줌으로써 페이지가 변경될때마다 현재 요청하는 페이지의 번호가 마지막 페이지보다 크다면 마지막 페이지를 가리킬 수 있도록 setSearchParams
을 실행해서 url을 바로 잡아줄 수 있다.
그러나 이렇게 했을 경우 생기는 문제가 또 있다.
우선 가장 먼저 이 컴포넌트에 의존성이 강하게 형성된다. url에서 page 정보를 받아와 사용하는 경우에만 의존하는 예외처리 로직이 된다. 만약에 이 의존성이 문제가 되지 않는다 하더라도 다른 문제가 있다.
또 다른 문제는 setSearchParams
에 있다. 이 컴포넌트를 사용하는 페이지의 url에 page 정보를 제외한 query string 이 여럿 있을 수 있다. 예를 들어, /profile/51396282?tab=reviews&page=7
라고 한다면 이 컴포넌트 내부에서 위 코드 스니펫과 같이 tab: searchParams.get('tab') || FILTER.USER_PROFILE_TAB.REVIEWS,
이렇게 지정을 해줘야 한다. 만약에 다른 페이지에서는 'tab' 이 아니라 'sert' 이런 식의 query string이 사용된다면? 그럼 그 부분에 대해서도 또 처리를 해줘야 할 것이다.
실제로 다른 페이지에서 이 컴포넌트를 사용하다가 페이지가 의도치 않게 리다이렉트 되는 버그가 발생했다. 원인을 찾다 보니까 결국엔 범용적인 컴포넌트 내부 로직에 의해서 발생한 버그라는 것을 알게 되었다.
이 컴포넌트의 가장 큰 문제는 범용적인 컴포넌트라고 하기엔 외부 url에 의존성이 너무 강하다. 그리고 이 컴포넌트를 사용하기 위해서는 무조건 query param 을 사용해야 한다고 강제하고 있다.
추가적인 prop 으로 받아와서 해결할 수 있긴 하지만 외부에 의존성이 강한 실패한 컴포넌트라는 생각이 들었다.
이래서 재사용 가능하고 범용적인 컴포넌트 및 훅을 만들 때는 외부 의존성을 최대한 없애야 한다.
본질적으로 범용 컴포넌트 내부에서 리액트 라우터를 사용하는 것이 문제였기 때문에 이 부분을 해결해주기 위해서 이 컴포넌트를 사용하는 외부에서 예외 처리 로직을 만들고 해당 함수를 onPageError prop으로 받아와서 해결해주는 방식을 채택했다.
PaginationBar.tsx
export interface PaginationBarProps extends React.HTMLAttributes<HTMLDivElement> {
visiblePageButtonLength?: number;
itemCountInPage: number;
totalItemCount: number;
focusedPage?: number;
scrollReset?: boolean;
onClickPageButton: (pageNumber: number) => void;
onPageError?: () => void; //새로 추가
}
...
useEffect(
function handleError() {
return () => {
if (onPageError) {
onPageError(); //에러 핸들링 로직이 있는 경우에만 외부 함수 실행
}
};
},
[focusedPage, onPageError],
);
이렇게 함으로써 이 컴포넌트의 사용 방식을 외부에 강제하지 않을 수 있게 되고 의존성이 해결되었다. page 정보를 url 에서 받아오든, 컴포넌트 state로 관리하든 전혀 상관이 없다.
다만 없는 페이지 정보를 처리할 로직을 prop으로 받아와서 처리해줄 수 있도록 구조를 개선했다.
이 컴포넌트를 사용하는 외부 페이지에서는 다음과 같이 페이지 버튼을 눌렀을 때 핸들러와 처리하고 싶은 예외 상황 핸들러를 prop으로 넘겨줄 수 있다.
이-컴포넌트를-사용하는-페이지.tsx
// 버튼을 눌렀을 때 현재 query string을 유지한 상태로 url상에서 page만 이동하는 핸들러 (onClickPageButton prop으로 PaginationBar 컴포넌트로 넘겨줌)
const handleClickPagination = (pageNumber: number, replace = false) => {
if (searchQueryString) {
setSearchParam({ search: searchQueryString, page: String(pageNumber) }, { replace });
} else {
setSearchParam({ sort: currentTab, page: String(pageNumber) }, { replace });
}
};
// 적절하지 않은 page 정보가 url을 통해 들어왔을 때 가장 마지막 페이지로 url을 이동시켜주는 예외 처리 핸들러(onPageError prop으로 PaginationBar 컴포넌트로 넘겨줌)
const handlePageError = () => {
const totalPageLength = Math.ceil(numberOfTemplates / PAGE_OPTION.TEMPLATE_ITEM_SIZE);
const redirectReplace = true;
if (pageNumber > totalPageLength || pageNumber <= 0) {
handleClickPagination(totalPageLength, redirectReplace);
}
};
프론트엔드 프로그래밍을 할 때 의존성을 잘 생각하고 설계하고 로직을 분리하는 일이 까다로울 때가 있다.
범용적으로 만든 컴포넌트의 경우 의존성이 생겨서 유지보수가 어려워지거나 확장성이 떨어지는 것을 항상 염두에 두고 구조를 짜고 설계를 해야 한다는 생각을 다시 한 번 느끼게 됐다.