본문 바로가기
프론트엔드

[프론트엔드] 코드 스플릿에 대한 정리

by ISA(류) 2022. 7. 20.

벡엔드뿐 아니라 프론트엔드 개발에서 성능 최적화를 위해서 고려해야 할 요소는 상당히 많다. 디테일한 부분을 제외하고 큼직만 한 요소들을 축약해서 꼽자면 리소스 용량을 줄이는 것(압축 및 캐시와 분할), 렌더링 비용을 줄이는 것(DOM, CSSOM, VDOM), 네트워크 비용을 줄이는 것(커넥션 릴리즈) 정도로 분류할 수 있을 거 같다.

 

그중 리소스 용량 즉 js나 html, css, image등의 파일의 용량을 관리하는 방식으로 웹을 최적화할 수 있는데 단순히 압축(webp, gzip 등)을 통해서 해당 리소스들의 용량을 줄이는 방향과 cache를 통해서 중복 요청을 줄이는 방법 등이 존재한다. 그리고 단순히 용량을 줄인다는 측면이 아닌 꼭 필요한 부분만을 미리 가져다 쓴다는 측면의 분할을 적용하기도 하는데 보통 해당 행위를 코드 스플릿이라고 부른다.

 

코드 스플릿의 경우 번들러(webpack,vite)에서 적용을 하는 부분이 있고, 리액트 등의 SPA 오픈소스의 경우 lazy으로 다이내믹 임포트를 지원한다. 다이내믹 임포트를 지원하는 리액트와 번들 분석 도표를 플러그인으로 제공하는 웹팩을 기준으로 실제로 코드 스플릿을 하는 방법에 대해서 간단히 정리해보고자 한다. SSR의 경우 next.js를 쓸 경우 프레임워크 단위로 처리해주니 논외로 치고 CSR 기준으로 바닐라 리액트와 웹팩 커스터마이징을 하는 경우를 기준으로 하는 내용이다.

 

먼저 다이나믹 임포트를 통해서 lazy 로드로 코드를 분할하는 방법의 경우 기본적으로 pages 즉 라우팅에 따라서 나뉜다. react를 예로 들면 각 router에 들어가는 root components(개인적으로는 feature)를  lazy loading 하는 방식으로 접근하지 않는 라우팅에 대한 코드를 분할해서 해당 라우팅에서 필요로 하는 코드들만 먼저 불러오는 방식이다.

// 동적 import
const OtherComponent = React.lazy(() => import('./OtherComponent'));
// suspense
<Suspense fallback={<div>Loading...</div>}>
	<router path='/' elements={<main>메인</main>} />
	<router path='/other' elements={<OtherComponent />} />
</Suspense>

Suspense를 통해서 로딩하는 동안 보여줄 fallback을 지정하고 특정 path라우팅에 접근했을 시 해당 컴포넌트를 lazy 로딩해준다. 기본적으로 page단위로 코드를 분할하는 이유는 보통 페이지 단위로 UI나 내부 상태 등 로직 등이 연관성이 없어지고 페이지 전환의 경우 명확한 목적 없이 잘 일어나지 않기 때문인데 그렇기에 기본적인 구조를 조금 신경 쓰는 것이 좋다.

 

보통 그렇게  쪼갠 후 웹팩의 webpack-bundle-analyzer 플러그인 옵션을 사용해보면 아래의 이미지와 같은 도표를 얻을 수 있는데 보통 웹팩 설정이 따로 없다면 entry 하나와  분할한 page별로 번들들이 생기는 것을 볼 수 있다.

예제(출처: 구글검색)

그리고 각 페이지를 로딩할때마다 위의 그림처럼 분할된 번들(색깔로 구분)중 해당 페이지를 구성하는데 필요한 번들들을 불러오게 된다. 당연히 해당 페이지를 구성하는데 필요가 없는 번들들은 불러오지 않기에 로딩에 필요한 리소스 용량은 줄어들고 페이지를 로딩하는데 걸리는 퍼포먼스(성능)가 좋아진다. 단순히 계산해도 600kb를 로딩할걸 필요 없는 부분을 덜어내고 300kb를 로딩하면 후자가 더 빨라지는 게 당연하다.

특정 페이지에서만 사용하는 코드와 패키지등을 한 번에 불러오지 않고 다 따로 필요한 번들에 들어가서 나뉜다고 생각하면 더 잘 이해가 될 것이다.

 

