본문 바로가기

FrontEnd

[Epic React][Build an Epic React App][Cache Management]

반응형
애플리케이션 상태 관리는 앱 개발자에게 가장 어려운 문제 중 하나입니다.
수많은 라이브러리가 이를 입증합니다. 이 문제는 과도한 엔지니어링, 조기 추상화, 적절한 상태 분류 부족으로 인해 훨씬 ​​더 어려워졌습니다.

상태는 두 개의 버킷으로 묶일 수 있습니다.

  • UI 상태: 모달 open, 항목강조 등
  • 서버 캐시: 사용자 데이터, 채팅 데이터, 연락처 등
사람들이 이 두 가지 다른 유형의 상태를 하나로 묶으려 할 때 상당한 복잡성이 발생합니다.
특히 전역이 아닌걸 전역으로 만드는게 복잡도를 가져옵니다.
캐싱이 일반적으로 소프트웨어 개발에서 가장 어려운 문제 중 하나라는 사실 때문에 더욱 복잡합니다.
서버 캐시를 별도의 것으로 분할하면 UI 상태 관리를 크게 단순화할 수 있습니다.
다양한 사용 사례와 최적화를 지원하는 유연하고 환상적인 상태 관리 솔루션이 있습니다.
바로 react-query 입니다.
 

React Query

Hooks for fetching, caching and updating asynchronous data in React

react-query.tanstack.com

독창적인 방법으로 서버에서 데이터를 쿼리, 캐시 및 변경할 수 있는 일련의 React Hook입니다.
  • 내부 구조 알아보기. 언제 한번 번역해서 정리하려다가 못했음 ㅡㅡ;

Let's Build React Query in 150 Lines of Code! – Tanner Linsley, React Summit Remote Edition 2021 - YouTube

리액트 쿼리 만들어보기

기본적인 사용법은 다음과 같다.

function App({tweetId}) {
  const result = useQuery({
    queryKey: ['tweet', {tweetId}],
    queryFn: (key, {tweetId}) =>
      client(`tweet/${tweetId}`).then(data => data.tweet),
  })
  // result has several properties, here are a few relevant ones:
  //   status
  //   data
  //   error
  //   isLoading

  const [removeTweet, state] = useMutation(() => tweetClient.remove(tweetId))
  // call removeTweet when you want to execute the mutation callback
  // state has several properties, here are a few relevant ones:
  //   status
  //   data
  //   error
}

📜 here are the docs:

실습


프로젝트 구조

페이지 설명

  • 책 상세보기 : Book.js
  • 책 검색 : Discover.js 

핵심 : 해당 URL에 대한 get, post, put, delete (query, mutation)을 하나의 훅으로 묶는다.

queryKey를 통해 데이터 변경 시 쿼리를 트리거한다.

mutation후 가장 간단한 구현 방법은 쿼리를 invalidate하는 것이다. (쿼리 키 이용)

해당 실습은 구현이 너무 많아서 전부 다 적을수 없는 수준이다.

해당 유틸리티를 보면 어느정도 이해가 갈 것이다.

bookshelf/list-items.extra-7.js at 9037f590bc11ff200d7b7ff920bf7e2f0491bd41 · kentcdodds/bookshelf (github.com)

 

GitHub - kentcdodds/bookshelf: Build a ReactJS App workshop

Build a ReactJS App workshop. Contribute to kentcdodds/bookshelf development by creating an account on GitHub.

github.com

React-Query 공통 설정 (ReactQueryConfigProvider)

useErrorBoundary : 리액트 쿼리의 에러를 throw한다.

refetchOnWindowFocus : 다른 앱을 작업하다 다시 브라우저를 작업하면 다시 쿼리한다. (거의 불필요한 기능)

retry : false면 재시도 하지 않는다.

import {loadDevTools} from './dev-tools/load'
import './bootstrap'
import * as React from 'react'
import ReactDOM from 'react-dom'
import {ReactQueryConfigProvider} from 'react-query'
import {App} from './app'

const queryConfig = {
  queries: {
    useErrorBoundary: true,
    refetchOnWindowFocus: false,
    retry(failureCount, error) {
      if (error.status === 404) return false
      else if (failureCount < 2) return true
      else return false
    },
  },
}

