메인 콘텐츠로 건너뛰기

레이어 패널의 성능 향상

Shannen WuSoftware Engineer, Figma
Peter HayesSoftware Engineer, Figma

레이어 패널은 Figma 파일의 중심 설계도입니다. 우리는 새로운 계산 및 캐싱 전략으로 이를 재구조화하여, 가장 크고 복잡한 파일들에서 인터랙션 속도가 30~50% 빨라졌습니다.

레이어 패널의 성능 향상공유

일러스트: Chou Chia Yu

Figma를 사용해 보셨다면 모든 디자인 요소를 중첩된 계층 목록으로 표시하는 레이어 패널에 익숙하실 것입니다.

레이어 패널은 파일 크기가 더 작고 Figma의 기능이 더 적었던 거의 7년 전에 처음 구축되었습니다. 오늘날에는 수만 개의 레이어가 있는 파일을 보는 것이 일반적입니다. 특히 매우 큰 파일의 경우 패널과의 인터랙션이 느려지는 현상이 보이기 시작했습니다. 레이어 패널의 성능 저하로 인해 드래그나 타이핑과 같은 편집기 작업까지 느려질 수도 있었습니다.

이 핵심 UI를 다시 빠르고 매끄럽게 만들기 위해, 레이어 패널 아키텍처를 처음부터 완전히 새롭게 재구축했습니다.

이전 접근 방식

Figma 파일은 HTML 파일과 유사한 노드의 트리 구조로 되어 있습니다. 각 노드에는 일련의 속성(이름, 표시 여부 등)과 자식 노드 목록이 있습니다. 레이어 패널 UI를 렌더링하기 위해, 각 노드에 대한 모든 데이터를 포함하는 거대한 JavaScript 개체를 구성합니다.

기존 아키텍처에서는 단일 데이터 수집 단계(일명 '패스(pass)')를 통해 이 개체를 조합했습니다(그리고 레이어가 변경되면 다시 조합했습니다). 현재 페이지의 각 최상위 노드에서 시작하여 노드에 대한 데이터 묶음을 수집했습니다. 그런 다음 노드가 확장된 경우(캐럿 아이콘을 통해), 하위 항목으로 재귀하여 해당 항목에 대한 동일한 데이터를 수집하고 그 자식 항목으로 다시 재귀했습니다. 패널에 확장된 노드가 많은 대용량 파일의 경우, 레이어 패널에 대한 데이터를 반복적으로 재생성해야 했기 때문에 이는 상당한 속도 저하를 초래했습니다.

궁극적으로 이 아키텍처에는 두 가지 주요한 약점이 있었습니다.

  1. 과도한 연산: 일반적인 화면에서 사용자에게 실제로 보이는 행은 20~30개에 불과함에도 불구하고 확장된 모든 노드에 대한 데이터를 연산하고 있었습니다.
  2. 너무 빈번한 연산: 증분 연산에 대한 캐싱을 충분히 수행하지 않았습니다. 레이어 패널의 모든 변경(예: 노드 확장)은 기본적으로 확장된 모든 노드에 대해 처음부터 모든 것을 완전히 다시 연산해야 했습니다.

이러한 각각의 문제는 개별적인 해결책이 있었습니다.

해결책 1: 2단계 연산(Two-pass computation)

연산량이 너무 많은 문제를 먼저 해결하기 위해, 당사는 데이터 수집 단계를 2단계(pass)로 나누었습니다.

첫 번째 단계에서는 패널을 구성하는 정렬된 행 ID 목록만 가져오고, 나머지 모든 데이터는 나중을 위해 남겨둡니다. 이러한 ID 목록조차도 연산하기가 만만치 않다는 점에 유의하세요. 까다로운 부분 중 일부는 다음과 같습니다.

  • 오토레이아웃 프레임의 자식은 역순으로 표시됩니다.
  • 위젯 및 FigJam 스티커 메모와 같은 특정 노드 유형은 패널에 하위 항목을 표시하지 않습니다.
  • 프로토타이핑 프레임의 고정/스크롤 헤더는 하위 항목을 두 개의 하위 섹션으로 분할합니다.
  • 최상위 프레임과 컴포넌트는 고정되어 있습니다.
다양한 프레임 조건과 종속성이 레이어 패널 출력 동작을 결정하는 방법을 보여주는 손으로 그린 순서도입니다.다양한 프레임 조건과 종속성이 레이어 패널 출력 동작을 결정하는 방법을 보여주는 손으로 그린 순서도입니다.
행 ID 목록을 연산하는 것조차 제품 종속성이라는 복잡한 거미줄을 해결해야 합니다.

두 번째 단계에서는 노드에 대한 데이터(이름, 아이콘, 잠금 상태, 표시 여부, 선택 상태 등)를 연산합니다. 첫 번째 단계에서 ID를 얻었으므로, 당사는 중요한 노드, 즉 '윈도우잉된(windowed)' 노드에 대해서만 데이터를 연산할 수 있습니다.