복잡도가 낮은 웹앱이라면 사실 라우팅 단위의 코드 스플릿이 끝났다면 추가적인 동적 로딩을 통한 코드 스플릿을 할 필요는 거의 없다. 다만 복잡도가 높고, 비교적 높은 수준의 최적화를 위해서는 추가적으로 코드 스플릿을 진행한다고 할 경우에는 보통 조건부 렌더링을 기준으로 그리고 해당 라우팅의 기본적인 목적에 부합하게 또 가능하다면 사용자 인터랙션 데이터를 참고해서 추가적인 분할을 하는 게 좋은데

 

보통은 그냥 일반적으로 웹앱에서 자주 볼 수 있는 안내 팝업이나 확인 모달 등 부분의 경우 조건부 렌더링으로 특정 조건이 충족된 상황에서 노출되고 그 조건은 보통 사용자 action에 따르기에 해당 부분을 lazy하게 로딩해주는 게 성능이나 사용자 데이터를 위해서도 좋다.

여기서 주의해야 할 부분은 어차피 무조건 사용자에게 노출 되는 부분은 굳이 단순히 lazy 로딩을 적용할 필요가 없다는 것이다. 그럴 경우 괜히 렌더링 직후 레이아웃 시프트 나 추가적인 네트워크 비용을 발생시켜서 오히려 성능에 악영향을 주게 된다.  특정 부분의 리소스가 너무 커서 상대적으로 중요한 레이아웃 등을 먼저 사용자에게 보여주기 위해서 코드 스플릿을 하는 경우가 아니라면 하지 않는 게 오히려 좋다.(사실 이건 개발 전반에 모두 해당된다. 로직이 추가되면 연산 비용이 성능에 악영향을 줄 수밖에 없다.)

 

설명하는데 필요한 적절한 예제코드가 있으면 좋겠지만... 회사 밖에서는 보통 생략하는 작업(귀찮음)이고 회사에서 작업한 코드를 보여줄 순 없으므로 해당 부분에 대해서 조금 더 자세히 알아보고 싶다면 인프런에서 유동근님의 프런트엔드 개발자를 위한, 실전 웹 성능 최적화 강의를 추천한다.

 

그렇게 다이나믹 임포트를 통한 코드 스플릿을 완료했다면 이제 웹팩 설정을 통해서 코드 스플릿을 진행해야 하는데 그 이유로 왜냐하면 dynamic import를 통한 코드  스플릿에는 명확한 한계가 존재하기 때문이다. 바로 중복 코드로 인한 전체 용량의 증가다. 개인적인 경험상 대부분 그럴리가 없을 거라 생각은 하지만.. dynamic import를 통한 코드 스플릿을 통해서 로딩해오는 용량을 줄였는데 어떻게 중복 코드로 인해서 전체 용량이 증가하냐고 생각하는 사람이 있을 수 있을 거 같다.

 

그런 분들을 위해서 추가로 설명 드리자면  동적 로딩을 통해서 페이지와 컴포넌트를 쪼개어서 한 번에 로딩해오는 용량은 확실히 줄어들었지만  같은 웹앱은 보통 아주 막장으로 개발을 진행하지 않는 이상 공통부분이라는 게 존재한다. 기본적인 React나 React-dom 같은 패키지나 라우팅이나 상태 관리, API 호출, 유틸 함수, 프로 바인더 등, 그리고 프런트엔드의 경우 UI를 재활용하는 즉 디자인 시스템이라고 명명 지을 수 있는 공통 컴포넌트들이 존재한다.

 

그리고 이런 코드들의 경우 여러 컴포넌트나 도메인, feature, page, route(이름은 중요하지 않다.)등 프로그램 여러 곳에서 자주 사용된다. 그리고 하나의 프로그램을 동적 로딩을 통해서 분할하면 당연히 해당 프로그램 여기서는 편의상 웹앱과 페이지라고 지칭하겠다. 웹앱의 페이지 별로 필요한 부분들을 로딩해서 리소스를 절약하게 되는데 그럴 경우 각 번들 입장에서는 자기한테 필요한 공통 부분들을 다른 페이지에서 불러왔다 한들 자동으로 불러오게 되는 것이다.

 

왜냐하면 특정 페이지에 들어가서 동일한 모듈을 불러왔다는 사정을 보장 할 수 없다는 이유로 말이다.

그렇기에 단순히 dynamic import를 통해서 코드 스플릿을 끝냈다고 할 수 없고 관련해서 웹팩이나 롤업 또는 요즘 뜨는 vite의 설정을 커스터마이징 할 필요가 있다.

 

