UX 향상을 위한 Optimistic Updates 구현하기 (with 리액트 쿼리)

제제 🍊·2023년 1월 30일
7
post-thumbnail

SNS의 "좋아요"

페이스북이나 인스타그램에서 "좋아요" 기능을 사용해본 경험이 있으신가요?
좋아요 버튼을 클릭하면, 클릭과 동시에 해당 피드에 내 좋아요가 반영되는 모습을 확인할 수 있습니다.

좋아요 기능은 서버에 side effect를 발생시키는 PUT 요청을 보내고 그 응답을 받아오기까지 딜레이가 생기는 기능입니다. 하지만 좋아요 기능이 동작하는 모습을 보고 있으면, 클릭과 동시에 동기적으로 응답을 받아오는 것처럼 보이죠.

Optimistic Updates 개념

이와 같이 서버에 side effect를 발생시키는 요청에 대해,
요청을 보내는 것과 동시에 결과를 예측하고, 예측한 결과를 UI에 반영하는 것을 Optimistic Updates라고 합니다.

똥손 죄송...
이처럼 Optimistic Updates를 구현함으로써 유저는 자신의 액션에 따른 즉각적인 피드백을 받을 수 있고, 향상된 UX를 경험할 수 있습니다.

이번 포스트에서는 useState, 그리고 리액트 쿼리를 통해 Optimistic Updates를 구현하는 방법을 간단하게 정리해보려고 합니다.

(1) 구현하기 with useState

먼저 useState 훅을 통해 Optimisitc Updates를 구현해봅시다.
다음과 같은 UI를 구현해보려고 하는데요.

완성된 슈도 코드는 다음과 같습니다.

import { useState } from 'react';

const Feed = ({ feed }: {
	id: number;			// 피드 아이디
    isLiked: boolean; 	// 피드에 대한 유저의 좋아요 여부 (요청을 보낸 유저 기준)
    likeCount: number;	// 피드 좋아요 개수
}) => {
	// props로 받아온 feed 정보를 state에 저장하기
	const [localFeed, setLocalFeed] = useState(feed);
    
    // 좋아요 아이콘 클릭 시 호출
    const handleClick = () => {
    	// 이전 상태 저장하기
        const prevFeed = localFeed;
    
    	// Optimistic Updates 구현
    	setLocalFeed((prev) => ({
        	...prev,
        	isLiked: !prev.isLiked,
      		likeCount: prev.isLiked ? prev.likeCount - 1 : prev.likeCount + 1,
        }));
        
        // 서버 요청 보내기
        api.put('피드에 좋아요', { feedId: feed.id })
        	.catch(() => {
            	// 에러 발생 시 이전 상태로 되돌리기
                setLocalFeed(prevFeed)
                
            	// ... 추가적인 에러 핸들링
            });
    };
    
    return (
    	<div>
        	<h3>피드 #{localFeed.id}</h3>
            <img src={localFeed.isLiked ? '빨간하트' : '검은색하트'} onClick={handleClick}/>
            <span>...님 외 {localFeed.likeCount}명이 좋아합니다.</span>
        </div>
    );
};

export default Feed;

