티스토리 뷰

 

웹에서는 Flutter 또는 Jetpack Compose와 달리 레이아웃 계산에 직접 관여하거나 제어할 수 없습니다. 즉, 자식의 크기를 직접 계산하거나 그려지는 요소의 위치를 정의하는 로직에 직접적으로 관여할 수 없습니다. 대신, CSS 스타일이나 JavaScript를 사용하여 레이아웃에 간접적으로 영향을 미칠 수 있습니다.

레이아웃 계산을 변경하거나 특정 요소의 크기나 위치를 조정하는 경우, 브라우저는 이러한 변화를 반영하기 위해 Reflow를 수행해야 합니다. Reflow는 레이아웃을 다시 계산하는 과정으로, 빈번하게 발생하면 성능 저하를 일으킬 수 있습니다.

 

이 Reflow의 경우, 레이아웃의 크기와 위치를 참조하거나 요소의 돔 구조가 변경되면 수행되는 레이아웃 단계입니다.


따라서, 여러 번의 레이아웃 단계를 거치는 웹 라이브러리의 경우, 성능 최적화를 위해 불필요한 Reflow를 최소화하는 것이 중요합니다. 예를 들어, 스타일 변경을 한 번에 모아서 수행하거나, DocumentFragment를 사용하여 DOM 변경을 최소화하는 등의 방법이 있겠죠.


다행히 현재 존재하는 브라우저들은 이미 많이 최적화되어 있으며, 웹에서는 성능보다는 높은 접근성이 매우 중요하다는 사실을 인지해야 합니다. (웹에서 성능 저하가 사용자 경험에 영향을 미치지 않는다는 것은 아닙니다.)

 

따라서 이 글에서는 Reflow를 적절하게 활용하고 최소화하는 방법에 대해 설명하도록 하겠습니다.

 

Reflow 유발하기

typescript

element.opacity = "0"; element.opacity = "1"; element.transition = "opacity 1s";

해당 코드는 스타일 속성 값을 정의하여 특정 요소의 Fae-out 효과를 구현하는 간단한 예시 중 하나입니다, 해당 코드는 언뜻 보면 잘 동작할 것 같지만 한가지 문제점이 존재합니다.

 

트렌지션 애니메이션이 동작하기 위해서는 이전 스타일 속성 값과 현재 스타일 속성 값을 브라우저가 알고 있어야 합니다.

해당 코드에서는 이전 값과 현재 값을 정의한 것이 아니라, 단순히 기존 스타일 속성 값을 새로운 값으로 정정한 것일 뿐입니다.

 

브라우저에서 이전 값과 현재 값을 인지하게 하려면 Reflow 단계를 거쳐 레이아웃과 CSSOM 등을 재계산할 필요가 있습니다.

typescript

element.opacity = "0"; element.getBoundingClientRect(); // reflowed element.opacity = "1"; element.transition = "opacity 1s";

해당 코드에서와 같이 중간에 Reflow를 유발하는 함수를 호출시키거나 clientWidth 또는 clientHeight와 같은 Getter를 호출하여 브라우저에서 CSSOM 등을 재계산하도록 유도합니다.

 

참고로 Reflow을 유발하는 경우는 대부분 크기와 위치를 참조하기 위해 사용되는 함수와 Getter들을 호출하는 경우입니다.

typescript

element.opacity = "0"; element.transition = "opacity 1s"; requestAnimationFrame(() => { element.opacity = "1"; })

다음은 해당 코드를 간략하게 살펴보면 requestAnimationFrame 함수를 사용하여 비동기적으로 다음 프레임에 새로운 스타일 속성 값을 정의하도록 하고 있습니다.

 

Repaint 단계 이전에는 Reflow 단계를 거칠 것 같으므로 언뜻 봐서는 좋은 방법인 것 같지만 해당 경우는 사용자에게 완전히 계산되지 않은 레이아웃을 보여줄 가능성이 높습니다. (깜박임 현상이 발생할 가능성이 높습니다.)

 

또한 Repaint가 발생한다고 해서 Reflow가 발생하지 않습니다, 따라서 이와 같은 코드는 되도록이면 구현하지 않아야 합니다, 애초에 제대로 동작하지도 않으니까요.

 

Reflow 최소화하기

Reflow를 최소화하기 위해서는 Reflow를 최대한 유발하는 상황 자체를 피해야 합니다.

typescript

for (let i = 0; i < 10; i++) { element.style.width = `${element.clientWidth + 10}px`; }

