-
[Portfolly] 북마크, 좋아요 낙관적 업데이트로 개선하기Studying/Proj 과정 2024. 1. 10. 21:47
들어가면서...
Portfolly 프로젝트를 react-query로 마이그레이션 하면서, 하는 김에 낙관적 업데이트도 구현해보자! 했습니다.
코드가 다소 지저분하나 과정을 기록하며 깨닫는 데 의미를 두고 기록해보려 합니다..ㅎㅎ
북마크, 좋아요 버튼 낙관적 업데이트
낙관적 업데이트란, '무조건 요청이 성공했을 경우' 모습을 화면에 그렸다가, 진짜 서버 요청에 대한 결과가 응답으로 도착했을 때 실패하면 다시 원래대로 돌려놓고, 성공하면 그대로 놔두는 기법입니다.
서버 요청 대기 시간이 길어서 사용자가 요청에 대한 변화를 빨리 파악하지 못 할 때 사용하면 좋습니다.
좋아요/북마크의 경우 좋아요 버튼을 누르고 몇초 뒤 하트가 채워지면 사용자에게 어색한 UX를 제공하기 때문에 낙관적 업데이트 방식을 많이 적용합니다.
Portfolly 북마크, 좋아요 버튼 UI 다음은 북마크, 좋아요 버튼 동작에 사용되는 useToggleButtonQuery 입니다.
매개변수로 좋아요/북마크할 포트폴리오의 id와, 좋아요/북마크 버튼 중 무엇인지 구분하는 type을 받습니다.
먼저 필요한 상수부터 확인하겠습니다.
export const useToggleButtonQuery = (portfolioId: string, type: Toggle) => { const dispatch = useDispatch(); const queryClient = useQueryClient(); const currentSection = useSelector(section); const filter = getFilterQueryString(); const portfolioDetailQueryKey = ['portfolios', 'detail', portfolioId]; const portfolioAllQueryKey = [ 'portfolios', currentSection, {filters: { appCategory: filter.appCategory, }}]; ... };
⦁ queryClient: setQueryData로 쿼리를 업데이트하기 위해 불러옵니다.
⦁ currentSection: 현재 포트폴리오의 섹션(앱, 웹, 일러스트 등) 값으로, queryKey를 정의하는 데 사용합니다.
⦁ filter: 현재 포트폴리오의 카테고리 값으로, queryKey를 정의하는 데 사용합니다.
⦁ portfolioDetailQueryKey: '포트폴리오 상세 보기 페이지'에서 북마크/좋아요 할 경우 사용되는 queryKey 입니다.
⦁ portfolioAllQueryKey: '(포트폴리오 목록)메인 페이지'에서 북마크/좋아요 할 경우 사용되는 queryKey 입니다.
이어서 살펴보겠습니다.
const prevPortfolioAll = queryClient.getQueryData(portfolioAllQueryKey) as any; const prevPortfolio = queryClient.getQueryData(portfolioDetailQueryKey) as Portfolio | undefined; const portfolioList = prevPortfolioAll && JSON.parse(JSON.stringify(prevPortfolioAll)) as any; const portfolio = prevPortfolio && JSON.parse(JSON.stringify(prevPortfolio)) as Portfolio; const handleToggleButton = () => fetch(`/${type}?id=${portfolioId}`, 'POST');
⦁ prevPortfolioAll: 포트폴리오 목록 query 데이터 입니다. 요청이 성공하고 쿼리가 업데이트 되기 전 과거의 값이므로 prev라고 명칭합니다.
⦁ prevPortfolio: 포트폴리오 디테일(낱개 1개) query 데이터 입니다.
⦁ portfolioList: 이전 데이터에 대한 불변성을 지키면서 쿼리를 업데이트 하기 위해 prevPortfolioAll을 깊은 복사 한 값입니다.
⦁ portfolio: 마찬가지로 prevPortfolio를 깊은 복사 한 값입니다.
⦁ handleToggleButton: 서버에 POST API 요청을 하는 함수입니다. Promise 객체를 반환합니다.
이제 위 상수 및 함수를 활용해 useMutation 훅을 반환해보겠습니다.
이때 주의할 점은 메인 페이지와 포트폴리오 상세보기 페이지 두 군데에서 북마크를 할 수 있다는 점입니다!!
먼저 메인 페이지에서의 북마크 등록 처리를 보겠습니다.
return useMutation({ mutationFn: handleToggleButton, onMutate: async () => { // 메인 페이지에서의 북마크 등록인 경우 if(prevPortfolioAll && type === 'bookmark') { await queryClient.cancelQueries({queryKey: portfolioAllQueryKey}); portfolioList.pages.flat().forEach((portfolio: Portfolio) => { if(portfolio.id !== portfolioId) return; portfolio.isBookmarked = !portfolio.isBookmarked; return false; }) queryClient.setQueryData(portfolioAllQueryKey, portfolioList); } ... })
prevPortfolioAll 쿼리가 존재하고, type === 'bookmark'인 경우는 무조건 메인 페이지에서 북마크를 했다는 겁니다.
이번 마이그레이션의 핵심 포인트는 react-query로 낙관적 업데이트를 구현하는 것입니다.
가장 먼저, cancelQueries로 portfolioAllQueryKey를 가지는 쿼리에 대한 모든 업데이트를 무시합니다.
그 다음 해당 포트폴리오 쿼리의 isBookmarked 값을 반대로 바꿔주고, setQueryData로 성공했을 때 화면을 리렌더링 해줍니다.
포트폴리오 상세보기 페이지에서의 북마크도 다를 바 없습니다.
굳이 다른 점이라면 좋아요에 대한 낙관적 업데이트도 추가된다는 점입니다.
return useMutation({ mutationFn: handleToggleButton, onMutate: async () => { ... // 포트폴리오 디테일 페이지에서의 북마크/좋아요 등록인 경우 if(prevPortfolio) { await queryClient.cancelQueries({queryKey: portfolioDetailQueryKey}); if(type === 'bookmark'){ portfolio!.isBookmarked = !prevPortfolio.isBookmarked; } if(type === 'like'){ portfolio!.isLiked = !prevPortfolio.isLiked; if(prevPortfolio.isLiked){ portfolio!.likes = prevPortfolio.likes - 1; } else portfolio!.likes = prevPortfolio.likes + 1; } queryClient.setQueryData(portfolioDetailQueryKey, portfolio); } }, ... })
좋아요는 숫자도 +1, -1 함께 업데이트 시켜줍니다.
이제 가장 중요한 성공했을 때, 실패했을 때 콜백 함수입니다.
onSuccess: () => { if(prevPortfolioAll && type === 'bookmark') { return queryClient.setQueryData(portfolioAllQueryKey, portfolioList); } return queryClient.setQueryData(portfolioDetailQueryKey, portfolio); }, onError: () => { if(prevPortfolioAll) { queryClient.setQueryData(portfolioAllQueryKey, prevPortfolioAll); } if(prevPortfolio) { queryClient.setQueryData(portfolioDetailQueryKey, prevPortfolio); } if(type === 'bookmark') { dispatch(setToast({id: 0, type:'error', message: '북마크 등록을 실패했습니다.'})); } if(type === 'like') { dispatch(setToast({id: 0, type:'error', message: '좋아요 등록을 실패했습니다.'})); } return; },
onSuccess 콜백 함수의 경우 성공했으면 쿼리를 정상적으로 업데이트 시켜줍니다.
만약 요청이 실패해서 onError 콜백 함수가 호출될 경우에는, 이전 값 불변성을 지키기 위해 만들어뒀던 업데이트 전 쿼리로 다시 되돌려놓습니다.
그리고 전역 Toast 컴포넌트로 에러 Toast를 화면에 띄웁니다.
개선 후기
왠지 포트폴리오 CRUD를 react-query로 개선시킨 것보다 조금 더 복잡한 과정이었습니다.
막 특별히 어려운 에러를 맞닦뜨리진 않지만, 늘 동작이 제대로 안 돼서 그걸 해결하느라 길을 빙빙 돌아 오래 걸리는 것 같습니다.
이번 경우에는 포트폴리오 상세보기 페이지의 북마크/좋아요만 생각하고 구현하다가,
뒤늦게 메인 페이지에서도 북마크 기능이 있다는 걸 깨닫고 부랴부랴 코드를 추가했습니다.
게다가 query 불변성을 지키지 않으면 업데이트 감지를 하지 않아서 리렌더링이 발생하지 않는다는 사실도 잘 몰라서 화면이 안 바뀌는 탓에 한참 헛 삽질을 했습니다.
후에 query를 깊은 복사해 불변성을 지키면서 업데이트 시키는 방법을 터득하자 그 뒤는 단순한 로직 뿐이었습니다.
react-query는 queryKey를 배열로 관리해 하나씩 콕콕 찾아 바꾸는 재미가 있습니다.
'Studying > Proj 과정' 카테고리의 다른 글
[MindBook] Nextron + Typescript + TailwindCSS 설치하기 (1) 2024.02.03 [Portfolly] ErrorBoundary로 컴포넌트 단위 에러 화면 띄우기 (0) 2024.01.11 [Portfolly] react-query로 뒤로 가기 가능한 무한 스크롤 재구현 (0) 2024.01.09 [Portfolly] 포트폴리오 CRUD를 react-query로 마이그레이션 (0) 2023.12.25 [Portfolly] 직접 반응형 카테고리 Slider 만들기 (0) 2023.12.24