Published on

인트로페이지 성능 개선 내용 정리

아이엠에스모빌리티에 재직중일때 인트로페이지 성능 개선 작업을 했었는데 그때 왜 그걸 했었고 어떻게 해서 어떤걸 개선했는지 기억이 잘 나지 않아 정리를 하려고 합니다.

이 내용을 정리하면서 애니메이션관련해서도 같이 정리를 해보려고 합니다.
간단한 예제들을 만들어서 어떻게 개선했는지 기억을 더듬어보려고합니다.

문제 발견

메인서비스에 해당하는 프로젝트는 아니었어서 사실 유저의 피드백에 의한 해결 목적은 아니었고 네트워크상황이 안좋은 조건에서도 페이지가 잘 동작하는지 체크하다가 발견하게 되었습니다.

일반적인 상황에선 크게 문제가없지만 조금이라도 네트워크 상황이 좋지 않거나 페이지를 처음 방문하여 캐싱정보가 없을경우에는 사용자에게 안좋은 경험을 줄 수 있겠다 생각되어 진행하게 되었습니다.

목표

간단한 예제를 만들었더니 성능이슈를 크게 느낄 수 없어서 네트워크탭에서 제약사항을 걸었습니다.

  • 캐시 사용 중지
  • 네트워크 빠른 3G

제가 원하는건 이러한 제약사항에서도 애니메이션이 최대한 정상적으로 사용자에게 보여지도록 하는것이 목표입니다.


첫번째 애니메이션

car_animation
car_animation_slow

첫번째처럼 애니메이션이 보여져야 하는데
두번째 이미지를 보면 애니메이션 동작은 없고 이미지또한 버벅이면서 나타나게됩니다.

netword

네트워크탭을 보면 bundle.js 의 다운로드가 완료되고나서 자동차 이미지를 다운로드받기 시작합니다.

문제점

  1. 번들파일이 다운로드 되는 시간동안 사용자는 빈 페이지를 보게된다.
  2. 스크립트 파일이 다운로드완료되고 이미지 다운로드가 완료되기전에 이미 애니메이션 동작은 끝나있다.
  3. 이미지가 나타날때 버퍼링이 걸린다.

1번 문제점

이건 이전에 적용했던건 아니지만 문제점으로 보여져서 추가하려고합니다.
리액트의 SPA 특성상 빈 html 을 먼저 다운로드하고 번들파일의 로드가 완료되어야 스크립트가 실행됩니다.

그래서 로딩스피너를 스크립트나 컴포넌트내에 포함시키더라도 네트워크가 느린상황에서는 사용자에게 그냥 흰색 화면만 보여주게 됩니다.

번들파일이 로드되는동안 로딩스피너를 보여주기위해 index.html 자체에 로딩 스피너 css 를 적용한 엘리먼트를 추가하였습니다.

index.html
<div id="root"></div>
<!-- Loading Spinner Div -->
<div class="loader-container">
  <div class="loader"></div>
