웹팀 성능개선TF에 어서오세요

성능 저하 이유를 분석하고 다양한 방향에서 개선했던 4개월

Dugi 🎈

  • 테크 인사이트

들어가며

안녕하세요, 채널톡 웹팀 Dugi 입니다 👋

채팅은 채널톡 제품의 핵심 기능이며 대부분의 사용자가 제품 내에서 만나는 feature입니다. 채널톡의 채팅 경험을 더 빠르고 부드럽게 만드는 일은 사용자 경험의 큰 부분을 개선하는 일이기도 합니다. 당장 저희들부터가 팀 메신저로 채널톡을 활용하고 있으니까요!

웹팀의 task force 중 하나인 챗뷰성능개선 TF에서는 챗 화면에서 성능 저하가 일어나는 이유를 분석하고, 다양한 방향에서 개선해보는 작업을 약 4개월 정도 진행했습니다! 어떤 문제점과 돌파구가 있었을지 살펴봅시다 🚀

성과 확인하기 📊

문제점을 해결하기도 전에 성과부터 이야기하다니.. 순서가 바뀐 것 아니냐고 생각할 수도 있습니다. 기능 개발이나 버그 수정과 같은 티켓은 해결해야 하는 문제와 그 문제를 해결했을 때의 결과가 명확하게 드러납니다. 하지만, 성능 개선 작업은 같은 방법으로는 얼마나 개선이 있었는지 파악하기 어렵습니다. 따라서, 본격적으로 개선 작업에 들어가기 전 먼저 챗 화면의 성능을 나타낼 수 있는 지표를 마련하고, 지표 측정을 자동화할 수 있도록 조사했습니다.

웹 성능을 측정함에 있어 대표적인 도구 중 하나로 Lighthouse 지표를 들 수 있습니다. Lighthouse에서 제공하는 performance score는 여러 개의 세부 측정치에 가중치를 두어 산정됩니다. 세부 측정치 중 의미있게 보고자 한 metric은 다음과 같습니다.

  • firstContentfulPaint

  • firstMeaningfulPaint

  • largestContentfulPaint

Lighthouse SDK 를 활용하여, local machine에서 headless browser 환경으로부터 lighthouse 지표를 측정하는 스크립트를 작성했습니다.

아래는 로컬에서 측정한 결과입니다.

이제, 이것을 자동화하는 일만 남았습니다! Github Actions workflow를 통해 측정 스크립트를 실행하고 채널톡 open api SDK를 통해 측정 결과를 팀에 공유하도록 했습니다. 충실한 봇 매의 눈 🦅 이 매주 월요일 저녁 지표를 측정하여 공유하고 있습니다.

매의 눈은 lighthouse 지표 측정 외에도, 번들 파일의 크기 변화를 reporting 하는 등 채널 웹팀에서 알람을 울리는 역할을 담당하고 있습니다! 🦅

성과 측정하기 - Points

  • 성능 개선 작업이 이루어질수록 지표가 개선되는 것을 기대할 수 있습니다.

  • 새로운 기능 추가나, 기존 코드가 수정되었을 때, 예상하지 못한 side effect로 성능이 악화되는 상황이 발생할 때, 무언가 잘못되었음을 인지하고 빠르게 수정할 수 있는 alarm이 되어줍니다.

  • Github Actions workflow를 통한 performance 측정은 어려움이 많다는 것을 확인할 수 있었습니다. 성능 측정에 있어 일관성을 확보하기 위해서는 일정한 runner를 확보할 필요가 있습니다. 그러나, github actions의 runner는 constraint를 부여하기 어렵습니다. 신뢰성이 높은 performance 지표를 측정하기 위해서는 in-house 환경 등, 더 컨트롤할 여지가 많은 실행 환경을 선택하는 것이 좋겠습니다.

원인 분석하기

채널톡 데스크 제품에서, 그리고 챗 화면에서 성능 저하가 일어나는 원인은 다양합니다.

  • 데이터 구조와 흐름 - 채널톡은 redux를 통해 클라이언트의 상태를 관리합니다.

    Redux의 철학은 synchronous, centralized한 state management입니다.

    어플리케이션이 복잡하고 한 화면에 관여하는 상태가 많을수록 동기적 계산을 수행하기 위해 block time이 증가합니다.

  • 스타일링 - CSS-in-JS 스타일링을 위해 styled-components 를 사용합니다. CSS-in-JS는 pre-compiled CSS와 달리 런타임에 stylesheet를 수정합니다. 따라서 컴포넌트와 스타일 종류가 많은 화면에서는 런타임에 style recalculation이 일어나며 성능 저하의 원인이 될 수 있습니다.

  • Reflow - 뷰의 일부에서 사용하는 DOM API로 인해 reflow가 발생하고 있었습니다.

  • 리렌더링 - Prop, state 관리가 어긋나 컴포넌트의 re-rendering이 불필요하게 일어나고 있는 곳들이 있었습니다. 특히, 목록을 렌더링하는 컴포넌트에서 주의해야 하는 부분입니다.

    목록의 모든 요소가 리렌더링되는 현상이 빈번하게 발생한다면, 이것은 사용자가 느낄 수 있는 정도의 성능 저하로 쉽게 이어질 수 있습니다.

