ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [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로 낙관적 업데이트를 구현하는 것입니다. 

     

    가장 먼저, cancelQueriesportfolioAllQueryKey를 가지는 쿼리에 대한 모든 업데이트를 무시합니다.

     

    그 다음 해당 포트폴리오 쿼리의 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를 배열로 관리해 하나씩 콕콕 찾아 바꾸는 재미가 있습니다.

Designed by Tistory.