From b1420669b90c96d864a5e35b251fe7deee339648 Mon Sep 17 00:00:00 2001 From: constantly-dev Date: Mon, 2 Mar 2026 17:19:00 +0900 Subject: [PATCH 1/6] setting: add react-intersection-observer --- apps/client/package.json | 5 +++-- pnpm-lock.yaml | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/client/package.json b/apps/client/package.json index 2c34b324..ba5b8693 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -15,11 +15,12 @@ "class-variance-authority": "^0.7.1", "firebase": "^12.2.1", "framer-motion": "^12.23.12", + "lottie-react": "^2.4.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-error-boundary": "^6.0.0", - "react-router-dom": "^7.8.2", - "lottie-react": "^2.4.1" + "react-intersection-observer": "^10.0.3", + "react-router-dom": "^7.8.2" }, "devDependencies": { "@eslint/js": "^9.33.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b7db4b1..fe006cc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: react-error-boundary: specifier: ^6.0.0 version: 6.0.0(react@19.1.1) + react-intersection-observer: + specifier: ^10.0.3 + version: 10.0.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1) react-router-dom: specifier: ^7.8.2 version: 7.8.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -4129,6 +4132,15 @@ packages: peerDependencies: react: '>=16.13.1' + react-intersection-observer@10.0.3: + resolution: {integrity: sha512-luICLMbs0zxTO/70Zy7K5jOXkABPEVSAF8T3FdZUlctsrIaPLmx8TZe2SSA+CY2HGWfz2INyNTnp82pxNNsShA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -8988,6 +9000,12 @@ snapshots: '@babel/runtime': 7.28.3 react: 19.1.1 + react-intersection-observer@10.0.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + react: 19.1.1 + optionalDependencies: + react-dom: 19.1.1(react@19.1.1) + react-is@16.13.1: {} react-is@17.0.2: {} From 1974109a5a5ca12b59c109fead26e648b84b51e6 Mon Sep 17 00:00:00 2001 From: constantly-dev Date: Mon, 2 Mar 2026 17:19:27 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20useInfiniteScroll=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20useInView=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/shared/hooks/useInfiniteScroll.ts | 94 ++++++++++--------- 1 file changed, 51 insertions(+), 43 deletions(-) diff --git a/apps/client/src/shared/hooks/useInfiniteScroll.ts b/apps/client/src/shared/hooks/useInfiniteScroll.ts index 63bdee18..1fe171b7 100644 --- a/apps/client/src/shared/hooks/useInfiniteScroll.ts +++ b/apps/client/src/shared/hooks/useInfiniteScroll.ts @@ -1,51 +1,59 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useInView } from 'react-intersection-observer'; -interface UseInfiniteScrollProps { - fetchNextPage: () => void; - hasNextPage?: boolean; - root?: React.RefObject; - threshold?: number; -} +type UseInfiniteScrollProps = { + /** 더 불러올 데이터가 있는지 */ + hasMore?: boolean; + /** 추가 로딩 중인지 (중복 호출 방지용) */ + isLoadingMore?: boolean; + /** 다음 데이터를 불러오는 함수 */ + loadMore: () => void | Promise; -/** - * Intersection Observer를 사용하여 무한 스크롤을 구현하는 커스텀 훅 - * @returns observer가 감지할 엘리먼트에 연결할 ref 객체 - */ + /** 내부 스크롤 컨테이너를 root로 쓰고 싶을 때 */ + rootRef?: React.RefObject; + + /** 바닥에 닿기 전에 미리 불러오기 (ex: '200px 0px') */ + rootMargin?: string; + + /** 무한 스크롤을 일시적으로 끄기 */ + enabled?: boolean; +}; export const useInfiniteScroll = ({ - fetchNextPage, - hasNextPage, - root, - threshold = 0.5, + hasMore, + isLoadingMore = false, + loadMore, + rootRef, + rootMargin = '200px 0px', + enabled = true, }: UseInfiniteScrollProps) => { - const targetRef = useRef(null); + const [rootEl, setRootEl] = useState(null); + + useEffect(() => { + setRootEl(rootRef?.current ?? null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const lockRef = useRef(false); + + const { ref, inView } = useInView({ + root: rootEl, + threshold: 0, + rootMargin, + }); useEffect(() => { - if (!hasNextPage) return; - - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - fetchNextPage(); - } - }, - { - root: root?.current, - threshold, - } - ); - - const currentTarget = targetRef.current; - if (currentTarget) { - observer.observe(currentTarget); - } - - return () => { - if (currentTarget) { - observer.unobserve(currentTarget); - } - }; - }, [fetchNextPage, hasNextPage, root, threshold]); - - return targetRef; + if (!enabled) return; + if (!inView) return; + if (!hasMore) return; + if (isLoadingMore) return; + if (lockRef.current) return; + + lockRef.current = true; + Promise.resolve(loadMore()).finally(() => { + lockRef.current = false; + }); + }, [enabled, inView, hasMore, isLoadingMore, loadMore]); + + return ref; }; From 17cf36035a0e980c3dfa95f2198d2f0445939bbb Mon Sep 17 00:00:00 2001 From: constantly-dev Date: Mon, 2 Mar 2026 17:20:10 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EB=B0=94=EB=80=90=20useInfiniteScr?= =?UTF-8?q?oll=20interface=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/src/pages/jobPins/JobPins.tsx | 9 +++++---- .../hooks/useMyBookmarkContentData.ts | 20 +++++++++++++------ apps/client/src/pages/remind/Remind.tsx | 13 ++++++------ 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/apps/client/src/pages/jobPins/JobPins.tsx b/apps/client/src/pages/jobPins/JobPins.tsx index 23571801..53c946c8 100644 --- a/apps/client/src/pages/jobPins/JobPins.tsx +++ b/apps/client/src/pages/jobPins/JobPins.tsx @@ -13,7 +13,7 @@ const JobPins = () => { const { scrollContainerRef, isBottomNoticeVisible, handleBottomWheel } = useJobPinsBottomNotice(); - const { data, isPending, fetchNextPage, hasNextPage } = + const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useGetJobPinsArticles(); const { mutate: getJobPinDetail, @@ -22,9 +22,10 @@ const JobPins = () => { } = useGetJobPinsArticleDetail(); const observerRef = useInfiniteScroll({ - fetchNextPage, - hasNextPage, - root: scrollContainerRef, + loadMore: fetchNextPage, + hasMore: hasNextPage, + isLoadingMore: isFetchingNextPage, + rootRef: scrollContainerRef, }); const articlesToDisplay = diff --git a/apps/client/src/pages/myBookmark/hooks/useMyBookmarkContentData.ts b/apps/client/src/pages/myBookmark/hooks/useMyBookmarkContentData.ts index d81ecd28..dcca585b 100644 --- a/apps/client/src/pages/myBookmark/hooks/useMyBookmarkContentData.ts +++ b/apps/client/src/pages/myBookmark/hooks/useMyBookmarkContentData.ts @@ -27,16 +27,18 @@ export const useMyBookmarkContentData = ({ data: bookmarkArticlesData, fetchNextPage: fetchNextBookmarkArticles, hasNextPage: hasNextBookmarkArticles, + isFetchingNextPage: isFetchingNextBookmarkArticles, } = useGetBookmarkArticles(readStatus); + const { data: bookmarkCountData } = useGetBookmarkArticlesCount(); - const { data: categoryCountData } = useGetCategoryBookmarkArticlesCount( - categoryId - ); + const { data: categoryCountData } = + useGetCategoryBookmarkArticlesCount(categoryId); const { data: categoryArticlesData, fetchNextPage: fetchNextCategoryArticles, hasNextPage: hasNextCategoryArticles, + isFetchingNextPage: isFetchingNextCategoryArticles, } = useGetCategoryBookmarkArticles(categoryId, readStatus); const categoryList = @@ -62,14 +64,20 @@ export const useMyBookmarkContentData = ({ const hasNextPage = isCategoryView ? hasNextCategoryArticles : hasNextBookmarkArticles; + const fetchNextPage = isCategoryView ? fetchNextCategoryArticles : fetchNextBookmarkArticles; + const isFetchingNextPage = isCategoryView + ? isFetchingNextCategoryArticles + : isFetchingNextBookmarkArticles; + const sentinelRef = useInfiniteScroll({ - fetchNextPage, - hasNextPage, - root: scrollContainerRef, + loadMore: fetchNextPage, + hasMore: hasNextPage, + isLoadingMore: isFetchingNextPage, + rootRef: scrollContainerRef, }); return { diff --git a/apps/client/src/pages/remind/Remind.tsx b/apps/client/src/pages/remind/Remind.tsx index 285963cd..de5e684d 100644 --- a/apps/client/src/pages/remind/Remind.tsx +++ b/apps/client/src/pages/remind/Remind.tsx @@ -46,15 +46,14 @@ const Remind = () => { useGetArticleDetail(); const { mutate: updateToReadStatus } = usePutArticleReadStatus(); const { mutate: deleteArticle } = useDeleteRemindArticle(); - const { data, isPending, fetchNextPage, hasNextPage } = useGetRemindArticles( - formattedDate, - activeBadge === 'read' - ); + const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = + useGetRemindArticles(formattedDate, activeBadge === 'read'); const observerRef = useInfiniteScroll({ - fetchNextPage, - hasNextPage, - root: scrollContainerRef, + loadMore: fetchNextPage, + hasMore: hasNextPage, + isLoadingMore: isFetchingNextPage, + rootRef: scrollContainerRef, }); const { From c7b06af25e4cef0a95976cc88044bf5f157f148c Mon Sep 17 00:00:00 2001 From: constantly-dev Date: Mon, 2 Mar 2026 17:20:48 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20getNextPageParam=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=A1=B0=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/src/pages/jobPins/apis/queries.ts | 4 +++- apps/client/src/pages/myBookmark/apis/queries.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/client/src/pages/jobPins/apis/queries.ts b/apps/client/src/pages/jobPins/apis/queries.ts index 3a9894b6..533c6b91 100644 --- a/apps/client/src/pages/jobPins/apis/queries.ts +++ b/apps/client/src/pages/jobPins/apis/queries.ts @@ -6,13 +6,15 @@ import { JobPinsDetailResponse, } from './axios'; +const PAGE_SIZE = 20; + export const useGetJobPinsArticles = () => { return useInfiniteQuery({ queryKey: ['jobPinsArticles'], queryFn: ({ pageParam = 0 }) => getJobPinsArticles(pageParam as number, 20), initialPageParam: 0, getNextPageParam: (lastPage, allPages) => { - if (lastPage.articles.length === 0) { + if (lastPage.articles.length < PAGE_SIZE) { return undefined; } diff --git a/apps/client/src/pages/myBookmark/apis/queries.ts b/apps/client/src/pages/myBookmark/apis/queries.ts index 9b722659..741e3072 100644 --- a/apps/client/src/pages/myBookmark/apis/queries.ts +++ b/apps/client/src/pages/myBookmark/apis/queries.ts @@ -3,15 +3,15 @@ import { useSuspenseInfiniteQuery, useSuspenseQuery, } from '@tanstack/react-query'; +import { CategoryBookmarkArticleResponse } from '../types/api'; import { getBookmarkArticles, getBookmarkArticlesCount, getCategoryBookmarkArticles, getCategoryBookmarkArticlesCount, } from './axios'; -import { - CategoryBookmarkArticleResponse, -} from '../types/api'; + +const PAGE_SIZE = 20; export const useGetBookmarkArticles = (readStatus: boolean | null) => { return useSuspenseInfiniteQuery({ @@ -20,7 +20,7 @@ export const useGetBookmarkArticles = (readStatus: boolean | null) => { getBookmarkArticles(readStatus, Number(pageParam), 20), initialPageParam: 0, getNextPageParam: (lastPage, allPages) => - lastPage.articles.length === 0 ? undefined : allPages.length, + lastPage.articles.length < PAGE_SIZE ? undefined : allPages.length, }); }; @@ -55,7 +55,8 @@ export const useGetCategoryBookmarkArticles = ( initialPageParam: 0, getNextPageParam: (lastPage, allPages) => { - if (!lastPage || lastPage.articles.length === 0) return undefined; + if (!lastPage) return undefined; + return lastPage.articles.length < PAGE_SIZE ? undefined : allPages.length; return allPages.length; }, }); From 78d06ea3cd7797cc112dc94856d2d15d79b44b76 Mon Sep 17 00:00:00 2001 From: constantly-dev Date: Mon, 2 Mar 2026 17:47:51 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/src/pages/myBookmark/apis/queries.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/client/src/pages/myBookmark/apis/queries.ts b/apps/client/src/pages/myBookmark/apis/queries.ts index 741e3072..4863f399 100644 --- a/apps/client/src/pages/myBookmark/apis/queries.ts +++ b/apps/client/src/pages/myBookmark/apis/queries.ts @@ -57,7 +57,6 @@ export const useGetCategoryBookmarkArticles = ( getNextPageParam: (lastPage, allPages) => { if (!lastPage) return undefined; return lastPage.articles.length < PAGE_SIZE ? undefined : allPages.length; - return allPages.length; }, }); }; From ac30c770e3b950e454ce7d4875b7de8920f6fe06 Mon Sep 17 00:00:00 2001 From: constantly-dev Date: Mon, 2 Mar 2026 21:00:29 +0900 Subject: [PATCH 6/6] fix(client): pass scroll root directly to useInView in useInfiniteScroll --- apps/client/src/shared/hooks/useInfiniteScroll.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/client/src/shared/hooks/useInfiniteScroll.ts b/apps/client/src/shared/hooks/useInfiniteScroll.ts index 1fe171b7..331d4a48 100644 --- a/apps/client/src/shared/hooks/useInfiniteScroll.ts +++ b/apps/client/src/shared/hooks/useInfiniteScroll.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { useInView } from 'react-intersection-observer'; type UseInfiniteScrollProps = { @@ -27,17 +27,10 @@ export const useInfiniteScroll = ({ rootMargin = '200px 0px', enabled = true, }: UseInfiniteScrollProps) => { - const [rootEl, setRootEl] = useState(null); - - useEffect(() => { - setRootEl(rootRef?.current ?? null); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const lockRef = useRef(false); const { ref, inView } = useInView({ - root: rootEl, + root: rootRef?.current ?? null, threshold: 0, rootMargin, });