들어가며

새로운 홈페이지를 기획, 개발하면서 최대한 과 동일한 사용자 경험을 주기 위해 고민을 많이 했습니다. 일반적인 페지네이션으로는 모바일에서 과 같은 경험을 주지 못하는 점이 아쉬운 점이 있습니다. React QueryinfiniteScrollinfiniteScroll을 도입하면서 배운 내용들을 다룹니다. 속도가 중요한 것은 Modal로 표시하고 상호작용이 중요한 것은 Link로 페이지 이동하였습니다. 이미지 캐싱을 위한 방법과 불러온 데이터를 효과적으로 저장하는 방법에 대해서 알아봅니다. API는 장고로 만들었습니다.

고치는 것 보다 새로 만드는게 더 빠른…

고치는 것 보다 새로 만드는게 더 빠른…


무한 스크롤을 도입할 때 다음과 같은 내용이 고려되었습니다.

  1. 무한 스크롤이 적용된 리스트를 클릭했을때 어떤 식으로 상세페이지를 제공할 것인가?
  2. 상세페이지에서 다시 리스트로 돌아갈때 이미 불러온 데이터를 다시 불러오지 않아야한다.
  3. 2번이 적용된다면 다시 리스트로 돌아갈때 스크롤 했던 위치로 돌아가져야한다.

무한 스크롤이 도입될 곳은 내큐찾기커뮤니티 였습니다. 다만 두 카테고리는 각자 다른 특성을 가지고 있습니다. 서비스 핵심 모델이 적요되는 내큐찾기에서는 최대한 빠르게 유저에게 로딩되는 경험을 주어야하고, 커뮤니티에서는 댓글, 답글 등의 추가 기능들이 필요했습니다.

속도가 중요한 내큐찾기에는 Modal 을 이용하였으며, API 상호작용 및 새로고침이 필요한 커뮤니티에서는 새로운 페이지로 이동하고 뒤로가기 시에 LocalStorage를 참조하여 저장된 ScrollY값으로 스크롤 이동되게 하였습니다.

내큐찾기 (적용된 페이지)

현재는 SEO 문제로 모달로 표현되지 않습니다.
CHECK 👉 Next.js] SSR를 이용한 상세페이지 SEO 최적화 (모달을 포기한 이유)

  • 페이지 이동없이 Modal이 보여지는 형식으로 제공한다.
  • Modal이 열릴때 따로 API에서 정보 불러오지 않고 즉시 보여질 수 있도록 리스트 불러올 때 API를 모달안의 정보까지 제공할 수 있도록 제작한다.
  • 한번 불러온 이미지나 캐시에 남겨 로딩할 때도 매우빠른 로딩속도를 제공한다.
  • 스크롤 시 마지막 아이템이 보여질 때 마다 Fade in 애니메이션을 적용하여 로딩이 즉각적으로 되는 것처럼 보여지게 한다.
Infinite Scroll + Modal + aos fadein

Infinite Scroll + Modal + aos fadein

커뮤니티 (적용된 페이지)

  • List에서 Item 클릭 시 새로운 상세페이지로 이동
  • 이동할때 이때 LocalStoargescrollY 값을 저장
  • 상세페이지에서 다시 리스트로 돌아올 때 scrollY 값으로 스크롤 위치 이동
  • 단, 이때 처음부터 List를 다시 불러온다면 저장된 scrollY 값으로 가질 수 없으므로 (1번 이상 추가로딩이 되었을 경우) 리스트 데이터는 새로 불러오면 안된다.
Infinite Scroll + Link + LocalStorage

Infinite Scroll + Link + LocalStorage


API 준비

API는 Django를 사용합니다. ModelViewSet을 사용하였으며 LimitOffsetPagination을 적용하였습니다.

