virtualization

4 개의 포스트

figma

레이어 패널의 성능 개선 | Figma 블로그 (새 탭에서 열림)

Figma는 대규모 파일에서 레이어 패널의 느린 상호작용을 해결하기 위해 아키텍처를 전면 재설계했다. 행 목록 계산과 노드 상세 정보 계산을 분리하고, 파생 속성(derived properties)을 활용해 변경된 부분만 다시 계산한 결과, 가장 복잡한 파일에서 일부 상호작용이 30~50% 빨라졌다. 핵심은 보이지 않는 레이어까지 계산하지 않고, 필요한 데이터만 지연 계산·캐싱하는 것이다. ## 레이어 패널 성능 저하의 원인 - 레이어 패널은 Figma 파일의 노드 트리를 중첩된 목록으로 표현한다. - 초기 아키텍처는 파일의 각 노드에 대해 이름, 아이콘, 표시 여부, 잠금 상태 등 UI에 필요한 데이터를 하나의 큰 JavaScript 객체로 구성했다. - 레이어가 변경될 때마다 확장된 모든 노드에 대해 데이터를 처음부터 다시 계산했다. - 파일이 수만 개의 레이어를 포함하게 되면서 두 가지 문제가 발생했다. - **계산량 과다:** 화면에 실제로 보이는 행은 보통 20~30개뿐인데, 확장된 모든 노드의 데이터를 계산했다. - **재계산 빈도 과다:** 레이어 하나를 확장하거나 변경해도 전체 데이터를 다시 생성했다. ## 1단계: 두 번에 나누어 계산하기 - 데이터 수집 과정을 두 단계로 분리했다. - 첫 번째 단계에서는 패널에 표시될 **행 ID의 순서**만 계산한다. - 이 과정도 단순하지 않으며 다음과 같은 제품 규칙을 반영해야 한다. - 오토 레이아웃 프레임의 자식은 역순으로 표시될 수 있다. - 위젯이나 FigJam 스티키처럼 자식 노드를 패널에 표시하지 않는 노드가 있다. - 프로토타이핑 프레임의 고정 헤더와 스크롤 헤더는 자식을 두 영역으로 나눈다. - 최상위 프레임과 컴포넌트는 고정된 위치에 표시된다. - 두 번째 단계에서는 첫 단계에서 얻은 행 ID 중 현재 화면에 보이는 노드만 대상으로 상세 데이터를 계산한다. - 기존에도 화면 밖 항목을 렌더링하지 않는 윈도잉(windowing)을 사용했지만, 데이터 계산 자체는 모든 확장 노드에 대해 수행되고 있었다. - 새 구조에서는 이름, 아이콘, 잠금·표시 상태, 선택 상태 등을 실제로 필요한 수십 개 행에 대해서만 계산한다. - 그 결과 수십만 개 노드의 데이터를 계산하던 작업을 화면 주변의 작은 범위로 줄였다. ## 2단계: 파생 데이터 캐싱 - 변경되지 않은 레이어를 다시 그리거나 재계산하지 않고, 바뀐 부분만 갱신하도록 개선했다. - 이를 위해 Figma가 플랫폼 차원에서 개발한 **파생 속성(derived properties)** 기능을 사용했다. - Figma 노드는 직접 저장·조회할 수 있는 필드(fields)를 가진다. - 하지만 일부 속성은 직접 저장하지 않고 다른 필드와 속성으로부터 계산된다. - 예를 들어 노드는 부모 기준의 상대 위치만 저장하고, 절대 위치는 다음과 같이 계산할 수 있다. ```text Self.AbsolutePosition = Parent.AbsolutePosition + Self.RelativePosition ``` - 파생 속성 시스템은 이런 계산 관계를 명시적으로 선언하고, 의존하는 값이 바뀌면 최신 상태를 유지하도록 한다. - 시스템의 장점은 다음과 같다. - 각 파생 속성이 어떤 필드와 다른 파생 속성에 의존하는지 추적한다. - 의존 관계가 최적화된 그래프 형태로 컴파일된다. - 속도와 메모리 사용량 사이의 선택을 고려한 여러 캐싱 정책을 제공한다. - 기본적으로 지연 계산(lazy evaluation) 방식이어서 실제로 읽을 때만 계산한다. - 레이어 패널은 부모-자식 트리 구조를 갖기 때문에 파생 속성과 자연스럽게 결합된다. - 이전에는 트리 어디에서든 변경이 발생하면 노드 데이터를 전체적으로 다시 계산했지만, 새 구조에서는 의존 관계를 바탕으로 영향을 받은 데이터만 갱신할 수 있다. ## 성능 개선의 의미 - 대규모 Figma 파일에서 레이어 패널의 스크롤, 확장·축소, 선택 등의 상호작용이 더 빨라졌다. - 레이어 패널의 계산 비용이 줄어들면서 드래그나 텍스트 입력 같은 에디터의 다른 작업에도 영향을 주던 지연이 완화됐다. - 윈도잉만 적용하는 것보다, **계산 대상 자체를 가시 영역으로 제한하는 것**이 중요하다는 점을 보여준다. - UI 성능을 높이려면 다음 두 문제를 별도로 해결해야 한다. - 불필요한 항목까지 계산하는 문제 - 같은 항목을 반복해서 계산하는 문제 ## 실용적인 결론 대규모 트리형 UI에서는 렌더링만 가상화하는 것으로 충분하지 않다. 먼저 화면에 필요한 항목의 ID나 구조만 계산한 뒤, 실제 표시되는 항목의 상세 데이터만 지연 계산하고, 의존성이 변한 부분만 캐시를 무효화하는 방식이 효과적이다.

