계층형 데이터의 렌더링 최적화 전략: 가상화부터 페이지네이션까지

Riido는 사용자가 보다 직관적으로 프로젝트를 관리할 수 있게끔 여러 영역에서 계층 구조를 활용하고 있습니다. 프로젝트-목표-작업으로 이루어진 3계층 작업 관리, 백로그섹션-백로그와 같은 구조로 계층이 구현되어 있습니다.

이러한 계층 구조는 사용자가 프로젝트를 논리적으로 구성하고 관리하는 데 큰 도움이 되지만, 프론트엔드 성능 측면에서 상당한 도전과제가 되었습니다.
초기에 Riido는 백로그 데이터를 한 번에 모두 불러와 렌더링하는 방식을 사용했습니다. 하지만 백로그 항목이 많아질수록 초기 로딩 시간이 길어지고 사용자 경험이 저하되는 문제가 발생했습니다. 특히 백로그를 적극적으로 활용하는 팀일수록 이러한 성능 저하 문제가 두드러졌습니다.
이번 아티클에서는 계층형 데이터 렌더링 최적화를 위해 시도한 두 가지 주요 접근법 - 가상화(Virtualization)와 다중 레벨 페이지네이션(Multi-level Pagination)을 비교 분석하고, 성능 지표 개선 과정을 공유하고자 합니다.
데이터 구조
Riido에서 백로그는 다음과 같은 계층형 구조로 구성되어 있습니다:
├── 백로그 섹션 1
│ ├── 백로그 항목 1
│ ├── 백로그 항목 2
│ └── ...
├── 백로그 섹션 2
│ ├── 백로그 항목 1
│ ├── 백로그 항목 2
│ └── ...
└── ...
일반적인 리스트 형태의 데이터에서는 페이지네이션이 표준적인 해결책으로 적용되지만, 계층형 구조의 백로그 데이터에는 단순한 페이지네이션 적용이 쉽지 않았습니다. 섹션과 그 안의 백로그 항목들 간의 관계를 유지하면서 성능을 최적화 해야 하는 복잡한 요구사항이 있었기 때문입니다.
첫번째 접근: 가상화
가상화란?
리스트 가상화는 대량의 데이터를 화면에 렌더링 할 때 성능 문제를 해결하기 위해 도입된 기술입니다. 모든 데이터를 한 번에 렌더링 하면 브라우저의 메모리 사용량이 늘어나고, 초기 로딩 속도가 느려지며, 스크롤 시 버벅임이 발생할 수 있습니다. 가상화는 화면에 보이는 항목만 렌더링하고, 스크롤 시 동적으로 필요한 항목을 추가 및 제거하여 이러한 문제를 해결해 줍니다.

Riido의 데이터 구조는 백로그 섹션 내부에 백로그들이 존재하는 계층형 구조였기 때문에, 각 백로그 섹션의 높이를 예상하여 섹션 별로 가상화를 진행하는 것을 목표로 하였습니다.
가상화 라이브러리 선택
가상화는 적용하기 편리한 라이브러리가 많기 때문에 선택지가 다양했습니다. 저희는 tanstack-virtual 을 사용했습니다. 해당 라이브러리를 선택한 기준은 다음과 같습니다.
- 최근 다운로드 수가 많았음 (weekly downloads: 3,735,890)
- 주기적으로 유지 보수가 이루어짐 (Updated : 4 days ago)
- tanstack에서 제작한 react-query의 사용성이 좋았음

