내용으로 건너뛰기
빠른 데이터 그리드 설계: 1M+ 데이터 레코드 최적화 Ignite UI 교훈

빠른 데이터 그리드 설계: 1M+ 데이터 레코드 최적화 Ignite UI 교훈

그리드 성능은 단순히 속도만이 아닙니다. 무거운 데이터 부하 속에서도 일관성을 유지하는 것이 중요합니다. 데이터 작업 중에 그리드가 멈추면 느리고 신뢰할 수 없다고 느껴집니다. 실시간 의사결정 워크플로우에서는 이러한 신뢰성 부족이 오히려 부담이 됩니다.

28분 읽기

금융, 은행, ERP 등 데이터 중심 시스템을 구축하는 개발자들에게 데이터 그리드는 종종 주요 성능 경계, 즉 대규모 데이터셋 간의 정렬과 필터링이 메인 스레드 시간을 두고 경쟁하는 '핫 루프'입니다. 이런 경우에는 작은 비효율성이 빠르게 사용자에게 드러나 상호작용을 방해합니다.

하지만 해결책을 찾았습니다. 이 글에서는 프레임워크(Angular, React, Blazor, Web Components)에서 1M+ 행을 빠르게 Ignite UI 유지하기 위해 정렬과 필터링을 어떻게 최적화했는지 보여드릴 것입니다. 우리는 구체적인 데이터 그리드, 정렬 및 필터링 변경 사항에 초점을 맞추겠습니다. 성공한 변화와 그렇지 못한 변화들에 대해서요.

우리가 뭘 했는지 보자.

최적화 이전의 현실: 문제가 시작된 지점

모든 성능 문제는 비슷하게 시작됩니다 – 한 규모에서는 합리적이었던 아키텍처가 다른 규모에서는 병목 현상이 됩니다. Ignite UI의 정렬, 그룹화, 필터링 같은 기능들도 예외는 아니었습니다.

정렬: 숨겨진 가치 비용 해결

핵심 정렬 파이프라인은 재귀적으로 작동하여 각 정렬식을 순차적으로 처리했습니다. 다중 열 정렬의 경우, 기본 표현식으로 정렬한 후 같은 값의 레코드를 그룹화하고 각 그룹을 다음 표현식에 따라 재귀적으로 정렬했습니다. 깔끔하고 정확하며, 작은 데이터셋에 대해 완전히 합리적입니다.

문제는 가치 해결기였다.

그리드가 여러 열 데이터 타입을 지원하기 때문에—Date 객체의 날짜 부분, Date 객체의 시간 부분, 문자열, 숫자, 계층적 키-값 객체 등—모든 값 비교는 실행 시 필드 값을 해결해야 했습니다. 값 해석기는 경로 탐색, 날짜 구문 분석, 시간 정규화, 숫자 구문 분석 등 모든 비교를 처리했습니다. 비교 연산당 각 면에 대해 두 번 호출되었습니다:

compare(recordA, recordB): 

    valA = resolveValue(recordA, field)  // path traversal + date parsing + type coercion 

    valB = resolveValue(recordB, field)  // same cost, every single comparison 

    return compareValues(valA, valB) 

일반적인 비교 정렬 기준으로는영형(NogN)O(n log n)각 비교마다 리졸버가 두 번 호출되는 비교를 합니다. 10만 행 기준: 정렬된 열당 340만 개의 리졸버 호출. 100만 행 기준: 4천만 건의 리졸버 호출. 각 노드는 런타임 경로 해석과 잠재적 날짜 파싱을 수행하며, 호출 사이에 캐싱이 없습니다.

하지만 정렬 비교기만이 가치 해석기가 사용된 것은 아니었습니다. 다열 정렬의 경우, 표현식 i로 정렬한 후 알고리즘은 i+1로 정렬하기 전에 동일한 값의 그룹을 찾아야 했습니다. 이 그룹 감지는 모든 레코드를 반복하여 각 레코드마다 한 번씩 리졸버를 호출했습니다 – 추가적인영형(N)O(n)그 위에 패스하세요.

따라서 1M 행에 대한 2열 정렬의 경우, 값 해석기는 다음 차례로 호출되었습니다영형(NogN)O(n log n) + 영형(N)O(n)첫 번째 표정만을 위한 시간들 – 두 번째 표정이 손대기 전에.

  • 1만 행에서는 거의 눈에 띄지 않습니다.
  • 10만 행 정도 있을 때: 눈에 띄는 지연이 있지만 참을 만합니다.
  • 1M 행에서는 메인 스레드가 몇 초간 멈췄습니다. 드물게, 딥 재귀 호출 스택이 스택 오버플로우를 일으켰습니다.

그룹화: 같은 근원, 복리 비용

그룹화는 동일한 재귀적 패턴을 확장하며, 데이터를 먼저 정렬해야 합니다. 이렇게 하면 소트 시 한 번, 그룹 경계 검출 시 한 번씩 리졸버 비용을 지불했습니다.

groupDataRecursive(data, state, level): 

    while i < data.length: 

        group = groupByExpression(data, i, expressions[level]) 

            // resolver called once for group anchor value 

            // resolver called again for every subsequent record in the group 

  

        if level < expressions.length - 1: 

            groupDataRecursive(group, state, level + 1)  // recurse into subgroups 

        else: 

            result = result.concat(...)    // array allocation per group boundary 

