프로젝트/COWERKERS

버추얼 스크롤 이슈

인재재 2024. 11. 4. 19:49

버추얼 스크롤

팀 페이지에서 팀내의 할 일 목록, 멤버의 데이터를 불러와서 페이지에 뿌려야하는 부분이다.

{
  "teamId": "string",
  "image": "string",
  "name": "string",
  "id": 0,
  "members": [
    {
      "role": "ADMIN",
      "userImage": "string",
      "userEmail": "string",
    }
  ],
  "taskLists": [
    {
      "groupId": 0,
      "name": "string",
      "id": 0,
    }
  ]
}

 

API상으로 GetGroup요청 한 번으로 팀과 연관된 모든 할 일 목록들과 멤버들의 데이터를 불러올 수 있도록 설정되어있지만,

 

할 일 목록과 멤버의 수가 길어지는 경우 페이지가 한 없이 길어지고 성능에 부담을 줄수 있는 문제를 해결하기 위한 슈팅이었다.

 

TaskList, Member를 따로 get API로 불러와서 무한 스크롤이나 페이지네이션을 적용하려고 고민했었는데!

 

하지만 백엔드상에서 처리할 Offset이나 cursur등의 속성이 존재하지 않았고 Member는 목록리스트들을 한번에 가져오는 API자체를 가지고 있지 않았다. ( 부트캠프에서 제공한 백엔드 서버였기 때문에 타협 할 수 없었다...)

 

해결!

TaskList

그래서 TaskList에서는 특정 영역 이상으로 목록이 많아지면 스크롤이 생성되도록 구성하고 렌더링이 너무 과하게 일어나지 않도록 가상 스크롤로 구성하였다.

 

가상 스크롤은 화면에서 보이지 않는 부분의 내용을 출력하지 않고, 실제로 화면에 보여질 때만 항목을 렌더링하는 방식의 스크롤을 말한다. 많은 양의 리스트 데이터를 화면에 그리는 경우, 모든 항목을 그리면 성능문제 개선을 기대해 볼 수있다.

 

 

요소들이 최대 6개에서 더 늘어나지않는 상태에서 영역만 교체되는 것을 볼 수있다.

 

( 영역 내 배치될 수 있는 요소는 4개지만 사용자가 보는 시점에서 요소가 없어졌다, 나타났다하는 현상을 감추기위해 일부로 불러오는 시점의 높이를 늘렸다 )

 

요약하여 얘기하자면 가상 스크롤 범위를 설정해 스크롤 이벤트가 발생할 때마다 useRef의 current 값을 활용해 현재 스크롤 위치를 파악한다. 이 값을 useState로 관리하여 필요한 데이터를 갱신하고, 이를 바탕으로 현재 보여야 할 뷰포트의 높이를 동적으로 계산하여 해당 범위에 있는 item들만 랜더링이 되도록 로직을 짯다.

 

const scrollRef = useRef<HTMLDivElement>(null); 
const [scrollTop, setScrollTop] = useState(0); 
const [viewportHeight, setViewportHeight] = useState(0);

 

우선, scrollRef로 스크롤 이벤트로 감지할 DOM 요소를 참조하며, scrollTop은 현재 스크롤 위치를 저장한다.

viewportHeight는 스크롤 컨테이너의 높이로, 현재 보이는 영역의 높이를 한다.

const handleScroll = () => 
{ if (scrollRef.current) { setScrollTop(scrollRef.current.scrollTop); } };
 
  • handleScroll 함수는 스크롤이 발생할 때마다 현재 스크롤 위치(scrollTop)를 갱신
  • scrollRef.current.scrollTop은 스크롤의 상단 위치를 픽셀 단위로 반환
const containerHeight = itemHeight * React.Children.count(children);
  • containerHeight로 모든 항목이 렌더링될 때의 전체 높이를 계산
  • React.Children.count(children)로 children의 개수를 구한 후 각 항목의 높이인 itemHeight와 곱해 전체 컨테이너의 높이를 얻음
const startIndex = Math.max( Math.floor(scrollTop / itemHeight) - renderAhead, 0 );
const endIndex = Math.min( Math.ceil(viewportHeight / itemHeight) + startIndex 
+ renderAhead, React.Children.count(children) );

startIndexendIndex로 현재 스크롤 위치에서 렌더링할 항목의 범위를 정한다

  • startIndex는 scrollTop을 itemHeight로 나눈 값을 기준으로 스크롤 위치에 따라 시작 인덱스를 계산한다. 여기에 renderAhead 값을 빼주어, 추가적인 요소를 미리 렌더링하여 부드러운 스크롤을 지원한다.
  • endIndex는 viewportHeight를 itemHeight로 나눈 값을 이용해 시작 인덱스부터 미리 렌더링할 요소 수를 더한 범위를 설정
const visibleItems = React.Children.toArray(children).slice( startIndex, endIndex );

visibleItems는 children에서 startIndex와 endIndex 사이에 해당하는 항목들을 추출하여 화면에 표시할 목록으로 설정한다.

const translateY = itemHeight * startIndex;

 