조금씩 나눠서 정리해봅시다.

  1. props
    먼저 Feed 컴포넌트는 피드 정보가 담겨있는 feed를 props로 받아오고 있는데요.
    feed의 구조는 다음과 같습니다.

    	id: number;				// 피드 아이디
    	isLiked: boolean; 		// 피드에 대한 유저의 좋아요 여부 (요청을 보낸 유저 기준)
    	likeCount: number;		// 피드 좋아요 개수
  2. props -> state

    const [localFeed, setLocalFeed] = useState(feed);

    Feed 컴포넌트 내에서 이루어지는 요청을 Optimistic Updates 처리를 하기 위해서는 클라이언트가 좋아요 여부에 대한 상태를 핸들링할 수 있어야 하므로, props로 받아온 feed를 state에 담아주었습니다.

    feed의 id 정보는 Optimistic Updates 구현에 불필요하지만, 편의를 위해 id 정보까지 state에 담아주었습니다.

  3. 렌더링

    return (
    	<div>
      		<h3>피드 #{localFeed.id}</h3>
            <img src={localFeed.isLiked ? '빨간하트' : '검은색하트'} onClick={handleClick}/>
            <span>...님 외 {localFeed.likeCount}명이 좋아합니다.</span>
        </div>
    );

    useState 훅을 통해 관리되는 localFeed의 값에 따라 UI가 구성됩니다. 따라서 클라이언트가 localFeed 값을 수정하면, 그에 따라 UI가 즉각적으로 바뀌게 됩니다.

  4. onClick 핸들러 함수

    // 좋아요 아이콘 클릭 시 호출
    const handleClick = () => {
    	// 이전 상태 저장하기
    	const prevFeed = localFeed;
      
    	// Optimistic Updates 구현
    	setLocalFeed((prev) => ({
    		...prev,
    		isLiked: !prev.isLiked,
    		likeCount: prev.isLiked ? prev.likeCount - 1 : prev.likeCount + 1,
    	}));
          
    	// 서버 요청 보내기
    	api.put('피드에 좋아요', { feedId: feed.id })
    		.catch(() => {
    			// 에러 발생 시 이전 상태로 되돌리기
    			setLocalFeed(prevFeed)
                  
    			// ... 추가적인 에러 핸들링
    		});
    };

    Optimistic Updates의 핵심이 되는 onClick 핸들러 함수입니다.

    // 이전 상태 저장하기
    const prevFeed = localFeed;    

    먼저 현재 상태를 prevFeed 변수에 저장함으로써 Optimistic Updates 이후 서버 요청이 실패할 경우를 대비합니다. 서버 요청이 실패하면 원래 상태로 복원할 수 있겠죠.

    // Optimistic Updates 구현
    setLocalFeed((prev) => ({
    	...prev,
    	isLiked: !prev.isLiked,
    	likeCount: prev.isLiked ? prev.likeCount - 1 : prev.likeCount + 1,
    }));

    다음으로 아직 좋아요를 누르지 않은 유저라면 좋아요를 누른 상태로, 이미 좋아요를 누른 유저라면 좋아요를 누르지 않은 상태로 localFeed를 업데이트 해줍니다. 이 부분이 Optimistic Updates가 됩니다. 아직 서버로부터 응답이 돌아오지 않은 상태에서 결과를 예측하여 UI를 업데이트 했으니 말이죠.

    // 서버 요청 보내기
    api.put('피드에 좋아요', { feedId: feed.id })
    	.catch(() => {
    		// 에러 발생 시 이전 상태로 되돌리기
    		setLocalFeed(prevFeed)
                  
    		// ... 추가적인 에러 핸들링
    	});

    마지막으로 실제 DB에도 유저의 액션이 반영 될 수 있도록 api 요청을 보내줍니다. 적절한 에러 핸들링까지 더해진다면, 깔끔한 Optimisitc Updates가 될 것 같습니다.

    setLocalFeed(...)는 코드 라인 상에서 api.put(...) 아래에서 호출되어도 상관 없습니다. api 요청이 비동기적으로 이루어지고 있는 사이에 setLocalFeed 함수가 동기적으로 호출될 테니까요.

구현하기 with 리액트 쿼리

저는 서버 상태를 효율적으로 관리하기 위해 리액트 쿼리를 즐겨 사용하는데요.
리액트 쿼리를 통해 Optimistic Updates를 구현하는 것은 공식문서에 아래와 같이 아주 친절히 정리 되어있습니다.

Optimistic Updates : https://tanstack.com/query/v4/docs/react/guides/optimistic-updates

리액트 쿼리에서는 서버에 side effect를 발생시키는 요청에 대해 useMutation 훅을 제공합니다. 따라서 피드에 대한 좋아요 요청은 useMutation 훅을 통해 구현할 수 있습니다.
그럼 useMutation 훅을 통해 간단하게 Optimistic Updates를 구현해봅시다.
useMutation 훅이 궁금하다면 아래 링크를 참고해주세요.

useMutation : https://tanstack.com/query/v4/docs/react/reference/useMutation

완성된 슈도 코드는 다음과 같습니다.

import { useQueryClient } from '@tanstack/react-query'

