infinite scroll 구현하기 (2) swr-graphql

최근 swr이라는 fetch 전용 라이브러리가 핫합니다. 내용을 살펴보았는데, apollo-graphql을 이용할 때와 뭐가 얼마나 다를지가 잘 그려지지 않아서 이참에 연습을 좀 해보았습니다. 전체 코드는 제 깃헙에 올려 놓았습니다.

1부에서는 apollo-graphql로 간단한 앱을 하나 만들었습니다. 이번 편에서는 이를 토대로 swr로 migration 해보겠습니다.

lastMsgId -> page

1부에서 데이터의 실시간 정합성 등의 이유를 들어 fetchMore에 page 대신 lastMsgId를 활용한 방법을 소개하였습니다. 그런데 만약 데이터의 정합성을 라이브러리가 알아서 어느정도 해결해준다면 어떨까요? swr은 refreshInterval, revalidateOnFocus, revalidateOnReconnect 등 화면상의 데이터와 DB 데이터 간의 차이를 없애주는 다양한 옵션이 제공되고 있습니다. 그렇다면 이 부분을 크게 고려하지 않고도 충분히 신뢰할 수 있는 실시간 서비스 제공이 가능할 것 같아, 과감하게 lastMsgId를 제거하고 대신 page 단위의 fetchMore를 도입하기로 결정했습니다. 이 결정으로 많은 부분에서 코드가 상당히 가벼워졌습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//  back/src/resolvers/message.js 

// from
messages: (parent, { lastMsgId = '', limit = 15 }, { models }) => {
const messageIds = Object.keys(models.messages).reverse()
const nextIndex = messageIds.indexOf(lastMsgId)
return (nextIndex === -1 ? messageIds.slice(0, limit) : messageIds.slice(nextIndex, nextIndex + limit + 1)).map(
id => models.messages[id],
)
},
...

// to
messages: (parent, { page = 0, limit = 15 }, { models }) => {
const messageIds = Object.keys(models.messages).reverse()
return messageIds.slice(page * limit, (page + 1) * limit).map(id => models.messages[id])
},
...

mutation시 cache update를 직접 제어 -> swr에게 맡기기

apollo 체계에서는 글의 생성/수정/삭제 등의 mutation시 실제 리스트에 반영하기 위해 각각의 상황에 맞게 cache를 udpate해주는 동작에 대한 정의가 필요했습니다. swr을 쓰면 이런 부분을 모두 과감히 걷어내도 됩니다. 서비스의 성격에 따라 실시간성이 엄청나게 크리티컬하지 않은 경우라면 refreshInterval의 수치를 적절하게 조절하는 것만으로 충분합니다. 예를 들어 refreshInterval 값을 30초로 설정했다면, 어떤 변경이 있은 후 최대 30초 후에는 변경사항이 반영될 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// front/components/MsgInput.js 

// from
mutate({
variables: { ...variables, text },
update: (cache, { data }) => {
if (!variables.id) {
cache.writeQuery({
query: updateQuery,
data: { [updateTarget]: [data[mutationTarget]] },
})
return
}
const res = cache.readQuery({ query: updateQuery })
const source = [...res[updateTarget]]
const targetIndex = source.findIndex(m => m.id === variables.id)
if (targetIndex < 0) return
source[targetIndex] = data[mutationTarget]
cache.writeQuery({
query: updateQuery,
data: { [updateTarget]: source },
})
},
})

// to
mutate({ variables: getVariablesFromArray([...variables, 'text', text]) })
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// front/components/MsgItem.js

// from
const onDelete = e => {
e.stopPropagation()
deleteMessage({
variables: { id },
update: (cache, { data }) => {
const res = cache.readQuery({ query: updateQuery })
cache.writeQuery({
query: updateQuery,
data: { [updateTarget]: res[target].filter(m => m.id !== id) },
})
},
})
}

// to
const onDelete = e => {
e.stopPropagation()
deleteMessage({ variables: { id } })
}

useSWR

본격적으로 useSWR 문법을 살펴봅시다.

1
2
const fetcher = (...args) => fetch(...args)
const { data, error } = useSWR('/api/user', fetcher, options)