원인을 알았으니, 이제 하나씩 해결해 봅시다 💪

Redux selector 캐싱 전략

Redux에서 관리하는 state는 정규화(normalized)되어있고 최소화되어야 합니다. [1] Derived state를 만들기 위한 계산을 selector 레벨에서 수행하는데, 캐싱 전략을 적절하게 사용해야 redux selector의 계산을 줄여 성능 면에서 이득을 볼 수 있습니다.

react-redux에서 제공하는 useSelector hook은 다음과 같은 메커니즘을 가지고 있습니다.

  • useSelector(selector, equalityFn)은 redux state가 업데이트되면 selector(state)를 계산합니다.

  • 이 결과를 이전 계산 결과와 비교합니다. 비교 함수는 equalityFn을 사용하는데, 기본적으로는reference equality (===) 입니다.

  • 결과가 다르다면, useSelector hook은 리렌더링을 유발합니다.

따라서, selector(state)의 reference를 유지할수록 컴포넌트를 덜 렌더링하는 데 유리합니다. equalityFn을 reference equality가 아니라 deep equality 나 heuristic으로 판정하는 것도 가능한 전략입니다. 하지만, 프로젝트에서 관리하는 모델의 구조가 깊고 복잡하여, 비교하는 부분에서 어떻게 해보기는 어렵다는 판단을 내렸습니다. selector 함수에 적절한 cache 전략을 적용하여 최대한 selector(state)의 reference가 보존될 수 있도록 했습니다.

reselect는 selector가 수행하는 동작을 최적화하기 위한 라이브러리입니다. 앞서 useSelector의 메커니즘에 따라, selector 함수의 계산을 memo하는 것이 reselect의 원리입니다. Memo 옵션을 더 폭넓게 제공하는 re-reselect라는 라이브러리도 있습니다. 채널톡 데스크에서는 reselect와 re-reselect 모두 사용중입니다.

기존에 작성된 selector를 reselect, re-reselect에서 out-of-the-box로 제공하는 cached selector로 교체하거나, 병목이 존재하는 부분에는 커스텀 캐시 전략을 적용한 selector를 적용했습니다.

스타일링

💡 채널톡의 디자인 시스템이 궁금하다면? 베지어 디자인 시스템 살펴보기

styled-components 와 같은 라이브러리는 runtime에 stylesheet을 수정하여 CSS-in-JS를 구현합니다. 스타일이 수정되어 새로운 classname이 만들어지는 등 stylesheet가 수정되면 browser에서 style recalculation이 일어나게 됩니다.

성능 문제를 잡기 위해 유연한 스타일링을 가능하게 하는 CSS-in-JS를 포기할 수는 없습니다. 하지만 병목이 일어나는 몇 개 지점을 찾아 개선해볼 수 있습니다.

JavaScript

위 코드는 새로운 classname이 빈번히 생성되는 원인이 됩니다.

(1) 채널톡 디자인 시스템에서 태그는 다양한 색을 가질 수 있습니다.

(1) 의 코드는 태그 색상마다 classname 하나를 만듭니다. 다양한 색상을 가진 태그 컴포넌트가 자주 mount/unmount된다면, classname이 자주 stylesheet에 올라가고 내려가게 됩니다.

(2) 마찬가지로, user의 avatar url은 모두 서로 다르므로 많은 유저의 Avatar 컴포넌트가 화면에 보이는 상황에서는 유저 한 명마다 새로운 classname이 생성됩니다.

(3) Resizable component에서 자주 눈에 띄는 패턴으로, 여러 컴포넌트의 크기가 서로 관련 있을 때 이와 같은 코드를 작성하게 됩니다. width가 resize 등으로 변경될 수 있다면, 변경되는 동안 새로운 classname이 계속 생성됩니다.

새로운 classname 생성 없이 스타일링 이슈를 해결하기 위해 CSS variable을 사용했습니다.

JavaScript
HTML

color가 변해도 Tag 컴포넌트는 한 classname을 공유할 수 있게 되었습니다. (2), (3)의 패턴도 마찬가지로 CSS variable를 활용하여 개선했습니다.

