Lighthouse 개선 (w. Lazy, Image Optimization, Chunk Splitting)

김 주현·2024년 8월 27일

FADE 개발일지

목록 보기
3/3
post-thumbnail

FADE 서비스의 성능 개선을 위해 적어보는 포스팅. 성능 측정할 때 역시 만만한 건 Lighthouse이므로 Lighthouse를 기준으로 차근차근 개선해 나가겠다.


개선 전후

개선해 나가는 과정을 적기 전, 먼저 결과부터 보여드리면 좋을 것 같아요.

개선 전

스위그에 배포된 버전으로 측정해보았어요.

그야말로 처참

신경을 안 쓰긴 했었는데 이 정도의 점수라니! 포폴용으로 좋은 성능 지표다(?)

이 결과에 생각해야 할 점은 SPA 서비스라 최초 접속 시 나오는 온보딩을 기준으로 측정했다는 점과, 스크립트가 포함되어 있어서 LCP가 7.5초로 측정된 것입니다. FCP를 기준으로 LCP는 3초 내외라고 생각하면 될 것 같아요. 다시 캡처하기엔 이미 배포해버린 것

개선 후

  • LCP 2.8초에서 0.6초, 약 79%의 속도 향상
  • 청크 크기 최적화와 코드 스플리팅을 통해 초기 로딩 속도 13% 단축
    • 최적화 전: 가장 큰 두 개의 청크 크기가 496.22 kB와 557.91 kB
    • 최적화 후:
      • @react-vendor (196.84 kB)
      • @lib-vendor (178.31 kB)
      • @lottie-vendor (316.05 kB)
      • 초기 페이지 청크 (172.92 kB)
      • 초기 라이브러리 청크 (184.86 kB)
  • 최적화가 필요한 이미지 에셋 총 18MB -> 3.8MB으로 약 80%의 용량 압축

이 외에도 Component Prefetching, SEO, 웹 접근성을 추가했어요. 이 부분들은 수치로 표현하기 애매해서 ㅎ.ㅎ


개선 포인트

당장 필요하지 않는 컴포넌트

처음에 어떤 걸 개선해야 할까 고민하다가, 네트워크 탭부터 정리하기로 했어요. 온보딩에 불필요한 컴포넌트를 찾아냈습니다.

페이지 스켈레톤 컴포넌트 로딩

각 페이지에 대해 스켈레톤을 초기에 로드하고 있었어요. 이건 의도한 바이긴 했습니다. 스켈레톤 컴포넌트 자체는 용량이 얼마 안 되기도 하고, 메인 페이지를 Lazy하게 불러오고 있었으니 이를 대신 보여주는 fallback Component, 즉, 스켈레톤 컴포넌트는 미리 불러와져 있어야 한다고 생각했기 때문이에요.

그런데 저렇게 온보딩에 쓰지 않는 스켈레톤을 불러오고 있는 걸 보니 열받더라구요(?) 그렇다고 스켈레톤을 Lazy하게 불러오자니 위의 상황이 염려됐구요.

생각해낸 방법은, 첫 번째 주요 페이지가 렌더링 되고 나면 나머지 스켈레톤 컴포넌트를 불러오는 방법이었습니다. 즉, 프리패칭을 하는 거죠.

컴포넌트 프리패칭

프리패칭이라는 말이 주는 고급 스킬의 느낌 때문일까요? 쉽지는 않을 것 같다는 생각을 했습니다만, 실제로는 아주아주 간단했습니다.

컴포넌트 프리패칭

useLayoutEffect(() => {
  /** Prefetch the tab components */
  Promise.all([
    import('@Pages/Root/archive/page'), // 아카이브 탭
    import('@Pages/Root/archive/page.skeleton'), // 아카이브 탭
    // ...
  ]);
}, []);

단순히 Dynamic Import를 호출해주면 되는 일이었습니다. 이게 가능한 이유는 공교롭게도 이전에 적은 포스팅으로 갈음할 수 있을 것 같아요. 가볍게 정리하자면, Vite가 Dynamic Import를 처리하는 방식을 이용하여 미리 청크를 불러오는 거죠. 이에 대해선 아래에서 더 자세하게 다뤄볼게요.