개발
1️⃣ Tanstack Virtual 설정
useVirtualizer Hook을 통해 가상 리스트의 기본 설정을 구성합니다. 섹션의 총개수, 스크롤 엘리먼트 참조, 각 아이템의 예상 크기 등을 지정하여 가상화의 기본 틀을 설정합니다.
// useVirtualizer 훅을 사용한 가상화 설정
const rowVirtualizer = useVirtualizer({
count: sections.length,
getScrollElement: () => containerRef.current, // 가상화를 적용시킬 컨테이너
estimateSize: () => itemHeight, // 섹션의 전체 높이
overscan: calculateOverscan() // 미리 더 렌더링할 아이템의 갯수
});
2️⃣ 가상화된 리스트 렌더링
가상화 설정을 기반으로 화면에 보이는 아이템만 렌더링 합니다. Tanstack Virtual의 getVirtualItems() 메서드가 반환하는 아이템 정보를 사용하여 각 섹션의 위치와 크기를 계산하고, 절대 위치 지정 방식을 통해 정확한 위치에 배치합니다.
// 전체 컨테이너 설정
<div
ref={containerRef}
className="sections-container"
style={{ height: `${totalHeight}px`, position: 'relative', overflow: 'auto' }}
>
{/* 가상화된 아이템만 렌더링 */}
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const section = sections[virtualRow.index];
return (
<div
key={section.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
>
<Section section={section} />
</div>
);
})}
</div>
3️⃣ 동적 Overscan 계산
섹션의 예상되는 전체 높이는 백로그의 전체 개수로 정량적으로 판단이 가능했으나, overscan의 경우에는 사용자가 사용하는 모니터에 따라 값이 달라질 것이라고 생각했습니다.
화면의 세로 길이가 길다면, 미리 더 많은 양을 렌더링 해놓아야 스크롤 시, 아무 아이템도 보이지 않는 부분을 최소화할 수 있다는 판단이 들었습니다. 따라서 다음과 같이 함수를 만들어 동적으로 overscan 값을 관리할 수 있도록 했습니다.
// 동적 overscan 계산 함수
const calculateOverscan = () => {
if (!containerRef.current) return 10; // 기본값
const viewportHeight = containerRef.current.clientHeight;
const visibleItems = Math.ceil(viewportHeight / itemHeight);
const newOverscan = Math.ceil(visibleItems * 1.3);
return Math.min(Math.max(newOverscan, 10), 25);
};
성능 개선 결과
사용자 경험에 영향을 미치는 웹 성능 지표들을 살펴보기 전에, 각 지표가 의미하는 바를 간략히 살펴보겠습니다:
- FCP (First Contentful Paint): 페이지가 로드되기 시작한 후 화면에 첫 번째 콘텐츠가 표시되는 시점을 측정합니다. 이 지표는 사용자가 "페이지가 로드되고 있다"고 인식하는 첫 순간을 나타냅니다.
- CLS (Cumulative Layout Shift): 페이지 로드 중 발생하는 예상치 못한 레이아웃 이동을 측정합니다. 낮을수록 사용자 경험이 안정적임을 의미합니다.
- LCP (Largest Contentful Paint): 페이지의 주요 콘텐츠가 로드되는 시점을 측정합니다. 이는 페이지의 유용성을 판단하는 중요한 지표입니다.
측정 도구: Web Vital (Posthog) , Lighthouse, Chrome delveloper tool
성능 지표 | 기존 | 가상화 적용 | 기존 대비 개선율 (%) |
FCP | 0.915 | 0.76 | 16.9% |
CLS | 0.76 | 0.75 | 1.3% |
LCP | 1.84 | 1.32 | 28.3% |
FCP: 가상화를 통해 초기 렌더링 되는 DOM 요소의 수를 줄임으로써, 브라우저가 첫 콘텐츠를 더 빠르게 그릴 수 있게 되었습니다.
LCP: 데이터의 일부만 렌더링함으로써 브라우저가 처리해야 할 작업량이 줄어들어 메인 콘텐츠를 더 빠르게 표시할 수 있었습니다.
한계
CLS 개선의 한계
계층형 구조의 특성상 섹션별 높이가 데이터 로드 이후에야 확정되는 문제로 인해 초기 로딩 과정에서 발생하는 레이아웃 변화를 개선하지 못했습니다. 가상화를 적용했음에도 불구하고 CLS(Cumulative Layout Shift) 지표는 0.76에서 0.75로 단 1.3%만 개선되었는데, 이는 계층형 데이터의 특성상 각 섹션의 정확한 높이를 사전에 예측하기 어려웠기 때문입니다.
복잡한 계층형 데이터 가상화의 한계
계층형 데이터를 완전히 가상화하기 위해서는 데이터를 평면화(flatten) 해야 했으나, 이 과정에서 react-query로 서버 상태를 관리할 때 중대한 문제점이 발생했습니다. 서버의 계층형 데이터와 클라이언트의 평면화된 데이터 구조 간 양방향 변환 로직이 필요했고, 이로 인해 react-query의 캐싱 메커니즘과 불일치가 생겨 캐시 무효화와 업데이트 로직이 복잡해졌습니다.
또한 데이터 변경 시 발생하는 어려움도 있었습니다. 특히 낙관적 업데이트(optimistic update) 구현이 까다로워졌는데, 백로그 항목 추가나 섹션 간 이동처럼 일반적인 작업에서도 평면화된 구조에서 정확한 위치 계산과 업데이트가 복잡해져 결과적으로 사용자 경험이 저하되었습니다. 이러한 문제들은 가상화를 통해 얻는 성능 이점을 상쇄할 정도로 큰 부담이 되었습니다.

