2024. 02

사용했던 오픈소스 읽고 이해하기 - SWR

swr 기본 구조 분석해 보기

SWR: 데이터 가져오기를 위한 React Hooks

개요

  • React Hooks를 기반으로 구현되어 있다는 것을 전제하고 있음

  • HTTP RFC 5861의 stale-while-revalidate에서 유래됨 (HTTP 캐시 무효 전략)

  • 캐시(stale)로부터 데이터를 반환한 후, fetch(revalidate)하고, 최신화된 데이터를 가져옴

cf. stale-while-revalidate

HTTP 캐시 컨트롤 헤더 중 하나임

  • 리소스를 캐시하되, 새로운 버전을 백그라운드에서 가져와 갱신함

  • 클라이언트가 리소스를 요청하면, 서버는 캐시된 리소스를 반환함

  • 동시에 서버는 백그라운드에서 리소스를 다시 검증함(revalidate)

  • 만약 리소스가 변경되었다면, 새로운 버전을 서버에서 가져와 캐시를 갱신함

  • 변경이 없다면 기존 캐시를 계속 사용함

Cache-Control: max-age:60, stale-while-revalidate=300

  • 이렇게 사용하면 캐시는 60초동안 유효하며, 이 기간 동안은 캐시된 데이터를 사용함

  • 캐시 만료 후 300초 동안은 캐시된 데이터를 사용하며, 300초 동안에는 백그라운드에서 데이터를 다시 가져와 갱신함. 데이터가 달라진 경우라도, 달라진 이후에 처음으로 데이터를 요청하는 사용자는 캐시된 데이터를 받게 된다.

useSWR (GET)

core/use-swr.ts

전체 코드

기본 원리 - react hook의 useMemo를 베이스로

  1. 데이터의 snapshot이 있는지 판단하고,

  2. 있으면 해당 객체를 deep compare하여

  3. 같으면 cache hit 했다고 판단, 그대로 리턴한다.

How to Deep Compare

  • stableHash메서드는 내부적으로 WeakMap이라는 자료구조를 사용함

  • weakMap은 key에 대한 강한 참조를 생성하지 않음, 예를 들어 (javascript의) 배열이나 map, object는 key나 인덱스에 대한 강한 참조를 생성하기 때문에 계속해서 메모리상에 해당 참조값이 남아있게 됨

  • weakmap의 경우 강한 참조를 생성하지 않아 가비지 콜렉션이 이를 정리할 수 있다고 함. 단, map의 key list을 열거할 수 없다고 함

stableHash 는 재귀적으로 weakmap에 있는 key를 탐색해 value값들을 hashing함 (암호화는 아니고 그냥 문자열로 이어붙임)

Typescript
if (constructor == Array) {
      // Array.
      result = '@'
      for (index = 0; index < arg.length; index++) {
        result += stableHash(arg[index]) + ','
      }
      table.set(arg, result)
    }
    if (constructor == OBJECT) {
      // Object, sort keys.
      result = '#'
      const keys = OBJECT.keys(arg).sort()
      while (!isUndefined((index = keys.pop() as string))) {
        if (!isUndefined(arg[index])) {
          result += index + ':' + stableHash(arg[index]) + ','
        }
      }
      table.set(arg, result)
    }

즉 fetch하되, 리액트 렌더링을 무조건적으로 시키는 것이 아니라 같은 자료라면 리렌더를 막는다.

  • 여기서 fetch는 구현되어 있는 것이 아니라 사용자가 입력한 fetcher를 그대로 받아서 사용한다.

  • 다만 내부적으로 사용하는 상태비교용 메서드 isEqual이 비교하지 않는 필드가 존재해서 이 부분이 바뀌었다면 리렌더를 트리거하지 않는데, 이 부분은 따로 업데이트가 필요한 상황이라고 한다.

useSWR이 고유한 key를 필요로 하는 이유는 내부적으로 useRef를 가지고 객체를 저장하기 때문으로, 객체의 여러 속성값 중 공통된 값을 가지고 있을 때 캐시 저장소에 참조값을 하나만 생성하기 때문이다.

Typescript
const keyRef = useRef(key)
const fetcherRef = useRef(fetcher)
const configRef = useRef(config)

즉 SWR은 key가 같은 api 콜에 대해서는 중복호출을 하지 않고, 트리거될 때 영향받는 컴포넌트가 모두 같이 리렌더되도록 한다. (데이터 변화가 트리거되거나 재검증(mutate)될 때에도)

주요 옵션 파라미터

  • refreshInterval: number

  • dedupingInterval: number

  • errorRetryInterval: number

