CSS 속성을 통해 뚝뚝 끊기는 애니메이션 개선 | 리팩토링을 통해 서비스 최적화하기 1편

February 15, 2023

TodoMall !

내가 공동 창업자로 일을 하고 있는 회사에서 만드는 서비스인 투두몰.. CBT 버전은 React를 이용해서 구현을 했다.

리팩토링 하기 전 단계의 서비스 사진이다!

약 2~3개월 정도의 시간에 거쳐서 만들었는데, 그때 당시에 실력이 부족하기도 했고, 이런 저런 일들이 많았어서 개발에 온전히 집중을 못했다보니 서비스 자체의 완성도가 좀 많이 아쉬웠다.

그래도 신촌에서 오프라인 스타트업 페스티벌할 때 사람들이 서비스 완전 잘만들었다고 칭찬해주셔서 기분은 좋았다!

아무튼 딱 봤을 때는 앱의 완성도가 높아보일 수는 있어도, 코드 측면, 그리고 성능 측면에서 개선할 수 있는 부분이 많았기에 리팩토링을 하기로 결심했다!

왜 리팩토링을 하려고 결심했냐면..

기존 CBT 버전은 React만을 이용했었다. 그때 당시에는 Next.js에 살짝 덜 익숙하기도 했고, 내가 가장 중요시 여기는 도입의 필요성을 느끼지 못했기에 그냥 React만 이용해서 만들어도 문제가 없다고 판단했다. 우린 웹뷰 앱을 만드는거라서 SEO작업도 전혀 필요없다고 생각했기에 더더욱 Next가 필요없을거라 생각했다.

하지만 개발을 하면서 완벽한 오판이었다는 사실을 알 수 있었고, 중간에 개발을 하면서 Next로 리팩토링을 꼭 해야겠다고 마음을 먹게 되었다.

왜 Next를 도입했냐면..

일단 개인적으로 느끼는 Next 프레임워크의 가장 큰 장점 세가지를 얘기해보자면

1. 파일 기반 라우팅

이전엔 몰랐지만, Next를 한번 사용해보고 나니, React만 이용해서 개발할 때 React-Router를 설치하고 App.js에서 라우팅 처리하는게 너무나도 지저분하게 느껴짐. Next에서 라우팅을 위해 폴더 구조를 잡다보면, 폴더 구조가 가독성이 그냥 React만 이용했을 때보다 훨씬 좋다는 것을 느낄 수 있다.

2. SSR/SSG/ISR

우리 서비스에서는 SSR이 막 크게 중요하지는 않다고 생각했다. 그래서 더더욱 CBT 단계에서 Next를 도입할 필요가 없다고 생각했다. 하지만 SSR도 SSR은 둘째 치더라도, 우리 서비스에 가장 필요한건 SSG였다... 데이터가 변하지 않는 페이지가 많았기에 SSG가 너무나도 필요했다. Next로 리팩토링을 결정한 가장 큰 이유

3. 자동 코드 스플리팅

React와는 다르게 Next는 자동으로 코드 스플리팅을 해준다. 코드 스플리팅을 하면 초기 로딩 속도도 훨씬 빨라지기 때문에 Next를 이용하는 것 만으로도 최적화 포인트 하나가 사라지는 느낌. 사실 코드 스플리팅은 React에서도 할 수 있다. React에서 제공하는 lazy()와 Suspense를 이용하면 코드 스플리팅을 할 수 있는데, Next에서 자동으로 해준다는데 굳이 내가 직접 할 이유가 없지 않나? Next에서도 Dynamic Import는 가능하기 때문에, 추가적인 스플리팅이 필요하면 Next에서도 가능하기에 이 부분에서는 Next 압승.

1번같은 경우에는 사실 부가적인 느낌? 저걸 위해서 Next를 도입하는건 아니고, Next를 쓰다보니 이게 참 좋더라~ 같은 느낌이었으면, 2번 3번은 필요하다고 느낀 요소들이다. 특히 SSG는 이걸 위해 Next를 도입했다 라고 해도 과언이 아닐정도로 중요한 개선이었다!

하지만 SSG는 다음 글에서 다룰 예정입니다 ^^ 궁금하시다면 다음 글도..

아무튼 Next로 리팩토링을 하기로 결정을 하면서, 자연스레 신경이 쓰였던 부분들도 개선을 해야겠다라고 마음을 먹었는데,