github

Diff 라인 성능 개선을 위한 험난한 여정 (새 탭에서 열림)

GitHub는 대규모 풀 리퀘스트에서도 **Files changed** 탭의 반응성과 안정성을 유지하기 위해 단일 해결책이 아닌 여러 최적화 전략을 적용했다. 특히 diff 라인마다 생성되는 DOM 요소, React 컴포넌트, 이벤트 핸들러를 줄여 메모리 사용량과 입력 지연을 낮추고, 가장 큰 변경 사항에는 가상화를 적용해 렌더링 범위를 제한하는 방향을 택했다. 핵심 교훈은 작은 구조적 최적화도 수천 개의 diff 라인에 누적되면 큰 성능 개선으로 이어진다는 것이다. ## 대규모 풀 리퀘스트에서 성능이 어려운 이유 - GitHub의 풀 리퀘스트는 한 줄 수정부터 수천 개 파일과 수백만 줄을 포함하는 변경까지 규모 편차가 매우 크다. - 일반적인 풀 리퀘스트는 빠르게 동작했지만, 대규모 변경에서는 다음 문제가 발생했다. - JavaScript 힙이 극단적인 경우 1GB를 초과 - DOM 노드 수가 40만 개 이상으로 증가 - 페이지 상호작용이 매우 느려지거나 사실상 사용할 수 없게 됨 - 입력 후 다음 화면이 표시되기까지의 시간인 INP가 허용 수준을 초과 - 모든 기능과 브라우저의 기본 동작을 유지하면서 최악의 경우까지 해결하는 단일 기법은 현실적인 한계가 있었다. ## 풀 리퀘스트 규모별 최적화 전략 GitHub는 변경 규모와 복잡도에 따라 서로 다른 전략을 조합했다. - **diff 라인 컴포넌트 최적화** - 대부분의 풀 리퀘스트에서 기본 diff 화면을 빠르게 유지 - 중간 및 대규모 리뷰에서도 브라우저의 기본 `find-in-page` 같은 동작을 보존 - **가상화를 통한 점진적 성능 저하** - 가장 큰 풀 리퀘스트에서는 한 번에 렌더링하는 콘텐츠 양을 제한 - 모든 내용을 동시에 DOM에 올리지 않아 응답성과 안정성을 우선 - **기반 컴포넌트와 렌더링 개선** - 특정 모드에 관계없이 모든 크기의 풀 리퀘스트에 누적 효과를 제공 - 렌더링 구조 자체를 단순화해 메모리와 상호작용 비용을 줄임 ## 초기 목표와 측정 지표 최적화 작업의 목표는 단순히 평균 속도를 높이는 데 그치지 않았다. - JavaScript 힙 크기와 메모리 사용량 감소 - DOM 노드 수 감소 - 평균 INP 개선 - 특히 최악의 사용자 경험을 나타내는 p95와 p99 INP 대폭 개선 - 이를 위해 상태, HTML 요소, JavaScript 코드, React 컴포넌트 수를 전반적으로 줄이는 단순화 전략을 채택 ## v1의 문제: diff 라인당 높은 렌더링 비용 React로 처음 diff 화면을 구현한 v1은 작은 재사용 컴포넌트를 많이 조합하는 구조였다. - unified 뷰의 diff 한 줄에는 최소 약 10개의 DOM 요소가 필요했다. - split 뷰에서는 한 줄당 약 15개의 DOM 요소가 필요했다. - 구문 강조를 적용하면 추가 `<span>` 요소가 더해져 DOM 수가 증가했다. - React 계층에서도 다음과 같은 컴포넌트가 생성됐다. - unified 뷰: 한 줄당 최소 8개 컴포넌트 - split 뷰: 한 줄당 최소 13개 컴포넌트 - 댓글, hover, focus 등 추가 상태가 활성화되면 컴포넌트 수는 더 늘어났다. - 작은 컴포넌트마다 React 이벤트 핸들러를 5~6개씩 연결하는 경우가 많았다. - 결과적으로 diff 한 줄에 20개 이상의 이벤트 핸들러가 붙을 수 있었고, 이를 수천 줄에 적용하면서 비용이 급격히 커졌다. v1의 한 줄당 최소 구조는 다음과 같았다. - DOM 요소 10~15개 - React 컴포넌트 8~13개 - React 이벤트 핸들러 20개 이상 - 다수의 작은 재사용 컴포넌트 이 구조는 일반적인 규모에서는 문제가 없었지만, 데이터 크기가 사실상 제한되지 않는 대규모 풀 리퀘스트에서는 변경 줄 수가 늘수록 INP와 JavaScript 힙 사용량이 함께 악화됐다. ## v2의 방향: 작은 변경을 대규모로 누적 v2에서는 눈에 띄지 않는 HTML 구조까지 점검해 diff 라인당 비용을 줄였다. - 줄 번호 셀에 불필요하게 포함되어 있던 `<code>` 태그를 제거했다. - diff 한 줄에서 DOM 노드 2개를 줄이는 것은 개별적으로는 작은 개선처럼 보인다. - 그러나 10,000줄을 렌더링하면 DOM 노드 20,000개를 제거하는 효과가 발생한다. - 이처럼 줄 단위의 사소한 최적화도 대규모 데이터에서는 메모리 사용량과 렌더링 비용에 크게 누적된다. - 성능 개선에서는 큰 기능 변경만큼 불필요한 태그, 컴포넌트, 이벤트 핸들러를 하나씩 제거하는 작업도 중요하다. ## 실용적인 결론 대규모 목록이나 코드 diff를 렌더링할 때는 처음부터 최악의 데이터 규모를 고려해야 한다. 컴포넌트를 잘게 나누는 구조가 유지보수에는 유리할 수 있지만, 각 요소에 상태와 이벤트 핸들러를 추가하면 수천 개 항목에서 큰 비용이 된다. 따라서 DOM 구조를 단순화하고, 항목별 렌더링 비용을 측정하며, 극단적인 규모에는 가상화를 적용하는 조합이 효과적이다.