이게 기본입니다. 만약 추가로 id를 넘겨줘야 하는 경우에는 다음과 같이 템플릿 리터럴을 이용하길 권장하고 있습니다.

1
const { data, error } = useSWR(`/api/user/${id}`, fetcher, options)

여러개의 params를 넘겨줘야 하는 경우 fetcher를 변형하여 첫번째 인자를 배열로 넘기는 방법도 제안하고 있습니다.

1
2
const fetchUser = (url, id, page) => fetch(url, { id, page })
const { data, error } = useSWR(['/api/user', id, page], fetchUser)

useSWR은 얕은비교만을 수행하기 때문에, 다음과 같이 배열 안에 객체를 전달하면 안된다고 합니다.

1
2
const fetchUser = (url, params) => fetch(url, params)
const { data, error } = useSWR(['/api/user', { id, page }], fetchUser) // DON"T DO THIS!

그렇다면 param 값들을 한 데 모아 객체로 전달하지 않으면서도 실제 fetch시에는 객체로 만들어줘야 한다는게 관건이겠네요. 공식문서는 이 문제를 최대한 단순하게 소개하기 위해 각 상황에 맞는 fetcher 함수를 만드는 방식을 취하고 있지만, 저는 이런걸 원하지 않습니다.

1
const fetcher = (url, ...variables) => fetch(url, variables)

대충 이런 형태로 동작할 수 있다면 가장 좋을 것 같은데, 그러자니 나머지 인자로 취합한 variables는 배열이고, 실제 api 호출에 필요한 variables는 객체입니다. 배열을 객체로 전환하려면 각각의 ‘key’값도 전달해야 하겠습니다. 그러기 위해 우선적으로 떠오르는건 key-value pair로 이루어진 배열 형태입니다.

1
const { data, error } = useSWR(['/api/user', ['id', id], ['page', page]], fetcher)

그런데 이 방식은 앞서 언급한 ‘shallow compare’의 문제를 그대로 안게 되므로 사용할 수 없습니다. 따라서 다음과 같이 할 수밖에 없겠습니다.

1
2
3
4
5
6
7
8
const fetcher = (url, ...variableArr) => {
const variables = {}
for (let i = 0; i < variableArr.length; i += 2) {
variables[variableArr[i]] = variableArr[i + 1]
}
return fetch(url, variables)
}
const { data, error } = useSWR(['/api/user', 'id', id, 'page', page], fetcher)

이 함수를 graphql에서 사용하려면 아주 살짝만 바꿔주면 됩니다.

1
2
3
4
5
6
7
const fetcher = (query, ...variableArr) => {
const variables = {}
for (let i = 0; i < variableArr.length; i += 2) {
variables[variableArr[i]] = variableArr[i + 1]
}
return request('/graphql', query, variables)
}

useSWRInfinite

infinite scroll을 구현하기 위해 가장 중요한 부분입니다. useSWR 대신 useSWRInfinite를 씁니다. useSWRInfinite의 문법은 기본적으로는 useSWR과 동일하고, infinite loading을 위한 페이징 처리 및 revalidate 관련한 옵션 몇개가 추가되어 있습니다. 그런데 이 ‘page’를 처리하기 위해서, 첫번쨰 인자로 url string이나 배열을 넘기는 대신 getKey라는 함수를 이용하도록 정의되어 있습니다.

getKey 함수에는 현재 페이지의 index값과 마지막으로 불러온 데이터 정보가 들어옵니다. 이 둘을 잘 이용해서 ‘다음 페이지’의 정보를 만들어 배열로 반환하도록 함수를 작성하면 됩니다. 즉 다음과 같은 결과를 얻을 수 있으면 됩니다. useSWRInfinite 함수는 자동으로 getKey함수를 호출하여 배열 또는 문자열을 받고, 이를 바탕으로 useSWR과 동일한 요청을 수행하도록 구현되어 있는 것 같습니다.

1
2
3
4
5
6
const getKey = (prevIndex, prevData) => {
...
console.log(prevIndex, prevData)
return [query, ...variables]
}
const { data, error } = useSWRInfinite(getKey, fetcher, options)

