6 Client - GraphQL 무한스크롤

source: categories/study/react_restapi_graphql/react_restapi_graphql7.md

6.1 무한스크롤 및 useInfiniteQuery

이번시간엔 GraphQL에서 리스트를 불러올 때 전체 목록을 불러오게했던 거에서 무한 스크롤링으로 작동하도록 구현을 해보겠습니다.
server에서는 무한스크롤 관련 기능에 대해 구현할 때 어려운게 없습니다.
client에서 해야될 내용들이 많기 때문에 이번 시간을 별도의 챕터로 뺐습니다.

우선 server/src/resolvers/messages.js 파일로 가보겠습니다.


// server/src/resolvers/messages.js
const messageResolver = {
    Query: {
        // 일단은 아래 messages의 인자들은 messages를 가져오는 '정보'니깐, 필요한 부분은 3번째 인자인 context입니다.
        // context 안에 models라는 것이 들어있는데, 이 models는 아까 index.js에서 정의한.. 저희가 직접 정의한 것입니다.
        // 기존에 args에서 받던게 아무것도 없었는데, REST API와 마찬가지로 cursor를 받게하면 됩니다.
-        messages: (parent, args, {db}) => {
+        messages: (parent, {cursor = ''}, {db}) => {
            // 그리고 cursor를 받았을 때 REST API에서 했던 내용과 크게 다른게 없어서 routes/messages.js에 있는 내용을 그대로 복사해오겠습니다.
+            const fromIndex = db.messages.findIndex(msg => msg.id === cursor) + 1;
            // console.log({obj, args, context}) // 각 인자에 어떤 내용이 들어가있는지는 나중에 따로 살펴보도록 하겠습니다.
            // 아래와 같이 db.messages라고 정의하고 일단은 넘어가도록 하겠습니다.
            // slice 메소드를 활용해 fromIndex로부터 15개씩 보이도록 하면됩니다.
            // 그런데 만약 db.messages가 없을 경우에는 빈배열을 반환하도록하면 되겠습니다.
-            return db.messages;
+            return db.messages?.slice(fromIndex, fromIndex + 15) || [];
        },
        // message를 하나 불러오는 Query입니다.
        // 2번째 인자에서 Query에 필요한 parameter 값이 온다고 했죠? 여기에 id 값이 들어오게 될겁니다.
        // id가 없을 경우를 대비해 default 값을 넣어줬습니다.
        // 그리고 마지막 3번째 인자로 마찬가지로 db가 들어올겁니다.
        message: (parent, {id = ''}, {db}) => {
            // 그럼 db.messages에서 id가 2번째 인자로 넘어온 id와 일치하는 것을 찾아주면 될겁니다.
            return db.messages.find(msg => msg.id === id)
        },
    },
    // ...
}

위 상태에서 바로 client를 확인했을 때, server에서 가지고오는 데이터가 15개로 줄어있으면됩니다.

Note

코드보면 현재 로그인한 사람의 메시지만 불러오도록 작성했던데..
왜 이렇게 작성했었지..?

Note

위에 보면 15개만 들어와있죠?
바로 성공을 했습니다.
이번엔 client쪽으로 가보도록 하겠습니다.


// client/pages/index.js
import MsgList from "../components/MsgList";
// 이 index 파일에서 데이터를 불러오기 위해 fetcher를 불러옵니다.
// import fetcher from "../fetcher";
import {fetcher} from "../queryClient";
import {GET_MESSAGES} from "../graphql/messages";
import {GET_USERS} from "../graphql/users";

// 아래와 같이 return하면 Home에서 smsgs 프롭을 받아올 수 있습니다.
const Home = ({smsgs, users}) => (
    <>
        <h1>SIMPLE SNS</h1>
        <MsgList smsgs={smsgs} users={users} />
    </>
)

// 이거하나만 짚고 넘어가겠습니다.
// 아래 getServerSideProps async 함수 안에서 await를 두개 사용하고 있습니다.
// 아래와 같은 경우는 문제가 좀 있습니다.
// fetcher로 서버에 요청을 보내고 응답이 올때까지 다음 fetcher 요청을 보내지 않고있습니다.
// 이 부분은 성능상에서 엄청 느려지진 않더라도, 하나보내고 기다렸다가 받고, 다시 하나 보내고 다시 받고, 이런 직렬 구조인데, 이를 병렬구조로 바꾸는 것이 좋을 거 같습니다.
// 아래 요청을 한번에 해서 데이터도 한번에 받을 수가 있습니다. Promise.all()을 이용합니다.
export const getServerSideProps = async () => {
    // 여기서는 cursor 안 보내도 된다.
    // const smsgs = await fetcher('get', '/messages')
-    const {messages: smsgs} = await fetcher(GET_MESSAGES)
    // const users = await fetcher('get', '/users')
-    const {users} = await fetcher(GET_USERS)
+    const [{messages: smsgs}, {users}] = await Promise.all([
+        fetcher(GET_MESSAGES),
+        fetcher(GET_USERS)
+    ])
    
    // 위와 같이 코드를 수정하면, fetcher 요청은 2개 동시에보내고 응답이 둘 다 오기까지 기다렸다가 return을 하게됩니다.
    return {
        // 이렇게 return하면
        props: {smsgs, users}
    }
}

export default Home;

이제 MsgList로 가서 본격적으로 내용을 살펴보겠습니다.


// client/components/MsgList.js
import MsgItem from "./MsgItem";
import MsgInput from "./MsgInput";
import {useEffect, useRef, useState} from "react";
import {useRouter} from "next/router"; // localhost:3000/?userId=jay 에서 jay를 req.body로 넘겨주기 위해 불러온 라이브러리입니다.
import {useMutation, useQuery, useQueryClient} from "react-query";
import {fetcher, QueryKeys} from "../queryClient.js";
import {CREATE_MESSAGE, DELETE_MESSAGE, GET_MESSAGES, UPDATE_MESSAGE} from "../graphql/messages";
// 무한스크롤을 구현하기위해서 해야되는 작업들이 꽤 많이있는데, 일단 useInfiniteScroll는 둘째치고, cursor부터 살펴보겠습니다.
// import useInfiniteScroll from "../hooks/useInfiniteScroll";

// ...


// client/components/MsgList.js
- import {useMutation, useQuery, useQueryClient} from "react-query";
+ import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from "react-query";
// ...

const MsgList = ({smsgs, users}) => {
    // ...
    // GET_MESSAGES를 요청하는 이 코드에서 useQuery를 쓸게 아니고 useInfiniteQuery라는 것을 쓰겠습니다.
    // 이렇게 고치면 2번째 인자로 넘긴 queryFn에 어떤 인자가 들어가게됩니다.
    // 이 인자가 어떤 형태인지 좀 살펴보고 가겠습니다.
    // 아래와 같이 console로 res를 출력해보겠습니다.
-    const {data, error, isError} = useQuery(QueryKeys.MESSAGES, () => fetcher(GET_MESSAGES));
+    const {data, error, isError} = useInfiniteQuery(
+        QueryKeys.MESSAGES,
+        (res) => {
+            console.log(res);
+            return fetcher(GET_MESSAGES)
+        }
+    );
    // ...
}

Note

콘솔창을 보면 위와 같이 queryKey가 들어와있고 pageParam이란 것이 들어와있습니다.
이거를 이용해서 무언가를 해야됩니다.
pageParam이 무엇인지에 대해서 더 살펴봐야될 거 같습니다.


// client/components/MsgList.js
import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from "react-query";
// ...

const MsgList = ({smsgs, users}) => {
    // ...
    const {data, error, isError} = useInfiniteQuery(
        QueryKeys.MESSAGES,
        // 아래와 같이 수정해줍니다.
        // pageParam이 무엇인지에 대해 더 살펴봐야될 거 같습니다.
-        (res) => {
+        ({queryKey, pageParam}) => {
-            console.log(res);
            return fetcher(GET_MESSAGES)
        }
    );
    // ...
}

Note

useInfiniteQuery의 내용에 대해 보겠습니다.

Note

useInfiniteQuery도 역시 3종류가 있는데, 그 중에서 저희는 queryKey, queryFn을 보내는 3번째껄 쓰겠습니다.
3번째꺼엔 3번째 인자로 options도 보낼 수 있죠?


// client/components/MsgList.js
import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from "react-query";
// ...

const MsgList = ({smsgs, users}) => {
    // ...
    const {data, error, isError} = useInfiniteQuery(
        QueryKeys.MESSAGES,
        ({queryKey, pageParam}) => {
            return fetcher(GET_MESSAGES)
        },
        // 즉, 이렇게 3번째 인자로 options가 올 수 있는데, 이 options에 들어갈 내용들에 대해서.. 이는 react-query 사이트에서 좀 더 봐야될 거 같습니다.
        options
    );
    // ...
}

위 사이트에서보면 useInfiniteQuery를 쓰면 data.pages라는 것이 들어오고 data.pageParams라는게 들어온다고 되어있습니다.
방금 전에 pageParam이란게 있었죠?
그리고 getNextPageParam이란 옵션을 통해서 무엇을 할 수 있는지, fetchNextPage, fetchPreviousPage를 통해 무엇을 할 수 있는지 등 설명이 되어있습니다.
hasNextPage를 통해 다음 페이지가 존재하는지 여부를 react-query가 알려준다고 합니다.

infinite query 사용법을 보니까

Note

위의 'projects' 부분이 queryKey일거고,
두번째 인자 fetchProjects, 저희가 작성한 fetcher 함수와 같죠?
이 함수에 {queryKey, pageParam} 인자가 들어왔는데,
pageParam을 이용해서 cursor 값을 지정을 해주고 있습니다.
이런식으로 한다라는 것을 예제를 통해 알 수 있습니다.

Note

pageParam 초기값은 undefined 였습니다.


// client/components/MsgList.js
import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from "react-query";
// ...

const MsgList = ({smsgs, users}) => {
    // ...
    const {data, error, isError} = useInfiniteQuery(
        QueryKeys.MESSAGES,
        // queryKey는 현재 필요없으니까 삭제해주고, pageParam 초기값이 아무것도 설정안하면 undefined였잖아요?
        // 그래서 기본값으로 '' 빈문자열을 설정합니다.
        // 그리고 fetcher를 호출할 때, 두번째 인자(variables) 값으로 cursor로 pageParam을 넘기도록 하겠습니다.
-        ({queryKey, pageParam}) => fetcher(GET_MESSAGES)
+        ({pageParam = ''}) => fetcher(GET_MESSAGES, {cursor: pageParam}),
        // 그리고 이 3번째 인자인 options에 어떤 내용이 들어가는지도 봅시다.
        options
    ); // useQuery를 호출하면 data, error, isError 값들이 오게됩니다.
    // ...
}

Note

예제에서는 options 부분에 getNextPageParam 함수가 들어가있는걸 볼 수 있습니다.

여기서 options를 보면 queryFn, getNextPageParam, getPreviousPageParam 이런 것들이 온다고합니다.
이 부분은 useInfiniteQuery 정의에서도 볼 수 있었습니다.

Note

위와 같이 options에 들어올 수 있는 것을 확인할 수 있는데,
여러가지가 있지만 현재는 getPreviousPageParam, getNextPageParam 이 2가지가 중점인거 같습니다.


// client/components/MsgList.js
import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from "react-query";
// ...

const MsgList = ({smsgs, users}) => {
    // ...
    const {data, error, isError} = useInfiniteQuery(
        QueryKeys.MESSAGES,
        ({pageParam = ''}) => fetcher(GET_MESSAGES, {cursor: pageParam}),
        // 여튼 이 함수들이 options 안 쪽에 구현되어있으면 되는겁니다. 사용자가 콜백함수로 구현을 하면 되는거죠.
        {
            getNextPageParam: (res) => {
                // 인자로 어떤값이 들어오는지 확인합시다.
                console.log(res);
                // next page에 대한 param값을 return해주기만하면 될거 같죠?
                // 그걸 통해서 다음 페이지.. 무한스크롤 요청이 있을 때, 다음 페이지에 해당하는 cursor 값을 여기서 return해주게끔 하면 될거같습니다.
                // 2번째 인자로 전달된 queryFn의 fetcher 함수에 전달된 2번재 인자를 보시면 {cursor: pageParam}이란게 있고,
                // 다음번 요청이 있을 때의 여기서 return 한 값이 queryFn의 fetcher 함수의 cursor 값으로 전달돼서 cursor로 지정이 될겁니다.
                // 이런 감을 잡으신 상태에서 res에는 어떤 값이 오는지 살펴보도록 하겠습니다.
                return ''
            }
        }
    );
    // ...
}


// client/components/MsgList.js
// 그러기위해서 주석처리했었던 useInfiniteScroll를 다시 활성화시키고
import useInfiniteScroll from "../hooks/useInfiniteScroll";

const MsgList = ({smsgs, users}) => {
    // ...
    // fetchMoreEl과 intersecting도 다시 활성화시키겠습니다.
    const fetchMoreEl = useRef(null);
    const intersecting = useInfiniteScroll(fetchMoreEl);
    // ...

    return (
        <>
            {userId && <MsgInput mutate={onCreate}/>}
            <ul className='messages'>
                {
                    msgs.map(x => <MsgItem key={x.id}
                                           {...x}
                                           onUpdate={onUpdate}
                                           onDelete={() => onDelete(x.id)} // onDelete가 실행될 때 id가 넘어와야하므로 왼쪽과 같이 작성해준다.
                                           startEdit={() => setEditingId(x.id)} // setEditingId가 실행될 때 id가 넘어와야하므로 왼쪽과 같이 작성해준다.
                                           isEditing={editingId === x.id}
                                           myId={userId}
                                           user={users.find(x => userId === x.id)}
                    />)
                }
            </ul>
            {/*그리고 아래 요소도 다시 활성화시키겠습니다.*/}
            <div ref={fetchMoreEl} />
        </>
    )
}

그리고 intersectingtrue가 되면 다음 페이지를 불러오게끔하는 명령도 필요하겠죠?
이 부분을 원래는 getMessages()라는 함수로 했었는데,


// client/components/MsgList.js

const MsgList = ({smsgs, users}) => {
    // ...
    // 아래 useEffect도 다시 활성화를 시킵니다.
    useEffect(() => {
        // hasNext로 아직 남아있는 메시지가 있는지 없는지 판단하는 여부와 getMessages() 다음 메시지를 불러오는 요청 자체를 
        // useInfiniteQuery에 있는 명령어들 가지고 할 수 있을 거 같습니다.
        if (intersecting && hasNext) getMessages()
    }, [intersecting])
    // ...
}

Note

fetchNextPage, fetchPreviousPage, hasNextPage를 가지고 할 수 있을 거 같습니다.


// client/components/MsgList.js

const MsgList = ({smsgs, users}) => {
    // ...
    // fetchNextPage, hasNextPage만 이용을 하겠습니다.
    // 그럼 저희가 기존에 구현해놓은 hasNext는 필요가 없고 hasNextPage를 통해 구현을 할 수 있게되었습니다.
    const {data, error, isError, fetchNextPage, hasNextPage} = useInfiniteQuery(
        QueryKeys.MESSAGES,
        ({pageParam = ''}) => fetcher(GET_MESSAGES, {cursor: pageParam}),
        {
            getNextPageParam: (res) => {
                // 구분이가게끔 {} 객체형태로 찍어봅시다.
                console.log({res});
                return ''
            }
        }
    );
    // ...
    // 아래와 같이 hasNext를 hasNextPage로, getMessages를 fetchNextPage로 바꿔주면 될 거 같습니다.
    useEffect(() => {
        // if (intersecting && hasNext) getMessages();
        if (intersecting && hasNextPage) fetchNextPage();
    }, [intersecting, hasNextPage]) // 이 부분에 intersecting, hasNextPage를 감시합니다.

    // 렌더링될 때마다 data를 출력해 data가 어떤 형태인지를 보겠습니다.
    // 구분이가게끔 {data}로 찍어보겠습니다.
    console.log({data});
}

Note

스크롤이 끝에 도달하면 이렇게 여러번이 출력이 됩니다.
여기서 맨 마지막에 있는 것들만 보겠습니다.

Note

pageParamsundefined와 빈문자열이 와있습니다.

Note

그리고 pages에 0번째 페이지 15개,

Note

그리고 1번째 page에 똑같은 내용으로 15개가 들어와있습니다.

지금 이런식으로 data에 기존에는


// 기존에는 이렇게 data 안에 messages가 들어와있는 형태였다면,
data: { messages: [...] }

// 이제는 pageParams에 배열이 오고 pages 안에 배열이와서 각각의 messages들이 15개씩 배열로 들어오는 것을 확인할 수 있습니다.
data: { pageParams: [], pages: [ {messages: [...15개씩]} ] }

// 그런데 pageParams가 처음에는 undefined인채로 있다고 하더라도  
pageParams: [undefined, ""]
// 그래도 그 다음에 각 메시지의 마지막 id 값.. 위에선 마지막 14번째의 message의 id 값이 48이므로
// 아래와 같이 들어오게만 한다면,
pageParams: [undefined, "48"]
// pages에 담겨오는 값을 아래와같이 만들 수 있지 않을까요?
pages: [ {messages: [~48번까지], [47번부터~] } ]

// 위와 같이 될 수 있도록 pageParams에 아래와 같이 값이 계속 추가가될 수 있게 합니다.
pageParams: [undefined, "48", "33"]

// 그리고 pages에 담겨오는 messages가 하나로 합쳐져서오는 것이 아니라 page 단위로 15개씩 끊어서 묶음으로 되어있다라는 것까지 인지를 한 상태에서 보도록 하겠습니다.


// client/components/MsgList.js
// ...

const MsgList = ({smsgs, users}) => {
    // ...
    const {data, error, isError, fetchNextPage, hasNextPage} = useInfiniteQuery(
        QueryKeys.MESSAGES,
        ({pageParam = ''}) => fetcher(GET_MESSAGES, {cursor: pageParam}),
        {
            // getNextPageParam 부분을 보면됩니다. 
            // 이 부분을 어떻게 바꿔주면되느냐.
            // 아까 위에서보니까 res 부분에 data의 messages 부분이 들어왔었거든요?
            // 즉, messages를 그대로 구조분해할당으로 받을 수 있을 것 같습니다.
            // 즉, 이것이 마지막 페이지에 해당하는 message들이 들어온다고 보면 될거같습니다.
            getNextPageParam: ({messages}) => {
                // 즉, 여기서 return해야될 것은 현재 페이지의 가장 마지막 message의 id를 return하면 됩니다.
                // ? 연산자로 있는 경우에만 return하고 없는 경우엔 undefined를 return하도록 하게하면 됩니다.
                // messages 자체가 없을 수도 있으니까 아래와같이 작성합니다.
                return messages?.[messages.length - 1]?.id
            }
        }
    );
    // ...
}

위와 같이 pageParamspages가 들어오는 것을 보실 수 있습니다.

Note

스크롤을 마지막으로 내리면 다시 요청이가고 응답이 온 pageParams 값에 마지막 메시지의 id 값인 48이 들어와있는 것을 볼 수 있습니다.
그런데 pagesmessages에는 여전히 똑같은 내용이 15개씩 들어와있습니다.

다시한번 더 스크롤을 마지막으로 내려볼게요.

Note

위와 같이 똑같은 값들만 들어오죠?
뭔가 잘못되어있는 것 같습니다.
다시 확인해봅시다.


// client/components/MsgList.js
// ...

const MsgList = ({smsgs, users}) => {
    // ...
    const {data, error, isError, fetchNextPage, hasNextPage} = useInfiniteQuery(
        QueryKeys.MESSAGES,
        ({pageParam = ''}) => fetcher(GET_MESSAGES, {cursor: pageParam}),
        {
            getNextPageParam: ({messages}) => {
                return messages?.[messages.length - 1]?.id
            }
        }
    );

    useEffect(() => {
        // 저희가 setMsgs를 해주지 않았기 때문에 그렇습니다.
        // 현재는 data 구조가 data.messages가 아니라 pages로 변했잖아요?
        if (!data?.messages) return
        console.log('msgs changed')
        // 그러면 전체 메시지를 보여주는 이 구조는 각 page 단위로 쪼개져있는 messages를 한데 합쳐줄 필요가있겠죠?
        // 아까 보셨던 것처럼 각 page별로 15개씩 messages들이 담겨오는걸 볼 수 있었잖아요?
        // 그것을 한데 합쳐줄 필요가 있는겁니다.
        // 그런 작업이 더 필요한 상태인데 아직은 이것이 구현이되어있지 않기 때문에 이렇게 작동하는 것입니다.
        setMsgs(data?.messages) 
    }, [data?.messages])

    if (isError) {
        console.error(error)
        return null
    }

    useEffect(() => {
        if (intersecting && hasNextPage) fetchNextPage()
    }, [intersecting, hasNextPage])

    console.log(data);
    // ...
}


// client/components/MsgList.js
// ...

const MsgList = ({smsgs, users}) => {
    // ...
    // 현재는 최초 접속시 smsgs까진 보여지는데, 그 뒤로 변경된 메시지들에 대해서 setMsgs가 이뤄지고있지 않기 때문입니다.
    const [msgs, setMsgs] = useState(smsgs);
    // ...
    useEffect(() => {
        if (!data?.messages) return
        console.log('msgs changed')
        setMsgs(data?.messages) 
    }, [data?.messages]) // 그리고 이 부분 때문에.. 변경을 감지하지 못했죠?
    // 왜냐면 data의 messages라는 프로퍼티가 없어졌으니까 useEffect 자체가 실행이 안되는겁니다.
    // 그래서 이 부분을 수정해보도록 하겠습니다.
    // ...
}


// client/components/MsgList.js
// ...

const MsgList = ({smsgs, users}) => {
    // ...
    const [msgs, setMsgs] = useState(smsgs);
    // ...
    useEffect(() => {
        if (!data?.pages) return // 그리고 pages가 없을 경우엔 return을 하겠습니다.
        console.log('msgs changed')
        // 그리고 setMsgs를 하기 전에 data.pages를 순회하면서 하나의 messages로 머지를 해줘야될겁니다.
        // const data.pages = [ { messages: [...] }, { messages: [...] }, ... ] // data.pages 구조가 이런 구조일테니까 이를 [ ... ] 이렇게 하나로 합쳐야될겁니다.
        // 이때 map이 아니라 flatMap 메소드를 사용합니다.
        // flatMap은 depth 1단계에 대한 내용들을 하나로 합쳐주는 역할입니다. 쉽게말해 1 뎁스 배열을 벗겨줍니다.
        // 즉, 아래와같이 하면 messages에 각각 배열로 있던 것들이 하나로 합쳐지게 될 것입니다.
        const mergedMsgs = data.pages.flatMap(d => d.messages)

        console.log({mergedMsgs}); // 이 결과를 한번 출력을 해보겠습니다.

        // 이제 아래와 같이 setMsgs를 지정을 해주면 될거 같습니다.
        setMsgs(mergedMsgs) 
    }, [data?.pages]) // 이제는 messages가 아니라 pages가 변경이 될겁니다.
    // ...

    // 이 밑에쪽에 출력하는 것은 지워놓고 보도록 하겠습니다.
    // console.log({data});
}

Note

이 상태에서 새로고침하면 위와 같이 mergedMsgs에 15개의 메시지가 들어와있는 것을 볼 수 있습니다.

Note

스크롤을 마지막으로 내려보면,
mergedMsgs를 보면 한데 합쳐진 것은 맞지만,
id가 똑같은게 다시한번 겹쳐지고 있습니다.
이것은 요청이 뭔가 잘못들어갔다는 생각이 듭니다.

Note

마지막 요청의 variablescursor가 48이 들어갔는데,

Note

그때 응답값으로 48부터 그 다음 15개가 아니라 그냥 이전꺼가 그대로 불러와졌습니다.
이것은 server에서 뭔가 메시지 처리를 제대로해주고 있지 못한 것 같습니다.

schema를 한번 살펴보도록 하겠습니다.


// server/src/schema/messages.js
import {gql} from "apollo-server-express";

const messageSchema = gql`
    type Message {
        id: ID!
        text: String!
        userId: ID!
        timestamp: Float
    }
    
    extend type Query {
        // 아래 messages에서 저희가 cursor 요청사항을 없앴기 때문에 cursor값이 제대로 전달이 안되었던겁니다.
-        messages: [Message!]!
+        messages(cursor: ID!): [Message!]!
        message(id: ID!): Message!
    }
    
    extend type Mutation {
        createMessage(text: String!, userId: ID!): Message!
        updateMessage(id: ID!, text: String!, userId: ID!): Message!
        deleteMessage(id: ID!, userId: ID!): ID!
    }
`

export default messageSchema

그렇다면 client/graphql/messages.js 쪽도 빠졌겠네요.


// client/graphql/messages.js
import {gql} from 'graphql-tag';

export const GET_MESSAGES = gql`
-    query GET_MESSAGES {
+    query GET_MESSAGES($cursor: ID!) {
-        messages {
+        messages(cursor: $cursor) {
            id
            text
            userId
            timestamp
        }
    }
`

export const GET_MESSAGE = gql`
    query GET_MESSAGE($id: ID!) {
        message(id: $id) {
            id
            text
            userId
            timestamp
        }
    }
`

export const CREATE_MESSAGE = gql`
    mutation CREATE_MESSAGE($text: String!, $userId: ID!) {
        createMessage(text: $text, userId: $userId) {
            id
            text
            userId
            timestamp
        }
    }
`

export const UPDATE_MESSAGE = gql`
    mutation UPDATE_MESSAGE($id: ID!, $text: String!, $userId: ID!) {
        updateMessage(id: $id, text: $text, userId: $userId) {
            id
            text
            userId
            timestamp
        }
    }
`

export const DELETE_MESSAGE = gql`
    mutation DELETE_MESSAGE($id: ID!, $userId: ID!) {
        deleteMessage(id: $id, userId: $userId)
    }
`

위와 같이 수정해주면 될 거 같습니다.

새로고침을하면 $cursor에 요구되는 ID! 타입이 제공되지 않았다는 에러가 뜹니다.


// client/graphql/messages.js
import {gql} from 'graphql-tag';

export const GET_MESSAGES = gql`
// 느낌표로 가면 안될거같습니다. 처음에는 아무것도 없는 값이 갈 수도 있으니까요.
-    query GET_MESSAGES($cursor: ID!) {
+    query GET_MESSAGES($cursor: ID) {
        messages(cursor: $cursor) {
            id
            text
            userId
            timestamp
        }
    }
`

export const GET_MESSAGE = gql`
    query GET_MESSAGE($id: ID!) {
        message(id: $id) {
            id
            text
            userId
            timestamp
        }
    }
`

export const CREATE_MESSAGE = gql`
    mutation CREATE_MESSAGE($text: String!, $userId: ID!) {
        createMessage(text: $text, userId: $userId) {
            id
            text
            userId
            timestamp
        }
    }
`

export const UPDATE_MESSAGE = gql`
    mutation UPDATE_MESSAGE($id: ID!, $text: String!, $userId: ID!) {
        updateMessage(id: $id, text: $text, userId: $userId) {
            id
            text
            userId
            timestamp
        }
    }
`

export const DELETE_MESSAGE = gql`
    mutation DELETE_MESSAGE($id: ID!, $userId: ID!) {
        deleteMessage(id: $id, userId: $userId)
    }
`


// server/src/schema/messages.js
import {gql} from "apollo-server-express";

const messageSchema = gql`
    type Message {
        id: ID!
        text: String!
        userId: ID!
        timestamp: Float
    }
    
    extend type Query {
        // 스키마에서도 !를 빼주도록 하겠습니다.
-        messages(cursor: ID!): [Message!]!
+        messages(cursor: ID): [Message!]!
        message(id: ID!): Message!
    }
    
    extend type Mutation {
        createMessage(text: String!, userId: ID!): Message!
        updateMessage(id: ID!, text: String!, userId: ID!): Message!
        deleteMessage(id: ID!, userId: ID!): ID!
    }
`

export default messageSchema

Note

다시 새로고침해서 스크롤을 마지막으로 내려보면 이번에도 요청할 때 마지막 메시지의 id 값이 잘 넘어가고

Note

데이터도 이젠 48번 다음부터 15개가 잘 넘어오는 것을 볼 수 있습니다.

Note

스크롤을 끝까지 계속 내리다가 더이상 불러올 데이터가 없으면 hasNextPage가 없게돼서 이후로는 자동적으로 요청을 더이상 하지 않게됩니다.
이것으로 무한 스크롤링을 성공했습니다.

무한스크롤링 구조정리

6.2 mutation 처리 및 기타 기능 보완

MsgList로 다시 가서… 이렇게 무한 스크롤링을 성공했는데,
제가 지금 처리한 것은 기존 방식.. setMsgs, flatmessages 데이터 형태를 그대로 유지하기 위해서 바뀐 구조..
useInfiniteQuery를 썼을 때 내려오는 구조,
page가 내려오고 pageParam이 내려왔을 때, 그 안에 messages가 들어있었잖아요?
그걸 다시 하나의 단일한 배열로 바꾸기 위해서 flatMap이라는 메소드를 썼습니다.

  • 이 방법이 좋은지,
  • 아니면 페이지 그대로 유지한채로 컴포넌트를 여러개로 나눠서 작업을 하는 것이 좋은지

이것은 여러분들이 판단하시기 나름입니다.
이에 대해서도 잠깐 맛보기로 해보도록 하겠습니다.

Note

위와 같이 mergedMsgs를 사용하지 않고
setMsgs()data.pages를 인자로 그대로 넘기는 방식으로 하겠습니다.

Note

그럼 msgsmap 메소드를 사용해서 순회하는데 그 안에서 또 한번 더 순회를 해야될겁니다.

Note

위와 같이 map 메소드로 한번 더 순회를 하게하면됩니다.

Note

useState로 설정되는 처음 msgs 값도 data.pages와 구조를 맞춰줍니다. ([{messages: smsgs}])

Note

위와 같이 수정해도 무한 스크롤링은 문제없이 작동합니다.

Note

Components 탭을 가보면 MsgItem들이 똑같이 flat하게 보여지는 것처럼 되어있는데
이거는 사실 구조상으로는 State에서는 위와 같이 page단위로 잘게 쪼개져있단말이죠?
이거를 map을 돌릴 때 한번 더 map 메소드를 사용해 2중 순회를 시키는거죠.

Note

위와 같이 map 메소드를 2중으로써 2중 순회를 돌리는 것이 좋을지
아니면 기존 방법대로 flatMap 메소드를 쓰는 것이 좋은지는
여러분들의 판단에 맡기겠습니다.

저는 개인적으로는 2중 순회를 돌리는 것이 더 좋은 방법인 것 같습니다.

setMsgs(data.pages)

데이터 구조를 변경시키지 않은 상태로 유지한 상태로

msgs.map(({messages}) =&gt; messages.map(...))

화면에 보여질때만 변경을 가하는 방식이기 때문입니다.
이 방식으로하면 계속 데이터를 tracking 하기에도 수월하지 않을까 생각이듭니다.
이 상태에서 onCreate, onUpdate, onDelete 작업을 이어나가보도록 하겠습니다.

onCreate - POST

Note

onCreate일 때 onSuccesscreateMessage가 들어오는 것까진 똑같을 겁니다.
이를 데이터에 어떻게 반영할 것이냐가 관건입니다.
이거는 조금 고민을 해봐야되는 부분이 이미 첫페이지에 15개의 데이터정보가 들어있습니다.

[{messages: [15]}, {messages: [15]}, ..., {messages: [10]}]

이런식으로 데이터 정보가 들어옵니다.
data.pages에 들어오는 구조가 위와 같은데,

[{createMessage, messages: [15]}, {messages: [15]}, ..., {messages: [10]}]

createMessage를 위와 같이 앞쪽에 넣어야되는 거잖아요?
이렇게 했을 때 문제가 없을까요?
갯수가 16개가 되는건데 아무 문제가 없을까?가 조금 고민이 되는 부분입니다.

Note

새로운 글을 추가하는거는 언제나 page 단위로 나뉘어져있을 때, 맨 첫번째 messages 안에 추가를하면 되겠죠?

pages: [{createMessage, messages: [15]}, {messages: [15]}, ..., {messages: [10]}]

Note

return을 해줄때 pageParam은 그대로둔 상태에서

pageParam: old.pageParam

pages를 바꿔야하는데, 이 pages에 어떻게 들어가야되냐면,

pages: [{messages: [createMessage, ...old.pages[0].messages]}, ...old.pages.slice(1)],

이런식으로 데이터 구조 맞춰서 맨 앞에 createMessage를 넣어주면됩니다.

Note

이렇게 메시지추가가 잘됩니다.
위에서 고민했던 문제는 다행히 발생하지 않았습니다.

onUpdate - POST

Note

onUpdate는 개수가 똑같으니까 괜찮을겁니다.
똑같은 방식으로 한번 해보겠습니다.

Note

기존과 다른점은 return하는 부분뿐입니다.
pageParampages 값을 return해야된다는 점.
그것만 이 return에 반영하면 되는겁니다.

음.. updatedelete 경우가 조금 tricky한 부분이 있겠네요.
예를들어,

pages: [{messages: [15]}, {messages: [1, 2, 3,..., 7, 8,..., 15]}, ..., {messages: [10]}]

여기서 두번째 messages의 7번째 메시지를 수정을 했습니다.
그럼 위의 7의 위치를 정확하게 찾아서 7을 수정을 해야되는거죠.

그렇다면 이 7을 검색해내는 것이 관건이 되겠습니다.

findTargetMsgIndex라는 함수를 만들어보겠습니다.


// pages 인자엔 data.pages가 들어올 것입니다.
// id 인자엔 MsgInput으로 전달된 onUpdate 함수에서 id값(해당 메시지의 id)이 전달되는데 그 값이 들어옵니다.
const findTargetIndex = (pages, id) => {
    // pages 안의 messages 안의 일치하는 메시지의 id 값을 msgIndex에 담습니다.
    // msgIndex를 못찾을 때를 대비해서 미리 -1을 넣어둡니다.
    let msgIndex = -1;
    // 일치하는 msg.id가 있는 messages의 index값을 반환해 pageIndex에 담습니다.
    const pageIndex = data.pages.findIndex(({messages}) => {
        msgIndex = messages.findIndex(msg => msg.id === id);
        if (msgIndex > -1) {
            return true
        }
        return false
    })
    // data.pages의 인덱스와 data.pages[x].messages의 인덱스를 반환합니다.
    return {pageIndex, msgIndex}
}


const MsgList = ({smsgs, users}) => {
    // ...
    const {mutate: onUpdate} = useMutation(({text, id}) => fetcher(UPDATE_MESSAGE, {text, id, userId}), {
        onSuccess: ({updateMessage}) => {
            client.setQueryData(QueryKeys.MESSAGES, old => {
                const {pageIndex, msgIndex} = findTargetIndex(old.pages, updateMessage.id);
                if (pageIndex < 0 || msgIndex < 0) return old;
                const newPages = [...old.pages]; // deep copy
                newPages[pageIndex] = [...newPages[pageIndex]]; // deep copy
                newPages[pageIndex].splice(msgIndex, 1, updateMessage);
                return {
                    pageParam: old.pageParam,
                    pages: newPages,
                }
            })
            doneEdit();
        }
    })
    // ...
}

Note

이 상태에서 수정을해보면 위와 같이 data is not defined라는 에러가 뜹니다.


const findTargetIndex = (pages, id) => {
    let msgIndex = -1;
-    const pageIndex = data.pages.findIndex(({messages}) => {
+    const pageIndex = pages.findIndex(({messages}) => {
        msgIndex = messages.findIndex(msg => msg.id === id);
        if (msgIndex > -1) {
            return true
        }
        return false
    })
    return {pageIndex, msgIndex}
}

Note

이번엔 위와 같이 spread 연산자를 시도했는데 non-iterable이어서 안된다라는 에러가 떴습니다.


const MsgList = ({smsgs, users}) => {
    // ...
    const {mutate: onUpdate} = useMutation(({text, id}) => fetcher(UPDATE_MESSAGE, {text, id, userId}), {
        onSuccess: ({updateMessage}) => {
            client.setQueryData(QueryKeys.MESSAGES, old => {
                const {pageIndex, msgIndex} = findTargetIndex(old.pages, updateMessage.id);
                if (pageIndex < 0 || msgIndex < 0) return old;
                const newPages = [...old.pages]; // deep copy
-                newPages[pageIndex] = [...newPages[pageIndex]]; // deep copy
+                newPages[pageIndex].messages = [...newPages[pageIndex].messages]; // deep copy 
                // newPages[pageIndex] = {messages: [...newPages[pageIndex].messages]}; // 이렇게 작성해도됩니다.
-                newPages[pageIndex].splice(msgIndex, 1, updateMessage);
+                newPages[pageIndex].messages.splice(msgIndex, 1, updateMessage);
                return {
                    pageParam: old.pageParam,
                    pages: newPages,
                }
            })
            doneEdit();
        }
    })
    // ...
}

Note

이제 에러없이 수정이 아주 잘됩니다.

onDelete - DELETE

Note

삭제일 경우 어떨까요?

[{messages: [15]}, {messages: [15]}, ..., {messages: [10]}]

이렇던 구조가 삭제를 해서

[{messages: [14]}, {messages: [15]}, ..., {messages: [10]}]

이렇게 되었습니다.
문제가 생길 수도 있고 안생길 수도 있는데, 안생길거라고 기대를 하면서 작업을 해보도록 하겠습니다.

Note

deleteupdate(post)에서 하던 것과 사실 똑같습니다.

const {pageIndex, msgIndex} = findTargetIndex(old.pages, updateMessage.id);

이를 통해 pageIndex, msgIndex를 찾아내고

const newPages = [...old.pages];
newPages[pageIndex].messages = [...newPages[pageIndex].messages];

새로운 데이터를 위와 같은식으로 만들어내고

newPages[pageIndex].messages.splice(msgIndex, 1);

여기서 update와 다르게 updateMessage만 안들어가면 되는겁니다.


const MsgList = ({smsgs, users}) => {
    // ...
    const {mutate: onDelete} = useMutation(id => fetcher(DELETE_MESSAGE, {id, userId}), {
        // deleteMessage를 받아올텐데 이 이름을 deletedId라고 바꾸겠습니다.
        onSuccess: ({deleteMessage: deletedId}) => {
            client.setQueryData(QueryKeys.MESSAGES, old => {
                // deleteMessage 자체가 하나의 id이므로 id 인자값으로 deleteId를 전달합니다.
                const {pageIndex, msgIndex} = findTargetIndex(old.pages, deletedId);
                if (pageIndex < 0 || msgIndex < 0) return old;
                const newPages = [...old.pages];
                newPages[pageIndex].messages = [...newPages[pageIndex].messages];
                newPages[pageIndex].messages.splice(msgIndex, 1);
                return {
                    pageParam: old.pageParam,
                    pages: newPages,
                }
            })
        }
    })
    // ...
}

Note

위와 같이 작성하면 작동이 아주 잘되는 것을 확인하실 수 있습니다.

Note

삭제하면 위와 같이 state 0에는 값이 14개로 줄은 것을 볼 수 있죠?
그렇더라도 마지막 메시지의 id 값이 50이고, 어차피 이 50 다음부터 15개 불러오는거니까 문제는 딱히 없어보입니다.
실제로 스크롤해보면 삭제까지 문제없이 잘 동작을 합니다.


무한스크롤을 구현한 상태에서 그거를 CRUD에 반영하기위해 데이터가 조금 더 복잡해졌다.
그래서 findIndex를 한번 더 사용해야되는 과정이 필요하더라.
아래와 같이 pageIndexmsgIndex를 찾기위해서말이다.


const findTargetIndex = (pages, id) => {
    let msgIndex = -1;
    const pageIndex = pages.findIndex(({messages}) => {
        msgIndex = messages.findIndex(msg => msg.id === id);
        if (msgIndex > -1) {
            return true
        }
        return false
    })
    return {pageIndex, msgIndex}
}


const newPages = [...old.pages];
newPages[pageIndex].messages = [...newPages[pageIndex].messages];

이 부분도 updatedelete의 경우에 immutable한 새로운 데이터를 가져오기 위해서 (참조를 없애기 위해, 즉 불변한 데이터를 만들기위해 deep copy)
위와 같은 동작을 동일하게 거쳤습니다.
위 부분을 함수로 만들어놓으면 좀 더 수월할것 같습니다.
이런 유틸 함수는 query와 관련된 부분이므로 queryClient.js로 옮겨놓도록 하겠습니다.


// client/queryClient.js
import {request} from "graphql-request";

const URL = 'http://localhost:8000/graphql';

export const fetcher = (query, variables = {}) => request(URL, query, variables)

export const QueryKeys = {
    MESSAGES: 'MESSAGES',
    MESSAGE: 'MESSAGE',
    USERS: 'USERS',
    USER: 'USER'
}

export const findTargetIndex = (pages, id) => {
    let msgIndex = -1;
    const pageIndex = pages.findIndex(({messages}) => {
        msgIndex = messages.findIndex(msg => msg.id === id);
        if (msgIndex > -1) {
            return true
        }
        return false
    })
    return {pageIndex, msgIndex}
}

export const getNewMessages = old => ({
    pageParam: old.pageParam,
    pages: old.pages.map(({messages}) => ({messages: [...messages]}))
})

+ export const findTargetIndex = (pages, id) => {
+     let msgIndex = -1;
+     const pageIndex = pages.findIndex(({messages}) => {
+         msgIndex = messages.findIndex(msg => msg.id === id);
+         if (msgIndex > -1) {
+             return true
+         }
+         return false
+     })
+     return {pageIndex, msgIndex}
+ }

+ export const getNewMessages = old => ({
+     pageParam: old.pageParam,
+     pages: old.pages.map(({messages}) => ({messages: [...messages]}))
+ })


// client/components/MsgList.js
import MsgItem from "./MsgItem";
import MsgInput from "./MsgInput";
import {useEffect, useRef, useState} from "react";
import {useRouter} from "next/router";
import {useInfiniteQuery, useMutation, useQuery, useQueryClient} from "react-query";
- import {fetcher, QueryKeys} from "../queryClient.js";
+ import {fetcher, QueryKeys, findTargetIndex, getNewMessages} from "../queryClient.js";
import {CREATE_MESSAGE, DELETE_MESSAGE, GET_MESSAGES, UPDATE_MESSAGE} from "../graphql/messages";
import useInfiniteScroll from "../hooks/useInfiniteScroll";

const MsgList = ({smsgs, users}) => {
    const client = useQueryClient()
    const {query} = useRouter();
    const userId = query.userId || query.userid || '';

    const [msgs, setMsgs] = useState([{messages: smsgs}]);
    const [editingId, setEditingId] = useState(null);
    const fetchMoreEl = useRef(null);
    const intersecting = useInfiniteScroll(fetchMoreEl);

    const doneEdit = () => setEditingId(null)

    const {mutate: onCreate} = useMutation(({text}) => fetcher(CREATE_MESSAGE, {text, userId}), {
        onSuccess: ({createMessage}) => {
            client.setQueryData(QueryKeys.MESSAGES, old => {
                return {
                    pageParam: old.pageParam,
                    pages: [{messages: [createMessage, ...old.pages[0].messages]}, ...old.pages.slice(1)],
                }
            })
        }
    })

    const {mutate: onUpdate} = useMutation(({text, id}) => fetcher(UPDATE_MESSAGE, {text, id, userId}), {
        onSuccess: ({updateMessage}) => {
            client.setQueryData(QueryKeys.MESSAGES, old => {
                const {pageIndex, msgIndex} = findTargetIndex(old.pages, updateMessage.id);
                if (pageIndex < 0 || msgIndex < 0) return old;
-                const newPages = [...old.pages];
-                newPages[pageIndex].messages = [...newPages[pageIndex].messages];
+                const newMsgs = getNewMessages(old);
-                newPages[pageIndex].messages.splice(msgIndex, 1, updateMessage);
+                newMsgs.pages[pageIndex].messages.splice(msgIndex, 1, updateMessage);
-                return {
-                    pageParam: old.pageParam,
-                    pages: newPages,
-                }
+                return newMsgs
            })
            doneEdit();
        }
    })

    const {mutate: onDelete} = useMutation(id => fetcher(DELETE_MESSAGE, {id, userId}), {
        onSuccess: ({deleteMessage: deletedId}) => {
            client.setQueryData(QueryKeys.MESSAGES, old => {
                const {pageIndex, msgIndex} = findTargetIndex(old.pages, deletedId);
                if (pageIndex < 0 || msgIndex < 0) return old; 
-                const newPages = [...old.pages];
-                newPages[pageIndex].messages = [...newPages[pageIndex].messages];
+                const newMsgs = getNewMessages(old);
-                newPages[pageIndex].messages.splice(msgIndex, 1);
+                newMsgs.pages[pageIndex].messages.splice(msgIndex, 1);
-                return {
-                    pageParam: old.pageParam,
-                    pages: newPages,
-                }
+                return newMsgs
            })
        }
    })

    const {data, error, isError, fetchNextPage, hasNextPage} = useInfiniteQuery(
        QueryKeys.MESSAGES,
        ({pageParam = ''}) => fetcher(GET_MESSAGES, {cursor: pageParam}),
        {
            getNextPageParam: ({messages}) => {
                return messages?.[messages.length - 1]?.id
            }
        }
    ); 

    useEffect(() => {
        if (!data?.pages) return 
        console.log('msgs changed') 
        setMsgs(data.pages)
    }, [data?.pages])

    if (isError) {
        console.error(error)
        return null
    }

    useEffect(() => {
        if (intersecting && hasNextPage) fetchNextPage()
    }, [intersecting, hasNextPage])

    return (
        <>
            {userId && <MsgInput mutate={onCreate}/>}
            <ul className='messages'>
                {
                    msgs.map(({messages}) => messages.map(x => <MsgItem key={x.id}
                                           {...x}
                                           onUpdate={onUpdate}
                                           onDelete={() => onDelete(x.id)}
                                           startEdit={() => setEditingId(x.id)}
                                           isEditing={editingId === x.id}
                                           myId={userId}
                                           user={users.find(x => userId === x.id)}
                    />))
                }
            </ul>
            {/*무한 스크롤 기능을 구현하기 위해선 아래 요소가 필요합니다.*/}
            {/*아래 요소가 화면상에 나타났을 때, 다음 데이터를 불러와라 라고 요청하는 이 부분을 넣어줘야됩니다.*/}
            <div ref={fetchMoreEl} />
        </>
    )
}

export default MsgList;

Note

users 정보가 이상하게 넘어가는 거에 대해서 수정을하고 가겠습니다.
현재 MsgListusers 정보가 들어오고있는데, 이 users 정보를 굳이 줄 필요가 없습니다.
GraphQL을 쓰면 server/src/resolvers/messages.js에 있는 mutation 객체 아래쪽에 다음과 같은 것을 넣을 수 있습니다.

Note

위와 같이 resolvers/messages.jsMessageuser 필드를 만듭니다.
마찬가지로 schema/messages.js에도 user/User!라는 필드를 만듭니다.

Note

client/graphql/messages.js에서 user 정보도 요청합니다.

Note

위와 같이 user 정보가 들어와있는 것을 볼 수 있습니다.
이것만으로도 간단하게 userId가 없어도 처리가 가능할 것입니다.

Note

userId가 필요없으므로 지우겠습니다.
userId를 지워놓고 MsgList 파일로 다시가서,

Note

MsgList에 내려주는 users도 필요없습니다.
이것을 내려주는 이유가MsgItem 하나하나마다 user 정보를 내려주기위함이었는데, 이젠 이게 없어도되는겁니다.
위와 같이 수정해주고나서 다시한번 확인해보겠습니다.

Note

이제는 roy 메시지만 등장하는 것이 아니라 jay 메시지도 등장합니다.

Note

이젠 위와 같이 MsgItem마다 올바른 user 정보가 내려갑니다.
그래서 roy로 접속했을 땐 로이관련 메시지들만 수정/삭제 할 수가 있고 jay로 접속했을 때 제이 관련 메시지들만 수정/삭제할 수 있는 형태로 갈 수 있을거 같습니다.
이 부분까지 마저 작성을 해보도록 하겠습니다.

Note

id, user, timestamp, textMessages로 넘어오는 각 Message에 대한 정보들입니다.
원래 user대신 userId를 받아왔었는데, 아까 위에서 Messageuser정보 {id: "...", nickname: "..."} 자체를 받아오게하고 userId는 지웠었죠?

그래서 위와 같이 userIduser.id로 수정해줍니다.

Note

그럼 이제 로그인한 id로 작성된 메시지에 마우스 커스를 올려놓으면 수정/삭제 버튼이 뜨는 것을 볼 수 있습니다.

Note

아직 에러가 하나 있습니다.
메시지를 수정하고 완료를 누르면 위와 같이 nickname 프로퍼티를 읽을 수 없다는 에러가 뜹니다.

Note

아까 GET_MESSAGESuser 정보를 불러올 수 있도록 수정했었잖아요?
다른 CRUD도 이와 같이 불러올 수 있도록 해줘야합니다.

Note

GET_MESSAGE, createMessage, updateMessage 모두 반환값이 Message 형태이다.
userIduser { id nickname }으로 바꿔주자.
이 부분이 수정이 안되어있었습니다.

Note

위와 같이 수정하면 수정 및 삭제가 잘 되는 것을 확인할 수 있습니다.