여기 두 가지 복리 비용이 발생합니다:

  1. 값 해석기는 정렬 과정에서 이미 해결된 값에 대해 반복적으로 호출되었으며, 두 단계 간에 공유 캐시가 없었습니다.
  1. 각 그룹 경계는 concatslice를 통해 새로운 배열을 생성했는데, 이는  수천 개 그룹에 걸쳐 측정 가능한 GC 압력을 확장하는 할당입니다

엑셀 스타일 필터링: 전액 비용을 두 번 지불하는 것

빠른 필터링과 고급 필터링이 모두 빨랐습니다. 엑셀 스타일의 필터링(ESF)은 그렇지 않았고, 그 이유는 아키텍처 때문이었습니다.

ESF 대화가 열리면, 메인 스레드에서 동기식으로 전체 초기화 파이프라인이 실행되었습니다:

대화의 오프닝 애니메이션은 네 번의 연속이 모두 완료될 때까지 사실상 일시정지되었습니다. 대규모 데이터셋에서는 사용자가 볼 수 있는 고정 현상이었고, 대화가 어색해 보이지 않았습니다. 파이프라인이 끝날 때까지 전혀 나타나지 않았던 거죠.

더 심각한 문제는, 사용자가 'Apply'를 클릭하면 이 파이프라인 전체가 다시 실행되었는데, 기본 데이터는 열린 상태와 적용 사이에 변하지 않았다는 점입니다:

onApplyClick(): 
    filter data 

    re-run full ESF initialization  // same 4 steps, same cost, same blocking 

    close dialog 

이 때문에 ESF는 실제로 고급 필터링보다 훨씬 느렸습니다: ESF도 같은 작업을 하고 있었기 때문입니다영형(N)O(n)한 번의 작업당 두 번 작동하며, 두 번 모두 메인 스레드를 차단합니다.

왜 "그냥 더 가상화하라"는 답이 아니었나요?

가상화는 데이터셋 크기와 관계없이 DOM 노드로 렌더링될 가시적 행 수만을 보장합니다. 그래서 100만 행을 스크롤하는 게 가능해요. 하지만 그 행에 무엇이 포함되는지 결정하는 데이터 연산—정렬, 필터링, 그룹화—은 매번 전체 데이터셋과 대조적으로 실행됩니다. 가상화는 그 부분에서 도움이 되지 않습니다. 위의 모든 병목 현상은 단일 행이 렌더링되기 전에 데이터 파이프라인 내에 존재했습니다:

  • 리졸버는 다음과 같았습니다영형(NogN)O(n log n) + 영형(N)O(n)정렬 표현식당 여러 번, 몇 행이 보이든 상관없이 계산했습니다.
  • 그룹화는 정렬 외에도 해상도 비용을 다시 지불했고, 그룹 경계 간 concat/slice 할당 압력도 발생했습니다.
  • ESF의 전체 초기화 파이프라인은 전체 데이터셋을 열어둔 상태와 적용할 때 동기식으로 반복했습니다.

가상화는 큰 그리드를 스크롤 가능하게 만드는 올바른 도구입니다. 정렬, 필터링, 그룹화를 빠르게 만드는 데 아무런 도움이 되지 않습니다. 그것들은 다른 종류의 수리가 필요했다.

문제 측정: 그리드 성능 벤치마킹 방법

"느린 것 같다", "빠른 것 같다" 같은 일화는 진단이 아니라 출발점일 뿐입니다. 자신 있게 최적화하려면 노출 수보다는 재현 가능한 수치가 필요했습니다.

그리드 성능 진단을 위해 DevTools 플레임 그래프나 FPS 카운터에 의존하고 싶어질 수 있습니다. 하지만 이들은 전체 렌더링 파이프라인—변경 감지, DOM 업데이트, 레이아웃—을 측정하기 때문에 실제로 데이터 파이프라인에 소요되는 시간을 가릴 수 있습니다.

알고리즘 비용을 구체적으로 파악하기 위해, 우리는 네이티브 Performance API를 감싸는 경량 래퍼를 사용해 정렬, 그룹화, 필터링 로직을 직접 계측했습니다:

startMeasure(‘sorting’) 

        -> run sorting algorithm 

getMeasures(‘sorting’) // returns the duration 

이로 인해 노이즈나 변경 감지 오버헤드 없이 알고리즘을 단독으로 1밀리초 단위로 측정할 수 있었습니다. 원시 데이터 파이프라인 비용만 필요합니다. 참고로, 아래 모든 수치는 Angular 개발 모드에서 기록된 것입니다. 생산 빌드가 더 빠르겠지만, 개발 모드 오버헤드가 실행에 따라 일정하게 나오기 때문에 상대적 차이는 유효합니다.

데이터셋

Rows: 
        10K / 100K / 1,000,000 
Columns:   
        string - names, categories (with duplicates) 
        number - IDs, prices, quantities (with duplicates) 
        date - formatted date strings (require parsing) 
        time - HH:mm:ss formatted strings (require parsing) 

