infinite scroll 구현하기 (1) apollo-graphql

최근 swr이라는 fetch 전용 라이브러리가 핫합니다. 내용을 살펴보았는데, apollo-graphql을 이용할 때와 뭐가 얼마나 다를지가 잘 그려지지 않아서 이참에 연습을 좀 해보았습니다. 원래는 swr을 연습하기 위한 것이었는데 막상 작업을 착수하고 보니 2020년 7월에 Apollo Client v3.이 릴리즈되었더군요. 기존 2.x대와 달라진 내용이 많아 이 부분에서 더 오랜 시간을 할애했습니다. 아직 Apollo Client v3. 환경에서 GraphQL로 무한스크롤을 구현한 예제가 거의 없는 것 같아, 겸사겸사 공유하고자 블로깅 합니다.

전체 코드는 제 깃헙에 올려 놓았습니다.

우선 apollo-graphql로 간단한 앱을 하나 만들고(1부), 이를 토대로 swr로 migration 해보는 것(2부)이 목표입니다.

back-end

back-end 파트는 graphql의 동작을 확인할 수만 있으면 되기에, 최대한 간단한 방법을 이용했습니다. query는 단순히 json 파일을 불러오고, mutation은 node.js의 fs.writeFile을 이용하여 로컬 json을 계속 덮어씌우는 방식으로 구현했습니다.

1
2
3
4
5
6
7
8
9
10
// ./writeModel.js
const filenames = {
user: resolve(__dirname, '../models/user.js'),
message: resolve(__dirname, '../models/message.js'),
}
module.exports = (target, data) => {
fs.writeFile(filenames[target], `module.exports = ${JSON.stringify(data)}`, (...err) => {
console.log(err)
})
}
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
// ./resolver/message.js
const messageResolvers = {
Query: {
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],
)
},
message: (parent, { id }, { models }) => {
return models.messages[id]
},
},
Mutation: {
createMessage: (parent, { text }, { me, models }) => {
const id = uuidv4()
const message = {
id,
text,
userId: me.id,
timestamp: String(Date.now()),
}
models.messages[id] = message
writeModel('message', models.messages)
return message
},
// ...생략
},
Message: {
user: (message, args, { models }) => models.users[message.userId],
},
}

message list를 무한스크롤 방식으로 fetch하기 위해 가장 중요한 부분이 바로 pagination 처리일텐데, 최소한의 실시간성이 보장되어야 하는 환경, 즉 트위터나 페이스북 같은 경우를 상정했을 때엔 단순히 ‘페이지’ 단위로 리스트를 불러오는 것은 리스크가 있을 것입니다. 예를 들어 DB의 변화가 없는 경우라면 최초 1페이지의 메시지ID 목록이 최신순으로 [40, 39, 38, 37, 36]라고 했을 때 2페이지는 [35, 34, 33, 32, 31]이 되어야 맞겠지만, 그 사이 누군가 ID가 37인 글을 삭제하여 DB상에는 ID 37에 해당하는 글이 사라진 상태인 경우 2페이지는 [34, 33, 32, 31, 30]가 되어버립니다. 새 글이 추가된 경우에도 역시 페이지네이션은 꼬여버릴 수밖에 없습니다. 따라서 저는 화면상의 마지막 ID(lastMsgId)를 기준으로 다음 리스트를 불러오는 방식을 취했습니다. 그밖엔 백엔드 파트에선 특별히 언급할 내용이 없네요. 빠르게 프론트엔드 파트로 넘어가겠습니다.

front-end

front-end는 react.js, next.js, apollo-client를 기반으로 작업하였습니다.

apollo setting

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
// ./apollo.js
import { ApolloClient, InMemoryCache } from '@apollo/client'
import { withApollo } from 'next-with-apollo'

const mergeItems = (a = [], b = []) => {
return Array.from(new Set([...a, ...b].map(m => m.__ref))).map(r => ({ __ref: r }))
}
const messagePolicies = {
read(existing) {
return existing
},
merge(existing = [], incoming = [], { args: { lastMsgId }, readField }) {
const extIndex = existing.findIndex(e => readField('id', e) === lastMsgId)
if (extIndex > -1) return mergeItems(existing, incoming)
return mergeItems(incoming, existing)
},
}
export const initializeApollo = props =>
new ApolloClient({
uri: 'http://localhost:8000/graphql',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
messages: messagePolicies,
userMessages: messagePolicies,
},
},
},
}).restore(props?.initialState || {}),
})
export const getStandaloneApolloClient = async () => {
const { ApolloClient, InMemoryCache, HttpLink } = await import('@apollo/client')
return initializeApollo()
}
const withApolloClient = withApollo(initializeApollo)
export default withApolloClient