getKey가 어떤 방식으로 동작하는지 확인해보기 위해 콘솔로 출력을 해보았습니다. 그 결과는 다음과 같습니다.

1
0 null

이 상태에서는 최초의 fetch 이후로는 아무리 스크롤을 내려도 다음 데이터를 로드하지 않습니다. 0페이지만 불러오기 때문인 것 같습니다. 그래서 공식문서를 다시 살펴보니, size, setSize가 보입니다. 현재는 size가 1인 상태인데, intersecting에 이 값을 변경해줘야만 fetchMore가 수행되는 방식인 것 같습니다.

1
2
3
4
const { data, error, size, setSize } = useSWRInfinite(getKey, fetcher, options)
useEffect(() => {
if (intersecting) setSize(size + 1)
}, [intersecting])

이렇게 바꾸니 다음과 같은 데이터를 얻을 수 있었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
// 첫번쨰 intersecting
1 {messages: Array(15)}

// 두번쨰 intersecting
1 {messages: Array(15)}
2 {messages: Array(15)}

// 세번째 intersecting
1 {messages: Array(15)}
2 {messages: Array(15)}
3 {messages: Array(15)}
...

결과를 보니 getKey 함수는 이전 데이터의 페이지별로 호출되는 방식인 것 같습니다. 그렇다면 useSWRInfinite 역시 getKey의 개수만큼 query를 날리겠네요. 첫번째 intersecting시에는 2번을(page 0, 1), 3번쨰 intersecting 시에는 4번을 호출하는 식일 것입니다(page 0, 1, 2, 3). 확인해보니 실제로도 network상에 쿼리요청이 다만 swr의 컨셉 자체가 서버에의 요청을 받고 나서만 화면에 보여주는 것이 아닌 cache를 먼저 보낸 다음 나중에 revalidate하는 방식이므로, 이미 불러온 페이지들에 대해서는 캐시가 동작하여 성능상의 문제는 크지 않으리란 추측이 가능합니다.

[ 최초 요청시 vs. 첫 intersecting시 ]

추가로 모든 데이터가 로드된 시점에는 size가 더이상 늘어나지 않도록 처리해야 하겠습니다.

1
2
3
4
5
6
7
8
9
10
11
const getKey = (prevIndex, prevData) => {
if (prevData && prevData.messages.length < 15) {
setLoadFinished()
return null
}
return [query, ...variables]
}

useEffect(() => {
if (!loadFinished && intersecting) setSize(size + 1)
}, [intersecting, loadFinished])

나아가 서버로부터 전달받은 data 객체의 구조도 조금 살펴볼 필요가 있겠습니다. 일반적인 useQuery 또는 useSWR에 의한 결과는 data 내부에 바로 messages 객체가 들어있습니다. 그런데 useSWRInfinite는 페이지 단위로 나뉜 배열 형태를 띕니다.

1
{ data: [ {messages: Array(15)}, {messages: Array(15)}, {messages: Array(15)}, ...] }

이들 각 데이터를 하나의 배열로 취합하여 처리하기로 합니다.

1
const mergeMsgs = data => data.flatMap(d => d.messages)

이상의 내용을 반영하여 Migration한 MsgList 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// front/components/MsgList.js