여기서는 웹팩을 기준으로 설명을 하겠지만 사실 어느 번들러나 비슷하니 그냥 개념적인 부분만 참고하면 될 거 같다.

웹팩의 경우 기본적으로 entry 라는 것을 설정할 수 있다. entry라는 것은 말 그대로 진입점으로 해당 위치를 기준으로 해당 파일이 의존하는 모든 모듈들을 트리로 만들어서 번들링을 하는데 보통은 하나의 entry를 설정하지만 webpack4 버전이나 MPA(멀티페이지 애플리케이션)의 경우 여러 entry를 설정해서 번들링을 수행할 수 있다. webpack5 기준으로도 가능하지만 보통 레거시 개발을 위한 제이쿼리 등의 vendor 코드가 존재하지 않는 한 설정하지 않는게 좋고, 공식적으로도 설정하지 않는 것을 권장한다. 나도 잘 쓰진 않지만 필요하다면 아래의 코드와 웹팩 공식문서를 참고하자(요즘은 한글 번역도 되어있다.)

 const path = require('path');

 module.exports = {
   mode: 'development',
   entry: {
    index: {
      import: './src/index.js',
      dependOn: 'shared',
    },
    another: {
      import: './src/another-module.js',
      dependOn: 'shared',
    },
    shared: 'lodash',
   },
   output: {
     filename: '[name].bundle.js',
     path: path.resolve(__dirname, 'dist'),
   },
 };

그렇다면 무엇을 통해서 코드 스플릿을 수행할까? 바로 optimization 옵션과 splitChunks다. 해당 프로퍼티를 통해서 압축이나 청크 파일 분할 등을 설정할 수 있는데 기본적으로 코드 압축을 하는 옵션인 minimize(어글리 파이)를 주고 splitChunks의 cacheGroups에 특정 패키지의 경로들을 test로 추가하여서 분할해주면 된다.

// 공식문서
module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async',
      minSize: 20000,
      minRemainingSize: 0,
      minChunks: 1,
      maxAsyncRequests: 30,
      maxInitialRequests: 30,
      enforceSizeThreshold: 50000,
      cacheGroups: {
        defaultVendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10,
          reuseExistingChunk: true,
        },
        default: {
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true,
        },
      },
    },
  },
};
// 개인적인 설정
optimization: {
    minimize: true,
    splitChunks: {
      cacheGroups: {
        vendor: {
          chunks: 'initial',
          test: 'vendor',
          name: 'vendor',
          enforce: true,
        },
        react: {
          test:/[\\/]node_modules[\\/](react|react-dom)[\\/]/, // 묶을 모듈들을 모두 해당 형식으로
          name: 'react',
          chunks: 'all' // 3가지 옵션 중 적절한 것을
        },
      },
    },
  },

원하는 key들로 원하는 만큼 청크들을 분할 할 수 있는데 수동으로 하는 것이 아닌 자동으로도 옵션을 통해서 설정할 수 있다. 해당 코드처럼 분리한 청크들의 경우 기존의 번들에서 제거되어서 옵션에서 설정한 만큼 번들을 구성하고 해당 번들에 속한 코드가 필요할 경우 해당 청크가 같이 불려 가서 페이지 전환이 일어나도 이미 로드된 번들은 다시 로딩되지 않아서 중복된 코드를 불러와서 낭비되는 리소스와 데이터를 절약할 수 있다.

실제로 해당 설정을 통해서 번들링된 파일들의 전체 용량을 확인해보면 중복된 코드를 여러 번들에서 가지고 있는 것들이 제거되어서 파일 용량이 최적화된 것을 볼 수 있다.

 

또한 패키지 뿐만 아니라 자체적으로 관리하는 공통 코드들인 컴포넌트나 유틸 코드들도 설정을 통해서 청크로 관리할 수 있다. 다만 이런 설정을 하는 부분에서 주의해야 할 점들이 몇 가지 있다.

  1. 수동으로 분리한 chunks의 경우 묶인 파일들이 실제로 해당 페이지에서 사용되지 않는다 할지라도 같이 로딩 되기에 해당 부분에 대해서 전략적인 고려가 필요하다는 것.
  2. 파일을 분리하고 병렬 요청 하는 과정에 의해서 네트워크 비용이 추가된다는 점 때문에 너무 적은 용량의 경우 오히려 성능에 악영향을 주고 너무 쪼개진 리소스들의 경우 해당 리소스를 다운로드하는 과정에서 사용자 환경에 따라서 제약사항이 존재하고 개수가 너무 많을 경우 동시에 다운로드하는 과정에서 네트워크적인 문제가 발생할 수 있다는 것이다. 이해하기 쉽게 비유하면 DB 나 API 요청을 관리하는 것과 비슷하다 표현할 수 있다.