const Feed = ({ feed }: {
	id: number;			// 피드 아이디
    isLiked: boolean; 	// 피드에 대한 유저의 좋아요 여부 (요청을 보낸 유저 기준)
    likeCount: number;	// 피드 좋아요 개수
}) => {
	const queryClient = useQueryClient();
    const { mutate } = useMutation({
    	mutationFn: (feedId) => api.put('피드에 좋아요', { feedId }),
        
        // mutation이 발생할 때
        onMutate: (feedId) => {
        	// 현재 feed 정보를 prevFeed 변수에 저장한다.
            // Feed 컴포넌트가 props로 받은 feed와 동일한 값이다.
        	const prevFeed = queryClient.getQueryData(['feeds', feedId]);
            
            // 새롭게 갈아끼울 feed 정보
        	const nextFeed = {
            	id: feed.id,
        		isLiked: !feed.isLiked,
      			likeCount: feed.isLiked ? feed.likeCount - 1 : feed.likeCount + 1
            };
            
            // ['feeds', feedId] 키에 저장된 쿼리 데이터를 nextFeed로 갈아끼운다.
        	queryClient.setQueryData(['feeds', feedId], nextFeed);
            
            // prevFeed 정보와 함께 context를 반환한다.
            return { prevFeed };
        },
        
        // mutation이 실패할 때
        onError: (err, feedId, context) => {
        	// onMutate에서 반환한 context를 이용해서 에러 핸들링을 한다.
        	queryClient.setQueryData(['feeds', feedId], context.prevFeed);
            
            // ... 추가적인 에러 핸들링
        }
	)};
        
    
    // 좋아요 아이콘 클릭 시 호출
    const handleClick = () => {
    	mutate(feed.id);
    };
    
    return (
    	<div>
        	<h3>피드 #{feed.id}</h3>
            <img src={feed.isLiked ? '빨간하트' : '검은색하트'} onClick={handleClick}/>
            <span>...님 외 {feed.likeCount}명이 좋아합니다.</span>
        </div>
    );
};

export default Feed;