</div>
<style>
  .loader-container {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .loader {
    border: 16px solid #f3f3f3;
    border-top: 16px solid #3498db;
    border-radius: 50%;
    width: 130px;
    height: 130px;
    animation: spin 2s linear infinite;
  }

  @keyframes spin {
    0%  { transform: rotate(0deg); }
    100% { transform: rotate(360deg); }
  }
</style>

그리고 App.js 에서는 useEffect 를 활용하여 로딩의 상태를 변경하고 로딩엘리먼트를 제거하는 코드를 작성해줍니다.

App.js
const [isLoading, setLoading] = useState(true)

useEffect(() => {
  const loaderElement = document.querySelector(".loader-container");
    if (loaderElement) {
      loaderElement.remove();
      setLoading(!isLoading);
    }
}, []);

if (isLoading) return null
loading

위 이미지처럼 번들파일을 다운로드하는동안 로딩스피너를 보여줄 수 있습니다.

2번 문제점

webp 이미지 적용 + 이미지 사이즈 줄이기
이미지가 애니메이션이 끝난 상태에서 보여지게됩니다.
혹시 이미지 사이즈가 너무 커서 이미지를 로드하는데 시간이 오래걸리는지가 의심되어서 webp 이미지로 전환하여 테스트해보았습니다.

webp

png => webp 로 전환하면서 500KB 가 넘던 이미지 사이즈가 150KB까지 줄어들었습니다. 이미지로드시간도 3초 => 1초중반으로 줄었습니다. 그래서 버벅임없이 이미지가 나타나지만 여전히 애니메이션이 끝난상태로 나타나고 있습니다.

단일 페이지의 성능을 개선하기 위한 작업으로 이미지 확장자명변경이나 사이즈 변경 모두 수동으로 진행하였습니다. 만약 서비스 전체에 이러한 것들을 적용하기 위해서는 AWS 의 Lambda 함수에서 Sharp 라이브러리를 활용하여 처리할 수 있습니다.

추가로 이미지의 사이즈를 줄였습니다. 화면에 보여주기위해 필요한 이미지크기와 실제 이미지 크기의 갭차이가 너무커 레티나디스플레이 기준 해상도 2배기준으로 잡고 절반정도가량 크기를 줄였습니다.

이미지 preload
이미지를 preload 하는걸 생각해 볼 수 있는데 여기선 큰 의미가 없는 이유가 어차피 번들이후에 이미지를 다운받기 때문에 초기 진입시 문제가 있는 부분은 동일합니다. preload 를 적용했을때 어울리는 부분은 당장 뷰페이지영역에 이미지가 보여지지않지만 특정 조건에 의해서 바로 이미지 전환이 필요한 경우라고 볼 수 있을것같습니다.

코드분할 (lazy)
리액트에서는 lazy 를 활용하여 컴포넌트를 지연로드시켜 코드분할을 시도해 볼 수 있습니다. 코드분할을 적용하면 번들사이즈가 줄어들고 코드분할이 적용된 스크립트는 별도의 스크립트 파일이 생성되어 다운로드하게됩니다.

인트로 페이지는 총 네개의 섹션이 보여지는 영역의 전체 높이를 차지하면서 존재합니다. 즉 최초 로드될시에는 첫번째 섹션만 보여지고 나머지는 스크롤을 진행해야 확인 가능합니다.

때문에 첫번째 섹션을 제외한 나머지 섹션 컴포넌트들은 lazy 처리를 하였습니다.

Into.js
const Section02 = lazy(() => import('../components/intro/Section02'))
const Section03 = lazy(() => import('../components/intro/Section03'))
const Section04 = lazy(() => import('../components/intro/Section04'))
lazy

여기까지만해도 전체 애니메이션 동작까진 무리가 있지만 어느정도 이미지가 애니메이션이 적용되어서 동작하고 있다라는건 자연스럽게 보여지는것같습니다.

여기서 추가로 하나더 처리하면 재미있는걸 확인할 수 있습니다.

Suspense

Intro.js
<Suspense fallback={<Loading />}>
  <Section02 />
  <Section03 />
  <Section04 />
</Suspense>
suspense

폭포라는 부분을 보면 Suspense 적용전에는 이미지가 다른 섹션들의 로드가 완료된 다음부터 로드되기 시작하는걸 확인할 수 있습니다.

Suspense 적용후에는 이미지는 다른 섹션 스크립트들과 동일한 타이밍에 로드가 시작됨을 볼 수 있습니다.

지금이야 개별 섹션 컴포넌트들이 워낙 단순하게 되어있어서 실제 실행해봐도 둘의 차이는 눈에 띄지않습니다. 그치만 수치로 확인은 가능합니다.

before_suspense
after_suspense

왼쪽이 Suspense 적용전 이미지의 로드정보를 확인한결과이고 오른쪽이 적용후 정보입니다.

적용후가 대기열에 더 빨리 추가되고 시작합니다. 만약 섹션들의 사이즈가 더 컸다면 이 차이는 더 늘어났을거라고 생각합니다.

result01

완벽하진않지만 처음에 이미지가 노출될때 버퍼와 애니메이션이 종료된 다음에 이미지가 보여지는건 어느정도 처리가 된것같습니다.

좀더 극한으로?

네트워크를 느린 3G로 변경해보았습니다.

이미지는 최적화가 잘 되어있어 버퍼없이 한번에 제대로 노출됩니다. 그러나 또 애니메이션이 종료된 상태로 보여지게됩니다.

만약에 이런상황까지 고려해야한다면 이미지가 로드가 완료된 시점을 확인하고 그 시점을 기준으로 애니메이션이 시작하게끔 만들 수 있을것같습니다.

slow_result

네트워크가 느려서 로딩도 느리고 이미지도 엄청 느리게 뜨지만 의도했던대로 애니메이션 동작은 잘 보여지고 있습니다. 오히려 처음부터 이렇게 하면 어땠을까 하는 생각까지 드네요.

3번문제점

3번 문제점은 2번을 진행하면서 같이 하게 된것같습니다.

  • 이미지의 확장자를 webp 로 변환한다.
  • 이미지 자체의 사이즈를 필요한 만큼 줄인다

좀더 해결해야할 부분

네트워크가 안좋은 상황에서 캐싱된 데이터가 없더라도 이미지가 잘 나타나고 애니메이션까지 진행되는걸 확인했습니다.

그러나 아직 체크해야할 부분이 남아있는데 바로 애니메이션을 동작시키는 CSS 입니다. 현재 해당 애니메이션은 position 의 위치값을 활용하고 있습니다.

우선 애니메이션동작을 구현할때 JS 보다는 CSS 로 하는게 좋고 CSS 에서도 transform 이나 opacity 를 활용하여 구현하라고 권장합니다.

  • transform 및 opacity 속성은 랜더트리 재구성을 최소화 합니다. 속성이 적용된 요소만 변경하고 다른 요소의 랜더트리 구조에 영향을 주지 않습니다.
  • 하드웨어가속을 활용할 수 있습니다. 보통은 웹페이지를 그릴때 CPU 를 사용합니다. 그러나 저 두 속성은 GPU 를 사용하기 때문에 성능이 향상되고 이로인해 부드럽고 일관된 화면 갱신을 제공합니다.
  • position 속성은 레이아웃을 재계산할때 해당 요소 뿐만 아니라 주변 요소에도 영향을 미치게 됩니다. 레이아웃업데이트가 빈번하게 발생하는것은 CPU 리소스를 소모하게 됩니다.
  • 브라우저엔진에서 화면에 돔을 그릴때 랜더트리를 생성하게되는데 position의 변경은 이 랜더트리를 재구성하게 만들어 추가적인 작업이 진행되어야 합니다.
position
transform

크롬개발자도구에서 성능통계탭을 확인해보면 레이아웃변경에 대해서 확인할 수 있습니다.

왼쪽이 position 사용했을때 오른쪽이 transform 을 사용했을때 입니다. position을 사용한쪽이 확실히 잦은 레이아웃변경이 발생하는것을 확인할 수 있습니다.


정리

  • 이미지를 쓸때는 webp 확장자를 사용하고 레티나디스플레이를 고려하자
  • 확장자나 특정 property를 사용할때는 호환성을 체크하자
  • 이미지태그의 속성에는 로드의 우선순위를 정할 수 있는 속성이 있다.
  • 애니메이션을 만들땐 JS 보단 CSS , position 보다는 transform 을 이용하자
  • JS 로 애니메이션을 만들어야 할때는 requestAnimationFrame 을 활용하자
  • 이미지 태그의 속성중 onLoad 를 활용하여 이미지 로드 시점을 확인할 수 있다.
  • 크롬 개발자 도구를 활용하면 다양한 성능이슈에 대해서 체크할 수 있다.
  • 당장 필요한것이 아니라면 lazy 를 활용하여 코드를 분할하고 지연로드 시킬 수 있다.
  • 리액트의 특성과 브라우저 랜더링의 특성을 잘 이해하고 성능개선에 활용하거나 관련 힌트를 얻을 수 있다.

추가

인트로페이지 성능 개선을 진행하는 내용을 정리하는데 몇가지 빼먹은 내용이 있다.

  1. 하위 다른 섹션들에도 애니메이션동작이 포함되어있고
  2. 기존에는 뷰영역에 보여지는것과 상관없이 페이지가 로드되면 애니메이션이 동작
  3. 스크롤을 통해 다른 섹션 확인할땐 이미 애니메이션이 끝나있음
  4. IntersectionObserver 를 활용하여 뷰영역에 들어올때 애니메이션이 동작하도록 함
  5. 이미지뿐만 아니라 폰트리소스도 최적화 작업을 진행
  6. 경량화된 폰트파일 사용 (뷁, 핥) 등이 제거된 폰트파일
  7. 폰트파일 preload 적용
  8. 내가 적용 배포를 안하고 왔는지 현재 인트로페이지에는 하나도 개선 사항이 반영이 되어있지 않다.