AppLayout 프리패칭

또, 공통 레이아웃 및 필요한 Provider를 처리하기 위해서 AppLayout을 두고 처리하고 있었어요. 이 AppLayout에서는 Navbar를 Import하고 있었는데, Navbar에는 모달 컴포넌트를 직접 Import하고 있어서, 결국에는 온보딩 페이지에서 쓰이지 않는 모달 컴포넌트까지 로드하고 있던 상황이었어요.

AppLayout에서 로그인하지 않은 사용자는 온보딩 페이지로 보내고 있었기 때문에, 해당 로직은 그대로 놔두고 컴포넌트들은 따로 파일로 빼서 Lazy하게 처리하였어요.

결과

그래서 정말 온보딩에 필요한 리소스들만 불러올 수 있게 되었습니다. 위에 js는 GA 스크립트이니까 넘어가겠어요 ^~^

이미지 줄이기

FADE 서비스는 레티나 등 고해상도 디스플레이에 대응하기 위해 DPR 이미지를 제공하고 있었습니다...만, 디자이너 분이 png로 파일을 넘겨주셔서 웹 및 모바일 환경에서는 부담스러운 용량이었어요. 저도 창작자였던 만큼 고화질로 넘겨주는 거? 너무 이해감

온보딩 이미지는 DPR 1x 기준 330x440 이미지가 240kb ~ 250kb였는데요, 클로드한테 물어보니까 좀 크긴 하고, 100kb 안쪽이 좋다고 말해주더라구요.

으응..

결과

따라서 Webp Converter를 사용하여 최적화가 필요한 이미지 에셋들을 최적화 했어요. 최적화 전 용량이 18MB에서 3.8MB으로, 약 80% 용량 압축을 진행했습니다.

청크 분리

청크 분리에 대한 지식은 알고 있었으나 실제로 분리를 하는 건 처음이어서 설레는 마음으로 진행했어요.

청크 크기 최적화 전

현재 프로젝트를 빌드하면 다음과 같이 청크가 분리되고 있었습니다.

500kB를 넘어가는 청크가 있으면 Vite가 경고를 해주는데요, 이 청크를 적절하게 분리하기 위해 rollup-plugin-visualizer를 통해 어떤 녀석들이 모여져있는지 확인했습니다.

하나는 주요 페이지 컴포넌트와 로티 라이브러리로 이루어져 있었고, 다른 하나는 패키지 관련으로 이루어져 있었어요. 와 로티 무겁네

각각의 청크들을 분석해봤을 때 (1) 리액트 관련, (2) 라이브러리 관련, (3) 로티로 나누면 적절할 것 같아 각각으로 밴더를 나누고자 했어요.

분리 시도1: manualChunks 함수로

manualChunks를 함수 형태로 적어서 청크를 분리하고자 했어요.

vite.config.ts

build: {
  rollupOptions: {
    output: {
      manualChunks(id: string) {
        if (id.includes('lottie')) {
          return '@lottie-vendor';
        }
        if (id.includes('axios') || id.includes('date-fns') || id.includes('framer-motion')) {
          return '@lib-vendor';
        }
        if (id.includes('node_modules/react/') || id.includes('node_modules/react-dom/')) {
          return '@react-vendor';
        }
      },
    },
  },
},

가장 용량이 큰 axios, date-fns, framer-motion을 라이브러리 밴더로 묶었습니다. 지금 생각해보면 용량이 큰 분류와 자주 접근해야 하는 분류 중에 어떤 걸 선택하냐 했을 때, 후자를 선택하는 게 맞는 것 같은데,, 요 부분은 조금 더 공부가 필요할 것 같다는 생각이 들었어요. 일단 청크의 용량을 줄이는 게 목표였으니 전자로 진행했습니다.

빌드 후에 각 벤더별로 적절하게 나뉘어진 것을 확인할 수 있었어요.

이슈 발생

그런데, 분리를 하고 실행을 해보니 lottie vendor를 불러올 때 이슈가 발생했어요.