윈도우잉은 스크롤 가능한 목록 중 화면에 보이는 항목만 렌더링하는 UI 프레임워크의 일반적인 기술입니다. 기존 아키텍처는 윈도우잉을 사용했지만 단일 단계에서 모든 것을 연산했기 때문에 화면에 보이지 않아 렌더링되지 않는 노드에 대해서도 여전히 전체 데이터를 수집했습니다.

윈도우잉은 뷰포트 외부의 모든 것을 잘라내고, 내부에 있는 것에만 집중한다는 의미입니다. 이 그림은 관대한 수준의 범위를 보여주지만, 가상화는 잘라낸 부분이 전체 목록에 비해 아주 작을 때 가장 빛을 발합니다.

2단계 접근 방식으로 전환함에 따라, 기존에는 잠재적으로 수십만 개의 행에 대해 연산되었던 이름, 아이콘, 잠금 상태, 표시 여부, 선택 상태 등과 같은 데이터가 이제 수십 개의 행에 대해서만 연산됩니다!

해결책 2: 파생 데이터 캐싱

변경되는 레이어만 다시 그리고 나머지는 안정적으로 유지함으로써, 비용이 많이 드는 작업을 너무 자주 다시 연산하는 것을 방지합니다.

연산이 너무 빈번하다는 두 번째 문제를 해결하기 위해, 당사는 지난 몇 년 동안 개발해 온 플랫폼 수준의 원형인 파생 속성으로 눈을 돌렸습니다.

각 Figma 노드는 일련의 필드를 노출하며, 이는 변경 가능한 변수처럼 작동하여 애플리케이션 코드에서 쓰거나 읽을 수 있습니다.

하지만 노드의 많은 속성은 그 자체로 필드가 아니며, 대신 필드와 다른 속성에서 연산됩니다. 예를 들어, 캔버스 중앙의 (0, 0) 점에 상대적인 오프셋(offset)인 노드의 절대 위치를 생각해 보겠습니다. 노드는 이 값을 직접 저장하지 않습니다. 대신 부모 위치로부터의 오프셋인 상대 위치를 저장합니다. 이를 통해 커다란 노드 트리를 한 번에 쉽게 이동하고 회전할 수 있습니다.

하지만 렌더링을 위해 노드의 절대 위치를 어떻게 연산할까요? 다음과 같은 연산을 통해 이 속성을 파생시킬 수 있습니다.

Plain text
Self.AbsolutePosition = Parent.AbsolutePosition + Self.RelativePosition

파생 속성 원형은 스프레드시트에서 다른 셀을 참조하는 수식과 유사하게, 이러한 속성을 선언하고 자동으로 최신 상태로 유지하는 공식적인 방법을 제공합니다. 이 시스템을 사용하면 다음과 같은 훌륭한 이점을 무료로 얻을 수 있습니다.

  • 각 파생 속성은 자신이 의존하는 필드 및 파생 속성을 알고 있습니다. 이는 최적화된 종속성 그래프 표현으로 컴파일됩니다.
  • 속도와 메모리 사용량 간의 장단점이 있는 여러 캐싱 정책이 있습니다.
  • 파생 속성은 기본적으로 지연(lazy) 상태입니다. 즉, 읽을 때만 연산됩니다.

레이어 패널의 트리 구조는 파생 속성에 아주 적합합니다.

이전에는 트리의 내용이 변경될 때마다 노드 데이터를 다시 계산했습니다. 파생 속성을 사용하면 레이어 패널 구조와 그 안의 종속성을 명시적으로 모델링할 수 있으므로 트리의 관련 하위 섹션만 업데이트하면 됩니다. 또한 서브트리 수준의 패널 연산을 효율적으로 캐시할 수 있습니다.

따라서 정렬된 행 목록을 연산하는 첫 번째 데이터 단계에서는 이를 다음과 같이 표현합니다(명확성을 위해 단순화함).

Plain text
Self.OrderedChildren = 
  If(Self.Expanded)
    Children.FlatMap(Child => Child.OrderedChildren)
  Else
    []

노드의 OrderedChildren 속성은 확장 상태가 변경되거나, 자식 중 하나의 OrderedChildren이 변경될 때만 무효화됩니다.

예시로 다음과 같은 파일을 상상해 보세요.

이전에는 프레임 B를 확장하면 페이지에서 시작하여 전체 트리를 다시 계산했습니다.
파생 속성 덕분에 프레임 B를 확장하면 영향을 받는 서브트리(프레임 A에서 B까지)의 데이터만 다시 계산하며, 프레임 C와 그 100개의 하위 항목은 건너뜁니다!

