본문 바로가기

FrontEnd

Refactoring React(리팩토링 리액트) : Mutation(서버 데이터 변경)

반응형

리액트 프로젝트 서버 데이터 변경의 모범 사례를 탐구해 볼까요?

react logo

서버 상태 변경 시 고려해야 할 것들

서버 상태와 클라이언트 상태의 동기화와 더 나은 사용자 경험을 위해선 다음과 같은 것들이 필요합니다.

  • 요청 취소
  • 캐시 무효화
  • 낙관적 업데이트

간단한 예시 : useEffect와 Click handler

흔한 구현은 아래와 같습니다.

  • useEffect는 컴포넌트가 리렌더링 될 때마다 적절하게 호출되어 데이터를 페치해 옵니다.
  • 버튼 클릭 시 patch를 통해 해당 데잍커를 업데이트 합니다.
  useEffect(() => {
    axios
      .get("https://prolog-api.profy.dev/v2/issue?status=open", requestOptions)
      .then(({ data }) => setIssuePage(data));
  }, []);

  // update the issue status to resolved when clicking the button
  const onClickResolve = () => {
    axios.patch(
      `https://prolog-api.profy.dev/v2/issue/${issue.id}`,
      { status: "resolved" },
      requestOptions,
    );
  };

위 구현의 문제는, 새로고침 전까지 사용자는 변경 여부를 알 수 없다는 것입니다.

개선 1. 데이터 무효화시 다시 렌더링

위를 약간 개선하는 방법은 다옴가 같습니다.

invalidated 상태를 버전과 같이 생각하여, 버전이 올라갈 때마다 컴포넌트를 리렌더링 하도록 할 수 있습니다.

 