해당 코드에서와 같이 반복할 때마다 Reflow를 유발하는 Getter인 clientWidth를 호출하여 요소의 폭 크기를 참조하고 있으므로 짧은 시간 안에 다수의 Reflow가 발생할 것입니다. (clientWidth 속성은 앞서 설명한 것 처럼 매번 호출될 때마다 실제 DOM 트리를 다시 계산하여 값을 반환하기 때문입니다.)

typescript

let clientWidth = element.clientWidth; for (let i = 0; i < 100; i++) { element.style.width = `${clientWidth += 10}px`; }

앞서 설명된 예시 코드와 같은 구조를 사용하는 상황은 현실에선 거의 없겠지만, 아까 전의 코드를 최적화 한다면 해당 코드와 같이 개선될 수 있습니다, 간단히 살펴보면 for문에 진입하기 전에 크기를 미리 별도의 변수에 정의해놓는 것입니다.

typescript

const element = document.getElementById("test"); for (let i = 0; i < 100; i++) { const item = document.createElement("p"); item.textContent = i; element.appendChild(item); }

다음은 해당 예시의 경우 특정 요소에 하위 요소를 삽입하는 아주 흔한 경우입니다, 여기도 이전 상황과 마찬가지로 반복될 때 마다 Reflow가 발생하게 됩니다.

 

이는 요소의 트리 구조가 변경되는 경우인데, 이 경우에도 마찬가지로 Reflow를 유발합니다. (자식 요소가 추가 또는 제거 됨으로서 부모 요소의 크기와 다른 요소의 크기와 위치가 변경될 수 있기 때문에)

typescript

const element = document.getElementById("test"); const frag = document.createDocumentFragment(); for (let i = 0; i < 100; i++) { const item = document.createElement("p"); item.textContent = i; frag.appendChild(item); } element.appendChild(frag);

해당 코드는 아까 전의 코드를 최적화한 경우입니다. 이를 간략하게 살펴보면 Document Fragment를 이용하여 메인 DOM에 접근하는 것을 최소화하는 것을 볼 수 있습니다.

 

Document Fragment는 일종의 가상 DOM 이라고 볼 수 있는데 가상 메모리에 할당되는 임시 DOM 트리라고 생각하면 좋을 것 같습니다.

 

여기서 별도의 요소를 생성하여 그 요소에 요소를 삽입하면 되지 않느냐, 라고 생각할 수 있지만 이를 조금만 생각 해보면 이는 요소 트리의 깊이를 쓸데없이 늘리는 행위가 될 것입니다.

 

반대로 브라우저에서는 Document Fragment 요소를 실존하는 요소로 바라보지 않으므로 이를 제외한 그 하위 요소들을 모두 참조하여 정의된 모든 요소들을 삽입할 것입니다.

 

결과적으로 앞서 설명한 정보를 바탕으로 이제 아래와 같은 간단한 레이아웃 계산을 쉽게 개발할 수 있을 것입니다.

bash

if (!lowerSizeRef.current) return; outer.style.display = "contents"; inner.style.display = "contents"; outer.style.width = null; inner.style.width = null; outer.style.height = null; inner.style.height = null; const lowerSize = lowerSizeRef.current; const upperSize = ElementUtil.intrinsicSizeOf(inner); // reflowed // Is not the children in this element has resized. if (lowerSize.width == upperSize.width && lowerSize.height == upperSize.height) { return; } upperSizeRef.current = upperSize; outer.style.display = null; outer.style.width = `${lowerSize.width}px`; outer.style.height = `${lowerSize.height}px`; ElementUtil.reflow(inner); outer.style.width = `${upperSize.width}px`; outer.style.height = `${upperSize.height}px`; outer.ontransitionend = () => { outer.style.display = "contents"; inner.style.display = "contents"; outer.style.width = null; outer.style.height = null; inner.style.minWidth = null; inner.style.minWidth = null; } inner.style.display = null; inner.style.minWidth = `${upperSize.width}px`; inner.style.minHeight = `${upperSize.height}px`;

해당 코드는 제가 작성하여 사용하고 있는 코드이며, 자세히 살펴보면 중간에 Reflow를 유발하는 함수를 호출하여 정상적으로 트렌지션 애니메이션이 동작할 수 있게 하고 특정 속성 값의 요소의 크기를 구하는 경우에도 마찬가지로 Reflow를 인위적으로 유발하여 원하는 애니메이션 동작이나 레이아웃 계산을 구현하는 것을 볼 수 있습니다.

 

해당 코드에 대한 자세한 내용은 https://github.com/react-widgets/react_widgets/blob/main/src/widgets/AnimatedSize.tsx 를 참고해주세요.

 

끝, 해당 글을 읽어주셔서 감사합니다.

 

Happy coding in the another world!
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함