정렬 및 그룹 열에 중복 값이 존재하는 것은 의도된 것으로, 이는 현실적인 데이터 분포를 반영하고 그룹화 비용에 직접적인 영향을 미칩니다. 중복 값이 많을수록 그룹 경계 탐지와 더 깊은 재귀 호출이 증가하기 때문입니다. 날짜와 시간 열은 형식화된 문자열 표현을 사용했습니다. 이 점은 결과를 해석하는 데 중요합니다: 이 열들을 포함하는 모든 비교는 실행 시 문자열을 비교 가능한 값으로 파싱해야 합니다.

Scenarios and Results 

10K와 100K 행에서는 대부분의 연산이 허용되었습니다. 100만 행이 되자 상황이 극적으로 바뀌었습니다:

Scenario 시간 (1M 행)
Single column sort – string 3.38s 
Single column sort – number 1.50s 
Multi-column sort – string → number 3.88s 
그룹화 – 단일 문자열 열(정렬 + 그룹)3.31s 
정렬 후 그룹화 알고리즘만 적용합니다0.50s 
그룹화 – 격자 하중에 두 개의 열을 적용3.86s 
그룹화 – 두 열(정렬 후)1.01s 
ESF open – number column (15K unique values) 1.60s 
ESF open – date column (274 unique values) 5.20s 
ESF open – time column (86K unique values) 6.60s 
ESF apply – number column 1.37s 

숫자 읽기

여러 패턴이 즉시 나타나며, 각각은 특정 건축적 문제를 직접적으로 지목합니다.

분류가 그룹 비용을 지배합니다. 그룹 분석 알고리즘만 해도 0.50초가 걸렸습니다. 풀 소트 + 그룹은 3.31초를 기록해 6.6배 차이를 기록했습니다. 그룹 구성 논리 자체가 병목 현상은 아니었습니다. 정렬이 필요했고, 특히 값 리졸버가 호출되었습니다영형(NogN)O(n log n) 정렬 비교기 내 시간.

문자열 정렬은 숫자 정렬보다 두 배 이상 느립니다(3.38초 대 1.50초 대비). 숫자는 단순한 뺄셈과 비교할 수 있습니다. 문자열은 값 해석기, 대문자 구분 없는 정렬에 대한 잠재적 정규화, 그리고 문자열 비교를 거칩니다. 이 차이는 100만 행에서 ~2천만 건의 비교 전반에 걸쳐 누적됩니다.

ESF 날짜 이상 현상이 가장 중요한 데이터 포인트입니다. 날짜 열에는 고유 값이 274개뿐이었는데, 숫자 열의 15K 숫자에 비해 매우 작은 목록이었습니다. 그런데 ESF 대화 창을 열 때 숫자 열은 5.20초, 반면 1.60초가 걸렸습니다. 문제는 반복 횟수가 아니었습니다. 항목별 날짜 파싱 비용이었습니다. 전체 데이터셋은 ESF 초기화 과정에서 반복 처리되었으며, 모든 값은 문자열-날짜 파싱을 거쳤습니다. 고유 값이 적은 것은 파싱이 고유 레코드뿐만 아니라 모든 레코드에서 이루어졌기 때문에 도움이 되지 않았습니다. 시간 열(6.60초, 86K 고유 값 + 시간 문자열 파싱)도 같은 패턴을 확인시켜 줍니다: 서식화된 문자열 열은 기수와 상관없이 비용이 많이 듭니다.

ESF 오픈 + ESF 적용 = 전액 비용 두 번 지불. 숫자 열의 경우 가장 저렴한 경우는 필터 작업당 1.60초 + 1.37초 = ~3초의 차단입니다. 날짜나 시간 열로 작성할 경우 합산 비용이 훨씬 더 나빠집니다.

수치는 아키텍처 리뷰가 제시한 바를 확인시켜 주었습니다: 값 해석기, 재귀적 그룹화 패스, 그리고 ESF 이중 초기화가 병목 현상이었습니다. 이제 우리는 그것을 증명할 데이터를 갖게 되었습니다.

최적화 #1: 정렬 파이프라인 재고

명확한 기준선이 마련되자, 초점은 데이터 파이프라인 자체로 옮겨갔다. 개선의 대부분은 세 가지 변화로 이루어졌습니다: 슈워츠 변환을 정렬에 적용하고, 다중 열 정렬을 재귀 정렬에서 반복 정렬로 리팩토링하며, 재귀 및 중복 배열 할당을 모두 제거하기 위해 그룹 알고리즘을 재작업했습니다.

Fix #1: The Schwartzian Transform 

원래의 정렬 비교기는 비교 함수 내에서 필드 값을 해석했는데, 이는 비교된 레코드 쌍마다 값 해석기가 두 번 실행된다는 의미입니다.

슈워츠 변환은 비용이 많이 드는 정렬 키에 대한 고전적인 최적화 방법입니다: 각 값을 처음에 한 번 해결하고, 캐시된 값에 정렬한 후 원래 레코드로 다시 매핑합니다. 이로 인해 장 해상도가 향상됩니다.영형(NogN)O(n log n)받는 사람영형(N)O(n)

// Before: resolve inside comparator - O(n log n) resolver calls 

sort(data, field): 

    data.sort((a, b) => compare(resolveValue(a), resolveValue(b))) 

  

// After: Schwartzian transform - O(n) resolver calls 