당사는 파생 속성의 지연(lazy) 기능을 사용하여 일부 연산을 회피하기도 합니다. 예를 들어 OrderedChildren은 지연(lazy) 속성입니다. 노드가 한 번도 확장되지 않으면 해당 OrderedChildren은 요청되지 않으므로 연산되지 않습니다.

하위 항목을 표시하기 위해 프레임 B를 확장했다고 상상해 보세요. 기존 시스템에서는 프레임 A에서 시작하여 프레임 C의 100개 하위 항목에 대한 정보를 포함하여 모든 것을 다시 연산했습니다. 그러나 파생 속성으로 표현하면 프레임 A와 B에 대한 정보만 연산하면 됩니다. 프레임 C에 도달하는 즉시 기존 값을 재사용할 수 있다는 것을 알고 있으므로 연산을 멈출 수 있습니다.

메모리 사용

캐싱을 추가하면 필연적으로 메모리 사용량이 증가합니다. 성능 향상으로 이어진다면 약간의 메모리 증가는 감수할 수 있었습니다. 하지만 메모리를 너무 많이 사용하면 짜증을 유발하는 "메모리 부족(out of memory)" 오류 발생 빈도가 증가할 겁니다.

OrderedChildren 캐싱의 단순한(naive) 구현은 다음과 같을 수 있습니다.

Plain text
Frame A: [B, C, D]
  Frame B: [C, D]
    Frame C: [D]
      Frame D: []

메모리 비용은 삼각수 합을 따릅니다. 1 + 2 + … + n 을 더하면 n(n + 1)/2가 됩니다.

트리의 각 수준은 모든 자손의 ID를 캐시해야 합니다. 이는 삼각수 합을 생성하여 O(n²) 메모리 사용량으로 이어집니다. 매우 깊은 트리의 경우 이는 수십 메가바이트가 될 수 있으며, 이는 허용되지 않습니다.

로프(rope)는 더 작은 조각으로 시퀀스를 저장하는 트리와 유사한 방식으로, 전체 시퀀스를 다시 빌드하는 대신 편집 및 연결 시 기존 조각을 재사용할 수 있습니다.

대신 로프 데이터 구조와 유사한 재귀적 목록을 사용하여 OrderedChildren 캐싱을 구현했습니다. 이에 따라 복사가 아닌 포인터를 통해 구성함으로써 구조적 공유를 극대화했습니다. 메모리 사용량은 읽기 중 추가 포인터 순회(traversals)라는 약간의 비용을 치르면서, 고유 노드 수에 엄격하게 비례하게 되었습니다(O(n)). 이로 인해 원래 프로토타입에 비해 메모리 사용량이 최대 99% 줄었습니다.

노드 B, D, C, E 및 F에 대한 단순화된 그룹 출력과 "기존 접근 방식"의 트리 계층을 비교하는 다이어그램입니다.노드 B, D, C, E 및 F에 대한 단순화된 그룹 출력과 "기존 접근 방식"의 트리 계층을 비교하는 다이어그램입니다.
각 수준에서 아래 수준의 데이터 사본을 추가함에 따라 메모리가 삼각수 합으로 증가했습니다.
계층적 노드가 레이어 패널에서 확장 가능한 세분화된 그룹에 매핑되는 "로프 구조" 표현을 설명하는 다이어그램입니다.계층적 노드가 레이어 패널에서 확장 가능한 세분화된 그룹에 매핑되는 "로프 구조" 표현을 설명하는 다이어그램입니다.
로프와 유사한 구조를 사용함으로써 메모리 증가를 다시 통제할 수 있었습니다.

성능에 미치는 영향

이 작업의 결과로 우리는 극적인 성능 향상을 관찰했습니다. 행을 확장/축소하거나 가시성/잠금 상태를 전환하는 것과 같은 중요한 레이어 패널 인터랙션이 가장 크고 복잡한 일부 파일에서 거의 30~50% 더 빨라졌습니다.

레이어 패널 자체에서 가장 큰 성능 향상을 보였지만, 그 영향은 Figma 디자인 편집기 전체로 확대되었습니다. 불필요한 작업을 줄임으로써 전반적인 렌더링 성능도 향상시켜 FPS를 높이고 느린 프레임을 줄였습니다. 결과적으로 이전에 특정 복잡한 파일에서 타이핑, 드래그 및 색상 선택과 같이 느리게 느껴졌던 작업이 이제 일관적으로 매끄럽게 느껴집니다!

갈 길이 아직 멀지만, 항상 Figma 전반에서 성능을 개선할 방법을 찾고 있습니다.

현재 엔지니어를 채용 중입니다!

Figma에서의 생활에 대해 더 알아보고 채용 중인 직무를 확인해 보세요.

기여해 주신 Eli Fitch, Amy Shan, Russell McClellan, Josh Ferrell, Connor Smith, Elynn Lee, Alex Triana 님께 감사드립니다!

Create and collaborate with Figma

Get started for free