diff --git a/package.json b/package.json index 3a052a2b..e74e3074 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,10 @@ "dayjs": "^1.11.12", "firebase": "^10.13.0", "heic2any": "^0.0.4", + "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-responsive": "^10.0.0", + "react-responsive": "^10.0.0", "react-router-dom": "^6.24.1", "recoil": "^0.7.7", "recoil-persist": "^5.1.0", @@ -29,6 +30,7 @@ "swiper": "^11.1.8" }, "devDependencies": { + "@types/lodash": "^4.17.13", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/src/pages/Home/HomeTopBar/index.tsx b/src/pages/Home/HomeTopBar/index.tsx index b3e4bcd6..def7acee 100644 --- a/src/pages/Home/HomeTopBar/index.tsx +++ b/src/pages/Home/HomeTopBar/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Button, ButtonContainer, HomeLogo, HomeTopBarContainer } from './styles'; import logo from '@assets/default/oodd.svg'; -import alarm from '@assets/default/alarm.svg'; +import Alarm from '@/components/Icons/Alarm'; // Home 페이지의 상단 바입니다. 로고와 알림이 있습니다. // TODO: 버튼 클릭 이벤트 처리 필요 @@ -11,7 +11,7 @@ const HomeTopBar: React.FC = () => { - + diff --git a/src/pages/Home/OOTD/Feed/index.tsx b/src/pages/Home/OOTD/Feed/index.tsx index 7963ae35..9a3251b4 100644 --- a/src/pages/Home/OOTD/Feed/index.tsx +++ b/src/pages/Home/OOTD/Feed/index.tsx @@ -21,12 +21,11 @@ import { } from './styles'; import more from '@assets/default/more.svg'; import xBtn from '@assets/default/reject.svg'; -import likeBtn from '@assets/default/heart.svg'; -import likeFillBtn from '@assets/default/heart-fill.svg'; -import commentBtn from '@assets/default/message-white.svg'; import { useNavigate } from 'react-router-dom'; import defaultProfile from '@assets/default/defaultProfile.svg'; import dayjs from 'dayjs'; +import Heart from '@/components/Icons/Heart'; +import Message from '@/components/Icons/Message'; import { OptionsBottomSheetProps } from '@components/BottomSheet/OptionsBottomSheet/dto'; import OptionsBottomSheet from '@components/BottomSheet/OptionsBottomSheet'; import CommentBottomSheet from '@components/CommentBottomSheet'; @@ -240,14 +239,13 @@ const Feed: React.FC = ({ feed }) => { - {isLikeClicked ? ( - - ) : ( - - )} + + {/* Heart 컴포넌트의 isFilled 프로퍼티에 isLikeClicked 상태를 전달 */} + + - + 매칭 요청 diff --git a/src/pages/Home/OOTD/index.tsx b/src/pages/Home/OOTD/index.tsx index ffe42dda..7b8f79b8 100644 --- a/src/pages/Home/OOTD/index.tsx +++ b/src/pages/Home/OOTD/index.tsx @@ -1,4 +1,5 @@ -import React, { useState, useEffect, useRef, useLayoutEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; +import debounce from 'lodash/debounce'; import { OOTDContainer, FeedContainer } from './styles'; import Feed from './Feed/index'; import { getPostListApi } from '@apis/post'; @@ -13,34 +14,35 @@ const OOTD: React.FC = () => { const [modalContent, setModalContent] = useState('알 수 없는 오류입니다.\n관리자에게 문의해 주세요.'); const [isStatusModalOpen, setIsStatusModalOpen] = useState(false); - const [reachedEnd, setReachedEnd] = useState(false); + // API 요청 중인지 확인하는 변수 (렌더링 없이 상태 관리) const isFetchingRef = useRef(false); + // 모든 데이터를 불러왔는지 확인하는 변수 + const isReachedEndRef = useRef(false); + + // 현재 페이지 번호를 참조하는 변수, 리렌더링 없이 값만 업데이트하기 위해 상태가 아닌 useRef 사용 const feedPageRef = useRef(1); + + // 세션 스토리지에서 이전 스크롤 위치를 가져와 초기화 const savedScrollPosition = sessionStorage.getItem('scrollPosition'); const scrollPositionRef = useRef(Number(savedScrollPosition) || 0); - // 스크롤 이벤트 핸들러 추가 - const handleScroll = () => { - // 모든 데이터를 불러왔거나 아직 렌더링이 다 안 된 경우 반환 - if (reachedEnd || isFetchingRef.current) return; - - if (window.innerHeight + document.documentElement.scrollTop >= document.body.scrollHeight - window.innerHeight) { - isFetchingRef.current = true; - scrollPositionRef.current = window.scrollY; // 현재 스크롤 위치 저장 - getPostList(); - } - }; + // IntersectionObserver 인스턴스를 참조하는 변수 + const observerRef = useRef(null); + // 더 많은 데이터를 로드할 때 관찰할 마지막 요소의 DOM을 참조 + const loadMoreRef = useRef(null); - // 전체 게시글(피드) 조회 api + // 전체 게시글(피드) 조회 API const getPostList = async () => { - if (reachedEnd) return; + // 모든 데이터를 불러왔거나 요청 중이라면 함수 실행 중단 + if (isReachedEndRef.current || isFetchingRef.current) return; + isFetchingRef.current = true; // 요청 중임을 표시 try { const response = await getPostListApi(feedPageRef.current, 20); if (response.isSuccess) { if (response.data.post.length === 0) { - setReachedEnd(true); + isReachedEndRef.current = true; // 더 이상 불러올 데이터가 없음을 표시 } else { setFeeds((prevFeeds) => [...prevFeeds, ...response.data.post]); feedPageRef.current += 1; @@ -50,22 +52,58 @@ const OOTD: React.FC = () => { const errorMessage = handleError(error); setModalContent(errorMessage); setIsStatusModalOpen(true); + } finally { + isFetchingRef.current = false; } }; useEffect(() => { - getPostList(); - window.addEventListener('scroll', handleScroll); + if (isReachedEndRef.current && observerRef.current && loadMoreRef.current) { + observerRef.current.unobserve(loadMoreRef.current); // 데이터의 끝에 다다르면 옵저버 해제 (더이상 피드가 없으면) + return; + } + + // Intersection Observer 생성 + observerRef.current = new IntersectionObserver( + debounce((entries) => { + const target = entries[0]; + console.log('Intersection Observer:', target.isIntersecting); + if (target.isIntersecting && !isFetchingRef.current && !isReachedEndRef.current) { + // 요소가 화면에 보이고 있고, 요청 중이 아니며 끝에 도달하지 않았다면 API 호출 + getPostList(); + } + }, 300), // 디바운스 적용, 마지막 스크롤 이후 300ms 동안 동작이 없으면 이벤트 호출 + { + root: null, + rootMargin: '100px', // 요소가 보이기 100px 전에 미리 데이터 로드 + threshold: 0, // 요소가 아주 조금이라도 보이면 트리거 + }, + ); + // 옵저버를 마지막 요소에 연결 + if (loadMoreRef.current) { + observerRef.current.observe(loadMoreRef.current); + } return () => { - window.removeEventListener('scroll', handleScroll); + // 컴포넌트 언마운트 시 옵저버 해제 + if (observerRef.current && loadMoreRef.current) { + observerRef.current.unobserve(loadMoreRef.current); + } }; }, []); - useLayoutEffect(() => { - window.scrollTo(0, scrollPositionRef.current); // 저장된 스크롤 위치로 이동 - isFetchingRef.current = false; - }, [feeds]); // feeds가 변경될 때 실행 + useEffect(() => { + // 첫 로드 시 API 호출 + getPostList(); + + // 세션 저장된 이전 스크롤 위치 복원 + window.scrollTo(0, scrollPositionRef.current); + + return () => { + // 컴포넌트 언마운트 시 현재 스크롤 위치를 세션 스토리지에 저장 + sessionStorage.setItem('scrollPosition', String(window.scrollY)); + }; + }, []); const statusModalProps: ModalProps = { content: modalContent, @@ -82,6 +120,8 @@ const OOTD: React.FC = () => { ))} + {/* Intersection Observer가 감지할 마지막 요소 */} + {isStatusModalOpen && } diff --git a/yarn.lock b/yarn.lock index cd5cf80b..ac22970f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1068,6 +1068,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/lodash@^4.17.13": + version "4.17.13" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.13.tgz#786e2d67cfd95e32862143abe7463a7f90c300eb" + integrity sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg== + "@types/node@>=12.12.47", "@types/node@>=13.7.0": version "22.10.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" @@ -1927,6 +1932,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + long@^5.0.0: version "5.2.3" resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1"