Todo 서비스를 구현하는 중, 다음과 같은 현상이 있었다.
.
이를 해결하고자 optimistic UI를 도입해보고자 했다.
말 그대로 낙관적 UI로, 내가 보내는 요청이 성공했다고 가정하고 성공한 이후의 UI를 미리 그리는 것이다. 고로, 성공할 확률이 매우 높은 요청에 주로 적용한다. 혹시라도 실패한 경우, 다시 이전 값을 보여주게 된다.
기존의 구조는 이랬다.
// 렌더링 부
import React from "react";
import styled from "@emotion/styled";
import { useGetTodos } from "../hooks/useTodo";
import TodoItem from "./TodoItem";
import LoadingSpinner from "./LoadingSpinner";
const TodoList = () => {
const { todos, isLoading } = useGetTodos();
return (
<>
{isLoading ? (
<LoadingContainer>
<LoadingSpinner />
</LoadingContainer>
) : (
<ul aria-label={"todo list"}>
{todos.map(({ id, title }, idx) => (
<TodoItem key={`${id}-${idx}`} id={id} title={title} />
))}
</ul>
)}
</>
);
};
export default TodoList;
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
`;
// useTodo.ts
import { useMutation, useQuery, useQueryClient } from "react-query";
import { QUERY_KEY_TODOS } from "../queryKey/todos";
import { createTodo, deleteTodo, getTodoList } from "../api/todo";
export const useGetTodos = () => {
const { data, isLoading, error } = useQuery(
QUERY_KEY_TODOS.TODOS,
getTodoList
);
return { todos: data?.data, isLoading, error };
};
export const useCreateTodo = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error } = useMutation(
QUERY_KEY_TODOS.TODOS,
(newItem: { title: string }) => createTodo(newItem),
{
onSuccess: () => {
queryClient.invalidateQueries(QUERY_KEY_TODOS.TODOS);
},
}
);
return { mutate, isLoading, error };
};
export const useDeleteTodo = () => {
const queryClient = useQueryClient();
const { mutate, isLoading } = useMutation(
QUERY_KEY_TODOS.TODOS,
(id) => deleteTodo(id),
{
onSuccess: () => {
queryClient.invalidateQueries(QUERY_KEY_TODOS.TODOS);
},
}
);
return { mutate, isLoading };
};
간단한 CRD를 수행하는 코드이다.
이에 낙관적 UI를 도입하기 위해선,Mutate 시 previousTodos를 가져온 후, 추가면 추가, 삭제면 삭제된 리스트를 바로 반환하게 하는 것이다. 실제 서버 데이터는 그렇지 않지만 말이다.
아래는 해당 로직을 적용한 코드이다.
구체적인 과정은 코드에 주석으로 남겨놓았다.
// useTodo.ts
import { useMutation, useQuery, useQueryClient } from "react-query";
import { createTodo, deleteTodo, getTodoList } from "../api/todo";
import { QUERY_KEY_TODOS } from "../queryKey/todos";
export const useGetTodos = () => {
const { data, isLoading } = useQuery<ApiResponse<Todo[]>>(
QUERY_KEY_TODOS.TODOS,
getTodoList
);
return { todos: data?.data, isLoading };
};
const useTodoMutation = <T>(
mutationFn: (data: T) => Promise<any>, // 서버에 전송할 실제 mutation 함수
mutationUpdateFn: ( // 쿼리 캐시를 업데이트 하기 위한 함수. 인자로 oldData, data를 받음
oldData: ApiResponse<Todo[]>,
data: T
) => ApiResponse<Todo[]>
) => {
const queryClient = useQueryClient(); // 쿼리 캐시에 접근,조작 가능
return useMutation({
mutationFn, // 실제 데이터 변경 작업을 수행함
onMutate: async (data) => { // mutation이 시작되기 전 호출됨
await queryClient.cancelQueries(QUERY_KEY_TODOS.TODOS); // 현재 진행 중인 QUERY_KEY_TODOS.TODOS 관련 쿼리를 취소
const previousTodos = queryClient.getQueryData(QUERY_KEY_TODOS.TODOS); // 현재 캐시된 Todo 데이터를 previousTodos에 저장
queryClient.setQueryData( // 캐시 데이터 직접 수정. mutationUpdateFn에 의해 업데이트된 데이터 반환받고, 그 결과를 캐시에 저장
QUERY_KEY_TODOS.TODOS,
(oldData: ApiResponse<Todo[]>) => mutationUpdateFn(oldData, data)
);
return { previousTodos };
},
onError: (err, newTodo, context) => { // 실패 시, 원래의 투두 데이터(context.previousTodos)로 캐시를 롤백
queryClient.setQueryData(QUERY_KEY_TODOS.TODOS, context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries(QUERY_KEY_TODOS.TODOS);
},
});
};
export const useCreateTodo = () => {
return useTodoMutation<{ title: string }>(
(newItem: { title: string }) => createTodo(newItem),
(oldData: ApiResponse<Todo[]>, newTodo: { title: string; id: null }) => {
const oldTodos = oldData?.data || [];
return {
...oldData,
data: [...oldTodos, newTodo],
};
}
);
};
export const useDeleteTodo = () => {
return useTodoMutation<string>(
(id: string) => deleteTodo(id),
(oldData: ApiResponse<Todo[]>, deletedTodoId: string) => {
const oldTodos = oldData?.data || [];
return {
...oldData,
data: oldTodos.filter((todo) => todo.id !== deletedTodoId),
};
}
);
};
개념만 알고 있다가 실제로 해보고 나니, 생각보다 고려할 점이 많았다.
일단, 유저 입장에서 긴 로딩시간을 보지 않아도 된다는 것이 가장 큰 장점이다.
하지만 주의해야할 점도 있다.
첫번째로, 서버의 실제 데이터와 사용자가 보고 있는 화면의 데이터가 일치하지 않을 수 있다.
이는 위 코드상에서 onSettled로 해당 로직을 처리해주고 있다.
// useTodoMutation 함수
onSettled: () => {
queryClient.invalidateQueries(QUERY_KEY_TODOS.TODOS);
},
onMutate에서 낙관적 업데이트를 진행하고, 서버와의 통신에서 실패가 발생하면 onError에서 롤백이 이루어진다. 이 상황에서 onSettled가 없다면, 서버와 캐시의 데이터가 일치하지 않을 수 있다. 이 경우, 사용자는 새로고침 등의 추가 작업 없이는 서버의 실제 데이터를 볼 수 없게 된다.
또한 서버의 데이터와 클라이언트의 캐시 데이터 간의 일관성을 유지하려면, 뮤테이션 후 해당 쿼리를 무효화하고 새로운 데이터를 패칭하는 것이 좋다. onSettled에서 쿼리 무효화를 수행하면 이 일관성을 보장할 수 있다.
만약 onSettled를 사용하지 않을 경우, 다른 방법으로 데이터 일관성을 관리해야 한다.
두번째는 깜박거리는 현상이다. 실제 내가 요청을 너무 빠르게 하면 중간 과정에서 데이터가 refetchting 되는 부분에서 잠시 item 들이 렌더링되는 현상이 발생한다.
이는 loading을 전체로 걸거나, 처리 중에는 disabled처리를 하거나, staleTime: infinity
조건을 주어 데이터 패칭을 막을 수 있다. (이렇게 하면 관련 쿼리에서 추후에 데이터 패칭이 필요할때 수동으로 써야한다.)
export const useGetTodos = () => {
const { data, isLoading } = useQuery<ApiResponse<Todo[]>>(
QUERY_KEY_TODOS.TODOS,
getTodoList,
{
staleTime: Infinity, // here
}
);
return { todos: data?.data, isLoading };
};
요청을 빠르게 N번 연속해서 보내는 경우 이를 처리하는 방법에는 여러가지가 있다.
1) 각 요청을 순차적으로 보낸다.
첫 번째 요청이 완료되기를 기다린 후 두번째 요청을 보내는 등의 방식이다.서버와의 통신 순서가 사용자의 액션 순서와 동일하므로 데이터 일관성을 유지하기 쉽다. 그러나, 각 요청을 순차적으로 보내기 때문에 통신 시간이 길어질 수 있다. 주로 데이터의 일관성이 중요한 서비스(금융 등)에서 데이터의 일관성이 매우 중요하므로 각 요청을 순차적으로 보내는 방식을 선택한다.
2) 즉시 요청을 보낸다.
말 그대로 이벤트 발생 즉시 요청을 보내는 것이다. 상호작용의 즉각 피드백이 더 중요한 경우에 고려해볼 수 있다. 빠른 사용자 경험을 제공하지만, 서버의 응답 순서가 요청 순서와 일치하지 않을 수 있으므로 이를 처리하는 로직이 필요하다.
3) 배치 요청을 사용한다.
여러개의 요청을 하나의 배치 요청으로 묶어서 보내는 방식으로, 네트워크 요청 수를 줄일 수 있어 효율적이지만 구현이 복잡할 수 있고 서버의 지원이 별도로 필요하다. 사용자의 액션이 서버에 큰 부하를 주는 경우나 많은 양의 데이터를 처리해야 하는 경우에 고려할 수 있다.
결론적으로, 어떤 방식을 선택할지는 서비스 요구 사항, 사용자 경험, 네트워크 상황 등 다양한 요소를 고려하여 결정해야 할 것이다. 각 방식마다 장단점이 있으므로, 가장 적합한 방식을 선택하는 것이 좋다.
결과적으로 GET, DELETE 요청이 빈번한 Todos 서비스에 Optimistic UI를 적용하는 것은 적절하지 않았다고 생각한다. 사용성 개선 효과에 비해 과도한 로직이 구현되기도 하고, 요청이 매우 빈번하며 서버 데이터와 실 데이터의 일치가 중요하다고 느꼈기 때문! 하지만 toggle과 같이 ture or false만 있는 경우라면 적용해봄직하다! 토글엔 로딩을 걸기도 애매하거니와 토글이 몇초씩 걸리면 사용성이 매우 저하될 것이기 때문.. 특정 API의 속도가 너무 느리다면 서버팀에 조심스레 문의해보는 것이 어떨까..?