loadDevTools(() => {
  ReactDOM.render(
    <ReactQueryConfigProvider config={queryConfig}>
      <App />
    </ReactQueryConfigProvider>,
    document.getElementById('root'),
  )
})

로그아웃 시 쿼리 캐시 clear

로그아웃 함수에서

// ...
import * as auth from 'auth-provider'
import {queryCache} from 'react-query'
// ...  
// function App()
  const logout = () => {
    auth.logout()
    queryCache.clear()
    setData(null)
  }

서버 응답 에러 시

// utils/api-client.final.js
import {queryCache} from 'react-query'
import * as auth from 'auth-provider'
const apiURL = process.env.REACT_APP_API_URL

async function client(
  endpoint,
  {data, token, headers: customHeaders, ...customConfig} = {},
) {
  const config = {
    method: data ? 'POST' : 'GET',
    body: data ? JSON.stringify(data) : undefined,
    headers: {
      Authorization: token ? `Bearer ${token}` : undefined,
      'Content-Type': data ? 'application/json' : undefined,
      ...customHeaders,
    },
    ...customConfig,
  }

  return window.fetch(`${apiURL}/${endpoint}`, config).then(async response => {
    if (response.status === 401) {
      queryCache.clear()
      await auth.logout()
      // refresh the page for them
      window.location.assign(window.location)
      return Promise.reject({message: 'Please re-authenticate.'})
    }
    const data = await response.json()
    if (response.ok) {
      return data
    } else {
      return Promise.reject(data)
    }
  })
}

export {client}

인라인 에러 메세지

// ErrorMessage 표기 위한 컴포넌트
function ErrorMessage({error, variant = 'stacked', ...props}) {
  return (
    <div
      role="alert"
      css={[{color: colors.danger}, errorMessageVariants[variant]]}
      {...props}
    >
      <span>There was an error: </span>
      <pre
        css={[
          {whiteSpace: 'break-spaces', margin: '0', marginBottom: -5},
          errorMessageVariants[variant],
        ]}
      >
        {error.message}
      </pre>
    </div>
  )
}

// 함수형 컴포넌트 내부 label 옆에 에러를 inline으로 표기해 주는 것이 좋다.
function NotesTextarea({listItem, user}) {
  // 뮤테이션에서 상태 가져올 수 있음
  const [mutate, {error, isError}] = useUpdateListItem(user)
  const debouncedMutate = React.useMemo(() => debounceFn(mutate, {wait: 300}), [
    mutate,
  ])

  function handleNotesChange(e) {
    debouncedMutate({id: listItem.id, notes: e.target.value})
  }

  return (
    <React.Fragment>
      <div>
        <label
          htmlFor="notes"
          css={{
            display: 'inline-block',
            marginRight: 10,
            marginTop: '0',
            marginBottom: '0.5rem',
            fontWeight: 'bold',
          }}
        >
          Notes
        </label>
        // label 옆에 에러를 inline으로 표기해 주는 것이 좋다.
        {isError ? (
          <ErrorMessage
            error={error}
            variant="inline"
            css={{marginLeft: 6, fontSize: '0.7em'}}
          />
        ) : null}
      </div>
      <Textarea
        id="notes"
        defaultValue={listItem.notes}
        onChange={handleNotesChange}
        css={{width: '100%', minHeight: 300}}
      />
    </React.Fragment>
  )
}

커스텀 에러 핸들링

리액트 쿼리는 기본적으로 에러를 삼키기 때문에, 밖에서 에러를 이용하려면 설정이 필요하다.

// 커스텀 훅을 옵션을 오버리이딩 할 수 있도록 만든다.
function useUpdateListItem(user, options) {
  return useMutation(
    updates =>
      client(`list-items/${updates.id}`, {
        method: 'PUT',
        data: updates,
        token: user.token,
      }),
    {...defaultMutationOptions, ...options},
  )
}

throwOnError설정을 추가해준다.

 const [update] = useUpdateListItem(user, {throwOnError: true})

인라인 로딩 스피너 추가

라벨 옆에 로딩 스피너를 추가해주면 좋다.