섹션 토글(접기/펼치기)이나 드래그 앤 드롭과 같은 복잡한 인터랙션 처리에서도 한계가 드러났습니다. 섹션을 토글 할 때마다 평면화된 데이터 구조를 재계산해야 했고, 이 과정에서 렌더링 최적화가 어려웠습니다. 특히 드래그 앤 드롭 기능은 가상화된 환경에서 부모-자식 관계 유지에 추가적인 로직이 필요해 성능에 부정적 영향을 미쳤습니다.
두번째 접근 : 페이지네이션(Pagination)
백로그의 계층형 구조에서는 섹션과 그 안의 백로그 항목 간의 관계를 유지하면서 데이터를 분리해야 했습니다. 단순히 백로그 항목에 페이지네이션을 적용하면 섹션별 구분이 모호해지고, 섹션만 페이지네이션 하면 대량의 백로그 항목이 포함된 섹션에서 여전히 성능 문제가 발생했습니다. 또한 백로그 페이지에는 복잡한 필터링과 정렬 기능이 필요했는데, 이를 클라이언트에서 처리하려면 결국 전체 데이터를 로드해야 했습니다.
이러한 점들을 고려하여, 저희는 데이터 아키텍처를 근본적으로 재설계하기로 결정했습니다. 필터링과 정렬 로직을 클라이언트에서 서버 API로 이동시켜, 사용자가 설정한 조건에 맞는 데이터만 서버에서 처리하여 전송하도록 변경했습니다. 그리고 기존의 단일 객체 트리 구조를 섹션과 백로그 항목으로 논리적으로 분리하는 다중 레벨 페이지네이션을 도입했습니다.
이 접근법은 섹션 레벨에서의 페이지네이션과 각 섹션 내 백로그 항목에 대한 페이지네이션을 결합한 것으로, 필요한 데이터만 점진적으로 로드하면서도 계층적 관계를 유지할 수 있었습니다.

