* 주의: 실제 개발하면서 정리한 것이 아니라, 구현을 어떻게 할 수 있을지 찾아보고 생각을 정리한 글입니다.
- React Query 사용
- 특징
- QueryClient라는 객체에서 관리하고, 이는 브라우저 메모리에 존재함.
- Query Key를 기준으로 저장함. 데이터+상태(status, fetching, updateAt)을 메모리 캐시에 보관함.
- staleTime으로 캐시된 데이터가 오래된 상태인지 확인하고, 오래됐으면 서버로 데이터 갱신
- useInfiniteQuery: 페이지네이션 데이터 관리할 때, 각 페이지 데이터를 구분해 메모리 캐시에 저장함.
- invalidateQueries: 서버 데이터가 변경된 경우, React Query는 invalidateQueries를 통해 캐시를 무효화.
- Garbage Collection: 일정 시간 동안 사용되지 않는 캐시를 자동으로 제거. cacheTime (기본 5분)
- 브라우저 새로고침하면 어떻게 됨?
- 캐시가 메모리 기반으로 저장되어, 브라우저 새로 고침하면 캐시 사라지고, 데이터 다시 가져옴.
- Persist Query Cache: 브라우저 저장소(localStorage, sessionStorage)에 저장하면 방지할 수 있음.
-
더보기더보기더보기
import { useInfiniteQuery } from "react-query"; const fetchTodos = async ({ pageParam = 1 }) => { const res = await fetch(`/api/todos?page=${pageParam}`); return res.json(); }; const InfiniteTodos = () => { const { data, isLoading, isError, fetchNextPage, hasNextPage, } = useInfiniteQuery("todos", fetchTodos, { getNextPageParam: (lastPage) => lastPage.nextPage || false, }); if (isLoading) return <p>Loading...</p>; if (isError) return <p>Error loading todos</p>; return ( <div> {data.pages.map((page, i) => ( <div key={i}> {page.todos.map((todo) => ( <p key={todo.id}>{todo.title}</p> ))} </div> ))} {hasNextPage && ( <button onClick={fetchNextPage}>Load More</button> )} </div> ); };
- 특징
- Intersection Observer 사용
- 특정 요소가 뷰포트에 들어왔을 때 다음 데이터를 불러오도록 구현.
- 스크롤 이벤트를 직접 사용하는 것보다 효율적이며 성능에 좋음.
-
import { useEffect, useRef } from 'react'; import axios from 'axios'; const InfiniteScrollWithObserver = () => { const [items, setItems] = React.useState([]); const [page, setPage] = React.useState(1); const [hasMore, setHasMore] = React.useState(true); const observer = useRef(); const fetchItems = async (page) => { const response = await axios.get(`/api/items?page=${page}`); if (response.data.length === 0) setHasMore(false); setItems((prev) => [...prev, ...response.data]); }; const lastItemRef = useRef(); useEffect(() => { fetchItems(page); }, [page]); useEffect(() => { if (hasMore && lastItemRef.current) { observer.current = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { setPage((prevPage) => prevPage + 1); } }, { threshold: 1.0 } ); observer.current.observe(lastItemRef.current); } return () => observer.current && observer.current.disconnect(); }, [hasMore, lastItemRef.current]); return ( <div> {items.map((item, index) => ( <div key={item.id}>{item.name}</div> ))} {hasMore && <div ref={lastItemRef}>Loading...</div>} </div> ); };
- Virtualization 라이브러리 사용
- 많은 양의 데이터를 렌더링할 경우, React Window 같은 가상화 라이브러리 사용.
- React Window: 화면에 보이는 요소만 렌더링. 나머지 요소는 렌더링 안해 DOM 노드의 개수 제한.
- Fixed Size List/Fixed Size Grid: 고정된 크기의 리스트/그리드. 높이, 너비 고정 선언.
- Variable Size List/Variable Size Grid: 가변 크기의 리스트/그리드. 높이, 너비 동적으로 선언.
- React Virtualized: 대규모 데이터 렌더링 최적화 위한 라이브러리.
- AutoSizer: 부모 요소 크기에 맞게 컴포넌트 자동 리사이징. 반응형 레이아웃에 좋음.
- WindowScroller: 브라우저 전체 스크롤바 사용해 스크롤 가능한 콘텐츠 렌더링.
-
import { FixedSizeList } from 'react-window'; const VirtualizedList = ({ items }) => { return ( <FixedSizeList height={500} width={300} itemSize={50} itemCount={items.length} itemData={items} > {({ index, style, data }) => ( <div style={style}> {data[index].name} </div> )} </FixedSizeList> ); };
- 클라이언트와 서버 간 협업
- API 응답: 페이지당 데이터 수(limit)와 다음 페이지가 있는지(hasNextPage)를 명시적으로 제공해야 함
- 로딩 상태 관리: isLoading 및 isFetchingNextPage 같은 상태를 잘 관리해 중복 호출 방지.
- 성능 최적화
- 디바운스(Debounce), 쓰로틀(Throttle): 스크롤이벤트 관리
- Debounce: 이벤트가 연속적으로 발생할 때, 마지막 이벤트가 발생한 후 일정시간 지나야 이벤트 핸들러 실행
- Throttle: 일정시간 간격 동안 발생한 이벤트 중 첫 번째 or 마지막 이벤트만 처리함(스크롤 구현에 좀 더 좋음)
- Skeleton UI: 서버 응답 데이터 반영을 기다리는 동안 빈 UI를 보여주어 UX를 향상 시킴.
- 에러 처리: 데이터 로딩 실패 시 적절한 메시지 표시 및 재시도 기능 제공
- 디바운스(Debounce), 쓰로틀(Throttle): 스크롤이벤트 관리
상황에 맞는 방법?
- 간단한 구현: React Query
- 최적화 및 성능 우선: Intersection Observer + React Window 조합
- 큰 데이터 세트 처리: React Window, React Virtualized
만약에 paginiation을 할 때, 이전에 이미 불러온 page 중 하나가 지워진다면 어떤 식으로 해결하는 게 좋을까?
- 시나리오를 구상해보자! (왜 저렇게 구현함?;;이 아니라 이런 시나리오가 있다고 생각해보자.
- 블로그 페이지가 있다. 서버 DB에는 1번부터 20번까지 글이 있고, 클라이언트는 한 번에 10개씩 보여준다.
- 클라이언트는 현재 1페이지이며, 10개의 글(1번~10번)을 보여준다.
- 서버 관리자가 DB에서 5번 글을 삭제했다!
- 클라이언트는 이 사실을 모른다. 다음 10개의 페이지를 불러와달라고 요청한다.
- DB에서는 5번 글이 지워졌으니, 첫 10개의 글은 1번~11번이고, 그 다음 10개 글은 12번~22번이라 생각함.
- DB에서 10개의 글(12번~22번)을 보내준다.
- 클라이언트는 현재 UI에 남아있는 1번부터 10번까지 글 뒤에 12번부터 22번 글을 붙여서 보여준다.
- 문제! 클라이언트 측에서는 서버에 없는 5번을 화면에 보여주고 있고, 서버에 있는 11번을 안보여주고 있음..
- 문제를 해결해보자
- 다음 데이터 호출에 관한 문제
- cursor-based 페이지네이션 : DB에서 중간 데이터가 삭제 되어도 정확히 다음 10개를 불러오자
- 삭제된 데이터가 있어도 데이터의 고유한 식별자를 기준으로 다음 데이터 로드함.
- api요청 시 파라미터에 cursor=${lastItem.id}를 넣어서, 이를 기준으로 다음 데이터를 로드함.
- 중간에 데이터가 삭제되어도, 마지막 아이템의 id를 기준으로 다음 것을 불러와서 상관 없음.
- 일단 다음 데이터를 호출하는 것은 성공했으니, 서버에 있는 11번을 안보여주는 문제는 해결했음
- cursor-based 페이지네이션 : DB에서 중간 데이터가 삭제 되어도 정확히 다음 10개를 불러오자
- 삭제된 데이터가 여전히 화면에 남아있는 문제
- 여전히 1번부터 10번 글이 전부 보이고 있고, 5번 글은 화면에서 사라지지 않았는걸!
- 낙관적 업데이트
- 사용자가 5번 글 누르면 그때 DB 확인하지 뭐~
- 5번 글 누름 -> DB에 없는 글. "삭제된 글입니다." 팝업.
- fetch API 써도 리소스 감당 가능하면 1번부터 10번(5번 없음)글을 다시 보내서 클라이언트 data fetch
- 문제! 만약 민감한 데이터 잘못 올렸다면, 오랫동안 사용자 측에 민감 데이터가 남아있을 수도 있음.
- 민감 데이터가 올라갈 가능성이 있다면, 리소스가 들더라도 동기화를 자주 해주는 구현을 써야 함.
- 서버에서 불일치 데이터 필터링.
- 클라이언트가 가지고 있는 데이터 상태를 인식하고, 삭제된 데이터 필터링해서 반환.
- 클라이언트는 cursor=10과 현재 가지고 있는 글의 id인 [1,2,3,...,10]을 함께 보냄.
- 서버는 클라이언트가 가진 id 중 5번이 DB에 없는 걸 발견함.
- 클라이언트에서 deletedIds:[5]라고 알려주고, 다시 랜더링할 부분+새로운 10개 글을 반환함.
결과적으로 6번~20번 포스트를 반환함. - 만약 클라이언트에서 항상 한 페이지 10개 기준을 만족해야 한다면
빠진 5번 글을 감안해서 6번~21번 포스트 반환하도록 설계해야할 듯?
- 클라이언트 캐시 무효화, 데이터 재요청
- React Query 사용한 경우
- 5번 글이 삭제되면, 해당 페이지 데이터를 무효화(invalidateQueries)해서 전체 페이지 다시 가져옴.
- 전체 페이지 데이터를 통째로 re로드 하는거라 동기화는 좋지만, 네트워크 비용은 많이 나감.
- Soft Delete
- DB에서 is_deleted 필드 추가함. 클라이언트 측에서 이 값을 기준으로 UI에서 제거해서 보여줌.
- DB 변화사항을 서버에서 감지함. 클라이언트에게 변경 사항을 알림.
- 서버 응답 중 changes 정보 안에 있는 id 글들을 클라이언트 캐시에서 제거함.
- 이는 설계에 따라 다를 것 같은데, 사용자가 다음 페이지를 요청했을 때 응답으로 같이 알려줄 수 있을 것 같고, 아니면 주기를 정해서 한 번씩 변경 사항을 fetch할 수도 있을 것 같음.
- 다음 데이터 호출에 관한 문제
- 이미지 최적화
- 적절한 크기의 이미지 제공
- 브라우저에서 렌더링 되는 이미지의 크기만큼만 서버에서 제공
- 원본 이미지가 아니라 리사이징.
- 요청에 따라 이미지 크기를 동적으로 생성해 필요한 크기만 반환. ?w=300&h=200
- Lazy Loading
- 이미지가 화면에 나타날 때만 로드하도록. 초기 로딩 속도를 크게 개선할 수 있음.
- SSR과 병행해서 초기 페이지 로드 시 중요한 이미지만 로드하도록 개선할수도 있음.
- 이미지 포맷 최적화
- 브라우저 호환성 고려.
- WebP는 JPEG보다 약 30~50% 더 효율적인 압축을 제공함.
- AVIF는 WebP보다 더 높은 압축률을 제공하며, 최신 브라우저에서 지원함.
- 이미지 처리 서버나 CDN에서 자동으로 최적화된 포맷을 제공
- 이미지 압축
- 무손실 압축: 이미지 품질 유지하며 크기 줄임. ImageOptim, TinyPNG
- 손실 압축: 품질 약간 낮추고, 크기 더 줄임. Squoosh, Karken.io
- 서버에서 압축: Nginx(이미지 압축), ImageMagick(이미지 크기와 압축을 동적으로 조절)
- 이미지 캐싱: 동일한 이미지에 대해 중복 요청 방지
- HTTP 캐싱: 이미지 응답 헤더에 캐싱 지시어 추가. Cache-Control: public, max-age=31536000
- CDN(Content Delivery Network): 사용자와 가까운 서버에서 이미지 서빙.(CloudFront, Cloudflare)
- 반응형 이미지: srcset(여러 이미지와 해상도 옵션 제공), sizes(뷰포트 크기에 따라 사용할 이미지 크기 정의)
- UX 개선
- Placeholder 사용: Blurred Placeholder(로딩 중 블러처리된 작은 이미지 표시)
- Skeleton UI: 이미지를 로드하는 동안 스켈레톤 애니메이션 표시.
- 적절한 크기의 이미지 제공
'개발자 강화 > 프론트엔드' 카테고리의 다른 글
[매일메일] 시맨틱 마크업이란? (FE.250124) (0) | 2025.01.24 |
---|---|
[매일메일] 타입스크립트의 타입과 인터페이스 차이? (FE.250123) (1) | 2025.01.23 |
[매일메일] 낙관적 업데이트란? (FE.250121) (0) | 2025.01.21 |
[매일메일] 자바스크립트의 호이스팅(feat. 인터프리터 언어)(FE.250110/241230) (0) | 2025.01.20 |
[매일메일] React의 Controlled Component와 Uncontrolled Component의 차이점? (FE.241220) (0) | 2025.01.20 |