프론트엔드의 꽃은 비동기 요청 처리와 상태관리가 아닌가 생각한다.
나는 리액트에서 비동기로직 처리와 데이터 관리를 어려워하고 이에 대한 고민을 많이 하곤했다.
Context Api로 상태를 관리했을 때는 일단 컴포넌트 상태를 업데이트해서 보이는 화면만 먼저 변화시키고 서버는 따로 요청 보내서 db업데이트 시키고 새로고침 되거나 했을 때만 서버에서 받은 값으로 보여줬었고, redux-thunk를 알고나서는 액션함수를 통해 서버에 요청을 보내고 서버상태를 변화시켜 응답받은 값으로 전역상태를 업데이트해서 보여줬다.
이런식으로 관리하는게 정답인지 잘 모르겠지만 비동기 로직을 전역상태에 포함되게 하는것 자체가 복잡했고 하나의 저장소에서 클라이언트측 상태와 서버 상태가 왔다갔다하는 흐름을 파악하기가 복잡하고 서버의 데이터를 받아오는 시점이 나를 매우 혼란스럽게 만들었다.🤮
(그리고 전역상태를 관리한다는게 사실은 서버상태를 동기화해주는것에는 큰 관련이 없기 때문에 이 부분은 개발하는 사람이 알아서 효율적으로 코드를 짜는게 필요한 것이었다.)
조금더 업그레이드를 거쳐 중앙집중식의 상태가 아닌 클라이언트쪽 상태와 서버쪽 상태를 분리해서 바라본다는 개념을 알게되었고 클라이언트와, 서버쪽 각각의 상태로 나뉘어 있다가 필요 시점에 데이터를 동기화한다고 생각하니 좀 더 다루기가 수월해졌었다.
클라이언트 state : 세션간 지속적이지 않는 데이터, 동기적, 클라이언트가 소유, 항상 최신 데이터로 업데이트(렌더링에 반영)
ex) 리액트 컴포넌트의 state, 동기적으로 저장되는 redux store의 데이터
서버 state: 세션간 지속되는 데이터, 비동기적, 세션을 진행하는 클라이언트만 소유하는게 아니고 공유되는 데이터도 존재하며 여러 클라이언트에 의해 수정될 수 있음, 클라이언트에서는 서버 데이터의 스냅샷만을 사용하기 때문에 클라이언트에서 보이는 서버 데이터는 항상 최신임을 보장할 수 없음.
ex) 리액트 앱에서는 비동기 요청으로 받아올 수 있는, 백엔드 DB에 저장되어있는 데이터
React-query를 사용한다면 내가 고민했던 점들을 대부분 해결해줄 수 있다.
🔗 리액트 쿼리 문서에서도 소개되어있는 서버 상태 관리의 어려움🥲
애플리케이션에서 서버 상태의 특성을 파악하고 나면 더 많은 문제가 발생합니다 . 예를 들면 다음과 같습니다.
- 캐싱... (프로그래밍에서 아마도 가장 어려운 일)
- 동일한 데이터에 대한 여러 요청을 단일 요청으로 중복 제거
- 백그라운드에서 "오래된" 데이터 업데이트
- 데이터가 "오래된" 시점 알기
- 데이터 업데이트를 최대한 신속하게 반영
- 페이지 매김 및 지연 로딩 데이터와 같은 성능 최적화
- 서버 상태의 메모리 및 가비지 수집 관리
- 구조적 공유로 쿼리 결과 메모하기
React-query는 제공되는 함수들과 옵션설정을 통해 로직을 자동적(?)이고 선언적으로 관리할 수 있게 해준다.
React-query의 특징을 살펴보자
어쨋든 지금 내 고민은 화면에 최신 데이터를 바로바로 보여주는 것이기 때문에 react-query에서 관련내용으로 제공하는 메소드들을 공부해봤다.
일단 나의 프로젝트에서 변경이 바로바로 보여져야하는 경우를 보면
- 댓글 업로드, 수정, 삭제
- 좋아요 클릭했을 때의 ui
- 프로필 정보 변경시(닉네임, 태그변경)
의 3가지 경우가 있다. (3번째 경우는 유저데이터를 리코일로 관리중이기 때문에 따로 캐시값을 갱신해주어야 한다.)
React-query를 사용한 데이터 리패칭 방법
👉 invalidateQueries로 쿼리캐시를 무효화하기.
👉 setQueryData로 낙관적으로 업데이트 하기.
invalidateQueries를 사용하면 데이터를 리패칭하기 때문에 화면을 업데이트할 수 있다. 메소드가 호출되면 기존 쿼리를 버리고(무효화) 새로운 데이터를 가져오고 key가 동일한 쿼리들에 대해 한번에 모두 적용된다. (현재 렌더링 중인 항목들에 먼저 적용되고 나머지는 다음에 사용할때 가져온다.)
import { useQuery, useQueryClient } from 'react-query'
// Get QueryClient from the context
const queryClient = useQueryClient()
queryClient.invalidateQueries('todos')
// Both queries below will be invalidated
const todoListQuery = useQuery('todos', fetchTodoList)
const todoListQuery = useQuery(['todos', { page: 1 }], fetchTodoList)
주의할 점 : 문자열key인 경우는 배열key까지 영향이 가는데(옵션있는 경우 제외) 배열key인 쿼리에 적용하면 문자열key를 가지는 쿼리에는 적용이 되지 않는다.
const { mutate: commentMutate, isLoading: loadingComment } = useMutation(
submitComment,
{
onSuccess: () => {
queryClient.invalidateQueries("getPost");
reset();
},
onError: () => {
reset();
},
},
);
나는 댓글 관련해서 업데이트가 있을때 onSuccess옵션에서 invalidateQueries를 써주고 요청에서 발생하는 로딩시간에는 로딩스피너를 보여주는걸로 처리했다.
문제는 좋아요버튼이다. 좋아요 버튼은 클릭했을 때 보통 좋아요 수와 하트모양의 스타일이 바로바로 변화되는것을 볼 수 있다.
이를 구현하기위해 처음엔 좋아요 버튼을 클릭했을 때 뷰만 먼저 변화시키고 서버 요청을 따로 보내는 방식으로 구현했는데 버튼을 따다닥 눌렀을 때 로컬상태랑 서버상태 변화가 동시적으로 잘 처리되고 있는건지 의문이 들어서 setQueryData를 사용해서 다시 구현해봐야겠다고 생각했다.
리액트쿼리 문서에 Optimistic Updates부분에 나와있는 낙관적 업데이트를 하려고 했다.
낙관적 업데이트란 요청이 성공할 것이라는 가정하에 UI를 먼저 업데이트시킨 뒤 이전 쿼리의 스냅샷을 받아놓고 에러발생시에는 스냅샷의 데이터로 캐시를 복구하고 성공했을 때는 invalidateQueries를 사용해서 쿼리를 갱신해주는 방법이다.
현재 lookbook컴포넌트 안에서 useQuery로 하나의 게시물 데이터를 받아오고 있고 그 아래로 쭉 보여지는 게시물들의 데이터를 무한 스크롤 방식으로 받아오고 있다.
이렇게 분리해서 데이터를 받아온건 클릭한 해당게시물이 상단에 보여지고 아래로 보여질 데이터들은 랜덤하게 뿌려지게 하려고 했기 때문인데 이것 때문에 구현하는게 매우 까다로웠다.
문제는 좋아요를 요청하는 쿼리key가 같기 때문에 상단외의 아래의 데이터중 아무 좋아요 버튼을 눌러도 상단의 게시물 좋아요까지 영향이 간다. key를 다르게 주어야 하나 싶은데 여러 key에 대한 각각 업데이트를 어떻게 해야하는지 알아내지 못하고있다..
const { mutate } = useMutation(updateFav, {
onMutate: async ids => {
await queryClient.cancelQueries("getPost");
const snapshot = queryClient.getQueryData("getPost");
queryClient.setQueryData("getPost", (prev: any) => {
const prevFav = [...prev.fav];
const isIdExisted = prevFav.some(
(item: any) => item.userId === ids.currentUserId,
);
const updateFav = isIdExisted
? prevFav.filter((item: any) => item.userId !== ids.currentUserId)
: [...prevFav, { id: ids.lookId, userId: ids.currentUserId }];
return {
...prev,
fav: updateFav,
};
});
return snapshot;
},
시도해본 바로는 좋아요버튼이 하나일때는 가능하지만 여러개일때 위와같은 문제 때문에 Key를 각각 다르게 주어야할 것 같은데 그러면 코드의 양이 방대해진다..
나는 다시 이 방법이 정녕 맞는가.. 생각해보았고 ‘낙관적 업데이트’라는 목적에 집중해서 다시 고민을 했다.
내가 원했던 내용은 불필요한 서버의 요청을 줄이고 서버부하를 줄이는 것이 주된 목적인데 나는 리액트쿼리의 mutate를 이용하고 있었기 때문에 이것과 연계해서 꼭! setQueryData를 써야만 한다는 강박이 있었던것 같다고 판단을 했다.
결과적으로는 debounce를 적용하여 서버의 요청을 줄일 수 있도록 개선했다.
debounce는 이벤트발생시 특정시간이 지난후 이벤트를 한번만 발생하도록하는 패턴이다.
useRef와 setTimeout을 이용해서 debounce되도록 update함수에 코드 몇줄만 추가했다.
const timer = useRef<NodeJS.Timer | null>(null);
const updateUserFav = (currentUserId: number, payload: any) => {
const data = apiPost.UPDATE_USER(currentUserId, payload);
return data;
};
const updateFav = useCallback(
async (favConfig: {
currentUserId: number;
active: boolean;
lookId?: number;
productId?: number;
}) => {
const { currentUserId, productId, lookId, active } = favConfig;
const payload = productId
? { productId, active: !active }
: { lookId, active: !active };
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
timer.current = setTimeout(
() => updateUserFav(currentUserId, payload),
300,
);
},
[],
);
테스트해본 결과 버튼을 빠르게 여러번 누른경우에 마지막만 요청이 가는것을 확인할 수 있었다.
화면이 업데이트되는것은 항상 API요청과 로딩이 동반되기 때문에 어떻게해야 호출양도 줄이고 효율적으로 업데이트를할 수 있을지 고민이 필요하다. 리액트 쿼리는 대부분 효율적인 방안을 제공하지만 상황에 따라, 그리고 현재 쓰고 있는 기술스택에 따라서 적절한 방법을 선택해 적용해야 할것 같다.