내부에 exponential backoff가 구현되어 있었다... 해당 부분 코드는 이 부분이었는데 메서드에 넣은 옵션 인터벌에 난수를 생성해 실패했을 때 인터벌을 벌리면서 8번까지 재시도한다.

Typescript
const onErrorRetry = (
  _: unknown,
  __: string,
  config: Readonly<PublicConfiguration>,
  revalidate: Revalidator,
  opts: Required<RevalidatorOptions>
): void => {
  const maxRetryCount = config.errorRetryCount
  const currentRetryCount = opts.retryCount

  // Exponential backoff
  const timeout =
    ~~( //소숫점 이하가 버려짐
      (Math.random() + 0.5) * //난수 생성
      (1 << (currentRetryCount < 8 ? currentRetryCount : 8))
			// exponential backoff, 최대 8회까지만
    ) * config.errorRetryInterval //사용자가 설정한 interval

  if (!isUndefined(maxRetryCount) && currentRetryCount > maxRetryCount) {
    return
  }

  setTimeout(revalidate, timeout, opts)
}

Mutation (POST, PUT, PATCH, DELETE)

mutation/index.ts

전체 코드

mutation(key, options)

  • 모든 useSWR(key) 에 해당하는 데이터를 재검증한다.

  • 동일한 cache provider (SWRConfig의 provider option) 범위 내에 있는 모든 데이터가 해당된다

useSWRMutation(key, fetcher, options)

  • POST, PUT, PATCH, DELETE 요청 등으로 데이터 변경을 트리거할 때 쓰인다.

  • useSWR 과 달리 수동으로만 트리거된다.

Javascript
function Profile() {
  const { trigger, data, error } = useSWRMutation('/api/user', fetcher, options)
 
	//trigger 안의 인수는 options 안의 객체로 전달됨
	//렌더링 결과로 mutation의 결과를 사용할 수도, 사용하지 않을 수도 있음
  return <button onClick={() => {
    trigger('data')
  }}>Update User</button>
}

Race Conditions

focus, polling, 또는 다른 조건으로 인해 데이터를 새로고침하면서, cache provider에 있는 데이터가 경합 상태에 있을 수 있다. 다음과 같은 예시가 있다.

ex 1. 서로 다른 두 부분에서 같은 key의 useSWR이 거의 동시에 트리거되는 경우.

Typescript
//   req1------------------>res1        
//        req2---------------->res2

ex 2. 같은 key의 useSWR과 mutation이 거의 동시에 트리거되는 경우. 이때 useSWR 이 더 일찍 시작되면서 mutate 보다 늦게 끝나는 상황.

Typescript
//   req1-------------------------->res1    (useSWR: get)
//        req2---------------->res2         (mutation: post, ...etc)

이런 경우를 대비해 timestamp를 기억했다가 이전에 시작한 요청(req1)을 버린다.

Typescript
//use-swr.ts

// If there're other ongoing request(s), started after the current one,
// we need to ignore the current one to avoid possible race conditions:
//   req1------------------>res1        (current one)
//        req2---------------->res2
// the request that fired later will always be kept.
// The timestamp maybe be undefined or a number

if (!FETCH[key] || FETCH[key][1] !== startAt) {
		if (shouldStartNewRequest) {
			if (callbackSafeguard()) { //key가 변경되었는지와, 최초 렌더링인지의 여부
				getConfig().onDiscarded(key)
			}
		}
	return false
}

optimistic UI

낙관적 UI는 요청이 성공할 것이라고 가정하고 그 요청의 결과를 보여주는 것이다. 요청이 아주 높은 확률로 성공할 것 그리고 그 요청이 너무 늦지 않은 시간 안에 온다는 보장이 있을 때를 전제한다.

  • 예를 들어 버튼을 누른 순간 바로 응답 결과가 반영되지만, 실제 요청이 가지는 않은 상태

  • 성공했을 때의 값 update, 실패했을 때 rollback 이 반드시 발생하여야 함 (예를 들어 좋아요 숫자가 올라갔다가, 실패하면 다시 떨어져야 함)

  • optimisticData 옵션으로 설정할 수 있다.

_internal

유틸 메서드와 타입 정의들이 있다. 이것들을 뭉쳐서 하나로 export하기도 하고, export되지 않고 내부적으로 사용되기만 하는 것들도 있다.

e2e

playwright를 이용한 e2e테스트가 작성되어 있다. 즉 예제 사이트에서 데이터를 가져오는 시나리오가 테스트 코드로 짜여져 있는 것.

examples

data fetch 라이브러리(fetchers) 상황 및 무한스크롤, optimistic ui 등 상황에 따른 예제들이 들어있다.