sort(data, field): 

    prepared = data.map(record => [record, resolveValue(record, field)])  // O(n) - resolve once 

    prepared.sort(([, valA], [, valB]) => compareValues(valA, valB))      // O(n log n) — compare only 

    return prepared.map(([record]) => record)                              // O(n) - unwrap 

비교기는 필드 해상도, 경로 탐색, 날짜 파싱 없이 순수한 값 비교가 됩니다. ignoreCase의 경우, 문자열 정규화 호출은 맵 단계로 넘어가며, 이는 비교 면마다 한 번이 아니라 레코드당 한 번씩 해결됩니다.

날짜와 시간 열의 경우 그 영향이 특히 크다: 문자열-날짜 파싱이 핫 비교 루프 내부에서 단일 초기 패스로 이동한다. 100만 행에서 약 4천만 개의 파싱 호출과 정확히 100만 개의 차이가 됩니다.영형(N)O(n)열 유형과 관계없이 곱셈수는 1입니다.

Fix #2: Iterative Multi-Column Sorting 

원래의 다중 열 정렬은 재귀적이었습니다: 표현식 0으로 정렬하고, 같은 값 그룹을 찾으며, 각 그룹을 표현식 1로 재귀적으로 정렬하는 식이었습니다. 맞습니다만, 두 가지 문제가 있습니다: 재귀 호출 스택 깊이와 매 패스마다 모든 레코드에서 그룹 감지 내에서 값 리소버가 다시 호출되는 문제입니다.

새로운 접근법은 정렬 안정성을 유지하기 위해 의도적으로 반복하여 표현식을 반복하며, 원래 재귀 구현의 동작과 일치합니다:

// Before: recursive 

sortDataRecursive(data, expressions, index): 

    sort by expressions[index] 

    for each equal-value group: 

        sortDataRecursive(group, expressions, index + 1)  // recursive 

  

// After: iterative - reverse pass maintains stability 

sortData(data, expressions): 

    for i = expressions.length - 1 down to 0: 

        data = expressions[i].strategy.sort(data)    // iterative, no recursion 

역으로 반복하면 가장 중요한 정렬 키가 마지막으로 적용됩니다. 최종 타이브레이커가 되며, 전체 순위는 안정적으로 유지됩니다. 재귀 호출 스택이 없고, 표현식 간 중간 그룹 감지 전달도 없 으며; 추가 해결자 호출도 없습니다. 슈워츠 변환은 각 표현식 패스에 독립적으로 적용됩니다.

수정 #3: 스택을 이용한 반복적 그룹화

그룹화 알고리즘은 두 가지 독립적인 비용 원천을 가졌습니다: 재귀 호출 구조와 모든 그룹 경계에서의 concat / slice 배열 할당입니다. 두 사람은 함께 호칭되었다.

// Before: recursive with concat/slice 

groupDataRecursive(data, state, level): 

    group = data.slice(start, end)                // allocation per group 

    result = result.concat(groupRow, group)        // allocation per group 

    groupDataRecursive(group, state, level + 1)   // recursive 

  

// After: iterative with explicit stack + direct push 

groupData(data, state): 

    stack = [{ data, level: 0 }] 

    while stack.length > 0: 

        { data, level } = stack.pop() 

        for each group boundary in data: 

            result.push(groupRow)                 // no intermediate allocation 

            result.push(...groupRecords)         // no intermediate allocation 

            if level < expressions.length - 1: 

                stack.push({ data: groupRecords, level: level + 1 }) 

배열 사전 할당은 그룹 수가 미리 알려지지 않아 현실적으로 불가능했습니다. 하지만 concat / slice에서 직접 푸시로 전환하면 모든 그룹 경계에서 중간 배열 할당이 사라졌습니다. 수천 개의 그룹 경계에 걸쳐 대규모로 구현되면서, 실행 시간과 GC 압력 모두에서 측정 가능한 차이가 발생했습니다.

결과

원초적인 밀리초가 이야기의 한 부분을 말해줍니다. 더 중요한 지표는 인지된 반응성입니다:

  • 1M 행에서 단일 열로 정렬할 때, 3.38초(눈에 띄고 충격적인 멈춤)에서 0.42초로 줄어들어 대부분의 사용자가 알아차리지 못할 정도로 변했습니다
  • 다중 열 정렬은 3.88초에서 0.57초로 감소했으며, 순차 정렬을 적용하는 사용자는 복리 지연을 경험하지 않습니다
  • 그리드 부하의 2열 그룹화는 3.86초에서 0.88초로 증가했으며, 그리드는 거의 즉시 준비가 된 느낌입니다

실제 사용에서는 이득이 누적됩니다: 정렬 후 그룹화하고 다시 정렬하는 사용자는 각 작업마다 몇 초씩 기다리지 않아도 됩니다. 파이프라인이 충분히 빠르게 돌아가서 상호작용이 멈추는 대신 연속적으로 느껴집니다.

최적화 #2: 대규모 엑셀 스타일 필터링

정렬과 그룹화가 가장 눈에 띄는 병목 현상이었지만, 엑셀 스타일의 필터링에는 자체적인 문제점이 있었습니다. 빠른 필터링과 고급 필터링이 데이터를 직접 조작합니다: 술어가 각 레코드에 대해 실행되어 매칭을 반환합니다. 단순하고, 선형적이며, 예측 가능하다.