//////// 함수형 컴포넌트 내부
// 훅 사용
const [mutate, {error, isError, isLoading}] = useUpdateListItem(user)

리턴 JSX 부분

(<div>
		<label
          htmlFor="notes"
          css={{
            display: 'inline-block',
            marginRight: 10,
            marginTop: '0',
            marginBottom: '0.5rem',
            fontWeight: 'bold',
          }}
        >
          Notes
        </label>
        {isError ? (
          <ErrorMessage
            error={error}
            variant="inline"
            css={{marginLeft: 6, fontSize: '0.7em'}}
          />
        ) : null}
        {isLoading ? <Spinner /> : null}
</div>)

Prefetch

미등록 리스트 > A 등록 > A 상세조회 > 미등록 리스트로 이동하면

캐시 때문에 A의 정보가 남아있다가 사라진다.

클린업 때 캐시를 invalidate하고,

해당 invalidate된 쿼리를 prefetch하면, 해당 쿼리를 비동기적으로 처리한다.

 

먼저 refetch와 쿼리를 위해 동시에 사용되는 config를 분리한다.

그 후 아래와 같이 훅을 구현한다.

const getBookSearchConfig = (query, user) => ({
  queryKey: ['bookSearch', {query}],
  queryFn: () =>
    client(`books?query=${encodeURIComponent(query)}`, {
      token: user.token,
    }).then(data => data.books),
})


function useBookSearch(query, user) {
  const result = useQuery(getBookSearchConfig(query, user))
  return {...result, books: result.data ?? loadingBooks}
}


async function refetchBookSearchQuery(user) {
  queryCache.removeQueries('bookSearch')
  await queryCache.prefetchQuery(getBookSearchConfig('', user))
}

쿼리를 갱신해줘야 하는 페이지에 아래와 같은 부수효과를 추가한다.

(해당 쿼리를 갖고 있는 페이지에 추가한다!)

  React.useEffect(() => {
    return () => refetchBookSearchQuery(user)
  }, [user])

캐시에 있는 정보 활용하기

react-query는 relay나 apollo-client같이 정규화된 쿼리를 사용하지 않는다.

따라서 이미 로딩된 정보가 있다면 해당 정보를 활용하는 설정이 필요하다.

const getBookSearchConfig = (query, user) => ({
  queryKey: ['bookSearch', {query}],
  queryFn: () =>
    client(`books?query=${encodeURIComponent(query)}`, {
      token: user.token,
    }).then(data => data.books),
  config: {
    onSuccess(books) {
      for (const book of books) {
        setQueryDataForBook(book)
      }
    },
  },
})

// 성공 시 queryKey로 cache data를 등록한다.
function setQueryDataForBook(book) {
  queryCache.setQueryData(['book', {bookId: book.id}], book)
}

setQueryDataForBook를 export해서, 다른 곳에서도 해당 정보를 받아오면 세팅 가능하다.

function useListItems(user) {
  const {data: listItems} = useQuery({
    queryKey: 'list-items',
    queryFn: () =>
      client(`list-items`, {token: user.token}).then(data => data.listItems),
    config: {
      onSuccess(listItems) {
        for (const listItem of listItems) {
          setQueryDataForBook(listItem.book)
        }
      },
    },
  })
  return listItems ?? []
}

개선방법 => 이벤트를 퍼블리싱 하는 방식으로 모듈 간의 결합도를 개선할 수 있다.

Optimistic Updates and Recovery

일단 클라이언트에서 정보를 수정하고, 서버에 요청한다.

그 후 서버와 동기화한다.

 

onMutate에서 캐시를 조작한다.

리턴 함수는 recover를 담당한다.

function useUpdateListItem(user, ...options) {
  return useMutation(
    updates =>
      client(`list-items/${updates.id}`, {
        method: 'PUT',
        data: updates,
        token: user.token,
      }),
    {
      onMutate(newItem) {
        const previousItems = queryCache.getQueryData('list-items')

        queryCache.setQueryData('list-items', old => {
          return old.map(item => {
            return item.id === newItem.id ? {...item, ...newItem} : item
          })
        })

        return () => queryCache.setQueryData('list-items', previousItems)
      },
      ...defaultMutationOptions,
      ...options,
    },
  )
}
반응형