translateY는 startIndex에 따라 실제 요소들이 표시될 위치를 설정하고, 스크롤에 따라 요소의 위치를 translateY로 설정하여 상단에 올바르게 배치되도록 한다.

useEffect(() => {
if (scrollRef.current) { setViewportHeight(scrollRef.current.clientHeight); 
scrollRef.current.addEventListener('scroll', handleScroll);
return () => scrollRef.current?.removeEventListener('scroll', handleScroll); } }, []);

useEffect는 컴포넌트가 마운트될 때 스크롤 이벤트를 등록하고 언마운트 시 이벤트 리스너를 제거하며,

scrollRef.current.clientHeight를 통해 뷰포트의 높이를 설정한다.

return ( 
<div className="h-full overflow-y-auto" ref={scrollRef}> 
<div style={{ height: `${containerHeight}px`, position: 'relative' }}> 
<div style={{ transform: `translateY(${translateY}px)` }}> {visibleItems}
</div>
</div> 
</div> );
  • 최상위 div: 스크롤 컨테이너를 scrollRef에 연결하고, overflow-y-auto로 세로 스크롤을 활성화
  • 컨테이너 div: containerHeight로 전체 컨테이너의 높이를 지정
  • 내부 요소 div: transform: translateY(${translateY}px)를 적용해 startIndex 위치에 맞춰 visibleItems를 배치

 

성능 비교 - 어떻게 성능이 향상되었는지 확인할 수 있을까 ?

 라이트하우스에서 쉽게 확인할수 있는 웹 성능 측정 지표(Performance Metrics)인

  • FCP (First Contentful Paint): 첫 번째 콘텐츠(텍스트, 이미지 등)가 화면에 렌더링되는 시점
  • LCP (Largest Contentful Paint): 가장 큰 콘텐츠가 화면에 렌더링되는 시점
  • TTI (Time to Interactive): 사용자가 페이지와 상호작용할 수 있는 상태가 되는 시점

이 지표들은 사용자가 느끼는 체감 성능과 관련이 있다.

 

초기 렌더링의 방식을 바꿀 뿐, 최초 로딩 속도(FCP, LCP, TTI)에는 거의 영향을 주지 않을 것

 

1️⃣ FCP는 "최초 콘텐츠가 언제 보이는가"를 기준으로 하기 때문

  • 버추얼 스크롤을 적용해도 초기 렌더링되는 TaskItem 몇 개는 그대로 렌더링됨
  • 즉, 기존 방식이나 버추얼 스크롤이나 처음 화면에 나타나는 속도는 동일하므로, FCP에는 영향이 없음.

2️⃣ LCP는 보통 큰 타이틀, 배너 이미지 등이 기준이기 때문

  • 버추얼 스크롤을 적용해도 LCP 요소(예: 페이지의 큰 제목, 헤더 이미지 등)가 변화하지 않음
  • 리스트 아이템(TaskItem)은 LCP 요소가 아닐 가능성이 높아 LCP에 영향이 없음.

3️⃣ TTI는 페이지의 인터랙션 가능 여부 기준이기 때문

  • TTI는 페이지가 언제부터 버튼 클릭, 입력 등이 원활해지는지를 측정
  • 버추얼 스크롤은 스크롤 시 동적 렌더링을 할 뿐 초기 TTI에는 영향을 주지 않음
  • 오히려, 사용자가 스크롤할 때 느끼는 CPU 사용량 감소 효과가 더 중요함.

 

 

그렇기에 React Profiler가 측정하는 actualDuration ( 컴포넌트가 업데이트될 때 React가 해당 컴포넌트를 다시 렌더링하는 데 소요된 시간 )

이 값은 실제 실행된 렌더링 연산에 걸린 시간만 측정하며, 브라우저의 다른 작업이나 비동기 로직은 포함되지 않음.

 

 

 320.59ms에서 19.59ms로 93.89% 단축시킨 것을 볼수있다.

 

Virtual Scroll 적용 후 actualDuration이 줄어들었다면, 화면에 그리는 아이템 개수가 줄어서 렌더링 최적화 효과가 있는 것! 

 

 

 

https://github.com/team-collabor/coworkers/blob/main/src/components/TaskList/VirtualScroll.tsx

 

coworkers/src/components/TaskList/VirtualScroll.tsx at main · team-collabor/coworkers

Contribute to team-collabor/coworkers development by creating an account on GitHub.

github.com

 

멤버는 프론트 단에서 페이지네이션을 적용하였다.

 

https://github.com/team-collabor/coworkers/blob/main/src/components/Team/Members.tsx

 

coworkers/src/components/Team/Members.tsx at main · team-collabor/coworkers

Contribute to team-collabor/coworkers development by creating an account on GitHub.

github.com

 

이렇게 하나의 API에서 호출해 받아온 모든 데이터들을 가져오면서도 성능 최적화 문제를 해결하기 위한 노력들을 하였다.

 

 

'프로젝트 > COWERKERS' 카테고리의 다른 글

COWERKERS 에러 바운더리  (1) 2024.12.13
COWERKERS 에러 핸들링  (1) 2024.12.13