ApolloClient v3.에서 가장 달라진 점은 뭐니뭐니해도 typePolicies 부분일 것입니다. 이 부분만 요구사항에 맞게 잘 구현해 놓으면 컴포넌트에서 제어해야 하는 부분이 상당히 줄어드는 것 같습니다. 그런데 그런것치고는 제가 느끼기에는 공식 문서가 좀 빈약하여 애를 많이 먹었습니다.
각 필드별로 정책을 달리 할 수 있는데, query가 호출될 때마다 해당 정책 내의 모든 메서드가 실행됩니다. 제 코드상에서는 read와 merge가 실행됩니다. 공식문서에는 merge 부분에서 단순히 return [...existing, ...incoming] 식으로 처리하라고 되어있는데, 이러면 상황에 따라 데이터가 중복되어 버립니다. 중복을 제거하기 위해 mergeItems라는 메서드를 따로 만들어야 했습니다. read 없이 merge만 있는 경우, fetchMore시 새로 불러온 데이터만 화면에 노출되게 됩니다.

graphql

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ./graphql/message.gql
query GET_MESSAGES($lastMsgId: ID = "", $limit: Int) {
messages(lastMsgId: $lastMsgId, limit: $limit) {
id
text
user {
id
nickname
fullname
}
timestamp
}
}
# 이하 생략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# ./graphql/user.gql
query GET_USER($id: ID!) {
user(id: $id) {
id
fullname
nickname
}
}
query GET_USER_MESSAGES($id: ID!, $lastMsgId: ID, $limit: Int) {
userMessages(id: $id, lastMsgId: $lastMsgId, limit: $limit) {
id
text
timestamp
user {
nickname
}
}
}

user/[id] 페이지에서는 해당 유저의 글목록을 노출하고자 했습니다. GET_USER_MESSAGES는 userId가 필요하다는 점을 제외하곤 모든 면에서 GET_MESSAGES와 동일합니다.

pages

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
// ./pages/index.js
import { CREATE_MESSAGE, GET_MESSAGES } from '../graphql/message.gql'

const msgTarget = 'messages'
const Home = ({ smsgs }) => (
<>
<MsgInput
updateQuery={GET_MESSAGES}
updateTarget={msgTarget}
mutationQuery={CREATE_MESSAGE}
mutationTarget="createMessage"
/>
<MsgList updateTarget={msgTarget} updateQuery={GET_MESSAGES} smsgs={smsgs} />
</>
)

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],
},
}
}

MsgList 컴포넌트를 뒤이어 나올 user/[id] 에서도 활용하기 위해, ‘updateQuery, updateTarget’의 두 개의 프로퍼티를 작성했습니다. /(root)에서는 GET_MESSAGE(updateQuery)로 쿼리를 보내고, 그 결과는 {data: messages: [MSG] }가 되는 반면(updateTarget), /user/[id]에서는 GET_USER_MESSAGES(updateQuery)로 쿼리를 보내고, 그 결과는 {data: userMessages: [MSG] }가 됩니다(updateTarget).

한편 MsgInput은 새 글을 작성할 때도 사용하고, 이미 작성한 글을 수정할 때도 사용합니다. 새 글 작성시에는 mutation으로 CREATE_MESSAGE(mutationQuery)를 보내고, 그 결과는 {data: createMessage: MSG }가 되며(mutationTarget), 이를 messages에 반영(udpateTarget)하기 위해 기존 메시지 리스트를 불러와야(updateQuery) 합니다. 글 수정은 /(root)에서도 할 수 있고, /user/[id]에서도 할 수 있어야 합니다.

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
// pages/user/[id].js
import { GET_USER, GET_USER_MESSAGES } from '../../graphql/user.gql'

const msgsTarget = 'userMessages'
const User = ({ suser, smsgs }) => {
const router = useRouter()
const id = router.query.id
const [getUser, { data: userData }] = useLazyQuery(GET_USER)
const [user, setUser] = useState(suser || {})

useEffect(() => {
if (!suser) getUser({ variables: { id } })
}, [])

useEffect(() => {
if (userData?.user) setUser(userData.user)
}, [userData])

const { nickname, fullname } = user
return (
<>
<Header />
{nickname} {fullname}
<MsgList updateTarget={msgsTarget} updateQuery={GET_USER_MESSAGES} variables={{ id }} smsgs={smsgs} />
</>
)
}