조금씩 나눠서 정리해봅시다.

  1. props
    먼저 Feed 컴포넌트는 피드 정보가 담겨있는 feed를 props로 받아오고 있는데요.
    feed 정보는 이전과 동일합니다.
    	id: number;				// 피드 아이디
    	isLiked: boolean; 		// 피드에 대한 유저의 좋아요 여부 (요청을 보낸 유저 기준)
    	likeCount: number;		// 피드 좋아요 개수
    useState로 구현했던 때의 props와 차이가 있다면, 위의 props는 리액트 쿼리를 통해 저장된 쿼리 데이터라는 정도가 있겠네요.
  1. mutationFn 옵션

    mutationFn: (feedId) => api.put('피드에 좋아요', { feedId })

    useMutation 훅이 반환한 mutate 함수가 호출될때 호출되는 api 요청입니다.

  2. onMutate 옵션

    // mutation이 발생할 때
    	onMutate: (feedId) => {
    		// 현재 feed 정보를 prevFeed 변수에 저장한다.
    		// Feed 컴포넌트가 props로 받은 feed와 동일한 값이다.
    		const prevFeed = queryClient.getQueryData(['feeds', feedId]);
              
    	// 새롭게 갈아끼울 feed 정보
    	const nextFeed = {
    		id: feed.id,
    		isLiked: !feed.isLiked,
    		likeCount: feed.isLiked ? feed.likeCount - 1 : feed.likeCount + 1
    	};
              
    	// ['feeds', feedId] 키에 저장된 쿼리 데이터를 nextFeed로 갈아끼운다.
    	queryClient.setQueryData(['feeds', feedId], nextFeed);
              
    	// prevFeed 정보와 함께 context를 반환한다.
    	return { prevFeed };
    }

    mutate 요청이 발생할 때 호출되는 함수를 onMutate에 전달함으로써 Optimistic Updates를 구현할 수 있습니다.

    // 현재 feed 정보를 prevFeed 변수에 저장한다.
    // Feed 컴포넌트가 props로 받은 feed와 동일한 값이다.
    const prevFeed = queryClient.getQueryData(['feeds', feedId]);

    먼저 현재 feed 정보를 prevFeed 변수에 저장하는데, 이는 props로 받아온 feed 정보와 동일합니다.

    // 새롭게 갈아끼울 feed 정보
    const nextFeed = {
    	id: feed.id,
    	isLiked: !feed.isLiked,
    	likeCount: feed.isLiked ? feed.likeCount - 1 : feed.likeCount + 1
    };

    다음으로 새롭게 끼워넣을 feed 정보를 만들어 nextFeed 변수에 저장합니다.

    // ['feeds', feedId] 키에 저장된 쿼리 데이터를 nextFeed로 갈아끼운다.
    queryClient.setQueryData(['feeds', feedId], nextFeed);

    그리고 ['feeds', feedId]라는 쿼리 키에 저장되어 있는 쿼리 데이터를 nextFeed로 갈아끼워넣습니다. 이러한 과정은 동기적으로 이루어지며, 해당 쿼리 데이터를 observe하고 있는 Feed 컴포넌트의 부모는 업데이트 된 feed 정보를 Feed 컴포넌트에 props로 내려주게 됩니다. 이를 통해 props가 변경된 것을 인식한 Feed 컴포넌트의 리렌더링이 발생하며 업데이트 된 UI를 즉각적으로 반영하게 되는 것이죠.

    // prevFeed 정보와 함께 context를 반환한다.
    return { prevFeed };

    마지막으로 에러 핸들링을 위해 앞서 저장해둔 prevFeed를 반환해줍니다.

  3. onError 옵션

    onError: (err, feedId, context) => {
        // onMutate에서 반환한 context를 이용해서 에러 핸들링을 한다.
        queryClient.setQueryData(['feeds', feedId], context.prevFeed);
    
        // ... 추가적인 에러 핸들링
    }

    mutate 요청이 실패할 때 호출되는 함수를 전달함으로써 에러 핸들링을 돕습니다.
    onMutate에서 반환한 값은 onError의 세번째 파라미터로 전달되고, mutate 요청이 실패했으므로 변경 전의 feed 정보로 다시 복원해줍니다. 추가적인 에러 핸들링을 해줄수도 있구요.

  4. 렌더링

    return (
    	<div>
    		<h3>피드 #{feed.id}</h3>
    		<img src={feed.isLiked ? '빨간하트' : '검은색하트'} onClick={handleClick}/>
    		<span>...님 외 {feed.likeCount}명이 좋아합니다.</span>
    	</div>
    );

    useState로 구현했던 것과 다르게 props로 내려받은 feed 값을 그대로 가져다 쓰는 것을 볼 수 있습니다. 이는 Optimistic Updates가 Feed 컴포넌트에 내려주는 props의 원본 데이터(쿼리 데이터)에 작용하기 때문이죠.

정리

Optimistic Updates는 용어에 친숙하지 않은 사람은 있을지라도, 개념 자체가 생소한 사람은 별로 없을 것이라고 생각합니다. 이미 SNS 속에서, 직접 구현한 프로젝트 속에서 자연스럽게 경험하고 또 녹여냈을 테니까요.

Optimistic Updates를 구현하기 위해서는 변경 전의 상태를 저장하고, 에러가 발생했을 때 변경 전의 상태로 되돌려 놓는 등의 추가적인 처리가 필요하기 때문에 코드가 다소 길어지고 장황해진다는 느낌을 받을수도 있습니다. 구현하는데 시간도 더 소요되구요.
하지만 좋아요를 클릭했을 때 1초가 지나서야 하트가 채워진다면 얼마나 실망스러울까요...
해당 기능의 중요도와 해당 기능이 UX에 끼치는 영향을 잘 고려해서 Optimisitc Update를 도입하면 좋을 것 같습니다.

한편, useState와 리액트 쿼리의 구현을 비교했을 때, Optimistic Updates에서 리액트 쿼리의 효용이 크게 와닿지 않을 수도 있을 것 같습니다.
하지만 props를 불필요하게 state로 복사하지 않아도 되고, useMutation 훅을 통해 isLoading, isError 등의 flag 값도 별도의 상태 관리 없이 편하게 사용할 수 있으며, 앱 전반에서 일관되게 효율적으로 서버 상태를 관리할 수 있다는 장점을 생각하면 리액트 쿼리의 효용은 충분하다고 생각합니다.

긴 글 읽어주셔서 감사합니다.

profile
블로그 꿈나무 🌱

0개의 댓글