다들 유튜브 영상을 보다가 좋아요를 누르면 좋아요 표시가 바로 적용되는걸 본 적 있을 것이다. 만약 좋아요를 눌렀는데 1~2초 있다가 좋아요가 표시된다면? '마우스 클릭이 제대로 안됐나?' 혹은 '렉 걸린건가..'라고 생각할 수도 있겠다. 특히 성격이 급한 한국인은 이런걸 더욱 참지 못한다😠
이러한 사용자 경험을 개선하는 방법 중 하나로 낙관적 업데이트(Optimistic Update)가 있다. 낙관적 업데이트를 한마디로 정의한다면,
클라이언트의 요청이 어떻게 처리됐든, UI는 미리 바꿔놓는 작업
결국 낙관적 업데이트는 사용자 경험 향상을 위한 일종의 장치라고 볼 수 있겠다. 하지만 이러한 기능도 적절한 곳에 잘 사용하여야 하는데, 가령 쿠팡에서 상품을 주문했는데 실제로 결제 서버가 성공적으로 결제를 처리하지 못했는데도 '주문 처리 완료' 표시를 미리 보여준다면 아주 큰 일이 나겠다. 따라서 서비스의 신뢰성에 영향을 주지 않는 선에서 사용자 경험 에 긍정적인 영향을 줄 수 있을 때, 낙관적 업데이트를 적용해야 한다.
리액트 쿼리 공식 홈페이지에는 이 기능을 구현할 수 있는 방법을 크게
"변수와 직접 UI 업데이트 방법"
"캐시 직접 조작 방법"
두 가지로 나누어 설명하며, 아래와 같이 각 방법을 적용하기에 적절한 상황에 대해서도 언급하고 있다.
이 중에서 동아리 홈페이지 프로젝트에 적용한 방식인 '캐시를 통한 방법'에 대해 소개하고자 한다. 물론 '변수와 직접 UI 업데이트 방법'이 더 적합할지도 모르겠지만, 참고자료가 많고 우선적으로 시도했던 방식이라 이를 선택했다. 추후에 UI 업데이트 방식도 적용해봐야겠다.
리액트 쿼리는 기본적으로 캐싱을 통하여 클라이언트 상태와 서버 상태를 독립적으로 관리할 수 있다. 이에 대한 개념은 아래 블로그에서 아주 자세하게 설명해주셨다. 반년 전만 해도 블로그의 내용이 잘 와닿지 않았는데, 이번에 다시 한 번 읽어보니 이제 어느정도 이해가 되는 듯하다(한 번에 이해가 안 돼서 이때까지 4~5번은 읽어본 것 같다). 이 개념을 완벽히 이해를 하고, 왜 공식문서에서 낙관적 업데이트를 그런 방식으로 구현하라고 하는지 깨우칠 수 있었다.
Pozafly's Blog님의 블로그: React Query에서 mutation 이후 데이터를 업데이트하는 4가지 방법
useMutation'캐시 직접 조작 방법'을 통한 낙관적 업데이트를 이해하기 위해서는 우선 useMuation 개념에 대해 알아야 한다. 쉽게 설명하자면 useMutation은 CRUD에서 CUD를 담당하는 리액트 쿼리의 훅이라 할 수 있다. 낙관적 업데이트를 위해 사용해야 할 파라미터는 다음과 같고, 특히 onMutate 옵션이 중요하다.
mutationFn: (variables: TVariables) => Promise<TData>: Promise를 반환하는 비동기 작업을 정의onError: (err: TError, variables: TVariables, context?: TContext) => Promise<unknown> | unknown: 에러 시 해야할 작업을 정의 (catch 같은 개념). onSettled: 성공이든 실패든 무조건 실행하는 작업을 정의 (finally 같은 개념)onMutate: (variables: TVariables) => Promise<TContext | void> | TContext | void: mutationFn 수행 이전에 해야할 작업을 정의. 반환하는 값인 context는 onError나 onSettled에 사용.queryClient.setQueryData / queryClient.getQueryData : 특정 쿼리키로 저장된 캐시 데이터를 불러오거나 저장할 수 있다.queryClient.invalidateQueries : 특정 쿼리키로 저장된 캐시를 무효화하고 다시 불러온다. 즉, 저장된 캐시가 stale(신선하지 않은)하다고 취급하여 신선한 데이터를 가져오는 것이다. 클라이언트와 서버의 데이터 정합성을 맞춰주는 작업이다.우선 낙관적 업데이트를 적용할 코드의 기본 뼈대부터 확인해보자. '좋아요 상태'에 따라 좋아요 표시를 나타내는 버튼 컴포넌트와 실제 요청을 수행하는 함수를 포함한 커스텀 훅이 있다. 현재 이 버튼은 사진 게시판 상세 페이지에 존재하며, 페이지 진입 시점에 이미 데이터 페칭을 수행하여 props로써 좋아요와 관련한 데이터를 넘겨준 상태이다(useQuery를 수행했다는 뜻이며, 쿼리키는 ['album', boardId]이고 저장된 데이터에는 좋아요 여부에 대한 데이터 뿐 아니라 사진데이터 등 다른 데이터도 포함되어 있다).
좋아요 버튼

좋아요 api 요청 수행 커스텀 훅