첫번째는 Scroll Progress를 보여주는 Progress 바가 툭툭 끊기는 현상 개선 두번째는 업로드를 위한 이미지를 압축하는 기능 구현

이렇게 두개 최적화 포인트를 잡아서 리팩토링을 했고, 이번 글에서는 CSS 속성을 통해 Progress Bar 애니메이션을 개선한 경험에 대해 얘기해보려 한다.

Scroll Progress를 보여주는 Progress 바 !

투두몰 서비스에는 이렇게 헤더 부분에 스크롤 한 위치에 따라 얼마나 스크롤을 했고, 얼마나 남았는지를 대략적으로 알 수 있는 Progress Bar가 구현 되어있다! 하지만 스크롤을 하면 Progress Bar의 변화가 스크롤을 빠릿빠릿하게 따라오지 못한다... 이런 느낌으로

이게 크게 불편한가? 라고 물어보면 솔직히 그정도는 아니다. 민감하지 않은 사람들은 이게 뭐가 문제인지도 모를수도 있다. 하지만 이게 최선이냐고 물어보면 그거 역시 아니다. 나는 프레임에 유난히 민감하기에 더더욱 이부분을 고치고 싶었다.

최적화를 하고 난 결과물을 먼저 보여주자면

이렇게 둔한 사람들도 바로 눈치챌 정도로 개선된 것을 확인할 수 있다. ~~빠릿빠릿하니 마음에 드는구나.~~

이제 그럼 어떻게 개선을 했고, 왜 이게 이렇게 큰 차이가 나는지를 알아보자.

CBT 단계에서 이 기능을 구현한 방법을 간략하게 설명을 하자면

  1. 현재 스크롤 위치를 찾는다
  2. 페이지 전체 높이를 구한다
  3. 비율을 구해서 Progress Bar의 Width를 조절한다

이렇게 세가지 단계를 거친다.

그럼 스크롤을 아래로 내리기 전에 어떤 단계에서 문제가 발생한건지 잠깐만 고민을 해보자. 틀려도 괜찮다. 이 글을 다 읽고 나면 완전히 이해할 수 있을거다!

정답은 3번, "비율을 구해서 Progress Bar의 Width를 조절한다" 이다.

뭔가 되게 뻔한 듯 아닌 듯 애매모호하다. 1번과 2번은 뭔가 연산 속도가 빠를 것 같아서 3번일 것 같다 라고 생각한 사람들이 많을텐데, 왜 3번인지를 좀 더 자세히 살펴보려면 브라우저의 렌더링 원리에 대해 살짝의 이해가 필요하다.

Reflow -> Repaint -> Composite

브라우저는 렌더링을 할때 크게 세개의 단계를 거친다.

  1. Reflow - dom 요소에 width, height, font-size 등등 레이아웃에 영향이 있을 만한 속성이 변경되었을 때 레이아웃을 다시 계산하는 과정
  2. Repaint - dom 요소에 색상 변경같은 레이아웃에 영향은 없고 스타일 속성만 영향이 있을 때 다시 계산 하는 과정
  3. Composite - 생성된 각각의 레이어들을 합성하는 과정 (transform, opacity 요소의 변경)

이 세가지 단계는 레이아웃의 변경이 일어날 때 마다 동작한다. 따라서 변경이 잦으면 이 단계들을 엄청 빠른 시간 안에 완료를 계속해서 해야하는데, 대부분의 기기에서는 그러지 못한다.

그럼 그 연산을 필요할때 하지 못하면 어떤 결과가 발생하냐?

이렇게 되는거다... 연산을 빠릿빠릿하게 하지 못하기 때문에 중간에 연산이 씹히고, 필요한 프레임수가 뽑히지 않기 때문에 애니메이션에 끊김이 발생하는 것이다.

개선 방법

그럼 어떻게 이 끊기는 애니메이션을 개선할 수 있을까?

정답은 연산의 수를 줄이는 것이다.

바로 떠오르는 방법은 onScroll 핸들러에 Throttle을 걸어서 특정 시간안에 특정 수의 연산만 가능하도록 제한을 두는 것이다. 그러면 연산의 절대적인 양이 줄어들테니 애니메이션이 끊기는 현상을 개선할 수 있지 않을까?