엑셀 스타일의 필터링은 다릅니다. 대화 상자가 무언가를 보여주기 전에, 열의 모든 고유 값을 포함해 데이터의 완전한 그림을 구축하고, 표시 포맷을 맞추고, 정렬하며, 현재 필터 상태와 교차 참조해야 합니다. 그건 단순한 필터링 작업이 아닙니다. 이것은 완전한 데이터 파이프라인이며, 대화가 열릴 때마다 메인 스레드에서 동기식으로 실행됩니다.

앞서 언급했듯이, 원래 엑셀 스타일의 필터링 초기화는 데이터를 네 번의 순차적으로 처리했습니다:

  1. 사전 필터가 적용되어 있다면 데이터셋을 필터링하세요 –영형(N)O(n) pass 
  1. 필터링된 값들을 정렬하세요 –영형(NogN)O(n logn)  
  1. 라벨 + 형식 값 추출 –영형(N)O(n) pass 
  1. Deduplicate -> build unique items list – 영형(N)O(n) pass 

적용 재초기화가 가장 낭비적인 부분이었습니다: 기본 데이터는 열린 상태와 적용 사이에 변하지 않았지만, 파이프라인 전체가 처음부터 다시 실행되었습니다.

이중 비용 외에도 파이프라인 자체가 비효율적이었습니다: 2, 3, 4단계 모두 전체 필터링된 데이터셋에서 작동했습니다. 정렬은 중복 제거 이전에 이루어졌는데, 이는 그리드가 고유 값만 정렬하면 되는데도 수백만 개의 레코드를 정렬해야 한다는 뜻이었습니다. 라벨 추출과 중복 제거도 동일한 데이터를 별도로 처리하여 모든 값을 불필요하게 두 번 방문했습니다.

날짜와 시간 이상 현상

비효율성은 날짜와 시간 열에서 가장 두드러졌다. 문제 측정의 벤치마크에서:

Unique values ESF 오픈 타임
숫자15k 1.60s 
날짜274 5.20s 
시간86k 6.60s 

날짜 열은 274개의 고유 값을 가지고 있어 숫자 열의 15,000개보다 훨씬 적었지만, 열리는 데 3× 더 오래 걸렸습니다. 그 이유는 라벨 추출과 값 형식 지정이 고유 값뿐만 아니라 전체 데이터셋에 걸쳐 날짜 구문을 포함했기 때문입니다. 모든 기록이 방문되었고, 방문할 때마다 연속 변환이 이루어졌습니다. 고유 값이 적은 것은 파싱이 전체 데이터 처리 중에 이루어졌기 때문에 도움이 되지 않았습니다.

수정 #1: 이중 초기화 제거

가장 영향력 있는 변화는 구조적인 것이었습니다: ESF가 더 이상 Apply에서 재초기화되지 않습니다. Open에 구축된 고유 값 목록은 사용자가 '적용'을 클릭할 때 직접 재사용됩니다. 두 번째 전체 파이프라인 작업은 완전히 사라졌습니다.

// Before 

onApplyClick(): 

    re-run full ESF initialization    // O(n) - redundant 

    close dialog 

  

// After 

onApplyClick(): 

    apply filter using existing list  // O(1) - list already built 

    close dialog 

수정 #2: 연기 정렬을 이용한 단일 패스 중복 제거

두 번째 변경은 파이프라인을 완전히 재구성하여 라벨 추출과 중복 제거를 단일 패스로 통합하고, 중복 제거된 결과만 정렬했습니다:

// Before: separate passes 

filteredData → sort → extract labels (pass 1) → deduplicate (pass 2) 

  

// After: deduplicate in single pass → sort unique list only 

filteredData (n records) 

    → single pass: 

        resolve + normalize + deduplicate inline   // O(n), parse only for new unique values 

    → unique list (m items) 

    → sort unique list                             // O(m log m) where m <= n 

여기 두 가지 복합적인 개선점이 있습니다:

  1. 라벨 서식과 날짜 구문 분석은 이제 데이터셋 내 모든 레코드에 대해 고유 값에만 적용되지 않습니다. 1M 행 데이터셋에서 274개의 고유 값을 가진 날짜 열의 경우, 1M 파스 호출과 274 개의 차이가 됩니다.
  1. 정렬은 이제 전체 필터링된 데이터셋이 아니라 중복 제거된 리스트에서 작동합니다. 274개의 고유 값 덕분에 정렬은 사실상 즉각적입니다. 86K 고유 값이 있는 시간 열에서도 86K 항목 정렬은 1M 정렬보다 훨씬 저렴하며, 각 비교가 시간 문자열 구문을 포함하기 때문에 정렬 입력을 줄이면 비용 절감 효과가 더욱 커집니다.

Fix #3: Non-Blocking Dialog Open 

세 번째 변경은 인지된 성능을 직접 다루는 것으로, 이제 대화 창이 데이터 파이프라인이 실행되기 전에 즉시 열립니다. 초기화가 완료되는 동안 로딩 표시기가 표시됩니다. 즉, UI가 아직 나타나지 않은 대화를 기다리며 멈추지 않는다는 뜻입니다. 초기화에 시간이 걸리더라도 사용자는 즉각적인 피드백을 볼 수 있습니다 – 대화가 열려 있고 무언가가 진행되고 있습니다.