figma

버전 관리: 피그마 (새 탭에서 열림)

Figma의 Layers 패널에 가로 스크롤을 추가하는 일은 단순한 UI 개선이 아니었다. 계층 구조, 접기·펼치기와 잠금·숨김 상태, 가상화 렌더링, 다양한 텍스트 길이와 다중 스크롤 방향이 서로 얽혀 있었기 때문이다. Figma 팀은 세 가지 프로토타입을 시험하며 사용자의 계층 구조 인식과 작업 맥락을 해치지 않는 방향을 탐색했다. ## 가로 스크롤이 어려웠던 이유 - Layers 패널은 자주 사용되고 신뢰성이 중요해 작은 동작 변화도 신중해야 했다. - 레이어는 정적인 목록이 아니라 다음과 같은 상태를 가진다. - 숨김·잠금 - 계층 접기·펼치기 - 부모·자식 관계 - 성능을 위해 현재 화면에 보이는 레이어만 렌더링하는 **가상화**가 적용되어 있었다. - 세로로 스크롤하면 새 레이어가 렌더링되고, 레이어 이름 길이가 달라져 가로 스크롤 영역과 정렬이 복잡해졌다. - 핵심 목표는 단순히 콘텐츠를 옮기는 것이 아니라 사용자가 현재 계층상의 위치와 “더 볼 내용이 있음”을 계속 이해하도록 하는 것이었다. - 디자이너 Giorgio Caviglia는 JavaScript, HTML, CSS, React로 직접 프로토타입을 만들어 수천 개 레이어와 다양한 상호작용을 실제로 검증했다. ## 첫 번째 시도: 화면 왼쪽의 보이지 않는 레이어 표시 - 레이어가 패널의 왼쪽 위나 오른쪽 아래 경계를 벗어나면 해당 위치에 아이콘을 표시하는 대칭형 UI를 실험했다. - 아이콘을 패널 가장자리에 고정하는 것은 쉬웠지만, 레이어 이름이 스크롤될 때 배경이 일부 요소 아래로 지나가고 다른 텍스트는 가려야 했다. - 컴포넌트가 위에 놓인 요소의 정확한 위치를 알지 못해 다음 문제가 발생했다. - 배경이 텍스트를 제대로 덮지 못함 - 레이어 행 구조와 불투명 배경 처리가 충돌함 - 스크롤 상태에 따라 시각적 가림 처리가 달라짐 - 디자인 측면에서도 왼쪽 상단에 레이어 이름의 끝부분이 들쭉날쭉하게 남아 시각적으로 어색했다. - 결과적으로 대칭성을 유지하려던 해결책이 새로운 문제를 만들었고, 패널 상단의 빈 공간을 어떻게 다룰지 재검토하게 됐다. ## 두 번째 시도: 선택한 레이어로 자동 스크롤 - 캔버스에서 선택한 레이어가 Layers 패널에 보이지 않으면 해당 레이어가 패널 중앙에 오도록 자동으로 가로 스크롤하는 방식을 실험했다. - 이론적으로는 편리했지만, 실제 사용에서는 가로와 세로 스크롤이 동시에 발생해 사용자가 현재 위치를 잃었다. - 특히 깊게 중첩된 레이어를 선택하면 부모 레이어가 화면에서 사라져 계층 구조를 파악하기 어려웠다. - 사용자는 작업 대상의 이름뿐 아니라 다음 정보도 함께 확인해야 한다. - 어떤 부모 아래에 있는지 - 계층상 어디에 위치하는지 - 특정 컴포넌트의 일부인지 - 화면을 갑자기 다른 위치로 이동시키는 동작은 Google Maps가 주행 중 지도를 갑자기 다른 장소로 옮기는 것과 비슷한 혼란을 유발했다. - 도구가 사용자를 돕기보다 현재 작업에 대한 정신적 모델을 깨뜨리는 결과가 되어 채택되지 않았다. ## 세 번째 시도: 레이어 이름 변경 중 스크롤 - 가로 스크롤 도입으로 레이어 이름을 편집하는 동안 다른 레이어로 스크롤할 때의 동작도 새롭게 정의해야 했다. - 사용자가 이름 입력 중 다른 레이어를 보기 위해 스크롤하면, 입력 중인 텍스트를 자동으로 새 이름으로 확정하는 방안을 검토했다. - 그러나 스크롤은 이름 변경을 확정했다는 충분히 강한 신호가 아니었다. - 이 동작을 채택하면 사용자가 의도하지 않게 레이어 이름을 변경할 위험이 있었다. - 따라서 스크롤과 편집 확정의 관계를 별도로 설계해야 한다는 점이 드러났다. ## 실용적인 시사점 - 복잡한 UI에서는 정적인 화면 설계만으로 모든 상태를 예측하기 어렵기 때문에 실제 코드 기반 프로토타이핑이 유용하다. - 자동 이동은 편리함보다 사용자의 공간적·계층적 맥락 보존을 우선해야 한다. - 스크롤, 선택, 편집처럼 서로 다른 의도를 가진 동작을 하나의 암묵적 신호로 처리하면 오작동과 혼란이 발생한다. - 특히 가상화된 계층형 UI에서는 렌더링 구조, 텍스트 가림, 상태 변화까지 함께 고려해야 한다.