CSS만으로 해결하기 어려운 스타일링을 잡기 위해 CSS-in-JS 패턴이 자리잡았다고 생각합니다. 하지만 CSS만을 사용해서도 할 수 있는 일이 늘어나고 있습니다. 특히 2022년 6월부터 IE11 브라우저가 많은 어플리케이션의 지원 대상에서 벗어나게 되며 IE와 호환되지 않는 CSS 기술을 활용할 수 있는 여지가 생겼습니다. CSS로만 해결할 수 있다면, 그렇게 하는 편이 훨씬 효율적이기 때문에 지속적으로 살펴보아야겠습니다!

Reflow

컴포넌트의 크기와 위치를 측정하는 방법 중 하나는 getBoundingClientRect() DOM API를 활용하는 것입니다.

JavaScript

(1)과 같은 구문이 컴포넌트 본문에서 일어나거나, 자주 수행되는 callback 구문에 있다면 문제가 생깁니다. getBoundingClientRect()는 대표적으로 reflow를 일으키는 API 중 하나이기 때문입니다. 이러한 패턴의 코드를 사용하는 곳이 상당수 존재했고, 성능 저하의 원인 중 하나였습니다.

컴포넌트의 크기 변화에 대응하여 무언가를 하고 싶을 때, reflow를 피하려면 ResizeObserver API 사용을 고려해볼 수 있습니다.

JavaScript

ResizeObserver 또한 IE에서는 지원하지 않는 API이기 때문에, 사용에 앞서 환경 고려가 필요합니다!

컴포넌트 리렌더링 잡기

불필요한 컴포넌트가 리렌더링되는 것은 막아야 합니다. 하지만 너무 성능을 신경쓰다가 코드의 간결함을 잃게 될 수도 있어요. 성급한 최적화 (premature optimization) 이라는 용어가 괜히 있는 것이 아니겠죠?

주의깊게 살펴보아야 하는 곳은 수십 개 정도의 item이 렌더링되는 목록 컴포넌트입니다. Item 하나를 렌더링하는 데 1ms 이하의 짧은 시간이 소요된다고 해도, 많은 item을 렌더링하게 되면 이 block은 금세 수십 ms로 증가할 수 있습니다. React DevTools를 이용하여 컴포넌트 트리의 업데이트 과정을 profiling하여 목록에서 불필요한 리렌더링이 일어나는지 관찰했습니다.

아래 프로파일링은 상담 목록에서 다른 상담으로 이동할 때 컴포넌트 트리의 변화를 기록한 것입니다.

슬프게도 목록 컴포넌트 내의 모든 컴포넌트가 리렌더링되고 있었습니다 😢

상담 목록의 각 item 컴포넌트는 React.memo를 통해 memoization이 적용된 컴포넌트입니다. 하지만 여러 번의 업데이트를 거치면서 코드가 점차 수정되어 memo가 깨지고 있었던 것을 발견했습니다.

문제가 되는 코드를 수정한 이후에는, 대부분의 컴포넌트는 memoization이 깨지지 않아 리렌더링이 일어나지 않고, 업데이트가 일어나야 하는 컴포넌트 2개만 리렌더링이 일어나는 것을 확인했습니다.

프로젝트 내에서 React.memo, useMemo를 사용하는 부분과, memoization에 관해 조사하며 얻은 insight입니다.

  • React.memo가 적용된 컴포넌트는 외부 context에 의존하지 않는 pure component (혹은, smart-dumb component 역할 구분에서는 dumb component, container - component 역할 구문에서는 component) 일수록 좋습니다.

  • React.memo가 적용된 컴포넌트의 각 prop은 primitive type일수록 좋습니다.

  • 목록을 렌더링할 때는 각 item이 memo가 적용되어 있고, item 소수가 리렌더링되어야 할 때 모든 item이 리렌더링되지 않는지 확인해야 합니다.

  • useMemoReactElement를 memo하는 것으로는 컴포넌트의 렌더링이 skip되지 않습니다.

Remarks

성능개선 TF에서 분투하는 동안, 매의 눈 🦅 은 성능개선 TF가 진행되는 동안 저희에게 계속해서 성능 지표를 물어다 주었습니다! 현재는 더 신뢰성 있는 지표 확보를 위해, Github Actions보다 더 안정적인 실행 환경을 마련하고 있어요.

이번에 소개한 성능 개선 전략에 더해, 다음 글에서는 채팅 화면의 스크롤 UX를 개선한 경험을 소개해드릴 테니 기대해주세요~ 🙏

References


[이런 글도 추천드려요]

We Make a Future Classic Product

채널팀과 함께 성장하고 싶은 분을 기다립니다

사이트에 무료로 채널톡을 붙이세요.

써보면서 이해하는게 가장 빠릅니다

회사 이메일을 입력해주세요