export function IssueList() {

  // fetch the data and store it in a state variable
  const [issuePage, setIssuePage] = useState({ items: [], meta: undefined });
  const [invalidated, setInvalidated] = useState(0);
  useEffect(() => {
    axios
      .get("https://prolog-api.profy.dev/v2/issue?status=open", requestOptions)
      .then(({ data }) => setIssuePage(data));
  }, [invalidated]);

  // update the issue status to resolved when clicking the button
  const onClickResolve = () => {
    axios
      .patch(
        `https://prolog-api.profy.dev/v2/issue/${issueId}`,
        { status: "resolved" },
        requestOptions,
      )
      .then(() => {
        setInvalidated((count) => count + 1);
      });
  };

이제 사용자 새로고침이 필요한 문제는 제거했지만 다른 문제가 있습니다.

  • 업데이트 반영이 네트워크 상황에 따라 느릴 수 있습니다.
    • 로딩 스피너를 보여줘야 할 것 같네요

개선 2. 낙관적 업데이트

사용자 경험을 개선하는 가장 좋은 방법은 낙관적 업데이트를 적용하는 것입니다.

미리 서버에서 가져올 데이터를 클라이언트 측 데이터를 조작해 반영해 두는 것입니다.

이후 실제 서버에서 가져오면 되겠죠

  const onClickResolve = () => {
    // optimistic update: remove issue from the list
    setIssuePage((data) => ({
      ...data,
      items: data.items.filter((issue) => issue.id !== issued)
    });

    axios
      .patch(
        `https://prolog-api.profy.dev/v2/issue/${issueId}`,
        { status: "resolved" },
        requestOptions,
      )
      .then(() => {
        setInvalidated((count) => count + 1);
      });
  };

해당 방법은 꽤 좋은 편이지만 복잡해지면 유지보수를 곤란하게 할 수 있으므로 생각해 보아야 합니다.

문제점

이제 새로운 문제들이 나타납니다.

  • 요청이 실패할 때의 낙관적 업데이트?
  • post요청이 처리 중일 때 get 요청이 pending 중이면?
    • pgg 요청 > gpg 응답은 비일관된 결과를 보여줄 것입니다.
  • 낙관적 업데이트 시, 데이터 갯수의 비일관성
    • ex) 10개의 데이터를 보여줘야 하는데 4개의 데이터를 삭제한 경우, 시각적 변경이 혼란스러울 수 있습니다.
      • 스크롤 막대가 사라졌다 나타납니다.
      • 데이터 갯수가 6개에서 10개로 갑가지 늘어납니다.

해결 방안 : 비동기 상태관리 라이브러리 사용

 

직접 데이터 가져오기 코드를 작성하지 않는 것이 좋습니다.
Redux를 사용하고 있다면 아마도 RTK 쿼리를 사용하세요
React만 사용한다면 React Query를 사용하세요.

- Mark Erikson , 리덕스 메인테이너

Apollo, RTK Query, React Query와 같은 비동기 상태관리 라이브러리가 존재합니다.

해당 게시글에서는 React Query를 알아봅니다.

해당 라이브러리는 비동기 데이터와 관련한 수많은 기능들을 제공합니다.

a lot of useful feature

개선 1. 쿼리 키를 이용해 가져온 데이터 무효화

데이터 갱신을 위해 새로고침이 필요한 문제를 해결합니다.

특정 쿼리는 쿼리키를 통해 식별 및 무효화 할 수 있습니다.

쿼리키는 퍼지 매칭으로 식별되며, 따라서 일반적인 쿼리키를 제공하면 더 많은 쿼리들을 대상으로 할 수 있습니다.

아래 코드는 다음과 같이 동작합니다.

  • onSettled는 finally와 유사하게 동작합니다.
    • 따라서 성공, 실패 여부와 관계없이 콜백을 실행합니다.
  • 쿼리키 ["issues"]는 issuePage 쿼리를 의미합니다.
    • 해당 쿼리 데이터를 서버에서 다시 가져옵니다.
  const issuePage = useQuery(["issues"], async () => ...);

  const queryClient = useQueryClient();
  const resolveIssueMutation = useMutation(
    (issueId) =>
      axios.patch(
        `https://prolog-api.profy.dev/v2/issue/${issueId}`, 
        { status: "resolved" },
        requestOptions,
      )
    {
      onSettled: () => {
        // flag the query with key ["issues"] as invalidated
        // this causes a refetch of the issues data
        queryClient.invalidateQueries(["issues"]);
      },
    }
  );

이제 새로고침하지 않아도 해당 쿼리를 재조회할 수 있습니다.

개선 2. 지연중인 이전 요청 취소

이전 get 요청이 이후에 반영되는 문제를 해결합니다.

해당 문제를 해결하기 위해선 AbortSignal, AbortController 객체가 필요합니다.

리액트 쿼리에서 해당 객체를 사용하는건 너무 간단합니다.

아래와 같이 fetcher에 파라미터로 전달해주기만 하면 됩니다.

  const issuePage = useQuery(["issues"], async ({ signal }) => {
    const { data } = await axios.get(
      "https://prolog-api.profy.dev/v2/issue?status=open",
      // pass the abort signla to axios
      { ...requestOptions, signal }
    );
    return data;
  });

그리고 queryClient.cancelQueries(…) 메서드를 호출해주기만 하면 됩니다.

해당 메서드를 호출할 적절한 시점은 변형이 트리거 되는 시점입니다.

(onSettled는 요청이 성공, 실패했을 때이니 좀 다릅니다.)

  const queryClient = useQueryClient();
  const resolveIssueMutation = useMutation(
    (issueId) =>
      axios.patch(
        `https://prolog-api.profy.dev/v2/issue/${issueId}`,
        { status: "resolved" },
        requestOptions,
      ),
    {
          onMutate: async (issueId) => {
        // cancel all queries that contain the key "issues"
        await queryClient.cancelQueries(["issues"]);
      },
      onSettled: () => {
        queryClient.invalidateQueries(["issues"]);
      },
    }
  );

개선 3. 낙관적 업데이트

해당 작업은 useQuery의 onMutate 콜백에서 수행합니다.

  // optimistically remove the to-be-resolved issue from the list
  onMutate: async (issueId) => {
    await queryClient.cancelQueries(["issues"]);

    // get the current issues from the cache
    const currentPage = queryClient.getQueryData(["issues"]);

    if (!currentPage) {
      return;
    }

    // remove resolved issue from the cache so it immediately
    // disappears from the UI 
    queryClient.setQueryData(["issues"], {
      ...currentPage,
      items: currentPage.items.filter(({ id }) => id !== issueId),
    });

    // save the current data in the mutation context to be able to
    // restore the previous state in case of an error
    return { currentPage };
  }

낙관적 업데이트의 문제 중 하나는 오류가 발생하면 원복이 복잡해진다는 것입니다.

이를 해결하는 방법은 뭘까요?

  • onMutate 함수의 return 값으로 이전 조회 데이터를 전달할 수 있습니다.
    • 이는 onError의 3번째 파라미터인 context에서 사용할 수 있습니다.
  onMutate: async (issueId) => {
    // optimistically remove the to-be-resolved issue from the list
    ...

    // save the current data in the mutation context to be able to
    // restore the previous state in case of an error
    return { currentPage };
  },
  // restore the previous data in case the request failed
  onError: (err, issueId, context) => {
    if (context?.currentPage) {
      queryClient.setQueryData(["issues"], context.currentPage);
    }
  },
  onSettled: () => {
    queryClient.invalidateQueries(["issues"]);
  },
  • 성공 실패 여부와 상관없이 수행할 로직은 onSettled에 둡니다.
  • 실패 했을때는 이전 결과로 원복하면 됩니다.

개선 4. 낙관적 업데이트와 Prefetch  같이 사용하기

낙관적 업데이트 시, 이전의 스크롤바, 데이터 갯수에 의한 UX 손상을 Prefetch를 이용하여 개선할 수 있습니다.

먼저 prefetch와 fetch를 위한 훅을 분리합니다.

// fetcher
async function getIssues(page, options) {
  const { data } = await axios.get("https://prolog-api.profy.dev/v2/issue", {
    params: { page, status: "open" },
    signal: options?.signal,
    ...requestOptions,
  });
  return data;
}

// useQuery + prefetch
export function useIssues(page) {
  const query = useQuery(
    // note that we added the "page" parameter to the query key
    ["issues", page],
    ({ signal }) => getIssues(page, { signal }),
  );

  // Prefetch the next page!
  const queryClient = useQueryClient();
  useEffect(() => {
    if (query.data?.meta.hasNextPage) {
      queryClient.prefetchQuery(
        ["issues", page + 1],
        async ({ signal }) => getIssues(page + 1, { signal }),
      );
    }
  }, [query.data, page, queryClient]);
  return query;
}

해당 훅을 컴포넌트 상단에서 호출하고 아래와 같이 구현합니다.

export function IssueList() {
    const [page, setPage] = useState(1);
  const issuePage = useIssues(page);
  
  const queryClient = useQueryClient();
  const resolveIssueMutation = useMutation(
    (issueId) =>
      axios.patch(...),
    {
      onMutate: async (issueId) => {
        await queryClient.cancelQueries(["issues"]);

        // note that we have to add the page to the query key now
        const currentPage = queryClient.getQueryData([
          "issues",
          page,
        ]);
        // get the prefetched data for the next page 
        const nextPage = queryClient.getQueryData([
          "issues",
          page + 1,
        ]);

        if (!currentPage) {
          return;
        }

        const newItems = currentPage.items.filter(({ id }) => id !== issueId);

        // add the first issue from the next page to the current page
        if (nextPage?.items.length) {
          const lastIssueOnPage =
            currentPage.items[currentPage.items.length - 1];

          // get the first issue on the next page that isn't yet added to the
          // current page (in case a user clicks on multiple issues quickly)
          const indexOnNextPage = nextPage.items.findIndex(
            (issue) => issue.id === lastIssueOnPage.id
          );
          const nextIssue = nextPage.items[indexOnNextPage + 1];

          // there might not be any issues left to add if a user clicks fast
          // and/or the internet connection is slow 
          if (nextIssue) {
            newItems.push(nextIssue);
          }
        }

        queryClient.setQueryData(["issues", page], {
          ...currentPage,
          items: newItems,
        });

        return { currentPage };
      },
      onError: (err, issueId, context) => {
        if (context?.currentPage) {
          queryClient.setQueryData(["issues", page], context.currentPage);
        }
      },
      onSettled: () => {
        // we don't have to add the page to the query key here
        // this invalidates all queries containing the key "issues"
        queryClient.invalidateQueries(["issues"]);
      },
    }
  );

  ...
}

뭔가 많아보이지만 실제로 추가된 부분은 이부분 입니다.

    // 현재 쿼리 데이터 가져오기
    const currentPage = queryClient.getQueryData([
      "issues",
      page,
    ]);
    // 다음 페이지 쿼리 데이터 가져오기
    const nextPage = queryClient.getQueryData([
      "issues",
      page + 1,
    ]);

    if (!currentPage) {
      return;
    }
	// 삭제
    const newItems = currentPage.items.filter(({ id }) => id !== issueId);

    // 해당 페이지의 마지막 아이템 id 가져오기
    if (nextPage?.items.length) {
      const lastIssueOnPage =
        currentPage.items[currentPage.items.length - 1];

      // 현재 페이지에 추가되지 않은 다음 페이지의 가장 빠른 id 가져오기
      const indexOnNextPage = nextPage.items.findIndex(
        (issue) => issue.id === lastIssueOnPage.id
      );
      const nextIssue = nextPage.items[indexOnNextPage + 1];

      // 인터넷이 느려 prefetch가 제대로 안되었거나, 사용자가 너무 많이 클릭헤서 데이터가 없어졌을 때
      // 엣지 케이스 해결
      if (nextIssue) {
        newItems.push(nextIssue);
      }
    }

    queryClient.setQueryData(["issues", page], {
      ...currentPage,
      items: newItems,
    });

오류난 경우 해당 페이지 데이터만 원복해 줍니다.

      onError: (err, issueId, context) => {
        if (context?.currentPage) {
          queryClient.setQueryData(["issues", page], context.currentPage);
        }
      },

이와 같이 낙관적 업데이트를 구현하면,

사용자 경험을 위해 해야할 일이 많습니다.

개선 5.  캐시 업데이트의 동시성 문제

자바스크립트의 메인 쓰레드는 하나지만,

개념적으로 여러 단위 작업들이 동시에 진행되는건 막을 수 없습니다.

아래 영상에서 이상한 모습을 발견할 수 있습니다.

동시성 문제 비디오사용자는 첫번째 이슈의 버튼을 클릭하고, 해당 이슈는 낙관적으로 바로 제거됩니다.

  1. 이슈를 업데이트 하기 위해 patch 요청이 전송되며, 응답이 바로 옵니다.
  2. 테이블 데이터를 다시 가져오기 위해 GET 요청이 전송됩니다.
    • 첫번째 get 요청 - 아직 응답을 받지 못한 상황입니다.
  3. 두 번째 이슈는 이제 테이블의 맨 위에 있습니다.
  4. 사용자가 두번째 이슈의 버튼을 클릭합니다. 다시 해당 이슈는 낙관적으로 바로 제거됩니다.
  5. 이슈를 업데이트하기 위해 다시 PATCH 요청이 전송됩니다. 해당 요청이 성공하면 다시 GET 요청을 보냅니다
    • 첫번째 get 요청 - 아직 응답을 받지 못한 상황입니다.
    • 두번째 get 요청 - 아직 응답을 받지 못한 상황입니다.
  6. 다른 GET 요청이 전송된 직후.  거의 동시에 첫 번째 GET 요청에 대한 응답이 도착합니다.
    • 즉, 쿼리 캐시는 첫 번째 GET 응답의 데이터로 업데이트됩니다.
      • 두 번째 이슈는 낙관적 업데이트로 제거되었지만, 쿼리 캐시에 의해 재등장하게 됩니다.
  7. 마지막으로 두 번째 GET 요청에 대한 응답이 최종 데이터와 함께 도착합니다.
    • 캐시가 업데이트됩니다. 두 번째 이슈는 테이블에서 다시 사라집니다.

즉, 동시 GET 요청이 이 문제의 원인 같네요.

따라서 목표는 병렬 GET 요청을 최대한 방지해야 합니다.

솔루션 : 세마포어

보류 중인 뮤테이션 요청(post, patch)이 있을 때만 쿼리를 무효화하면 됩니다.

상태의 변경은 리렌더링을 트리거 하므로 ref를 사용하도록 합시다.

export function IssueList() {
  // ...

  // 보류중인 변형 갯수 추적
  const pendingMutationCount = useRef(0);
  const resolveIssueMutation = useMutation(
    (issueId) =>
      axios.patch(...),
    {
      onMutate: async (issueId) => {
        // 보류 중인 변형 갯수 증가
        pendingMutationCount.current += 1;

        // ...

        return { currentPage };
      },
      onError: (err, issueId, context) => { ... },
      onSettled: () => {
        // 보류 중인 변형이 없는 경우에만 쿼리를 무효화
        // 이전 요청이 만료된 데이터로 캐시 업데이트하는 것을 방지
        pendingMutationCount.current -= 1;
        if (pendingMutationCount.current === 0) {
          queryClient.invalidateQueries(["issues"]);
        }
      },
    }
  );

  ...
}

참고

https://profy.dev/article/react-query-usemutation

 

REST APIs - How To Mutate Data From Your React App Like The Pros

Using simple click handlers to update API data seems easy but quickly gets out of hand. Let's build a snappy data-driven component with the react-query as example.

profy.dev

 

반응형