discord

강력해진 디스 (새 탭에서 열림)

Discord는 React Native 기반 모바일 앱의 성능을 높이기 위해 사용 빈도가 높은 화면과 컴포넌트를 집중적으로 최적화했다. 서버 목록 가상화, 채팅 리스트 재활용, Android 이모지 피커 네이티브 전환, 이미지 포맷 변경 등을 통해 메모리 사용량과 시작 시간, 스크롤 성능을 개선했다. 특히 저사양 Android 기기에서 발생하던 빈 프레임과 앱 충돌을 줄이는 데 초점을 맞췄으며, 일부 기능은 플랫폼별로 다른 구현을 유지하는 비용까지 감수했다. ## React Native 전환과 성능 최적화 방향 - Discord는 데스크톱에 React, 모바일에 React Native를 사용해 여러 플랫폼에서 기능을 빠르게 출시해 왔다. - Android에서는 초기 성능 우려 때문에 React Native 도입을 주저했지만, Android 기기 성능 향상과 Hermes 엔진 등장으로 2022년 React Native 기반으로 전환했다. - 전환 후 특히 저사양 기기의 시작 시간이 문제였으나, 2023년에 중앙값 기준 시작 시간을 절반으로 줄였다. - 이후에는 일반 사용자보다 서버와 메시지, 이모지를 많이 사용하는 ‘파워 유저’를 중심으로 최적화를 진행했다. ## 서버 목록 가상화 - 기존 서버 목록은 화면에 보이지 않는 서버의 뷰와 아이콘까지 모두 메모리에 유지했다. - Discord는 무료 사용자 최대 100개, Nitro 사용자 최대 200개의 서버 가입을 지원하기 때문에 서버가 많은 사용자의 메모리 부담이 컸다. - 새 가상화 구현은 현재 화면에 표시되는 서버 정보만 메모리에 유지한다. - 남은 리소스를 드래그 앤 드롭 애니메이션 개선에도 활용했다. - 서버가 100개 이상인 사용자는: - 시작 시 메모리 사용량 14% 감소 - 전체 시작 시간 10% 감소 ## 네이티브 채팅 리스트 최적화 - 채팅 리스트는 이미 네이티브로 구현되어 있었지만, 프로파일링을 통해 로딩과 스크롤 과정의 병목을 추가로 발견했다. - 주요 개선 사항: - 버튼 같은 Application Component와 이미지 격자형 Media Mosaic 재활용 - 스포일러, 업로드 오버레이처럼 자주 사용되지 않는 요소의 지연 생성 - Media Mosaic 바인딩 성능 개선 - 메시지 컨테이너와 리액션 측정 속도 개선 - 채팅을 열기 전에 자주 사용되는 요소를 재활용 풀에 미리 생성 - 결과적으로: - 느린 프레임이 최대 60% 감소 - 구형 기기에서 렌더링 프레임 수 증가 - 채팅 리스트 메모리 사용량 약 12% 감소 ## Android 이모지 피커의 네이티브 전환 - 서버 하나당 최대 250개의 커스텀 이모지를 지원하며, 여러 서버를 사용하는 경우 수백 개의 이모지가 한 화면에 표시될 수 있다. - 저사양 Android 기기에서는 이모지를 스크롤할 때 placeholder조차 표시되지 않는 빈 프레임 문제가 발생했다. - 단순한 점진적 최적화만으로는 해결하기 어렵다고 판단해 Android 이모지 피커를 네이티브로 다시 작성했다. - 200개 서버의 이모지를 사용하는 극단적인 테스트에서도: - 빈 프레임이 완전히 제거됨 - 저가형 기기에서도 일관되게 부드러운 스크롤 제공 - 메모리 부족으로 인한 충돌 방지 - Android는 네이티브, iOS는 React Native로 유지하게 되어 코드베이스와 유지보수 복잡성이 증가했다. - 향후 React Native New Architecture와 Android 기기 성능 향상에 따라 이 구조를 재검토할 예정이다. ## GIF에서 WebP로 전환 - 미디어 프록시에 애니메이션 WebP 지원을 추가했다. - 애니메이션 이모지 포맷을 GIF에서 WebP로 변경해 저사양 기기에서도 최대 60FPS 렌더링이 가능해졌다. - 이미지 포맷 선택만으로도 디코딩과 렌더링 성능을 개선할 수 있음을 보여주는 사례다. ## ChannelList의 빈 화면 문제 제거 - Discord는 자체 React Native 리스트 구현인 FastList를 사용해 왔다. - 모바일 앱 재설계 과정에서 Shopify의 FlashList를 도입했으며, 정확한 요소 크기를 미리 지정하지 않아도 되는 점과 빠른 개발 속도를 장점으로 평가했다. - 그러나 실제로는 저사양 Android 기기에서 FlashList가 더 자주 빈 화면을 표시하는 문제가 나타났다. - Discord는 다시 FastList를 리팩터링해 ChannelList에 적용했고, 50달러 수준의 저가형 기기에서도 빈 화면 문제를 제거했다. ## 적용할 수 있는 성능 개선 원칙 - 화면에 보이지 않는 요소는 가상화해 메모리에 보관하지 않는다. - 반복적으로 생성되는 UI는 재활용 풀과 recycling을 활용한다. - 자주 사용되지 않는 UI는 lazy inflation으로 늦게 생성한다. - 성능 문제가 심각한 경우 기존 프레임워크를 무리하게 유지하기보다 특정 화면을 네이티브로 재작성할 수 있다. - 평균적인 기기뿐 아니라 저사양 기기와 파워 유저의 극단적인 사용 시나리오를 기준으로 테스트해야 한다. - 범용 라이브러리가 항상 최선은 아니므로 실제 기기에서 blanking, 프레임 저하, 메모리 부족 여부를 검증해야 한다. Discord의 사례는 React Native를 전면적으로 포기하거나 모든 기능을 네이티브로 바꾸기보다, 병목이 명확한 부분만 가상화·재활용·지연 로딩·네이티브 구현으로 보완하는 접근을 보여준다. 특히 저사양 기기에서의 실제 사용자 경험을 기준으로 성능을 측정하고, 필요하다면 플랫폼별 구현 차이를 유지하는 것이 실용적인 전략이다.