Fix #4: Debounced Quick Filtering 

빠른 필터링 측면에서 작지만 의미 있는 개선점: 이전에는 필터링 파이프가 모든 키 입력마다 트리거되어, 사용자가 "Finance"를 입력하면 7번의 필터 작업이 연속으로 실행되어 각 작업이 전체 데이터셋을 반복했습니다.

// Before: filter on every keystroke 

input: "F"       → filter            // O(n) 

input: "Fi"      → filter            // O(n) 

input: "Fin"     → filter            // O(n) 

... 

  

// After: debounced 

input: "F", "Fi", "Fin", "Fina", "Finan", "Financ", "Finance" 

→ pause detected → filter once       // O(n) - only when user stops typing 

대규모 데이터셋의 경우, 이 값만으로도 일반적인 검색에서 메인 스레드 필터 연산 횟수가 5–10개에서 1–2개로 줄어듭니다.

결과

ESF 적용 수치는 특히 중요합니다: 90ms로 퀵 필터링과 고급 필터링과 같은 성능 범위에 들어갑니다. 세 가지 필터링 모드가 처음으로 비용 비교 가능해졌습니다.

이것이 실제로 의미하는 바

  • ESF 대화상자는 클릭 즉시 나타납니다. 더 이상 나타나지 않는 대화를 기다릴 필요가 없습니다.
  • ESF 대화 내에서 데이터가 로드되는 전체 시간은 모든 열 유형에서 더 빠릅니다. 데이터셋이 크더라도 사용자는 로딩 표시기를 바라보는 시간이 줄어듭니다.
  • 필터를 적용해도 전체 초기화 비용이 반복되지 않습니다. 이전과 비교하면 사실상 무료입니다.
  • 빠른 필터링이 더 이상 빠른 타이핑에 대한 핵심 스레드를 강하게 누르지 않습니다. 디바운싱은 사용자가 완료하거나 일시정지했을 때만 파이프라인이 실행되도록 보장합니다.

이러한 변화가 프레임워크 전반에 걸쳐 작동하는 이유

위에서 다룬 성능 향상은 Angular 코드베이스에서 이루어졌습니다. 하지만 그 자리에 머무르지 않습니다.

One Core, Multiple Frameworks 

Ignite UI의 그리드는 Angular 내장되어 있어 네이티브 Angular 컴포넌트로 직접 사용할 수 있으며, Angular의 템플릿 문법, DI 시스템, 변경 감지에 완전한 접근 권한을 제공합니다. 또한 Angular 요소를 사용하는 웹 컴포넌트로도 패키징되어 완전히 외부 Angular 사용할 수 있습니다. React와 Blazor는 그 웹 컴포넌트를 얇은 프레임워크 전용 래퍼를 통해 각각 React props와 Blazor 매개변수로 연결됩니다.

데이터 파이프라인 – 정렬, 그룹화, 필터링 – 은 전적으로 Angular 베이스에 위치해 있습니다. Angular Elements는 이를 웹 컴포넌트에 있는 그대로 패키징합니다. React Blazor 절대 손대지 않아. Angular 코드베이스에서 이루어진 모든 알고리즘적 개선은 전체 체인에 자동으로 전파됩니다. 여기서 '래퍼'가 정확히 무엇을 의미하는지 정확히 말할 가치가 있습니다. 이것은 재구현이 아니라 얇은 통합 계층입니다.

알고리즘 개선이 프레임워크에 구애받지 않는 이유

슈워츠 변환, 반복 그룹화 스택, 그리고 단일 패스 ESF 중복 제거는 순수 데이터 연산입니다. 배열을 들여오고 변환된 배열을 다시 내보냅니다. 그들은 Angular의 변경 감지, React의 조정기, Blazor의 렌더 트리에 대해 전혀 알지 못합니다. 그래서 이 모든 플랫폼에서 이렇게 깔끔하게 전파되는 것입니다.

개선점은 자바스크립트 엔진의 향상입니다:

  • Fewer resolver calls per sort operation. 
  • 그룹 경계당 중간 배열 할당이 적습니다.
  • Less GC pressure across the full pipeline. 
  • 모든 데이터 작업에서 메인 스레드 블로킹 시간이 짧아졌습니다.

이 중 어느 것도 프레임워크 개념이 아닙니다. 빠른 정렬은 결과가 Angular, React, Web Components, Blazor 중 어느 방식으로 렌더링되든 성능을 향상시킵니다. 최적화는 UI 프레임워크가 렌더링하기 전에 데이터 계층에서 이루어지기 때문입니다.

어떤 그리드를 사용할지 평가하는 개발자들을 위해: 엔진이 프레임워크 간 동일하기 때문에 성능 이야기가 동일합니다. 이 글에 나오는 숫자들은 Angular 숫자가 아닙니다. 이것들은 데이터 파이프라인 번호이며, 데이터 파이프라인은 공유됩니다.
 

이것이 기업 팀에 의미하는 바

엔지니어링 성능 향상은 밀리초 단위로 측정하기 쉽습니다. 데이터 그리드가 단순한 UI 요소가 아니라 분석가, 트레이더, 운영팀이 업무를 수행하는 주요 인터페이스인 기업 규모에서는 비즈니스 영향이 더 크지만, 이는 더욱 중요합니다.