onMutate에 백업 데이터와 신규 데이터 관련 작업하기개념 부분에서도 살펴봤듯이 onMutate는 mutationFn보다 우선적으로 수행되므로 해당 부분에서 캐시(클라이언트의 데이터)로부터 기존 데이터를 백업하고, 캐시에 신규 데이터를 주입한다. 기존 데이터를 백업하는 이유는, 서버에서의 처리 실패 등의 이유로 mutation이 실패한 경우 기존 상태로 돌려놓아야 하기 때문이다. 즉, 여기서는 좋아요 요청 실패 시 좋아요 버튼에 색을 칠하거나 비우는 작업, 좋아요 수를 +1하거나 -1하는 작업을 수행해야 한다는 말이다.
cancelQueries로 경쟁 상태 방지하기하지만 그 전에 cancelQueries를 통해 해당 쿼리키에 대한 모든 페칭 작업을 취소해야 한다. 이를 수행하는 이유는 아래 블로그에서 잘 설명해주신다.
권민수 님의 블로그: react-query optimistic update시 데이터 꼬임 방지

또, 관련 유튜브 영상 중 이해에 도움이 될 만한 부분을 캡쳐했다.
[사진 출처: https://www.youtube.com/watch?v=IsyUSO4aFVY]
한마디로 mutationFn 과정과 결과에 따른 정확한 상태 반영을 위해 잠시 락을 걸어 race condition을 방지하고, 여타 쿼리로 인한 오래되거나 불완전한 데이터가 우연히 UI에 재적용되는 문제를 예방하는 것이다.
요청 실패 시에 대응하기 위해, 기존 상태를 백업해 둘 필요가 있다. 이는 getQueryData로 수행하며, 각 상태를 변수에 저장한다.

setQueryData로 낙관적 업데이트의 결과 반영하기새로운 데이터를 계산하고 setQueryData로 캐시에 반영한다. 또한 백업 데이터를 return 해주며, 이는 onError에 context로써 넘겨진다. 완성된 onMutate는 다음과 같다.

setQueryData로 롤백 데이터를 입히기onMutate에서 반환한 기존 데이터는 onError에서 객체 형태의 context 파라미터를 통해 받아올 수 있으며 이를 활용하여 캐시를 복구한다.
onSettled에서는 성공/실패와 관계없이 서버의 최신 데이터를 불러와 동기화하는 작업을 한다. 이를 통해, 상태 변경 후에도 항상 최신 데이터를 반영하여 데이터 무결성을 보장한다.

마지막으로 버튼에 지금까지 작성한 커스텀 훅을 적용한다.

이렇게 낙관적 업데이트를 적용한 버튼에도 문제점이 존재한다. 바로 여러번 연속하여 버튼을 클릭했을 때 모든 요청이 서버로 전송된다는 건데, 이는 서버 과부하로 이어질 수 있으며 애플리케이션 성능 저하라는 결과를 초래할 수 있다. 따라서 디바운스 개념을 적용하여 이러한 문제를 해결해야 한다. 우선 다음과 같이 챗지피티의 답변을 참고하여 디바운스를 적용해 보았다. 이후 커스텀 훅으로 분리하는 등 최적화가 필요해보인다.
export default function usePostPhotoAlbumLike() {
// 디바운스 타이머를 위한 ref
const timerRef = useRef<number | null>(null);
return useMutation<AxiosResponse, AxiosError<IServerErrorResponse>, { isLiked: boolean }>({
mutationFn: async ({ isLiked }) => {
// 이전 타이머가 있으면 취소
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// 디바운스 구현
return new Promise<AxiosResponse>((resolve, reject) => {
timerRef.current = setTimeout(() => {
axios
.post(`/api/post/${isLiked ? 'cancel-like' : 'like'}`, { postId: boardId })
.then(resolve)
.catch(reject);
}, 2000);
});
},

낙관적 업데이트 하나를 적용하기 위해 리액트 쿼리에 대해 더 많이 알아보게 되었다. 사실 공식문서를 보아도 좋아요 상태를 useState로 따로 분리해야 하는지 아니면 react query의 클라이언트 측 데이터만 변경하면 되는 것인지 헷갈렸다. 챗지피티는 답변이 너무 이랬다 저랬다 하여, 개발자 오픈채팅방을 통해 도움을 얻을 수 있었다.
결론은 useState로 관리하는 것이 낙관적 업데이트의 'UI 변경사항 즉시반영'이라는 목적에 부합하다는 것이었는데, 구현하는 과정에서 클라이언트 측 데이터를 업데이트 하는 것도 완전히 틀린 구현은 아닌 것 같았다고 생각했다. 왜냐하면 서버 데이터를 꺼내와 클라이언트에서 따로 상태로써 관리(useState)하느냐, 캐시된 데이터를 클라이언트 측 데이터로써 관리하느냐의 차이만 있기 때문이다. 하지만 디바운스와 같은 부가적인 작업을 수행하기 위해서는 전자의 방법으로 구현하는 것이 확장성 면에서 더욱 유리한 것 같긴 하다.
아무튼 이번 시간을 통해 사용자 경험을 향상시킬 수 있는 방법에 대한 한 가지 방법을 체득할 수 있었고, 조금 더 확장성 있는 코드를 작성하기 위해서 더 많은 고민의 시간을 가져야 겠다고 느꼈다. 이후 낙관적 업데이트를 지원하기 위해 나온 react의 useOptimistic도 학습할 예정이고, 좋아요 뿐 아니라 댓글과 같은 기능에도 적용해 볼 예정이다.