Life is connecting the dots .

Optimistic UI 적용하기 (feat. Update cache in Apollo Client) 본문

Programming/Next.js

Optimistic UI 적용하기 (feat. Update cache in Apollo Client)

soyeori 2023. 7. 2. 12:00

Optimistic UI란 서버에 요청을 보내고 응답을 받기 전에 UI를 업데이트하는 데 사용할 수 있는 패턴이다. mutation을 시뮬레이션하고, UI에 업데이트 함으로써 낙관적인 결과를 사용자에게 보여주고, 이후 최종 서버에서 응답을 받으면 그 결과로 대체된다.

 

기존 프로젝트 기능 중 좋아요 기능👍에 Apollo Client에서 제공하는 옵션을 활용하여 적용해 보려고 한다. 좋아요 버튼을 누르면 아마도 대다수는 좋아요 숫자가 +1이 증가할 것이라고 예상한다. 이렇게 종종 결과를 예측할 수 있는 mutation에 UI를 낙관적으로 업데이트를 할 수 있다. 

optimisticResponse option

Apollo Client는 mutation을 실행시킬 때 해당 옵션을 추가할 수 있도록 제공한다. 우선 Docs를 확인하여 적용하는 방법을 살펴보면, mutation을 실행할 때 optimisticResponse옵션을 추가해 주고 값으로 서버에서 응답받을 것으로 예측하는 것과 일치하는 응답 객체를 넣어주면 된다. 중요한 점은, 고유한 값으로 id__typename을 넣어줘야 하는데 Apollo Client 캐시는 이 고유한 값을 응답받는 객체의 캐시 식별자로 생성하기 때문이다.  

 

apollo docs 예제

 

위의 예제에 comment를 수정하는 뮤테이션 코드가 실행되면 Apollo Client캐시는 optimistic 옵션의 필드값을 저장한다. 하지만, 기존 캐시된 comment를 덮어씌우지 않고 optimistic 버전으로 따로 저장한다는 점이 포인트다. 그렇기 때문에 예상한 값이 잘못된 값이더라도 기존 캐시된 정확한 데이터가 남아있도록 한다.

Apollo Client는 수정된 쿼리를 인지하여 자동적으로 업데이트하며, 연관된 컴포넌트들은 리렌더링 되어 optimistic 데이터를 반영한다. 이 과정에서 네트워크 요청이 필요하지 않기 때문에 사용자에게 즉각적으로 보이게 된다.

결과적으로 서버에서 mutation의 실제 결과를 반환받게 되고(comment object) Apollo Client 캐시는 기존 optimistic버전을 삭제하고 서버로부터 반환받은 정확한 값으로 덮어씌운다. 

다시 한번, Apollo Client은 영향받은 쿼리를 인지하고 관련된 컴포넌트들을 리렌더링 한다. 서버로부터 받은 응답이 optimistic 값과 일치하면 사용자에게 리렌더링 되는 과정들은 보이지 않는다.

코드에 적용하기

기존 좋아요 기능 실행 코드에 optimisticResponse 옵션을 추가하였는데, 해당 쿼리문의 인자로 게시판 고유 아이디(boardId)가 필요하고, 반환받는 값은 숫자(Int)를 반환받게 되어있어서 위의 예제의 id와 __typename을 추가로 생성할 필요가 없다.

 

  // mutation query
  const LIKE_BOARD = gql`
    mutation likeBoard($boardId: ID!) {
      likeBoard(boardId: $boardId)
    }
  `;

// execute mutation
  const onClickLikeCount = async () => {
    if (typeof router.query.boardId !== "string") return;
    await likeBoard({
      variables: { boardId: router.query.boardId },
      optimisticResponse: {
        likeBoard: Number(data?.fetchBoard.likeCount) + 1,
      },
      refetchQueries: [
        {
          query: FETCH_BOARD,
          variables: { boardId: router.query.boardId },
        },
      ],
    });
  };

캐시 업데이트

위에처럼 코드를 작성하고 실행했더니, refetchQueries 실행 전까지 좋아요 수가 올라가는 것을 확인할 수 없었다.🧐 처음 refetchQueries를 추가한 이유는 뮤테이션을 실행 후 fetch 하는 쿼리의 좋아요 필드가 즉각적으로 업데이트되지 않았기 때문이다. 서버에서 쿼리를 다시 가져와서 실행하는 것 대신에 캐시에서 수정된 필드를 업데이트해 주는 함수를 사용하여 해당 코드를 수정해 보기로 했다.

Docs에서는 readQuery/writeQueryreadFragment/writeFragmentmodify 등의 매서드를 소개하고 있고, 이들을 사용하면 서버와 상호작용하는 것처럼 캐시에서 GraphQL작업을 실행할 수 있다. Update 함수는 뮤테이션 실행 후의 결과를 포함하는 데이터 객체를 전달하면 이 값을 가지고 필요한 메서드를 사용하여 캐시를 직접 업데이트하면 된다!

