반응형 UI에서 양방향 크기 제약이 레이아웃 계산을 왜 느리게 만드는지, 의존적 크기/위치가 다중 패스와 지수적 복잡도를 낳는 과정을 살펴보고 실제로는 어떻게 최적화하는지 설명한다.
UI 레이아웃 계산은 느립니다. 이는 양방향 크기 제약(bidirectional size constraints)을 허용하는 반응형 시스템의 본질이며, 제대로 된 레이아웃 시스템이라면 대체로 이런 특성을 갖습니다. 저는 예전부터 레이아웃 작업을 많이 해왔고, Unity의 새로운 UI Toolkit에 대해 글을 쓴 뒤 이 문제가 다시 떠올랐습니다. 이 문제를 푸는 일은 제가 했던 프로젝트 중에서도 가장 도전적인 것 중 하나였고, 이미 여러 글로 다뤄왔습니다. 이번 글에서는 높은 수준에서 이 문제를 바라보며, 왜 UI 레이아웃 계산이 느린지 설명해 보겠습니다.
나쁜 소식부터 말하자면, 이는 지수(exponential) 알고리즘입니다. 다만 실제 환경에서는 강력하게 최적화하여 거의 선형(near linear) 성능까지 끌어올릴 수 있습니다. 하지만 더 나쁜 소식이 있습니다. 여러분이 좋아하는 라이브러리가 그런 최적화를 하고 있지 않을 수도 있습니다.
다음과 같은 기본 레이아웃을 생각해 봅시다. 이는 어떤 현대적 레이아웃 시스템으로도 만들 수 있습니다. 여기서는 의도한 레이아웃을 명확히 하기 위해 의사(pseudo) 언어를 사용하겠습니다. flex-box 같은 실제 도구의 문법은 꽤 혼란스러울 수 있기 때문입니다.
세로 스택 패널(Vertical Stack Panel), 화면의 왼쪽-중앙에 배치

전체 계산 시스템으로 바로 뛰어들기보다는, 무슨 일이 벌어지는지 손으로 대충(손짓으로) 이해해 봅시다. 이 패널을 올바른 위치에 놓으려면 자식 요소들의 크기를 계산해야 합니다. 이 경우는 비교적 쉽습니다. 텍스트의 외곽 크기(extents)를 계산하고, 이미지의 크기를 로드한 뒤, 둘을 더하면 됩니다. 패널의 크기를 알면 화면에서 어디에 놓을지도 계산할 수 있습니다.
하지만 이미지를 중앙 정렬한다고 했죠. 여기서는 패널 안에서 가로 방향으로 가운데에 오길 원합니다. 패널의 크기를 계산하기 전에는 “가운데”가 어디인지 알 수 없습니다. 이 예제가 단순하다고 한 이유는, 이미지의 중앙 정렬이 이미지 자체의 크기에는 영향을 주지 않기 때문입니다. 그럼에도 레이아웃 패스(layout pass)는 여러 번 필요합니다. 첫 번째 패스에서 필요한 크기를 알아내고, 두 번째 패스에서 요소들의 위치를 정합니다.

또한 이미지가 텍스트보다 넓다면 텍스트도 가운데 정렬되어야 한다고 가정할 수도 있습니다. 이것은 의존적인 위치(dependent positions)입니다. 어떤 요소의 위치가 주변 요소들의 위치에 의존하는 경우죠.
더 복잡한 시스템을 만들기 위해 요소 하나를 더 추가해 봅시다. 여러 줄로 줄바꿈되는 텍스트입니다.
세로 스택 패널(Vertical Stack Panel), 화면의 왼쪽-중앙에 배치

“패널에 맞춰 줄바꿈”이란 무엇일까요? 이 텍스트가 패널의 자연스러운 너비(natural width)에 맞춰 흐르되, 텍스트 자체는 패널의 너비 계산에는 기여하지 않게(너비를 늘리지 않게) 표현하고 싶습니다. 텍스트 문서를 떠올려 보면, 텍스트는 페이지의 전체 너비로 흐릅니다. 텍스트는 언제나 무언가를 가득 채우도록 흐르죠. 여기서는 패널을 채우도록 흐르는데, 바로 그것이 문제입니다.
자식 요소들의 크기를 알아내기 위한 첫 번째 패스에서, 줄바꿈 텍스트의 너비는 무엇을 기준으로 해야 할까요? 첫 번째 패스에서는 여러 줄 텍스트가 제대로 답할 수 없습니다. 자기 자신이 얼마나 커질지 모르기 때문입니다. 구체적으로는, 얼마나 “높아질지”는 얼마나 “넓어야 하는지”를 알아야만 결정할 수 있습니다.
이로 인해 의존적 크기(dependent size)라는 개념이 생깁니다. 여러 줄 텍스트의 크기가 형제(siblings) 요소들의 크기에 의존합니다. 더 일반적으로 말하면, 요소의 크기는 다른 요소의 크기에 의존할 수 있습니다. 바로 옆의 형제일 수도 있고, 조상(ancestors)일 수도 있으며, 사실상 트리의 거의 어디든 될 수 있습니다.
구현 측면에서 이게 의미하는 바는 무엇일까요? 우리는 얼핏 보고 해법을 떠올릴 수 있지만, 코드는 그렇지 못합니다. 레이아웃 엔진은 패널에 대해 여러 번 크기 계산 패스를 수행해야 합니다. 첫 번째 패스에서는 자식들에게 “원하는 크기”를 묻고, 두 번째 패스에서는 (새 제약 조건을 주고) 다시 묻습니다.
별로 심각해 보이지 않을 수도 있지만, 계속해 봅시다…
세로 패널을 가로 패널 안에 넣고, 그 안에 다른 이미지를 하나 더 넣으면 어떻게 될까요?
가로 스택 패널(Horizontal Stack Panel), 화면 왼쪽에서 세로 방향 중앙 정렬
세로로 긴 이미지(Tall-Image), 높이에 맞게 늘림(stretch to height)
세로 스택 패널(Vertical Stack Panel)