일반적인 페이지 페지네이션의 경우 사용자가 다음 리스트를 불러오기 전에 새로운 아이템이 1개가 등록되고 리스트를 불러온다면 중복되는 아이템이 표시될 수 있으므로 offset pagination을 적용하여 전체 게시글이 늘었는지 확인하고 늘은 만큼 offset을 추가로 더해주어야 중복되는 아이템을 방지할 수 있습니다.

리스트에서 로딩될 필터링 조건parameter 로 받기 위해 queryset을 변경하는 과정이 필요하였습니다. offset또한 파라미터로 받아옵니다.

views.py

views.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class someview(viewsets.ModelViewSet):
    queryset = somemodel.objects.exclude(Q(something)).prefetch_related(Prefetch(something)).all()
    permission_classes = somepermission
    serializer_class = someSerializer
    pagination_class = LimitOffsetPagination
    filter_backends = (somefilters)
    ordering_fields = ('created_at','id')
    ordering = ('-created_at')
    search_fields = ['$title', '$content']
    filterset_fields = ('__all__')

    def get_queryset(self):
        qs = super().get_queryset()
        # some condition
        return qs.filter(Query)

리턴 예시

GET
1
2
3
4
5
6
7
8
{
    "count": 131,
    "next": "api주소/?limit=20&offset=55&필터링조건",
    "previous": "api주소/?limit=20&offset=15&필터링조건",
    "results":[
        ...
    ]
}

Next 프로젝트 구조

Next + Tailwind + Typescript 스타터팩인 theodorusclarence/ts-nextjs-tailwind-starter를 설치하였습니다.

프로젝트 구조는 다음을 참고해주시면 됩니다.

src Tree

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
.
├── components
│   ├── dialog
│   │   └── BaseDialog.tsx
│   └── layout
│       ├── DialogZustandLayout.tsx
│       └── Layout.tsx
├── hooks
│   ├── useDialog.tsx
│   ├── useObserver.js
│   └── useIntersectionObserver.js
├── pages
│   ├── sandbox
│   │   └── dialog-zustand.tsx
│   └── somepages
├── partials
│   └── somepartials
├── store
│   └── useDialogStore.tsx
└── utils
    └── Transition.jsx

infiniteScroll 설치

react-qeury를 설치해줍니다. 사용된 버전은 다음과 같습니다.

package.json
1
2
3
...
"react-query": "^3.39.2",
...

Hydration을 통해 SSR(Server Side Rendering)을 할 수 도 있지만, 시도해본 결과 매 뒤로가기시 캐싱되지 않는 문제가 있었습니다. 정확히 말하면 이미 로딩했던 내용들을 다시 처음부터 로딩해야 했습니다. 내큐찾기 처럼 모달을 사용할 경우 Hydration 사용해도 지장없지만, 페이지 이동이 필요한 경우 해당 방법을 사용하면 비효율적입니다.

_app.tsx (Hydration)

src > pages > _app.tsx (Hydration)
 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