개발
1️⃣ 서버 API 및 쿼리 파라미터 설계
페이지네이션 구현에 앞서, 필터링과 정렬 로직을 서버로 이전하기 위해 API 구조를 개선했습니다. 기존에 클라이언트에서 처리하던 필터링과 정렬 로직을 서버에서 처리할 수 있도록 쿼리 파라미터를 설계했습니다.
const queryParams = new URLSearchParams();
if (memberIds && memberIds.length > 0) {
memberIds.forEach((memberId, index) => {
queryParams.append(`memberIds[${index}]`, memberId);
});
}
.
.
.
fetch(`${url}?${queryParams.toString()} // 쿼리 파라미터 서버에 요청
사용자가 필터링 옵션을 변경할 때마다 캐시 된 데이터를 무효화하고 새로운 데이터를 로드해야 했습니다. 이를 위해 react-query의 queryClient를 사용하여 필터링 값 변경 시 적절히 캐시를 관리했습니다:
// 필터링 값이 변경될 때 캐시 무효화 및 쿼리 재요청
useEffect(() => {
// 페이지네이션 상태 초기화 및 쿼리 무효화
queryClient.invalidateQueries(['sections', filterParams, sortParams]);
}, [filterParams, sortParams, queryClient]);
2️⃣ 데이터 Fetching 및 구조 설정
react-query의 무한 스크롤을 구현하기 위해 useInfiniteQuery Hook을 사용하여 페이지 단위로 데이터를 관리하고, 페이지별 섹션 데이터를 렌더링 했습니다.
const {
data: sectionsData,
hasNextPage,
fetchNextPage,
isFetchingNextPage
} = useInfiniteQuery(
['sections'],
({ pageParam = 1 }) => fetchSections(pageParam),
{
getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextPage : undefined,
}
);
// 페이지별 데이터 렌더링
{sectionsData?.pages.map((page) =>
page.sections.map((section) => (
<Section key={section.id} section={section} />
))
)}
3️⃣ 스크롤 감지 요소 생성
더 로드할 데이터가 있을 때만 스크롤 감지 요소를 생성하고, 이 요소가 화면에 보일 때 다음 데이터를 요청합니다.
{hasNextPage && (
<div
ref={(el) => {
if (!el) return;
// IntersectionObserver 설정 코드
}}
/>
)}
4️⃣ Intersection Observer 설정 및 관리
요소의 가시성을 감지하는 Observer를 설정하고, 요소가 화면에 보일 때 다음 페이지를 로드합니다.
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isFetchingNextPage) {
fetchNextPage(); // 다음 페이지 데이터 요청
}
},
{ threshold: 0.3 } // 요소가 30% 보일 때 감지
);
observer.observe(el);
return () => observer.disconnect();
이 세 단계를 통해 사용자가 페이지 하단에 도달하면 자동으로 다음 데이터를 로드하는 무한 스크롤 페이지네이션을 효과적으로 구현했습니다.
위와 동일한 단계로 섹션 내부 백로그들에 대해서도 페이지네이션을 적용하여 이중 레벨 페이지네이션을 구현했습니다.
성능 개선 결과
성능 지표 | 가상화 적용 | 페이지네이션 | 기존 대비 개선율 (%) |
FCP | 0.76초 | 0.75초 | 1.3% |
CLS | 0.75 | 0.44 | 41.3% |
LCP | 1.32초 | 1.2초 | 9.1% |
페이지네이션 접근 방식을 구현한 후, 성능 지표에서 더욱 큰 개선을 확인할 수 있었습니다. 특히 CLS 지표에서 가장 큰 개선이 이루어졌는데, 이는 초기 로딩 과정에서 레이아웃 변화가 크게 줄어들었기 때문입니다.
결론
계층형 데이터 렌더링 최적화를 위해 가상화와 페이지네이션 두 가지 접근법을 시도한 결과, 우리 서비스의 데이터 구조와 사용 패턴에는 페이지네이션 방식이 더 적합했습니다.
가상화 방식은 초기 로딩 성능을 개선했지만, 계층형 데이터 구조에 적용할 때 복잡도가 증가하고 CLS 개선이 제한적이었습니다. 반면 페이지네이션 방식은 구현이 상대적으로 단순하면서도 모든 성능 지표에서 더 나은 결과를 보여주었습니다.
향후 계획
페이지네이션 방식의 한계 개선
페이지네이션 또한 절대적으로 완벽한 방법은 아닙니다. API 호출이 늘어나게 되면서, 예상치 못한 사이드 이펙트가 발생할 수 있어 향후 집중적으로 트래킹 하며 개선할 예정입니다.
컴포넌트 리스트 렌더링 개선
백로그 페이지에서의 성능 개선을 시작으로, 이번에 시도했던 전략들을 바탕으로 다른 여러 페이지에도 적용해 나갈 예정입니다.
특히 , 컴포넌트 리스트가 렌더링 되는 페이지 (스프린트, 프로젝트 템플릿, 리스트 view)에 우선적으로 개선을 시도할 계획입니다.
마치며
앞으로도 다양한 환경에서도 항상 최고의 사용자 경험을 전달할 수 있도록 이번에 시도했던 전략들을 바탕으로 최적의 전략을 찾아 개선하도록 하겠습니다.
성능과 기능 모든 면에서 최고의 IT 프로젝트 관리 툴이 되기 위해 앞으로도 노력하겠습니다.
Riido에서 첫 백로그를 만들고, 우선순위를 관리하며 IT 프로젝트 관리를 시작해 보세요!
Riido Frontend Developer 최성관
