본문 바로가기

FrontEnd

Suspense와 리소스 캐싱하기

반응형

주의 : 해당 코드는 실전에서 사용할 만큼 완벽하지는 않음.

캐시와 동시성, 버퍼링은 컴퓨터 사이언스 있어 가장 어려운 문제 중 하나임.

실전에서는 검증된 라이브러리를 사용하자 (react-query)

캐시는 UI 상태가 아님.

리액트의 주 관심사는 UI를 위한 상태관리임.

UI를 위한 상태가 아닌 데이터는 관심사 분리의 대상임.

비동기 데이터 관리는 전적으로 개발자 판단임.

ex) 리덕스, apollo-graphql, relay, swr, 커스텀 컨텍스트, recoil

주의 2 : 렌더 메소드 안에서 프로미스를 만드는 것은 위험함

반드시 한번만 호출될 것이라는 가정이 없기 때문임.

따라서, 주의해서 사용해야함.

function MySuspendingComponent({value}) {
  let resource = promiseCache[value]
  if (!resource) {
    resource = doAsyncThing(value)
    promiseCache[value] = resource // 이런 식으로 여러번 호출 막아줌
  }
  return <div>{resource.read()}</div>
}

구현하기

1. 데이터가 있을 때만 데이터를 리턴하고, 그 외에는 promise를 리턴하는 헬퍼 메소드를 만든다.

function createResource(promise) {
  let status = 'pending'
  let result = promise.then(
    resolved => {
      status = 'success'
      result = resolved
    },
    rejected => {
      status = 'error'
      result = rejected
    },
  )
  return {
    read() {
      if (status === 'pending') throw result
      if (status === 'error') throw result
      if (status === 'success') return result
      throw new Error('This should be impossible')
    },
  }
}

// fetch와 함께 사용
function createPokemonResource(pokemonName) {
  return createResource(fetchPokemon(pokemonName))
}

2. 해당 메소드를 사용하는 프리젠테이셔널 컴포넌트를 구현한다.

function PokemonInfo({pokemonResource}) {
  const pokemon = pokemonResource.read()
  return (
    <div>
      <div className="pokemon-info__img-wrapper">
        <img src={pokemon.image} alt={pokemon.name} />
      </div>
      <PokemonDataView pokemon={pokemon} />
    </div>
  )
}

3. 캐시 컨텍스트를 만들고 사용한다.

캐시 컨텍스트는 글로벌로 사용하여, 전체에서 해당 아이템을 공유하도록 한다.

const PokemonResourceCacheContext = React.createContext()

function PokemonCacheProvider({children, cacheTime}) {
  const cache = React.useRef({})
  const expirations = React.useRef({})

  // timeout 처리 함수
  React.useEffect(() => {
    const interval = setInterval(() => {
      for (const [name, time] of Object.entries(expirations.current)) {
        if (time < Date.now()) {
          delete cache.current[name]
          delete expirations.current[name]
        }
      }
    }, 1000)

    return () => clearInterval(interval)
  }, [])

  const getPokemonResource = React.useCallback(
    name => {
      const lowerName = name.toLowerCase()
      // 캐시 등록
      let resource = cache.current[lowerName]
      if (!resource) {
        resource = createPokemonResource(lowerName)
        cache.current[lowerName] = resource
      }
      // 타임아웃 설정
      expirations.current[lowerName] = Date.now() + cacheTime
      return resource
    },
    [cacheTime],
  )

  return (
    <PokemonResourceCacheContext.Provider value={getPokemonResource}>
      {children}
    </PokemonResourceCacheContext.Provider>
  )
}

// 컨텍스트 사용 훅
function usePokemonResourceCache() {
  const context = React.useContext(PokemonResourceCacheContext)
  if (!context) {
    throw new Error(
      `usePokemonResourceCache should be used within a PokemonCacheProvider`,
    )
  }
  return context
}

// 전체 앱을 감쌈.
function AppWithProvider() {
  return (
    <PokemonCacheProvider cacheTime={5000}>
      <App />
    </PokemonCacheProvider>
  )
}

4. 캐시를 활용한다.

사용자는 캐시에서 가져오는지, 비동기 상태를 거쳐 가져오는지 몰라도 상관없다.

getPokemonResource는 해당 내용을 캡슐화한다.

function App() {
  const [pokemonName, setPokemonName] = React.useState('');
  const [startTransition, isPending] = React.useTransition(SUSPENSE_CONFIG);
  const [pokemonResource, setPokemonResource] = React.useState(null);
  const getPokemonResource = usePokemonResourceCache();

  React.useEffect(() => {
    if (!pokemonName) {
      setPokemonResource(null)
      return
    }
    startTransition(() => {
      setPokemonResource(getPokemonResource(pokemonName))
    })
  }, [getPokemonResource, pokemonName, startTransition])

  function handleSubmit(newPokemonName) {
    setPokemonName(newPokemonName)
  };

  function handleReset() {
    setPokemonName('')
  };

  return (
    <div className="pokemon-info-app">
      <PokemonForm pokemonName={pokemonName} onSubmit={handleSubmit} />
      <hr />
      <div className={`pokemon-info ${isPending ? 'pokemon-loading' : ''}`}>
        {pokemonResource ? (
          <PokemonErrorBoundary
            onReset={handleReset}
            resetKeys={[pokemonResource]}
          >
            <React.Suspense
              fallback={<PokemonInfoFallback name={pokemonName} />}
            >
              <PokemonInfo pokemonResource={pokemonResource} />
            </React.Suspense>
          </PokemonErrorBoundary>
        ) : (
          'Submit a pokemon'
        )}
      </div>
    </div>
  )
}
반응형