사용했던 오픈소스 읽고 이해하기 - SWR
swr 기본 구조 분석해 보기
개요
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
를 베이스로
데이터의
snapshot
이 있는지 판단하고,있으면 해당 객체를 deep compare하여
같으면 cache hit 했다고 판단, 그대로 리턴한다.
How to Deep Compare
stableHash
메서드는 내부적으로 WeakMap이라는 자료구조를 사용함weakMap은 key에 대한 강한 참조를 생성하지 않음, 예를 들어 (javascript의) 배열이나 map, object는 key나 인덱스에 대한 강한 참조를 생성하기 때문에 계속해서 메모리상에 해당 참조값이 남아있게 됨
weakmap의 경우 강한 참조를 생성하지 않아 가비지 콜렉션이 이를 정리할 수 있다고 함. 단, map의 key list을 열거할 수 없다고 함
stableHash
는 재귀적으로 weakmap에 있는 key를 탐색해 value값들을 hashing함 (암호화는 아니고 그냥 문자열로 이어붙임)
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
를 가지고 객체를 저장하기 때문으로, 객체의 여러 속성값 중 공통된 값을 가지고 있을 때 캐시 저장소에 참조값을 하나만 생성하기 때문이다.
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번까지 재시도한다.
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
과 달리 수동으로만 트리거된다.
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이 거의 동시에 트리거되는 경우.
// req1------------------>res1
// req2---------------->res2
ex 2. 같은 key의 useSWR과 mutation이 거의 동시에 트리거되는 경우. 이때 useSWR
이 더 일찍 시작되면서 mutate
보다 늦게 끝나는 상황.
// req1-------------------------->res1 (useSWR: get)
// req2---------------->res2 (mutation: post, ...etc)
이런 경우를 대비해 timestamp를 기억했다가 이전에 시작한 요청(req1
)을 버린다.
//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 등 상황에 따른 예제들이 들어있다.