1. 개요
대용량 데이터를 다루다 보면 데이터를 조회하는 것보다 조회한 데이터를 어떻게 가공하고 화면에 표현하는지가 성능에 더 큰 영향을 주는 경우가 있습니다.
실제 프로젝트에서 수만 건의 데이터를 가공하여 Grid에 표현해야 하는 화면을 개발한 경험이 있습니다. 단순히 서버에서 전달받은 데이터를 그대로 출력하는 구조가 아니라, 서로 분리된 데이터를 특정 기준으로 연결하고 여러 값을 가공한 결과를 하나의 Cell에 표현해야 했습니다.
개발 초기에는 비교적 적은 데이터로 테스트했기 때문에 성능 문제가 크게 드러나지 않았습니다. 하지만 실제 데이터가 수만 건 이상의 수준으로 증가하면서 Grid에 데이터를 반영하는 시점의 처리 시간이 길어졌고 데이터 로딩 시 다른 동작을 하면 화면이 멈추기도 했습니다.
처음에는 Grid 자체의 렌더링 성능을 원인으로 생각했습니다. 하지만 처리 과정을 확인하면서 Cell 데이터를 만들기 위해 반복적으로 수행되던 데이터 탐색과 가공 역시 상당한 비용을 발생시키고 있다는 점을 확인했습니다.
이번 글에서는 당시 경험을 바탕으로 대용량 데이터 처리 과정에서 발생한 성능 문제를 어떻게 개선했는지, 그리고 이러한 문제가 Main Thread Blocking 및 Long Task와 어떤 관계가 있는지 정리해보겠습니다.
2. 요청 및 문제 상황
요청 사항 및 문제 상황은 다음과 같았습니다.
요청 사항
-
페이징 대신 스크롤로 표현
-
수십 개가 넘는 컬럼에 필터 기능 적용 필요 (전체 데이터 필요)
-
Cell 값에 따라 서로 다른 배경 색 표시
문제 상황
-
데이터 수신 후 가공 및 표현에 오랜 시간 소요됨
-
로딩 중에 다른 화면 동작 시 화면 멈춤
3. 대용량 데이터에서 발생한 반복 연산
초기 구현에서는 Main Data와 별도로 관리되는 Related Data를 Cell 단위에서 조합했습니다.
단순화하면 다음과 같은 형태였습니다.
예시)
const columnDefs = [
{
headerName: 'Related Data',
valueGetter: params => {
const relatedItems = relatedData.filter(
item => item.targetId === params.data.id
);
return relatedItems
.map(item => item.name)
.join(', ');
},
},
];
데이터가 적을 때는 문제가 되지 않았지만 Main Data와 Related Data가 함께 증가하면서 Cell 값을 만들기 위한 탐색도 반복적으로 증가했습니다.
특히 Grid에서는 데이터 갱신이나 정렬, 필터 등의 동작에 따라 Cell의 값이 다시 계산될 수 있습니다.
결국 문제는 단순히 “Grid에 데이터가 많다”는 것이 아니었습니다.
데이터의 양이 증가하는 동시에 각 데이터를 화면에 표현하기 위한 탐색과 가공도 반복되고 있다는 것이 문제였습니다.
4. Main Thread 관점에서 문제 확인
브라우저의 Main Thread에서는 JavaScript 실행뿐만 아니라 사용자 이벤트 처리와 렌더링에 필요한 여러 작업이 수행됩니다.
따라서 JavaScript 연산이 Main Thread를 오랫동안 점유하면 사용자 입력이나 다음 렌더링 역시 영향을 받을 수 있습니다.
당시 화면에서 데이터를 받아 실제 화면에 표시하기까지는 다음과 같은 작업이 필요했습니다.
API Response
↓
데이터 탐색
↓
데이터 조합 및 변환
↓
Grid 데이터 구성
↓
Grid Rendering
↓
Layout / Paint
각각의 연산 자체는 크지 않았지만 수만 건의 데이터에 대해 반복되면서 전체 JavaScript 실행 비용이 증가했습니다.
이러한 문제는 Long Task와도 연결됩니다. 브라우저에서는 Main Thread에서 50ms를 초과하여 실행되는 Task를 Long Task로 분류합니다.
Long Task가 실행되는 동안 사용자 상호작용이 발생하면 해당 작업이 종료될 때까지 이벤트 처리가 지연될 수 있습니다.
따라서 대용량 데이터의 성능 문제는 전체 처리 시간을 줄이는 것뿐만 아니라 Main Thread에서 한 번에 수행하는 작업량을 줄이는 관점에서도 볼 필요가 있었습니다.
5. 사전 데이터 가공
먼저 Cell 데이터를 생성할 때 반복적으로 수행하던 데이터 탐색을 줄이고자 했습니다.
기존에는 Cell 값을 계산할 때마다 Related Data에서 현재 Row와 관련된 데이터를 찾기 위해 filter()를 수행했습니다.
Main Data와 Related Data가 각각 N건, M건이라면 각 Cell 값을 계산하는 과정에서 Related Data를 반복적으로 순회하게 되어 전체적으로 O(N × M) 수준의 탐색 비용이 발생할 수 있습니다.
이를 개선하기 위해 Related Data를 targetId를 기준으로 미리 가공하여 Lookup 구조를 구성했습니다.
예시)
const relatedNameMap = new Map<string, string[]>();
relatedData.forEach( item => {
const names = relatedNameMap.get(item.targetId) ?? [];
names.push(item.name);
relatedNameMap.set(item.targetId, names);
});
6. 가공된 데이터를 Grid에 전달
Related Data를 미리 가공한 뒤에는 해당 결과를 Main Data와 결합하여 Grid에 전달하도록 변경했습니다.
기존에는 Cell이 생성되는 시점에 필요한 데이터를 탐색하고 값을 조합했습니다.
Cell 생성
↓
Related Data 탐색
↓
데이터 조합 및 변환
↓
Cell 표현
이 방식에서는 Grid의 데이터 갱신이나 정렬, 필터 등의 동작에 따라 Cell 값이 다시 계산될 수 있었습니다.
개선 이후에는 앞에서 생성한 relatedNameMap을 이용하여 Grid에 데이터를 전달하기 전에 필요한 값을 Main Data에 포함했습니다.
예시)
const gridData = mainData.map( row => ({
...row,
relatedNames: relatedNameMap.get(row.id)?.join(“, ”) ?? “",
}));
const columnDefs = [ {
field: 'relatedNames',
headerName: 'Related Data',
},];
이를 통해 Cell이 생성될 때마다 반복되던 데이터 탐색과 가공을 렌더링 과정에서 제거할 수 있었습니다.
특히 데이터 갱신이나 정렬, 필터 등의 동작이 발생하더라도 Cell 값을 구성하기 위해 Related Data를 다시 탐색할 필요가 없어졌습니다. Grid는 이미 계산된 값을 화면에 표현하는 역할에 집중할 수 있게 되었습니다.
결과적으로 단순히 Cell 렌더링 속도를 개선한 것이 아니라, 반복적으로 수행되던 연산을 렌더링 과정에서 분리하고 계산 결과를 재사용하는 구조로 변경했다는 점에서 의미가 있었습니다.
7. Virtualization만으로 해결되지 않는 문제
수만 건의 데이터를 Grid에 표현할 때 모든 Row를 실제 DOM으로 생성하는 것은 비효율적입니다. 사용한 AG Grid 역시 Virtualization을 통해 현재 Viewport를 중심으로 필요한 Row만 렌더링하기 때문에 실제 DOM에 생성되는 요소의 범위는 제한되어 있었습니다.
하지만 Virtualization이 적용되어 있다고 해서 대용량 데이터 처리 과정에서 발생하는 모든 성능 문제가 해결되는 것은 아닙니다. Virtualization은 주로 화면에 렌더링되는 DOM의 범위를 줄이는 역할을 하며, Grid에 전달할 데이터를 구성하거나 Cell에 표시할 값을 계산하는 과정에서 발생하는 JavaScript 연산까지 줄여주지는 않습니다.
당시 화면에서는 수만 건의 데이터를 처리하면서 서로 분리된 데이터를 탐색하고 조합하는 과정이 필요했습니다. 따라서 Grid 자체의 렌더링 범위보다 Grid 데이터를 구성하거나 Cell 값을 계산하는 과정에서 반복되는 탐색과 가공 비용을 줄이는 것이 중요한 개선 지점이었습니다.
8. 정리
이번 경험을 Main Thread 관점에서 다시 보면 적용했던 개선 방법들은 결국 Main Thread가 수행해야 하는 작업을 줄이는 방향이었습니다.
|
문제 |
개선 방향 |
|---|---|
|
반복적인 데이터 탐색 |
Lookup 구조 구성 |
|
Cell 단위의 데이터 가공 |
사전 계산 |
|
동일한 계산 반복 |
계산 결과 재사용 |
이러한 방법을 적용한 뒤에도 실제 계산 자체가 무겁다면 Task를 분할하거나 Web Worker를 활용하는 방법도 고려할 수 있습니다.
하지만 처음부터 복잡한 방법을 적용하기보다 현재 Main Thread에서 어떤 작업이 반복적으로 수행되고 있는지를 먼저 확인하는 것이 중요하다고 생각했습니다.
실제로 불필요한 탐색과 중복 계산을 제거하는 것만으로도 Main Thread가 처리해야 하는 작업량 자체를 줄일 수 있기 때문입니다.
9. 마치며
이번 경험을 통해 대용량 데이터를 다룰 때는 단순히 렌더링 성능만 확인하기보다 데이터를 화면에 표현하기까지 어떤 연산이 반복되고 있는지도 함께 살펴볼 필요가 있다는 것을 알게 되었습니다. 또한 성능 최적화에서는 특정 함수의 실행 시간을 줄이는 것뿐만 아니라, 불필요한 계산 자체가 반복되지 않도록 데이터 처리 흐름을 설계하는 것이 중요하다는 점을 배웠습니다.
Jung