우리는 세로로 긴 이미지가 가로 패널의 높이를 가득 채우도록 늘어나길 원합니다. 이때 너비는 원래의 종횡비(aspect ratio)를 유지하면서 그 높이에 따라 달라집니다. 이런 유형의 레이아웃은 꽤 흔합니다. 예를 들어, 작성자 박스 옆의 아이콘, 혹은 섹션 사이를 나누는 세로 구분선 같은 것들입니다.
세로 스택 패널이 자신의 크기를 결정하기 위해 자식에 대해 여러 패스가 필요하다는 것은 이미 알고 있습니다. 같은 논리로, 가로 스택 패널도 여러 패스가 필요하다는 것을 알 수 있습니다. 세로로 긴 이미지는 세로 스택 패널의 높이를 알기 전까지 자신의 높이도, 너비도 결정할 수 없기 때문입니다.
여기서 성능 문제의 핵심에 도달합니다. 레이아웃에 제약을 하나 추가할 때마다, 계산 패스가 하나 더 필요해집니다. 가로 패널의 첫 번째 패스는 초기 값을 얻고, 그 값을 다음 패스의 제약 조건으로 사용합니다. 가로 스택 패널의 각 패스에서 세로 패널의 크기를 묻게 되는데, 두 경우 모두 서로 다른 제약을 주게 됩니다. 그리고 각 제약 세트마다, 세로 패널은 자식에 대해 2패스를 수행합니다.
만약 각 요소가 자신의 크기를 결정하는 데 2패스가 필요하다면, N개의 요소로 이루어진 트리는 모든 크기를 결정하기 위해 2^N 패스가 필요합니다. 끔찍하죠! 우리는 지수 알고리즘을 마주하고 있습니다. 레이아웃이 느린 것도 당연합니다.
그런데도 브라우저와 모바일 앱은 수백, 심지어 수천 개의 요소도 레이아웃을 잡아냅니다. 그들이 지수 알고리즘을 쓰지 않는 걸까요? 아닙니다. 써야만 합니다. 왜냐하면 그런 알고리즘이 필요한 레이아웃 시스템을 제공하고 있기 때문입니다. 트리의 아주 깊은 잎(leaf) 노드 크기가 전체 트리의 레이아웃을 바꾸게 되는, 깊게 중첩된 레이아웃을 만드는 것은 비교적 쉽습니다. 여기에 동적 요소를 추가하거나, 더 나쁘게는 애니메이션까지 넣으면, 값비싼 계산을 계속해서 반복해야 합니다.
레이아웃 시스템 설계자가 영리하다면, 일반적인 사용에서 지수적 패스를 요구하지 않는 시스템을 만들 수 있습니다. 우리가 의존적 크기를 가지고 장난칠 수도 있지만, 현실의 레이아웃에는 많은 제약과 고정 크기가 존재합니다. 알고리즘은 이런 사실을 활용해 대부분의 패스를 피할 수 있습니다.
제가 Fuse의 레이아웃 시스템을 만들 때 목표는 가능한 한 선형에 가까운 계산에 도달하는 것이었습니다. 저는 많은 메모이제이션(memoization)을 사용했고, 요소별 레이아웃 계산에 까다로운 요구사항도 부과했습니다. 단순하게(naive) 만들면 안 되었고, 제약을 고려해야 했습니다. 괜찮았습니다. 어차피 그것들도 제가 직접 작성해야 했으니까요. 어쨌든 이에 대해서는 “Writing a UI Engine”의 “Engine” 파트에서 더 자세히 다룹니다.
하지만 많은 레이아웃 시스템은 이런 추가 지식을 활용하지 않는 것 같습니다. 확실히 Fuse 시스템의 기원인 WPF는 그것을 허용하지 않았습니다. 저는 이 문제를 다룰 만한 수준(tractable)으로 만들기 위해 아키텍처를 바꿔야 했습니다. HTML에서는 table 요소가 다루기 어려운(intractable) 레이아웃 계산을 만들어 냅니다. 그리고 흔한 flex-box 레이아웃은 최적화할 수 있지만, 깊은 지수 패스를 허용하는 상황도 가능한 걸까요?
저는 레이아웃을 수행하는 모든 라이브러리를 떠올리며, 그중 얼마나 많은 것들이 지수적 재귀(exponential recursion)를 겪는지 궁금해집니다. 제 현재 프로젝트와 관련해서도 말이죠. Unity의 uGUI가 느린 이유가 이것일까요? UI Toolkit의 느리다고 보고되는 현상도 같은 문제에서 비롯된 걸까요? 답이 ‘예’이길 바랍니다. ‘예’라면 고칠 수 있다는 뜻이니까요. 또한 최적화가 끝난 뒤에는, 느린 UI를 만들지 않기 위해 지켜야 할 규칙이 있다는 뜻이기도 합니다.
이 글은 Writing a UI Engine 시리즈의 일부입니다