그렇기에 보통 코드 스플릿의 경우 실제 적용은 어느 정도 개발이 완료 된 직후에 하는 게 좋다. 개발 과정에서 패키지가 변경된다거나 구조나 구성이 변경될 수 도 있고 실제로 프로덕트 환경에 적용할 때는 E2E 테스트와 병행해서 각종 분석 도구들을 활용해서 환경에 맞게 테스트해보면서 적용하는 과정이 필요하다. 예상과 다르게 오히려 코드 스플릿을 해서 퍼포먼스나 기타 비용 등이 더 늘어나는 경우가 존재할 수 있기 때문에 그렇다.

 

보통 그렇기에 개발 단계에서는 코드 스플릿을 적용하지 않고 그저 추후에 분할 할 것을 어느 정도 염두에 두고 설계와 개발을 하는 것이 좋은데 개인적으로 생각할 때 사실 해당 부분은 그저 좋은 품질의 프로그램을 개발하려고 노력하면(클린 코드) 따로 추가적으로 고려할 점은 많지 않다. 공통 코드의 경우도 책임이나 도메인에 맞게 잘 쪼개어서 개발을 진행했다면 사실 불필요한 중복 등을 거의 고려할 필요가 없고(꼭 필요한 코드들만 불러올 테니) 개발이 완료된 직후에 간단하게 마이크로 매니징을 하면서 코드 스플릿을 진행하면 된다.

 

추가적으로 코드 스플릿과 많이 사용하는 gzip 코드 압축의 경우 두가지 방향에서 진행이 가능한데 하나는 nginx나 apache서버에서 gzip 압축을 해주는 것이고, 또 하나는 웹팩 등 번들러의 옵션이나 플러그인, 또는 오픈소스를 활용해서 자체적으로 소스코드를 gzip으로 압축해서 빌드하는 것이다.

장단점이 조금씩 다른데 보통 nginx등을 통해서 압축해서 제공하는 것은 해당 부분을 압축해서 응답하는 과정에서 nginx 등에 부담이 되기에(CPU 등 리소스 점유) 프런트 엔드에서 압축을 해놓고 제공해주는 것이 성능적인 측면에서는 더 좋은 방법이다. 다만 gzip을 지원하지 않는 환경이 존재할 수 있고(구버전 기기, 구버전 브라우저, 개발도상국 등) 그것을 프런트엔드에서 체크해서 고려해주기 힘든 측면이 있어서 서비스 대상에 대한 충분한 분석과 고려가 있은 후가 아니라면 nginx 등에서 압축해서 제공하는 것을 권장한다.

 

정리하자면

 

1. 책임과 도메인과 트레이드 오프를 고려해서 묶어서 코드 스플릿을 진행한다.

2. 개발 도중에는 설계적인 측면에서만 고려를 한다.

3. 개발 완료 이후 코드 스플릿을 진행할 때는 실제 분석 도구들을 활용하고 파일 용량과 네트워크 비용 등을 고려해서 진행을 한다.

4. 코드스플릿 순서의 경우 dynamic import를 통해서 먼저 기본적인 route과 사용자 인터랙션에 따른 스플릿을 진행한 후 번들러를 통해서 중복되는 부분들을 다듬어 준다.

 

정도로 정리 할 수 있다.

 

* 사실 개인적으로는 독학을 한 케이스라 해당 부분에 대해서도 독학을 했는데 최적화를 잘 모르던 시절에도 기본적인 것만 지켜서 개발을 해도 크롬 개발자 도구의 프런트엔드 분석 도구인 LightHouse 기준에서는 지표가 100~80점 대를 유지했다.

보통 관리 가능한 포인트에서는 100에서 90점을 유지하는데 오픈소스를 활용하는 경우나 디자인적인 측면이 문제가 되는 경우가 꽤 있어서 그런 경우를 고려한다해도 매 항목당 점수가 80점대 이하로 내려갈 경우 최적화등을 고민해야 하지 않을까?라는 생각이 있다.(라이트하우스 기준)

 

최근 진행하던 프로젝트

반응형