export const getServerSideProps = async ({ query: { id } }) => {
const apolloClient = await getStandaloneApolloClient()
const res = await Promise.all([
apolloClient.query({ query: GET_USER, variables: { id } }),
apolloClient.query({ query: GET_USER_MESSAGES, variables: { id } }),
])
const initialState = apolloClient.cache.extract()
return {
props: {
initialState,
suser: res[0].data?.user,
smsgs: res[1].data?.[msgsTarget],
},
}
}

/user/[id] 에서는 SSR로 user정보와 userMessages를 모두 호출했습니다. SSR이 호출되는 경우도 있고 그렇지 않은 경우도 있는데, 그렇지 않은 경우에는 LazyQuery로 최초 render시 한 번만 호출하게끔 했습니다.

components

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
// ./components/MsgInput.js
const MsgInput = ({
mutationQuery,
mutationTarget,
updateQuery,
updateTarget,
text = '',
doneEdit,
variables = {},
}) => {
const [mutate, { data }] = useMutation(mutationQuery)
const textRef = useRef(null)
const onSubmit = e => {
e.preventDefault()
const text = textRef.current.value
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 },
})
},
})
textRef.current.value = ''
doneEdit && doneEdit()
}
return (
<form className="messages_input" onSubmit={onSubmit}>
<textarea maxLength="140" ref={textRef} defaultValue={text} />
<button type="submit">전송</button>
</form>
)
}

MsgInput 컴포넌트는 인덱스 최상단의 ‘새글작성’ 및 각 메시지의 ‘수정’ 모두에서 활용합니다. 때문에 onSubmit 함수의 mutate 부분이 조금 길어졌습니다. 새 글은 writeQuery만으로 충분하지만, 수정의 경우 로딩된 글목록에 대상 메시지가 존재할 경우에만 cache를 업데이트해주어야 합니다.

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
const useInfiniteScroll = (targetEl, ssr = false) => {
const observerRef = useRef(null)
const isFirstRender = useRef(!ssr)
const [isIntersecting, setIntersecting] = useState(null)
const [loadFinished, setLoadFinished] = useState(false)

const getObserver = useCallback(() => {
if (!observerRef.current) {
observerRef.current = new IntersectionObserver(entries => {
const intersecting = entries.some(entry => entry.isIntersecting)
if (isFirstRender.current && intersecting) {
isFirstRender.current = false
return
}
setIntersecting(intersecting)
})
}
return observerRef.current
}, [observerRef.current])

const stopObserving = useCallback(() => {
getObserver().disconnect()
}, [])

useEffect(() => {
if (targetEl.current) getObserver().observe(targetEl.current)
return stopObserving
}, [targetEl.current])

useEffect(() => {
if (loadFinished) stopObserving()
}, [loadFinished])

return [isIntersecting, loadFinished, setLoadFinished]
}

intersectionObserver를 이용한 hook입니다. targetEl이 intersecting된 경우에 isIntersecting이 true가 됩니다. 더이상 불러올 데이터가 없을 경우 setLoadFinished를 호출하여 loadFinished 값이 true가 되도록 했습니다. SSR 값이 있는 경우에는 isFirstRender가 처음부터 false가 되도록 했습니다. SSR 값이 없을 경우에는 useInfiniteScroll이 처음 렌더되는 시점이 query data가 로딩되기 전이기 때문에, isFirstRender를 true로 하여 isIntersecting에 의한 fetchmore 트리거가 동작하지 않게끔 처리했습니다.

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
// ./src/components/MsgList
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] || {}

const doneEdit = () => setEditingMsgId(null)

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])

if (error) console.error(error)

return (
<>
<ul className="messages">
{msgs.map(msg => (
<MsgItem
{...msg}
key={msg.id}
updateQuery={updateQuery}
updateTarget={updateTarget}
editing={editingMsgId === msg.id}
startEdit={() => setEditingMsgId(msg.id)}
doneEdit={doneEdit}
/>
))}
</ul>
<div ref={fetchMoreEl} />
</>
)
}

앞서 apollo.js에서 typePolicies를 정의해두었기 때문에 fethMore에서 cache에 관여할 필요가 없습니다.

마치며

다음 파트에서 본격적으로 swr로 바꿔보겠습니다. (깃헙에는 이미 코드를 올려놓긴 헀습니다..)

뭘 더 적어야 좋을지 모르겠네요…하하 (도망)