51
/* eslint-disable @typescript-eslint/no-explicit-any */
import AOS from "aos";
import Head from "next/head";
import { SessionProvider } from "next-auth/react";
import React, { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "react-query";

import "@/styles/aos.scss";
import "@/styles/globals.css";
import "@/styles/colors.css";

const MyApp: NextComponentType<AppContext, AppInitialProps, AppProps> = ({
  Component,
  pageProps,
}) => {
  const queryClientRef = React.useRef<QueryClient>();
  if (!queryClientRef.current) {
    queryClientRef.current = new QueryClient();
  }
  useEffect(() => {
    AOS.init({
      once: true,
      duration: 500,
      easing: "ease-out-cubic",
    });
  });

  return (
    <>
      <QueryClientProvider client={queryClientRef.current}>
        <Hydrate state={pageProps.dehydratedState}>
          <Component {...pageProps} />
        </Hydrate>
        {/* <ReactQueryDevtools /> */}
      </QueryClientProvider>
    </>
  );
};
MyApp.getInitialProps = async ({
  Component,
  ctx,
}: AppContext): Promise<AppInitialProps> => {
  let pageProps = {};

  if (Component.getInitialProps) {
    pageProps = await Component.getInitialProps(ctx);
  }

  return { pageProps };
};
export default MyApp;

따라서 최종적으로 다음과 같이 적용했습니다. 중간에 meta 태그는 아이폰에서 모달이 열릴때 가로폭이 고정되지 않는 문제점을 해결하기 위해 넣었으며 따로 meta tag를 관리하는 컴포넌트가 있으면 추가해주시면 됩니다. 커뮤니티에서 댓글 작성이 로그인 여부를 확인하기 때문에 SessionProvider (next-auth)가 있습니다. 로그인 구현이 필요 없다면 넘기셔도 됩니다.

_app.tsx

src > pages > _app.tsx
 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
51
52
/* eslint-disable @typescript-eslint/no-explicit-any */
import AOS from "aos";
import Head from "next/head";
import { SessionProvider } from "next-auth/react";
import React, { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "react-query";

import "@/styles/aos.scss";
import "@/styles/globals.css";
import "@/styles/colors.css";

const MyApp = ({
  Component,
  pageProps: { session, ...pageProps },
}: {
  Component: any;
  pageProps: any;
}) => {
  const [queryClient] = React.useState(() => new QueryClient());

  // AOS 애니메이션 적용
  useEffect(() => {
    AOS.init({
      once: true,
      duration: 500,
      easing: "ease-out-cubic",
    });
  });
  ("");
  return (
    // next-auth 로그인 세션을 위해 사용됨
    <SessionProvider session={session}>
      <Head>
        {/* 아이폰 가로값 고정되지 않는 이슈 해결 */}
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
        />
      </Head>
      {/* infiniteScroll 위해 선언 */}
      <QueryClientProvider client={queryClient}>
        {/* PageProps 표시 영역 */}
        <Component {...pageProps} />
        {/* Debug 모드에서만 활용할 devtools */}
        {/* import { ReactQueryDevtools } from 'react-query/devtools';
        <ReactQueryDevtools initialIsOpen={false} /> */}
      </QueryClientProvider>
    </SessionProvider>
  );
};

export default MyApp;

무한 로딩 리스트를 구현하는 큰 뼈대는 다음과 같습니다. useIntersectionObserve 훅을 사용하여 리스트를 fetch 할지를 확인합니다.

InfiniteListSkeleton.tsx

InfiniteListSkeleton.tsx
  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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
const InfiniteListSkeleton = ('필터링 될 내용들') => {
  const { data, hasNextPage, fetchNextPage, refetch, isFetching } =
    useInfiniteQuery(
      'apiRequestFunction',
      ({ pageParam = '' }) =>
        // FetchList 함수는 axios로 api에 request 보내는 함수입니다. (파라미터로 필터링 될 내용을 받습니다.)
        FetchList(
          pageParam,
          '필터링 될 내용들'
        ),
      {
        // 다음 페이지는 offset 으로 확인합니다.
        getNextPageParam: (lastPage) => {
          if (lastPage.next === null) {
            return undefined;
          }
          const url = new URL(lastPage.next);
          const lastOffset = url.searchParams.get('offset');
          if (lastOffset) {
            return parseInt(lastOffset);
          } else {
            return undefined;
          }
        },
        staleTime: 60 * 1000,
        cacheTime: 60 * 1000,
        keepPreviousData: true,
        refetchOnMount: false,
        refetchOnWindowFocus: false,
      }
    );

  // 1. 모달로 오픈할 거면 (커스텀 훅)
  const dialog = useDialog();
  const openModal = (props: Results) => {
    dialog({
      title: '',
      description: <></>,
      catchOnCancel: true,
      submitText: '네',
      cancleText: '닫기',
      variant: 'warning',
      props: props,
    })
      .then()
      .catch();
    return <></>;
  };

  // 2. 페이지 이동후 스크롤 Y 값을 저장하여 해당 위치로 바로 보여지게 할 경우
  const [scrollY, setScrollY] = useLocalStorage('someScroll', 0);
  React.useEffect(() => {
    if (scrollY !== 0) {
      window.scrollTo(0, Number(scrollY));
      setScrollY(0);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // 필터가 적용되면 offset을 초기화하고 처음부터 로딩되어야 합니다.
  React.useEffect(() => {
    if (isRefreshing) {
      refetch();
    }
    setIsRefreshing(false);
  }, [isRefreshing, refetch, setIsRefreshing]);

  // useRef를 이용하여 해당 Ref를 하단에 두고 보여지면 다음 페이지를 로딩합니다.
  const loadMoreButtonRef = React.useRef(null);
  // https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver
  useIntersectionObserver({
    root: null,
    target: loadMoreButtonRef,
    onIntersect: fetchNextPage,
    enabled: hasNextPage,
  });

  return (
    <>
      <ul>
        {/* 로딩중일때 */}
        {isFetching && <LoadingDiv />}

        {/* 리스트를 표시합ㄴ디ㅏ. */}
        {data?.pages.map((page) =>
          page.results.map((props: Results) => (
            // data-aos 로 애니메이션 효과를 줍니다. (라이브러리 설치 필요))
            <li key={props.id} data-aos='fade-up' data-aos-duration='200'>

              {/* 1. 모달로 오픈할꺼면 */}
              <div onClick={() => openModal(props)}>
                Click ${props.id} to open Modal
              </div>

              {/* 2. 페이지 이동할거면 */}
              <Link
                href={{
                  pathname: `url/${props.id}`,
                  query: { props: JSON.stringify(props) },
                }}
                as={`url/${props.id}`}
                passHref
              >
                <a
                  href={`url/${props.id}`}
                  onClick={() => setScrollY(window.scrollY)}
                >
                    <div>Click ${props.id} to navigate</div>
                </a>
              </Link>

            </li>
          )))}
      </ul>

      {/* 로딩중 표시를 하고 ref가 focus 될때 (리스트의 끝일때) 마다 다음 리스트를 로딩합니다. */}
      {hasNextPage && (
        <>
          <LoadingDiv />
          <button onClick={() => fetchNextPage()} className='text-center' />
        </>
      )}
      <div ref={loadMoreButtonRef} />
    </>
  );
};

export default InfiniteListSkeleton;

apiRequestFunction.js

앞에서 limitOffsetPagination 으로 만든 API를 불러옵니다. 상황에 따라서 params가 추가될 수 있습니다. (필터링 조건 등)

src > pages > api > index.tsx
1
2
3
4
5
6
export const apiRequestFunction = async (offset = 0, ... , params) => {
  const { data } = await axios.get(
    `${apiUrl}?limit=${limit}&offset=${offset}...`
  );
  return data;
};

useIntersectionObserver.js

src > hooks > useIntersectionObserver.js
 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
import React from "react";

export default function useIntersectionObserver({
  root,
  target,
  onIntersect,
  threshold = 1.0,
  rootMargin = "0px",
  enabled = true,
}) {
  React.useEffect(() => {
    if (!enabled) {
      return;
    }

    const observer = new IntersectionObserver(
      (entries) =>
        entries.forEach((entry) => entry.isIntersecting && onIntersect()),
      {
        root: root && root.current,
        rootMargin,
        threshold,
      }
    );

    const el = target && target.current;

    if (!el) {
      return;
    }

    observer.observe(el);

    return () => {
      observer.unobserve(el);
    };
  }, [target, enabled, root, threshold, rootMargin, onIntersect]);
}

useObserver.js

src > hooks > useObserver.js
 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
import React from "react";

export default function useIntersectionObserver({
  root,
  target,
  onIntersect,
  threshold = 1.0,
  rootMargin = "0px",
  enabled = true,
}) {
  React.useEffect(() => {
    if (!enabled) {
      return;
    }

    const observer = new IntersectionObserver(
      (entries) =>
        entries.forEach((entry) => entry.isIntersecting && onIntersect()),
      {
        root: root && root.current,
        rootMargin,
        threshold,
      }
    );

    const el = target && target.current;

    if (!el) {
      return;
    }

    observer.observe(el);

    return () => {
      observer.unobserve(el);
    };
  }, [target, enabled, root, threshold, rootMargin, onIntersect]);
}

infiniteScroll + Modal

모달은 theodorusclarence/expansion-packDialog using Zustand를 설치 후 커스텀하였습니다. 설치하면 DialogZustandLayout.tsx 샘플이 생깁니다. 이를 참고하여 수동으로 Layout.tsx 를 수정해주어야합니다.

Layout.tsx

src > layout > Layout.tsx
 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
import * as React from "react";

import BaseDialog from "@/components/dialog/BaseDialog";
import Footer from "@/components/layout/Footer";

import useDialogStore from "@/store/useDialogStore";

import Header from "./Header";

export default function Layout({ children }: { children: React.ReactNode }) {
  const open = useDialogStore.useOpen();
  const state = useDialogStore.useState();
  const handleClose = useDialogStore.useHandleClose();
  const handleSubmit = useDialogStore.useHandleSubmit();
  return (
    <>
      <Header mode="" />
      <section className="min-h-full font-primary">{children}</section>
      <BaseDialog
        onClose={handleClose}
        onSubmit={handleSubmit}
        open={open}
        options={state}
      />
      <Footer />
    </>
  );
}

useDialog.tsx

src > hooks > useDialog.tsx
1
2
3
4
5
import useDialogStore from "@/store/useDialogStore";

export default function useDialog() {
  return useDialogStore.useDialog();
}

여기까지 진행되면 모달을 열 수 있는 환경이 구성됩니다. src > components > dialog > baseDialog.tsx 가 모달창입니다. 원래는 submt이나 error를 확인하는 모달이였으므로 리스트에서 클릭하여 모달이 열릴때 props를 같이 받아올 수 있도록 해당 내용을 커스텀 해줍니다. optionsprops를 추가하면 됩니다.

baseDialog.tsx

src > components > dialog > baseDialog.tsx
  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
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
type BaseDialogProps = {
  open: boolean;
  onSubmit: () => void;
  onClose: () => void;
  options: DialogOptions;
};

export type DialogOptions = {
  catchOnCancel?: boolean;
  title: React.ReactNode;
  description: React.ReactNode;
  variant: "success" | "warning" | "danger";
  submitText: React.ReactNode;
  cancleText: React.ReactNode;
  props: Results | null;
};

export default function BaseDialog({
  open,
  onSubmit,
  onClose,
  options: { title, description, variant, submitText, cancleText, props }, // <-- 추가
}: BaseDialogProps) {
  const current = colorVariant[variant];

  return (
    <Transition.Root show={open} as={React.Fragment}>
      <Dialog
        as="div"
        static
        className="fixed inset-0 z-40 overflow-y-auto"
        open={open}
        onClose={() => onClose()}
      >
        <div className="flex min-h-screen items-start justify-center px-5 py-5 text-center drop-shadow-xl sm:block sm:p-0 md:mt-16">
          <Transition.Child
            as={React.Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0"
            enterTo="opacity-100"
            leave="ease-in duration-300"
            leaveFrom="opacity-100"
            leaveTo="opacity-0"
          >
            <Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-75 transition-opacity" />
          </Transition.Child>

          <span
            className="hidden sm:inline-block sm:h-screen sm:align-middle"
            aria-hidden="true"
          >
            &#8203;
          </span>
          <Transition.Child
            as={React.Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0 trangray-y-4 sm:trangray-y-0 sm:scale-95"
            enterTo="opacity-100 trangray-y-0 sm:scale-100"
            leave="ease-in duration-200"
            leaveFrom="opacity-100 trangray-y-0 sm:scale-100"
            leaveTo="opacity-0 trangray-y-4 sm:trangray-y-0 sm:scale-95"
          >
            <div className="z-auto inline-block w-full transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-12 sm:min-h-[1000px] sm:max-w-6xl sm:p-6 sm:align-middle">
              <div className="absolute top-0 right-0  block pt-4 pr-4">
                <button
                  type="button"
                  className={clsx(
                    "rounded-md bg-white text-gray-400 hover:text-gray-500",
                    "focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2",
                    "disabled:cursor-wait disabled:brightness-90 disabled:filter"
                  )}
                  onClick={onClose}
                >
                  <span className="sr-only">Close</span>
                  <HiOutlineX className="h-6 w-6" aria-hidden="true" />
                </button>
              </div>
              <div>
                {/* 불러온 props를 표시해주면 됩니다. */}
                {props && <div> </div>}
              </div>

              <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
                <Button
                  type="button"
                  variant="outline"
                  onClick={onClose}
                  className="font mt-3 w-full items-center justify-center !font-medium sm:mt-0 sm:w-auto sm:text-sm"
                >
                  {cancleText}
                </Button>
              </div>
            </div>
          </Transition.Child>
        </div>
      </Dialog>
    </Transition.Root>
  );
}

const colorVariant = {
  success: {
    bg: {
      light: "bg-green-100",
    },
    text: {
      primary: "text-green-500",
    },
    icon: HiOutlineCheck,
  },
  warning: {
    bg: {
      light: "bg-yellow-100",
    },
    text: {
      primary: "text-yellow-500",
    },
    icon: HiOutlineExclamation,
  },
  danger: {
    bg: {
      light: "bg-red-100",
    },
    text: {
      primary: "text-red-500",
    },
    icon: HiExclamationCircle,
  },
};

useDialogStore.tsx

src > store > useDialogStore.tsx
 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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import { createSelectorHooks } from "auto-zustand-selectors-hook";
import produce from "immer";
import create from "zustand";

import { DialogOptions } from "@/components/dialog/BaseDialog";

type DialogStoreType = {
  awaitingPromise: {
    resolve?: () => void;
    reject?: () => void;
  };
  open: boolean;
  state: DialogOptions;
  dialog: (options: Partial<DialogOptions>) => Promise<void>;
  handleClose: () => void;
  handleSubmit: () => void;
};

const useDialogStoreBase = create<DialogStoreType>((set) => ({
  awaitingPromise: {},
  open: false,
  state: {
    title: "Title",
    description: "Description",
    submitText: "Yes",
    cancleText: "No",
    variant: "warning",
    catchOnCancel: false,
    props: null, // <-- 추가
  },
  dialog: (options) => {
    set(
      produce<DialogStoreType>((state) => {
        state.open = true;
        state.state = { ...state.state, ...options };
      })
    );
    return new Promise<void>((resolve, reject) => {
      set(
        produce<DialogStoreType>((state) => {
          state.awaitingPromise = { resolve, reject };
        })
      );
    });
  },
  handleClose: () => {
    set(
      produce<DialogStoreType>((state) => {
        state.state.catchOnCancel && state.awaitingPromise?.reject?.();
        state.open = false;
      })
    );
  },
  handleSubmit: () => {
    set(
      produce<DialogStoreType>((state) => {
        state.awaitingPromise?.resolve?.();
        state.open = false;
      })
    );
  },
}));

const useDialogStore = createSelectorHooks(useDialogStoreBase);

export default useDialogStore;

이제 아까 만들었던 (예제리스트)처럼 openModal 함수에서 props를 함께 넘겨주어 모달이 열릴때 미리 받아온 데이터를 넘겨받을 수 있습니다.

openModal with props
1
2
/* 1. 모달로 오픈할꺼면 */
<div onClick={() => openModal(props)}>Click ${props.id} to open Modal</div>

console에 찍어보면 다음과 같습니다. 맨 처음 리스트를 불러올 때 모달에 들어갈 내용들도 함께 불러왔으므로 모달이 열릴때 추가로 api에서 불러올 필요가 없어서 즉각적으로 표시되어 로딩속도가 빠르게 느껴지는 장점이 있습니다.

응용하여 모달창이 열렸을 때 새로고침을 할 경우 router.push 하여 해당 아이템의 상세페이지로 이동하게 할 수 있습니다. 빠른 반응을 위해서 리스트에서 클릭 시에만 모달이 열리고, 새로고침, 링크공유, SEO 최적화 위해 상세페이지를 제작해 두는 것이 좋습니다.

infiniteScroll + LocalStorage + ScrollY

리스트에서 아이템을 클릭할 때 scrollY값을 LocalStorage에 저장하고, useEffect를 이용하여 scrollY가 0이 아닌 경우에는 LocalStorage에 저장된 scrollY 값으로 이동시켜주면 됩니다. Next/Link를 사용할때 onClick 이벤트를 발생시키기 위해서 a 태그를 중첩시켜주어야 합니다.

ListExample.tsx

ListExample.tsx
 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
51
52
53
54
55
56
57
58
59

const ListExample = () => {
  const { data, hasNextPage, fetchNextPage, refetch, isFetching } =
    useInfiniteQuery(
        ...
    );

  const loadMoreButtonRef = React.useRef(null);

  useIntersectionObserver({
    root: null,
    target: loadMoreButtonRef,
    onIntersect: fetchNextPage,
    enabled: hasNextPage,
  });

  // 스크롤 값을 확인
  const [scrollY, setScrollY] = useLocalStorage('PostListScroll', 0);
  React.useEffect(() => {
    if (scrollY !== 0) {
      window.scrollTo(0, Number(scrollY));
      setScrollY(0);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);


  return (
    <>
      <ul>
        {isFetching && <LoadingDiv />}
        {data?.pages.map((page) =>
          page.results.map((post: Results) => (
            <li key={post.id} data-aos='fade-up' data-aos-duration='200'>
              <Link
                href={{
                  pathname: `community/post/${post.id}`,
                  query: { post: JSON.stringify(post) },
                }}
                as={`community/post/${post.id}`}
                passHref
              >
                <a
                  href={`community/post/${post.id}`}
                  onClick={() => setScrollY(window.scrollY)} // 클릭시에 해당 y값을 저장해줌
                >
                </a>
              </Link>
            </li>
          ))
        )}
      </ul>

      <div ref={loadMoreButtonRef} />
    </>
  );
};

export default ListExample;

콘솔을 찍어보면 다음과 같습니다.

LocalStorage를 초기화 해주는 조건을 Header나 다른 감싸지는 컴포넌트에 추가해주어서 A리스트 > B리스트 > A리스트로 다른 리스트를 거쳐 돌아왔을때 A리스트의 scrollY값을 초기화 해주는 것도 필요합니다.

저의 경우엔 Header에 추가주었습니다.

header.tsx

header.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
...
  const [, setCommunityScrollY] = useLocalStorage<number>('PostListScroll', 0);
  const NavigateCommunity = () => {
    setCommunityScrollY(0);
    router.push('/community');
    window.scrollTo(0, 0);
    setMobileNavOpen(false);
  };

  const [, setFindcueScrollY] = useLocalStorage<number>('CueListScroll', 0);
  const NavigateFindCue = () => {
    setFindcueScrollY(0);
    router.push('/findcue');
    window.scrollTo(0, 0);
    setMobileNavOpen(false);
  };
...


마치며

이제 무한 스크롤을 구현할 때 상황에 따라서 모달로 열거나 새로운 페이지 이동 후 뒤로가기시 로딩된 값은 다시 불러오지 않고 해당 값으로 다시 돌아오게 할 수 있습니다.

Reference


궁금하신 점이 있으시면 아래 Comments 남겨주세요 👇