보아하니 react의 memo 메소드를 찾지 못해서, 다시 말해서 react vendor보다 먼저 실행이 되어 발생한 이슈 같았어요. 이와 관련해서 찾아보니 의존성에 대한 청크 로드 순서가 되게 중요한 고려사항이더라구요.

청크 로드 순서

이 부분도 되게 깊은 지식이 필요해서,, 일단 얕게 이해한 대로 정리를 다음과 같이 해볼게요.

일단 Vite는 import를 만나면 head에 link 태그 modulepreload 형태로 가져옵니다. 그 이유는, Dynamic Import 시 여러 청크들에서 사용하는 공통 라이브러리를 병렬로 가져오기 위해서인데요, 예를 들어볼게요.

청크A와 청크B가 공통 청크C를 가진다고 해보겠습니다. Dynamic Import로 청크A를 가져오는 상황이라면, 청크A에 대한 파싱을 완료한 후에야 공통 청크C를 가져옵니다. 이를 병렬적으로 가져오기 위해서 Vite에서는 Dynamic Import 구문을 자동으로 재작성한다고 해요.

천재냐?

여기에서, 의도적으로 청크를 나눠 미리 필요한 라이브러리를 불러오는 기능이 바로 manualChunks인 것입니다.

분리 시도 2: manualChunks 객체로

그러니까,, 의존성이 있는 것들을 서로 묶어줘야 하는 게 포인트인 것 같더라구요. 따라서 다음과 같이 이번엔 객체로 분리했습니다.

build: {
  rollupOptions: {
    output: {
      manualChunks: {
        '@react-vendor': ['react', 'react-dom', 'react-router-dom'],
        '@lib-vendor': ['axios', 'date-fns', 'framer-motion'],
        '@lottie-vendor': ['react-lottie-player'],
      },
    },
  },
},

이렇게 하니, 성공적으로 분리도 되고 실행도 됐어요.

근데 왜 된 거지?

조금 헷갈렸던 게, 그냥 의존성이 있는 것들을 묶어주기만 한 건데 어떻게 순서 이슈가 풀렸는가? 하는 부분이었어요. 가만히 다시 생각해보다가 나름의 결론을 낸 건, 의존성을 묶어준 것보다 유심히 봐야 할 포인트는 manualChunks를 객체로 썼냐, 함수로 썼냐인 듯 싶더라구요.

객체로 쓰게 되면 최초로 불러오는 페이지 청크에서 쓰이지 않는 청크(라이브러리)는 preload하지 않고, 함수로 쓰면 최초로 Direct Import로 불러오는 것을 확인했어요.

객체로 썼을 때 라이브러리 밴더와 react 밴더만 불러오고 있다.

head에도 역시 두 개의 청크만 불러오고 있다.

이후 로티가 필요한 곳에서는 로드를 한다.

즉, 위에서 오류가 났던 이유는 manualChunks를 함수로 썼기 때문에 관련 패키지들을 우선적으로 불러왔기 때문인 거죠! 이를 본다면, 사실 '순서'보다 '차례'가 관건인 것 같았습니다. 첫 차례 때 불러올 녀석들은 서로의 의존성이 없는 녀석들로, 즉, 독립적인 청크로 구성되어야 하는 것 같았습니다. 물론, 청크 안에서는 의존성이 필요하지만요.

그러므로 manualChunks를 함수로 쓸 때는 서로의 청크가 독립적인 청크로 구성하는 것이 청크 로드 이슈를 겪지 않는 방법이 될 수 있을 것 같아요.


사실 저는 이런 성능에 대한 수치적인 향상보다는 예쁘게 보여주고 동적인 인터렉션을 넣는 것에 더 관심이 있었는데, 이번 성능 향상으로 이런 것도 나쁘지 않게 즐기고 있는 제 모습을 발견해서 신기했어요. 나 이런 거 좋아라 하네

물론 인터렉션 만큼은 아니지만,, 사용자 경험 향상이라는 프론트엔드 개발자로서의 가치 목적을 이뤄낼 수 있는 수단이 하나 더 생긴 것 같아 뿌듯합니다 ^~^

profile
FE개발자 가보자고🥳

0개의 댓글