이번 개인 프로젝트는 이전에 리액트 만을 이용해서 만든 todo list 어플리케이션을 새로 배운 typescript 로 다시 만들고, 지난번 todo list 만든 시점에서는 몰랐던 tanstack-query, axios, json-server, sweetalert2 등의 도구들까지 전부 다 사용하는것.
처음 계획할때부터 redux같은 전역상태 관리 툴은 사용하지 않기로 마음먹었다. 외부 서버와 tanstack query 를 사용한다면 굳이 todo를 전역 상태로 따로 관리할 이유가 없기 때문이다.
그런데 그렇게 금방 만들어서 배포까지 하고 나니 아래와 같은 문제가 발생했다.
위가 스크린샷에서 가장 오른쪽 column이 로컬호스트에서 실험할때 네트워크 응답시간이다. 26~32ms 정도 이기 때문에 실제로 사용하는동안 어플리케이션이 버벅인다는 느낌은 받기 어려웠다.
하지만 vercel과 glitch 로 배포하고 실제로 사용해보니 위와 같이 네트워크 응답시간이 거의 10배 가까이 늘어난 것을 확인할 수 있었다.
0.03초는 사용하면서 느끼기 어렵지만 0.3 초의 지연시간은 아주 분명하게 인지할 수 있는 정도여서 문제 해결이 필요한 상황.
todo를 새로 입력할때나, 삭제할때나, isDone 상태(완료상태) 를 토글할때 발생하는 문제이므로 해당 동작이 수행될때 모달창을 띄워서 0.3초 정도의 지연 시간을 숨기는 방법을 시도.
실제로 삭제할때나 새로운 항목을 추가할때 모달이 뜨는 것은 위화감이 느껴지지 않지만 isDone을 토글할때마다 모달창이 뜨는 것은 오히려 거슬린다는 생각이 들어 제대로 된 해결책이 아니라고 판단.
문제의 원인이 서버와의 통신 시간에서 발생하므로 화면의 리렌더링 자체는 redux 상태로 유발시키면 되지 않을까 하는 가정에서 출발. tanstack query 와 redux를 함께 사용해서 서버와의 CRUD는 그대로 하면서 동시에 정확히 같은 데이터를 담고 있는 리덕스 상태를 따로 관리하는 것을 시도.
결론부터 이야기 하자면 이 시도도 실패로 돌아갔다. 위 에러를 극복하지 못했기 때문. 검색해보니 위 에러는 컴포넌트를 렌더링 하고 있는 동안 상태가 또 바뀌어서 발생하는 것이라고 한다.
아마도 tanstack query가 관리하는 상태와 redux가 관리하는 상태가 둘다 바뀔때마다 리렌더링이 일어나는데 그래서 꼬인게 아닌가 추측된다.
redux 사용을 다시 포기하고 이번에는 tanstack query를 통해 optimistic update(낙관적 업데이트)를 구현하는 방향으로 문제를 해결해 보고자 시도했다.
이에 대해서는 후술.
서버와의 통신이 성공할 것이라는 '낙관' 을 가지고 일단 화면을 렌더링 하는 것을 의미한다.
todo 를 추가하는 상황을 생각해보자. 먼저 사용자의 입력을 받아서 서버에 저장할 형태로 가공한뒤, todo 데이터를 관리하는 서버쪽에 POST 요청등을 통해 서버에 등록하고, 서버쪽에서 변경사항이 반영된 데이터를 다시 받아와서 화면에 뿌려주는 것이 일반적인 흐름일 것이다.
하지만 이렇게 했을때의 문제는 서버와의 통신이 빠르게 이루어지지 않으면 사용자가 어플리케이션을 사용할때 어플리케이션에 lag이 발생한다고 느낄 수밖에 없다는 것.
다른 모든 부분이 최상의 상태로 동작한다고 해도 서버에 요청을 보내고 그 요청 결과를 받아오는데 0.3 초가 걸린다면 어플리케이션을 사용하는 사용자또한 반드시 0.3초를 기다려야만 한다.
이때 사용할 수 있는 방법이 바로 낙관적 업데이트. 서버와의 통신이 항상 성공하리라는 보장이 없는 상태에서 일단 성공할 것이라는 가정하에 데이터 변경 사항을 먼저 화면에 반영하고, 그다음 서버와의 통신이 실패하면 다시 이전 데이터로 롤백, 성공하면 그대로 서버와 동기화를 끝마치는 것이다.
이에 대해서는 tanstack query 공식문서에서 대단히 자세하게 설명하고 있으니 더 자세한 내용을 알고 싶다면 링크를 참조하기 바란다.
todo 추가할때와 삭제할때는 관련 모달이 나와서 서버 통신 지연 시간이 거슬리지 않으니 isDone 토글 로직에만 낙관적 업데이트를 적용하기로 결정. 아래와 같이 코드를 작성하였다.
//isDone 토글 기능
const toggleMutate = useMutation({
mutationFn: toggleTodo,
//Optimistic Update
onMutate: async (toggleTodoParams) => {
await queryClient.cancelQueries({ queryKey: ["todos"] });
const prevTodos = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (oldTodos: Todo[]) => {
const updatedTodos = oldTodos.map((todo) =>
todo.id === toggleTodoParams.id
? { ...todo, isDone: !toggleTodoParams.isDone }
: todo
);
return updatedTodos;
});
return { prevTodos };
},
onError: (err, __, context) => {
if (err) throw new Error("optimistic update중 에러가 발생했습니다", err);
queryClient.setQueryData(["todos"], context?.prevTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
기존에 onSuccess
와 관련 코드가 들어가던 자리에 이제 onMutate
, onError
, onSettled
세가지가 들어간다.
onMutate
는 토글 토글 버튼이 눌리면 실행될 코드. 크게 두 부분으로 나뉘는데, 하나는 getQueryData
로 기존 데이터를 읽어들여 prevTodos 에 저장하는 부분이고, 다른 하나는 setQueryData
로 데이터가 어떻게 바뀔지 정의하고 그것을 적용하는 부분이다.
onMutate
는 서버 통신의 성공/실패와 무관하게 Promise가 pending 일때 이루어진다. 먼저 변경사항을 화면에 반영하며 그 이후에 Promise의 결과가 에러로 나오면 onError가 실행되고, onError
는 눈치채기도 어려울 만큼 빠른 속도로 onMutate
가 반영한 데이터 변경 사항을 prevTodos에 저장해둔 이전 데이터로 다시 바꿔치기 한다.
마지막으로 onSettled
는 서버 통신의 결과가 성공이든 에러이든 상관없이 무조건 서버와 클라이언트 사이의 데이터를 동기화 시키는 역할을 한다.
낙관적 업데이트의 적용 결과는 대단히 만족스럽다. 코딩하면서 매번 사소한 즐거움을 많이 느끼지만 이번에 느낀 즐거움은 정말 정말 컸다.
이제 별도의 전역상태관리 없이 서버와의 직접 통신만으로 CRUD가 다 이루어지는 동시에 서버 통신에 걸리는 지연시간은 전혀 느껴지지 않는 어플리케이션이 되었다.
완성된 어플리케이션의 모습은 아래 링크에서 확인할 수 있다.
https://todo-list-ts-delta.vercel.app/
신규 입력할때나 기존에 있던 todo를 삭제할때는 300ms 정도의 통신 시간이 있지만 모달 때문에 눈치채기가 어렵고, todo의 isDone 상태를 토글할때도 300ms 의 서버 통신 시간은 여전히 존재하지만 막상 사용자 입장에서는 그것을 전혀 느낄수 없이 작동하도록 완성되었다.