절대 그럴리가 없다.

Throttle을 걸면 연산이 필요한 상황에 연산이 이루어지지 않으니 연산이 씹히는거나 다름이 없는거다. 해결책이 아니라는 뜻.

그러면 연산의 절대적인 양을 줄이는게 뭔가 애매하다는 사실을 알게 되었다. 사실 양을 줄이는 건 정답이 맞다. 하지만 아무 연산이나 줄이면 안된다. 우리가 줄여야 하는건 "쓸데없는" 연산이다.

앞서 얘기했지만, 나는 Progress Bar의 Width를 조절해서 기능을 구현을 했다. 하지만 Width를 조절을 하는 것은 배웠듯이 Reflow를 매번 일으키기 때문에 1 -> 2 -> 3 단계를 매 스크롤마다 해야한다. 너무 많은 연산이 필요한 것.

가장 좋은 시나리오가 아니다.. 더 개선할 부분이 있다

그러면 색깔만 변경을 해서 Reflow를 피해 연산의 양을 줄이는게 가능할까?

가능은 하겠지만 색깔만 변경해선 Progress Bar를 구현할 수가 없다.

색상의 변경만을 이용해선 길이가 변하는 애니메이션을 구현할 수가 없다.. 이것 역시 정답이 아니다

그럼... Transform 속성을 이용해서 Reflow, Repaint 둘다 건너뛰고 Composite 단계만 수행을 하면?

빙고

Width조절이 아닌, Transform 요소를 통해 Width의 변경이 일어나는 것 처럼 구현을 하면 Reflow -> Repaint -> Composite 단계중에서 마지막 단계인 Composite 단계만 연산을 하면 되는 것이다! 숫자로만 봐도** 연산이 1/3** 되는 것이니 개선이 될거라고 예상할 수 있다.

그럼 이제 코드를 살펴보자.

필요한 코드만 뽑아서 가져온거라서 그대로 돌리면 원하는 결과가 나오지 않을것이다.. 이해를 목적으로 코드를 보자.

width라는 로컬 상태를 만들고 스크롤이 발생할 때마다 전체 스크롤 퍼센트를 계산해서 setWidth를 하고 ProgressBar는 그 width값을 그대로 props로 받아서 이용하고 있다

자세히 보면 width: props.value라는 것을 알 수 있는데, 이 뜻은 props로 넘겨받은 value 값이 바뀔 때 마다 width 속성이 바뀐다는 의미.

Reflow가 발생한다!!!

그럼 어떻게 코드를 고치면 개선을 할 수 있을까??

정말 작은 변경만 있었다.

width는 100%로 고정시키고 transform 속성의 scaleX를 0~1 사이에서 조절을 해주고 transform-origin은 왼쪽에서부터 시작하도록 정해주면 된다.

Width 속성의 변경이 없었기 때문에 Reflow가 발생하지 않고, transform 속성의 변경만 있었기 때문에 Composite 단계만 거친다.

결과

리팩토링 전:

빨간색 부분이 연산이 씹히고 있는 부분이다. 그래프만 보더라도 연산이 계속 최대치로 발생하고 있다는 것을 확인할 수 있다.

리팩토링 후:

일단 연산이 씹히는 부분은 전혀 없고, 그래프만 보더라도 리팩토링하기 전 결과보다 훨씬 깔끔한 것을 확인할 수 있다.

참고로 아래에 Frames 부분은 한 프레임이 보여진 시간을 뜻하는데, 16.7ms는 60fps가 유지되었다는 의미이다. 최상의 결과!

리팩토링 이전에는 216.7ms, 133.3ms가 표시되듯이 엄청 문제가 심각했다는 것을 확인할 수 있다. 개선하길 잘했다

결론

최적화.. 어렵지 않습니다!

이번엔 리팩토링을 통해 가장 개선을 하고 싶었던 애니메이션 부분을 다뤄보았다! 코드 단 세줄의 변경만으로도 이렇게 큰 개선을 할 수 있다는 것을 알게 되니 미리 적용했으면 더 좋았겠다..라는 생각이 들기도 하지만, 이렇게 조금씩 발전해가는걸로!

다음글에서는 Next.js의 getStaticProps를 이용해 SSG를 적용하여 렌더링 속도를 개선한 경험에 대해 다뤄볼 예정이다!