// from
const MsgList = ({ updateQuery, updateTarget, variables = {}, smsgs }) => {
const [lastMsgId, setLastMsgId] = useState('')
const [editingMsgId, setEditingMsgId] = useState(null)
const [msgs, setMsgs] = useState(smsgs || [])
const { data, error, fetchMore } = useQuery(updateQuery, { variables })
const fetchMoreEl = useRef(null)
const [intersecting, loadFinished, setLoadFinished] = useInfiniteScroll(fetchMoreEl, !!smsgs)
const { id: incomingId } = msgs[msgs.length - 1] || {}

useEffect(() => {
const messages = data?.[updateTarget]
if (messages) setMsgs(messages)
}, [data?.[updateTarget]])

useEffect(async () => {
if (!intersecting || loadFinished || !incomingId) return
const { data: fetchMoreData } = await fetchMore({
variables: { ...variables, lastMsgId: incomingId },
})
setLastMsgId(incomingId)
if (fetchMoreData[updateTarget].length < 15) setLoadFinished(true)
}, [intersecting])
...

// to
const MsgList = ({ updateQuery, updateTarget, variables = [], smsgs }) => {
const [editingMsgId, setEditingMsgId] = useState(null)
const [msgs, setMsgs] = useState(smsgs || [])
const fetchMoreEl = useRef(null)
const [intersecting, loadFinished, setLoadFinished] = useInfiniteScroll(fetchMoreEl, !!smsgs)
const getKey = (pageIndex, prevData) => {
if (prevData && prevData[updateTarget].length < 15) {
setLoadFinished()
return null
}
return [updateQuery, ...variables, 'page', pageIndex]
}
const { data, error, size, setSize } = useSWRInfinite(getKey, fetcher)

useEffect(() => {
if (data?.length) setMsgs(mergeMsgs(data, updateTarget))
}, [data])

useEffect(() => {
if (!loadFinished && intersecting) setSize(size + 1)
}, [intersecting, loadFinished])
...

여기까지만 하고 구동시켜보면 동작은 잘 되지만, 아직 한가지 문제가 남아 있습니다. useSWRInfinite 역시 useSWR을 그대로 사용하기 때문인지, 기본적으로는 revalidate이 오직 0페이지에 대해서만 이뤄집니다. 이 상태로는 오래전 글이 수정/삭제되어도 화면에는 반영되지 않는 결과가 초래될 수 있습니다. 옵션 하나만 추가해주면 간단하게 해결됩니다.

1
2
3
const { data, error, size, setSize } = useSWRInfinite(getKey, fetcher, {
revalidateAll: true,
})

mutation to server

fetcher 함수가 graphql-request 라이브러리를 이용하고 있으니, 꼭 ‘query’에만 국한지어 사용할 이유가 없을 것 같습니다. mutation을 모두 fetcher로 대체하면 apollo-client를 아예 걷어낼 수 있겠네요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// front/components/MsgInput.js

// from
const [mutate, { data }] = useMutation(mutationQuery)
const onSubmit = async e => {
e.preventDefault()
const text = textRef.current.value
mutate({ variables: { ...variables, text } })
...

// to
const onSubmit = async e => {
e.preventDefault()
const text = textRef.current.value
fetcher(mutationQuery, ...variables, 'text', text)
...
1
2
3
4
5
6
7
8
9
10
11
12
// front/components/MsgItem.js

// from
const [deleteMessage] = useMutation(DELETE_MESSAGE)
const onDelete = async e => {
deleteMessage({ variables: { id } })
...

// to
const onDelete = async e => {
await fetcher(DELETE_MESSAGE, 'id', id)
...

apollo setting

이제 모든 graphql request를 swr 및 fetcher가 담당하게 되었으니, apollo는 필요가 없습니다.

SSR

getServerSideProps 내부도 상당히 단순해집니다. 앞서 만들어둔 fetcher 함수를 그대로 사용하면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// front/pages/index.js

// from
export const getServerSideProps = async () => {
const apolloClient = await getStandaloneApolloClient()
const res = await apolloClient.query({ query: GET_MESSAGES })
const initialState = apolloClient.cache.extract()
return {
props: {
initialState,
smsgs: res.data?.[msgTarget],
},
}
}

// to
export const getServerSideProps = async () => {
const msgData = await fetcher(GET_MESSAGES)
return { props: { smsgs: msgData?.[msgTarget] } }
}

mutation -> revalidate

타인의 수정/삭제/추가에 대한 반영은 refreshInterval, revalidateOnFocus 등의 옵션만 적절히 지정해주면 충분하겠지만, 현재 화면에서 이뤄진 동작은 즉시 반영하지 않으면 난감할 수 있습니다. 삭제하라고 명령했는데 화면상에선 그대로라면 난감하겠죠. apollo에서는 이런 상황을 처리하기 위해서 useQuery 내부에 update 메서드를 두었던 것인데, 지금은 이걸 제거하였으니 대신하여 처리할 무언가가 필요합니다. useSWR, useSWRInfinite에는 이 역할을 수행해줄 ‘mutate’라는 함수가 마련되어 있습니다. useSWR은 하나의 데이터를 처리하고, useSWRInfinite는 배열의 각 요소를 처리합니다.

mutate 함수는 두 군데에서 존재하는데, 하나는 swr에서 직접 import할 수 있는 함수이고, 다른 하나는 useSWR의 실행 결과로 얻을 수 있는 것입니다. 전자는 mutation이 발생한 key와 변경된 value를 넘겨주면 해당 key로 등록된 모든 useSWR 함수들에 broadcast 되는 함수이고, 후자는 호출한 useSWR 하나의 변경에만 국한된(bounded) 함수입니다. 두 함수 모두 그 자체로 서버에의 put / post / patch / delete 등의 요청을 수행하는 함수가 아닌, 어디까지나 화면상의 ‘데이터 갱신’에 관련한 함수입니다(필자가 공식문서를 잘못 이해한 것일지도 모르겠습니다. 만약 그렇다면 알려주시면 감사하겠습니다).

그런데 rest api의 경우 key는 곧 문자열이기 때문에 broadcast가 의미가 있겠으나, 아쉽게도 graphql의 경우에는 그렇지가 못합니다. query문 자체가 참조형 데이터이다 보니 각 useSWR에서 생성한 query는 모두 다르기 때문입니다. (이 역시 제가 이해하는 한에서는 그렇다는 것입니다. 틀렸기를 바랍니다 ㅠ)

하여 현재로서는 몹시 아쉽지만 mutation을 catch하여 변경사항을 반영하거나 revalidate하는 로직은 별도의 처리가 필요합니다. 전역에서 쓰일 context를 생성하여 mutate시에 updateTarget을 지정해주고, 각 컴포넌트에서 지정된 target과 일치할 때에 useSWR에 있는 mutate를 호출해주는 방식을 떠올렸습니다. mutate를 호출하자마자 context에 지정된 target을 지워주면 될 것 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// front/contexts/mutationObserver.js
import { createContext, useContext, useState } from 'react'

const MutationObserverContext = createContext({ target: '' })
const MutationObserverProvider = ({ children }) => {
const [mutated, setMutated] = useState('')
return (
<MutationObserverContext.Provider
value={{
mutated,
setMutated,
}}
>
{children}
</MutationObserverContext.Provider>
)
}
const useMutationObserver = () => useContext(MutationObserverContext)
1
2
3
4
5
6
7
8
9
10
11
12
// front/component/MsgInput.js
const { setMutated } = useMutationObserver()
const textRef = useRef(null)
const onSubmit = e => {
e.preventDefault()
const text = textRef.current.value
fetcher(mutationQuery, ...variables, 'text', text)
setMutated(updateTarget)
textRef.current.value = ''
doneEdit && doneEdit()
}
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// front/component/MsgList.js
const [intersecting, loadFinished, setLoadFinished] = useInfiniteScroll(fetchMoreEl, !!smsgs)
const { mutated, setMutated } = useMutationObserver()

const getKey = (pageIndex, prevData) => { ... }
const { data, error, mutate, size, setSize } = useSWRInfinite(getKey, fetcher, {
revalidateAll: true,
})

useEffect(() => {
if (mutated === updateTarget) {
mutate()
setMutated('')
}
}, [mutated])
...

마치며

이상으로 마이그레이션을 모두 마쳤습니다. 전체 변경사항은 PR을 보시면 더 용이하겠네요.

‘swr이라는 라이브러리를 한 번 써보기나 하자’ 라는 취지로 시작했던 것이 어쩌다보니 일이 커져버렸는데, 그래도 어떻게든 끝마쳐서 다행이네요. 실제로 사용해보니 swr은 생각보다 강력한 녀석 같습니다. 당장 실무에 적용해도 아무런 문제가 없을 것 같고, 기존 대비 상당히 적은 노력으로 더욱 훌륭한 퍼포먼스를 기대할 수 있을 것 같네요.