데이터 그리드의 성능 문제는 재현하기 어렵고, 진단하기 어렵고, 종료하기 어려운 특정하고 답답한 지원 티켓 범주를 생성합니다. "정렬할 때 그리드가 멈춘다"는 스택 트레이스가 있는 버그가 아닙니다. 이는 실제 데이터 볼륨 하에서 메인 스레드를 몇 초간 차단하는 파이프라인의 증상입니다.

Ignite UI 원격 데이터 바인딩을 지원하며, 정렬 및 필터링을 클라이언트 측에서 실행하지 않고 서버에 위임할 수 있습니다. 클라이언트 측 성능이 부족해 주로 원격 운영을 도입한 팀에게는 이러한 최적화가 계산을 바꾸게 됩니다. 클라이언트 측 정렬은 100만 행에서 0.5초 이내에 완료됩니다. 이전에는 팀이 서버 측 위임을 하도록 유도했던 많은 엔터프라이즈 데이터셋에서, 클라이언트 측 파이프라인이 이제 그 결정을 재고할 만큼 충분히 빠릅니다.

특히 금융 서비스의 기업 환경에서는 인식된 반응성이 플랫폼 채택에 직접적인 영향을 미칩니다. 정렬 속도를 3.38초에서 0.42초로 옮기는 것은 단지 고립 시 8× 향상만이 아닙니다. 워크플로우를 중단시키는 상호작용과 지연으로 인식되지 않는 상호작용의 차이입니다. 이 구분은 최종 사용자가 도구가 사용 가치가 있는지 결정할 때 중요합니다.
 

배운 교훈: 다시 한 번 (그리고 다르게) 우리가 할 일들

이 글에 나온 전후 수치는 깨끗합니다. 그것들을 만들어낸 과정은 그렇지 않았다. 그 과정이 실제로 어떻게 진행되었는지 설명드리겠습니다.

처음부터 보장된 것은 아무것도 없었다

이 작업에 들어갈 때는 이러한 최적화가 의미 있는 결과를 낼 것이라는 확신이 없었습니다. 슈워츠 변환은 잘 알려진 기법입니다. 하지만 '잘 알려져 있다'는 것이 '이 맥락에서 반드시 도움이 된다'는 뜻은 아닙니다. 반복적 그룹화 스택은 이론상으로는 유망해 보였지만, 재귀적 반복 구조는 특정 데이터 형태에서만 나타나는 미묘한 예외 사례를 도입한 역사가 있습니다.

이 접근법은 의도적으로 점진적이었습니다: 한 번에 한 문제씩 해결하고, 측정한 뒤 계속할지 결정하는 방식이었습니다. 분류 파이프라인이 먼저 시작되었습니다. 문자열 정렬에서 3.38초에서 0.42초 사이의 숫자가 돌아왔을 때, 방향이 검증되어 그룹화와 필터링을 계속할 이유가 입증되었습니다. 만약 첫 번째 최적화에서 미미한 이익이 보였다면, 전략은 바뀌었을 것입니다.

이는 공연 작업이 종종 결과가 미리 알려진 것처럼 계획되기 때문에 중요합니다. 그렇지 않습니다. 올바른 자세는 가설, 측정, 결정, 반복입니다.

메모리 트레이드오프

슈워츠 변환은 무료가 아닙니다. [레코드, 값] 쌍의 중간 배열을 처음에 할당하는데, 레코드당 하나의 항목입니다. 100만 행 개분이라면, 정렬이 시작되기 전부터 메모리 오버헤드가 꽤 큽니다.

이는 의도적인 트레이드오프였습니다: 더 높은 피크 메모리 사용량을 감수하는 대신영형(NogN)O(n log n)리졸버가 부른다. 이 라이브러리가 목표로 하는 사용 사례, 즉 최신 브라우저에서 실행 가능한 하드웨어에서 실행되는 엔터프라이즈 그리드에서는 속도 향상이 상당하며 메모리 비용도 허용 가능합니다.

하지만 명확히 언급할 가치가 있습니다: 만약 메모리 제약 환경이 주요 대상이 된다면, 슈워츠 변환을 다시 검토해야 할 것입니다. 여기서 속도와 메모리는 반대 방향으로 끌리며, 현재 구현은 속도를 선택했습니다.

벤치마크는 실제 사용량을 반영해야 합니다

이 작업의 벤치마크 스위트는 100만 행의 합성 데이터셋(통제된 열 유형과 값 분포를 가진 생성된 기록)을 사용했습니다. 이것이 알고리즘 성능을 분리하는 올바른 출발점이지만, 한계가 있습니다.

이 작업을 촉발한 두 가지 문제는 실제 고객으로부터 나왔습니다: ESF 대화 개방 시간과 ESF 적용 시간이 프로덕션 내 차단 문제로 보고되었습니다. 그 티켓들이 도착했을 때, 합성 벤치마크가 문제를 확인했습니다. 문제는 티켓 이전부터 존재했어요. 실제 사용 패턴이 드러나야 했습니다.

교훈은 명확합니다: 합성 벤치마크는 이미 테스트할 시나리오를 측정하는 데 능숙합니다. 고객 데이터는 포함하지 못한 고객들을 찾아냅니다. 두 가지 모두 필수이며, 벤치마크 스위트는 단순히 합성된 최악의 사례가 아니라 실제 사용 패턴이 드러나는 대로 반영하도록 진화해야 합니다.