또한, Docs에서는 optimistic response를 사용한다면 update함수를 2번 호출한다고 한다. (optimistic result를 받아올 때 한 번, 그리고 실제 result를 받아올 때 한 번)

 

writeQuery 매서드를 사용하여 서버와 통신하지 않고 캐시를 직접 업데이트해 보았다.  writeQuery는 readQuery와 비슷하지만 data 옵션을 추가할 수 있다는 점에서 차이가 있다. 여기서 data값은 likeCount 뮤테이션을 실행해서 얻는 데이터 객체이고, 캐시를 직접 수정하고 싶은 쿼리는 FETCH_BOARD라는 게시물을 패치하는 쿼리이다. data옵션을 사용하여 fetchBoard의 좋아요 수를 보여주는 likeCount 필드의 값을 직접 수정하였다. __typename과 _id를 필수로 넣어줘야 고유한 캐시를 식별할 수 있다.

 

// mutation query
  const LIKE_BOARD = gql`
    mutation likeBoard($boardId: ID!) {
      likeBoard(boardId: $boardId)
    }
  `;

// execute mutation
  const onClickLikeCount = async () => {
    if (typeof router.query.boardId !== "string") return;
    await likeBoard({
      variables: { boardId: router.query.boardId },
      optimisticResponse: {
        likeBoard: Number(data?.fetchBoard.likeCount) + 1,
      },
      update(cache, { data }) {
        cache.writeQuery({
          query: FETCH_BOARD,
          data: {
            fetchBoard: {
              __typename: "Board",
              _id: router.query.boardId,
              likeCount: data?.likeBoard,
            },
          },
          variables: { boardId: router.query.boardId },
        });
      },
    });
  };

 

fetchBoard의 필드에는 다음과 같이 다양한 값의 필드를 반환받을 수 있다. 하지만 위의 data에는 모든 필드가 기재되어 있지 않았는데 그 이유는 이미 동일한 _id를 가진 객체가 캐시에 존재하므로 writeQuery를 실행했을 때 다른 필드는 유지된 채로 필드가 덮어씌워지기 때문이다.💡

 

// query
// id, writer, title... 등 field가 존재
const FETCH_BOARD = gql`
  query fetchBoard($boardId: ID!) {
    fetchBoard(boardId: $boardId) {
      _id
      writer
      title
      contents
      youtubeUrl
      likeCount
      dislikeCount
      createdAt
      images
    }
  }
`;

UI 테스트

최종 코드를 완성하고, 개발자도구 네트워크탭에서 속도를 3G slow버전으로 변경한 뒤 변화를 확인해 보았다. 

기존 코드

노란색 좋아요 버튼을 눌렀을 때, graphql 서버통신이 2번 됨을 알 수 있다. 처음 한 번은 좋아요 수를 올리는데 한 번, 화면을 리패치하는 쿼리가 한 번, 이렇게 2번의 통신이 완료되어야 카운트가 올라가는 것을 확인할 수 있다.  

 

 

리팩토링 코드 (optimistic ui + update 적용)

캐시를 직접 수정함으로써 좋아요 수를 올리는 mutation을 할 때만 graphql 서버통신을 하게 되므로 총 1번만 통신한다. 중요한 점은 버튼을 누르고, ⭐️통신 결과를 기다리는 중일 때(status: pending) 이미 화면에는 카운트가 올라가는 것을 확인⭐️할 수 있다. 통신이 완료되면 optimisticResponse와 결과가 동일하므로 화면상에서도 추가적인 변화가 없다.

 

 

Time 34ms -> 17ms 

3G환경을 끄고 통신 시간을 확인해 보면 기존 코드는 좋아요 mutation과 화면 refetch 각각의 통신하는 데 걸리는 시간이 18ms, 16ms로 원하는 결과를 받기까지 총 34ms가 걸렸다. 반면에 리팩토링 한 코드는 좋아요 mutation 한 번의 통신으로 총 17ms이 걸림으로써 원하는 결과를 받기까지 시간을 50% 감소시켰다. 또한, 실제 사용자에게는 통신 중인 상태일 때 이미 화면에 반영되므로 즉각적인 반응효과를 통해 사용성 개선훨씬 더 빠른 느낌을 줄 수 있다.🤩

 

optimistic UI를 처음 사용해 보니 적절한 곳에 사용하면 여러 장점으로 유용하게 사용이 가능한 기능이라는 생각이 든다. 한편으로는 Docs에서 기능을 소개하면서 가장 가능성이 높은 결과 ("most likely result")를 예측할 수 있을 때 해당 기능 사용이 가능하다고 설명하고 있다. 그만큼 확실한 결과를 예측할 수 있을 때만 UI 최적화를 성공할 수 있다.

 


참고

[APOLLO DOCS] optimistic-ui