공연 작업은 결코 끝나지 않는다

이 글의 개선점은 실제적이고 의미 있습니다. 또한 한 장면의 스냅샷이기도 합니다. 데이터 파이프라인은 6개월 전보다 오늘날 더 빠릅니다. 6개월 후에는 날짜 파싱, 가상화 등과 같은 알려진 영역들이 이 작업 이전의 정렬 파이프라인과 비슷하게 보일 것입니다. 기능적으로는 가능하지만 개선할 여지가 남아 있어 아직 해결되지 않았습니다.

그것은 현재 작업의 실패가 아닙니다. 이것이 바로 퍼포먼스 엔지니어링의 본질입니다. 기준선이 이동하고, 고객 데이터 양이 증가하며, '충분히 빠르다'의 정의도 함께 변합니다. 이번 최적화의 가치는 단순히 몇 밀리초 절약에만 있지 않습니다. 다음 격차를 찾고 메우기 위한 과정입니다.

Ignite UI 그리드 성능의 다음 계획

이 글의 최적화는 한 라운드의 집중된 성과 작업일 뿐, 주제에 대한 최종 설명이 아닙니다. 이미 여러 분야가 진행 중이며, 더 많은 분야가 적극적으로 탐색되고 있습니다.

이미 개선된 점

가상화 성능은 이 글에서 다룬 정렬, 그룹화, 필터링 작업과 함께 개선되었습니다. 행 및 열 가상화는 대규모 데이터셋 렌더링을 가능하게 하는 기반입니다. 이 모든 개선은 데이터 파이프라인 성능 향상과 결합되어 그리드가 데이터 처리와 렌더링 모두 더 빠릅니다.

아직 작업 중인 것들

날짜 구문 분석은 여전히 개선의 여지가 있는 분야입니다. 날짜와 시간 열의 정렬 및 ESF 결과는 이전보다 훨씬 좋아졌지만, 날짜 문자열 파싱 방식과 관련된 문제로 숫자 열보다는 여전히 느립니다. 파싱 계층에 대한 보다 타겟팅된 작업이 논리적인 다음 단계입니다.

묶음 크기는 지속적인 관심사입니다. 필요 이상으로 많은 자바스크립트를 제공하는 빠른 그리드는 특히 초기 로딩 시간이 런타임 성능만큼 중요한 팀에 오히려 역효과를 냅니다. 전력망의 부적 공간을 줄이면서도 성능을 희생하지 않는 것은 지속적인 균형 조정입니다.

그리드 API 개선은 병행하여 계속되고 있습니다. 직접적인 성능 문제는 아니지만 연관된 문제입니다. 더 깔끔한 API는 성능에 민감한 코드 경로가 의도치 않은 방식으로 호출되는 표면적 면적을 줄여줍니다.

렌더링 비용, 변경 감지 압력, 고빈도 업데이트 하에서의 상호작용 반응성 등 보다 넓은 런타임 성능은 여전히 탐구가 이루어지지 않은 영역입니다. 구체적인 주장은 없지만 주목받고 있습니다.

성과에 대한 피드백을 공유하세요

각 성과 향상은 기준선과 기대치를 높입니다. 한때 느렸던 것이 빠르게 변하고, 결국 새로운 병목 현상이 나타납니다.

그래서 우리는 실제 사용 사례에서 얻은 유용한 피드백을 소중히 여깁니다. 운영 환경에서 Ignite UI 그리드를 사용하고 있고 성능 문제가 발생하면 GitHub에 이슈를 열어보세요. 실제 시나리오와 재현 가능한 사례는 다음 개선 기회를 찾는 데 도움을 줍니다.

마무리: 성과는 단순한 항목이 아닌 약속으로서

모든 그리드 라이브러리는 성능을 기능으로 명시합니다. "수백만 행을 처리한다"는 체크박스 같은 다른 기능과 함께 비교 테이블에 나타나지, 약속이 아닙니다.

기술적으로 큰 데이터셋을 처리하는 그리드와 사용자가 기다리지 않고 처리하는 그리드 사이에는 차이가 있습니다. 그 차이는 기능 목록에 나타나지 않습니다. 사용자가 열 헤더를 클릭하거나 필터 대화상자를 열면 즉시 응답이 오거나 UI가 멈추는 것을 볼 때 나타납니다.

이 글의 작업은 그 구분에 의해 추진되었습니다. 마케팅 요구가 아니라 실제 고객이 실제 성능 벽에 부딪히고, '효과가 있다'와 '빠른다'는 주장이 다르다는 인식 때문입니다. 슈워츠 변환, 반복적 그룹화 스택, 단일 패스 ESF 파이프라인 – 이 모든 것이 처음부터 명확하지 않았고, 작동이 보장되지 않았으며, 모두 측정이 필요했습니다.

성능은 출시 후 바로 넘어가는 기능이 아닙니다. 이 구성요소에 의존하는 개발자와 최종 사용자들에게 UI가 방해하지 않는 실제 작업을 실제로 대규모로 수행할 수 있도록 지속적인 의무가 요구됩니다.

우리는 계속해서 그 목표를 달성할 계획입니다.

데모 요청