Vite로 구버전 브라우저 지원하기

Jun.07.2024 김하림

Web Frontend

구버전 브라우저 지원의 필요성

배민앱의 장바구니는 웹뷰로 구현되어 있으며, 코어웹프론트개발팀은 React와 Vite를 활용하여 웹뷰 내부의 웹 애플리케이션을 개발하고 있습니다. 수많은 사용자가 지나가는 화면인 만큼, 프론트엔드 개발자가 해결해야 할 숙제도 다양합니다.

최근에는 구버전의 Safari와 Chrome 브라우저에서 화면 진입이 불가능한 문제가 발생했습니다. 구버전에서 지원하지 않는 문법이나 API를 사용하는 경우, 문법 에러나 타입 에러가 발생하고 사용자에게는 흰 화면만 노출됩니다. 이런 상황에서는 사용자가 브라우저나 OS를 업데이트하는 것이 가장 좋은 해결책이겠지만, 업데이트 없이도 서비스를 이용할 수 있다면 더욱 사용자 친화적일 것입니다. 특히 배민앱 사용자 중 약 10,000명에 가까운 사용자가 구버전(iOS 12와 Android 7)을 사용 중이기 때문에, 구버전 지원을 포기한다면 사업적으로 매출에 적지 않은 영향이 있을 수 있습니다. 따라서 구버전 지원 작업은 사업 관점에서도 중요한 작업이라고 할 수 있습니다.

이 글에서는 Vite의 공식 플러그인인 @vitejs/plugin-legacy(이하 레거시 플러그인)을 통해 구버전의 브라우저를 지원하는 방법과, 그 과정에서 마주친 문제들을 어떻게 해결했는지 살펴보도록 하겠습니다.

Vite 레거시 플러그인을 활용한 구버전 브라우저 지원

구버전 브라우저 문법 에러 해결하기

최근에 장바구니를 Webpack(Create React App)기반에서 ESBuild + Rollup(Vite)으로 개편하면서 적지 않은 이슈가 있었습니다. 가장 치명적인 이슈는 사용자가 화면에 진입했을 때 흰 화면이 표시되는 이슈였습니다.

처음으로 문제를 인지한 건 Sentry에서 받은 Object.fromEntries is not a function 에러 메일이었습니다. 문제가 발생한 OS를 확인해보니 Chrome 73 미만의 브라우저에서 발생하고 있었습니다. 배민앱의 장바구니는 웹뷰로 구현되어 있어, 사용자의 기기에 설치된 브라우저 버전에 따라 웹 애플리케이션이 렌더링됩니다. 따라서 오래된 버전의 Android나 iOS를 사용하는 사용자의 경우, 최신 브라우저 기능을 지원하지 않는 낮은 버전의 웹뷰에서 장바구니가 열리게 됩니다. 이로 인해 Chrome 73 미만의 브라우저에서도 이슈가 발생하게 됩니다.

실제로 구버전 Chromium 스냅샷 저장소를 통해 구버전의 Chromium을 다운로드하고 테스트 해보니 개발자 도구에서 문법 에러가 발생했습니다.

구버전 테스트는 다음과 같은 방법으로 진행했습니다.

  1. 구버전 Chromium 스냅샷 저장소에서 mac 65.0.3325.146 download_url의 주소로 파일 다운로드
  2. vite build && vite preview 명령어 실행
  3. Chromium에서 vite preview 개발 서버 접속 (예: localhost:4173/cart)
  4. 개발자 도구 열고 Object.fromEntries({}) 실행
  5. 문법 에러 확인

이미 구버전을 지원하기 위해 @vitejs/plugin-legacy을 적용했는데도 에러가 발생하고 있었습니다. 아래는 에러 발생 당시의 설정입니다.

// vite.config.js
import legacy from '@vitejs/plugin-legacy'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    legacy({
      targets: ['chrome >= 64', 'safari >= 12']
    })
  ]
})

당시 최소 지원 버전은 Can I use 기준 import.meta 사용이 가능한 버전인 Chrome 64, Safari 12 이상으로 잡아 두었기 때문에 targets에도 그렇게 설정해두었습니다. targets를 설정해두면 알아서 폴리필 지원을 해준다고 생각했지만, README를 보면서 잘못 알고 있었다는 사실을 깨닫게 되었습니다. 문서에 따르면 targets 설정만으로는 충분하지 않고, polyfills 혹은 modernPolyfills 필드에 추가적인 설정이 필요하다는 것을 알 수 있었습니다.

폴리필(Polyfill)이란?

폴리필은 특정 브라우저에서 지원하지 않는 기능을 사용할 수 있도록 해주는 코드입니다. 브라우저마다 지원하는 JavaScript 기능이 다르기 때문에, 최신 문법을 사용하면 오래된 브라우저에서는 에러가 발생할 수 있습니다. 이런 문제를 해결하기 위해 폴리필을 사용합니다.

결론부터 얘기하면, Object.fromEntries is not a function 에러 문제는 다음과 같이 modernPolyfills 옵션을 추가해서 해결할 수 있었습니다.

// vite.config.js
import legacy from '@vitejs/plugin-legacy'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    legacy({
      targets: ['chrome >= 64', 'safari >= 12'],
      modernPolyfills: ['es.object.from-entries'],
    })
  ]
})

이 코드가 어떻게 문제를 해결할 수 있었는지 천천히 설명해보도록 하겠습니다.

레거시 플러그인의 ‘모던’과 ‘레거시’ 개념 이해하기

먼저, 레거시 플러그인에서 사용하는 ‘모던’과 ‘레거시’의 개념에 대해서 알아야 합니다. 아래 용어에 대한 개념만 기억하시면 앞으로 설명할 내용들을 훨씬 쉽게 이해하실 수 있습니다.

  • 모던(Modern): Native ESM을 지원하는 환경 (Chrome >= 64, Safari >= 12)
  • 레거시(Legacy): Native ESM을 지원하지 않는 환경 (Chrome < 64, Safari < 12)

Native ESM(ECMAScript Modules)은 브라우저나 Node.js 환경에서 별도의 번들링 과정 없이 ESM을 직접 지원하는 것을 의미합니다. ESM은 JavaScript의 공식 모듈 시스템으로, import/export 문법을 사용하여 모듈을 정의하고 불러올 수 있습니다(MDN 참고). 오래된 브라우저들은 Native ESM을 지원하지 않기 때문에, Babel과 같은 트랜스파일러를 사용하거나 Webpack 등의 번들러를 통해 이전 버전의 JavaScript로 변환하는 과정이 필요합니다.

Vite에서 사용하는 '레거시'와 '모던'의 개념은 바로 이 Native ESM 지원 여부를 기준으로 나뉩니다. Native ESM을 지원하는 브라우저에서는 모던 청크를, 지원하지 않는 브라우저에서는 레거시 청크를 사용하도록 분기 처리하는 것입니다.

여러분이 Vite 컨피그 파일에 레거시 플러그인을 적용했다면, vite build 명령어를 통해 빌드된 결과물은 레거시 청크와 모던 청크 두 가지가 생성됩니다. 그리고, 레거시 청크를 사용해야 할지, 모던 청크를 사용해야 할지 판단하는 JavaScript를 실행하는 스크립트 태그가 HTML 파일에 삽입됩니다.

모던 브라우저와 레거시 브라우저의 분기 처리 과정

모던과 레거시 브라우저 분기 처리를 하는 코드를 살펴보겠습니다. 알아두면 좋으나, 결론만 알아도 문제 없기 때문에 적당히 스크롤을 내리면서 읽으셔도 됩니다.
아래는 실제 빌드된 HTML 파일에서 모던과 레거시 청크를 나누는 핵심 내용만 따로 정리한 코드입니다.

<head>
  <script type="module" crossorigin="" src="/assets/index-e45b7e40.js"></script>
  <script type="module">
    import.meta.url;
    import("_").catch(() => 1);
    async function* g() {}
    if (location.protocol != "file:") {
      window.__vite_is_modern_browser = true;
    }
  </script>
  <script type="module">
    !(function () {
      if (window.__vite_is_modern_browser) return;
      console.warn(
        "vite: loading legacy chunks, syntax error above and the same error below should be ignored",
      );
      var e = document.getElementById("vite-legacy-polyfill"),
        n = document.createElement("script");
      (n.src = e.src),
        (n.onload = function () {
          System.import(
            document
              .getElementById("vite-legacy-entry")
              .getAttribute("data-src"),
          );
        }),
        document.body.appendChild(n);
    })();
  </script>
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <script nomodule="">
    !(function () {
      var e = document,
        t = e.createElement("script");
      if (!("noModule" in t) && "onbeforeload" in t) {
        var n = !1;
        e.addEventListener(
          "beforeload",
          function (e) {
            if (e.target === t) n = !0;
            else if (!e.target.hasAttribute("nomodule") || !n) return;
            e.preventDefault();
          },
          !0,
        ),
          (t.type = "module"),
          (t.src = "."),
          e.head.appendChild(t),
          t.remove();
      }
    })();
  </script>
  <script
    nomodule=""
    crossorigin=""
    id="vite-legacy-entry"
    data-src="/assets/index-legacy-38fcc150.js"
  >
    System.import(
      document.getElementById("vite-legacy-entry").getAttribute("data-src"),
    );
  </script>
</body>

스크립트의 내용을 요약하면, Native ESM을 지원하는 브라우저는 head 태그의 스크립트를 사용하여 모던 청크를 불러오고, Native ESM을 지원하지 않는 브라우저는 body 태그의 스크립트를 사용하여 레거시 청크를 불러옵니다.

스크립트를 자세히 살펴보겠습니다. 먼저, Native ESM을 지원하는 모던 브라우저의 경우 head 태그에 정의된 스크립트를 통해 모듈을 불러옵니다. 아래 스크립트는 모든 스크립트 사이에서 가장 먼저 실행됩니다.

<script type="module" crossorigin="" src="/assets/index-e45b7e40.js"></script>

다만, 이 스크립트에는 구멍이 있습니다. Native ESM을 지원하지만 import.meta 등 핵심 문법을 사용하지 않는 버전 구간이 존재합니다. Native ESM 지원 버전(type="module" 사용 가능 버전)은 Chrome >= 61, Safari >= 11이지만 import.meta, dynamic import, async generator 함수를 모두 지원하는 버전은 Chrome >= 64, Safari >= 12 입니다.

따라서, ESM을 온전히 지원하지 않는 버전에서 ESM을 불러올 때 에러가 발생할 수 있습니다. 레거시 플러그인은 이러한 경우를 고려해서 다음 두 개의 스크립트를 추가로 넣고 있습니다.

<script type="module">
  import.meta.url;
  import("_").catch(() => 1);
  async function* g() {}
  if (location.protocol != "file:") {
    window.__vite_is_modern_browser = true;
  }
</script>
<script type="module">
  !(function () {
    if (window.__vite_is_modern_browser) return;
    console.warn(
      "vite: loading legacy chunks, syntax error above and the same error below should be ignored",
    );
    var e = document.getElementById("vite-legacy-polyfill"),
      n = document.createElement("script");
    (n.src = e.src),
      (n.onload = function () {
        System.import(
          document.getElementById("vite-legacy-entry").getAttribute("data-src"),
        );
      }),
      document.body.appendChild(n);
  })();
</script>

import.meta.url, dynamic import, async generator 함수 등의 문법을 모두 지원하지 않는 브라우저는 window.__vite_is_modern_browser 변수가 false로 설정되고, 레거시 청크와 레거시 폴리필을 불러옵니다.

레거시 플러그인 공식 문서에서도 이 케이스를 처리하는 방법에 대해서 설명하고 있습니다.

Native ESM을 지원하지 않는 레거시 브라우저는 조금 다른 과정을 거칩니다. script type"module"을 지원하지 않으므로 스크립트 태그 내부의 첫 번째 스크립트 태그는 무시되고, 두 번째 스크립트 태그에서 window.__vite_is_modern_browser 변수가 true로 설정되지 않습니다. 따라서 스크립트 태그의 첫 번째 스크립트가 실행되어 레거시 폴리필과 레거시 청크를 불러오게 됩니다.

<script nomodule="">
  !(function () {
    var e = document,
      t = e.createElement("script");
    if (!("noModule" in t) && "onbeforeload" in t) {
      var n = !1;
      e.addEventListener(
        "beforeload",
        function (e) {
          if (e.target === t) n = !0;
          else if (!e.target.hasAttribute("nomodule") || !n) return;
          e.preventDefault();
        },
        !0,
      ),
        (t.type = "module"),
        (t.src = "."),
        e.head.appendChild(t),
        t.remove();
    }
  })();
</script>

위 코드는 약간의 트릭을 이용해서 noModuleonbeforeload 이벤트 지원 여부를 검사하여, 레거시 브라우저임을 확인하고, 그 다음 스크립트 태그에서는 System.import를 사용하여 레거시 청크(/assets/index-legacy-38fcc150.js)를 불러옵니다.

<script
  nomodule=""
  crossorigin=""
  id="vite-legacy-entry"
  data-src="/assets/index-legacy-38fcc150.js"
>
  System.import(
    document.getElementById("vite-legacy-entry").getAttribute("data-src"),
  );
</script>

이렇게 Vite의 레거시 플러그인은 브라우저의 기능 지원 여부에 따라 모던 청크와 레거시 청크를 선택적으로 불러올 수 있도록 스크립트를 생성합니다. 모던 브라우저에서는 불필요한 레거시 관련 코드를 실행하지 않으므로 성능 저하 없이 최신 코드를 실행할 수 있습니다. 반면 레거시 브라우저에서는 폴리필과 함께 레거시 청크를 불러와 실행함으로써 브라우저 호환성을 확보할 수 있습니다.

modernPolyfills 옵션을 통한 문제 해결

이제 앞서 문제 해결을 위해 Vite 컨피그에 추가했던 modernPolyfills 옵션에 대해 알아보겠습니다. modernPolyfills는 모던 청크에 포함할 폴리필을 지정하는 옵션입니다. 원하는 문법에 대한 폴리필을 명시하거나, true로 설정해서 자동 감지를 통해 필요한 문법을 모두 불러올 수도 있습니다. 후자의 경우, 최종 번들 용량이 15KB에서 40KB까지 증가할 수 있습니다.

앞서 언급한 Object.fromEntries 에러는 모던 브라우저로 분류되는 Chrome >= 64, Safari >= 12 버전에서 발생한 문제입니다. 따라서, 모던 청크에 폴리필을 포함하기 위해 modernPolyfills 옵션에 es.object.from-entries 폴리필을 넣기로 결정했습니다.

modernPolyfills 옵션에 es.object.from-entries 폴리필을 지정해주면, 모던 청크에도 해당 폴리필이 포함되어 Chrome >= 64, Chrome = 12 버전에서도 Object.fromEntries를 사용할 수 있게 됩니다.

modernPolyfills: ['es.object.from-entries'],

이렇게 설정함으로써 Object.fromEntries is not a function 에러를 해결할 수 있습니다.

다만, 배포 전에 팀원들과 논의한 결과 번들 용량이 다소 증가하더라도 modernPolyfillstrue로 설정하는 것이 유지보수 측면에서 더 나은 선택이라는 데 의견이 모아졌습니다. 개별 이슈 발생 시마다 대응하기보다는 한 번에 필요한 폴리필을 모두 포함하는 것이 장기적으로 더 효율적이라고 판단했기 때문입니다.

물론 번들 용량 증가로 인한 성능 저하 우려도 있었지만, 레거시 브라우저를 사용하는 사용자가 여전히 상당 수 존재하며, 지원을 포기할 경우 잠재적인 매출 손실로 이어질 수 있다는 점을 고려했습니다. 또한, 구버전 브라우저 이슈로 인해 고객 문의가 증가하는 경우 고객 지원 팀의 업무 부담으로 이어질 수 있습니다. 안정성과 유지보수성을 확보하여 모든 사용자에게 동일한 경험을 제공하고, 고객 문의를 최소화하는 것이 장기적으로 더 나은 선택이라고 판단했습니다. 또한, 번들 용량 증가 폭이 크지 않아 사용자 경험에 미치는 영향이 제한적일 것으로 예상했습니다.

따라서, 최종적으로 modernPolyfills 옵션을 true로 변경하게 되었습니다. 최종적으로 배포된 Vite 설정 파일은 다음과 같습니다.

// vite.config.js
import legacy from '@vitejs/plugin-legacy'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    legacy({
      targets: ['chrome >= 64', 'safari >= 12'],
      modernPolyfills: true
    })
  ]
})

그런데 여기서 한 가지 의문이 들 수 있습니다. 모던 청크에는 폴리필이 포함되도록 설정했는데, 레거시 청크는 어떻게 될까요?

polyfills 옵션과 레거시 청크의 폴리필

레거시 청크를 위한 폴리필은 polyfills 옵션을 통해 지정할 수 있으며, 기본값은 true입니다. polyfills 옵션이 true이면 Vite는 targets에 명시된 브라우저 범위와 프로젝트 코드를 분석하여 필요한 ES 문법 폴리필을 자동으로 레거시 번들에 포함시켜 줍니다. 따라서 레거시 브라우저에서 폴리필 누락으로 인한 동작 이슈는 발생하지 않습니다.

요컨대 modernPolyfillspolyfills 옵션을 적절히 활용하면 모던 브라우저와 레거시 브라우저 모두에서 안정적으로 동작하는 번들을 생성할 수 있습니다.

ResizeObserver 이슈 해결하기

배포까지 완료하고 모든 문제가 해결된 줄 알았으나, 또 다른 이슈가 발생했습니다. Sentry로부터 Safari 12 브라우저에서 Can't find variable: ResizeObserver 에러가 발생했다는 메일을 받았습니다. 참고로 Resize Observer는 iOS Safari 13.4 이상, Chrome 64 이상부터 지원됩니다.

레거시 플러그인이 필요한 폴리필을 자동으로 포함해 줄 것이라 예상했으나, 실제로는 ResizeObserver에 대한 폴리필이 누락되어 발생한 문제였습니다.

modernPolyfillstrue로 설정하면 Babel의 자동 감지 폴리필 로딩 스크립트가 로드되는데, 이 스크립트는 ES 문법 관련 폴리필만 포함하고 있어 ResizeObserver와 같은 Web API는 별도로 포함되어 있지 않습니다. 이는 Vite Legacy Plugin 문서에도 명시되어 있습니다.

additionalLegacyPolyfills
Type: string[]

Add custom imports to the legacy polyfills chunk. Since the usage-based polyfill detection only covers ES language features, it may be necessary to manually specify additional DOM API polyfills using this option.

따라서 ResizeObserver에 대한 폴리필을 별도로 추가해야 합니다. 그리고 ‘레거시’와 ‘모던’ 청크 모두 이 폴리필을 포함시켜야 합니다. Safari 12 부터 ‘모던’ 브라우저로 분류되지만, ResizeObserver는 Safari 13부터 지원되기 때문입니다.

한편, 문서를 살펴보던 중 모던 브라우저와 레거시 브라우저 모두에서 폴리필이 필요한 경우 애플리케이션 코드에서 직접 정의하여 사용하라는 내용을 발견했습니다.

Note: if additional polyfills are needed for both the modern and legacy chunks, they can simply be imported in the application source code.

이에 따라 @juggle/resize-observer와 같은 npm 패키지를 엔트리 파일에서 import하거나, polyfill.io 스크립트 태그를 index.html에 직접 추가하는 방식으로 문제를 해결할 수 있습니다.

처음에는 polyfill.io를 사용하려 했으나, 몇 가지 이유로 @juggle/resize-observer 라이브러리를 사용하는 것으로 결정했습니다.

우선, 외부 서비스를 사용할 경우 장애 가능성이 존재합니다. 스크립트 로드에 실패하면 구버전 브라우저에서 폴리필이 작동하지 않을 수 있고, 서비스 지연이 발생하면 버전에 관계없이 초기 로드 속도가 저하될 수 있습니다. 실제로 polyfill.io 서비스 다운 사례도 몇 차례 있었습니다. 또한 polyfill.io의 소유권 변경과 관련된 이슈도 있었습니다. (현재는 Cloudflare로 이전되어 안정화된 것으로 보입니다.)

반면, polyfill.io에서 사용 중https://github.com/juggle/resize-observer 라이브러리를 직접 사용하면 외부 서비스에 대한 의존성을 줄일 수 있어 안정성을 확보할 수 있습니다. 이 라이브러리는 2022년까지도 꾸준히 유지보수되고 있으며, 많은 사용자들에 의해 안정성이 입증되었기에 도입을 결정하게 되었습니다.

최종적으로 Vite 엔트리 포인트(main.tsx)에 다음 코드를 추가했습니다.

import { ResizeObserver } from '@juggle/resize-observer';

window.ResizeObserver = window.ResizeObserver || resizeObserverPolyfill;

이후 vite build && vite preview 명령으로 빌드된 버전으로 서버를 실행하고, Chrome 62 브라우저에서 ResizeObserver가 폴리필 처리된 것을 확인할 수 있었습니다.

레거시 플러그인의 한계와 브라우저 호환성에 대한 고민

이상으로 Vite 레거시 플러그인을 통해 구버전 브라우저를 지원하면서 겪었던 문제들과 해결 과정을 공유해 보았습니다.

웹 서비스를 개발할 때 모든 사용자에게 동일한 경험을 제공하는 것이 이상적이지만, 현실적으로는 다양한 브라우저와 버전을 모두 지원하기란 쉽지 않습니다. 하지만, 회사의 입장에서 구버전 사용자를 포기하는 것은 어려운 선택입니다.

이런 상황에서 Vite의 레거시 플러그인은 개발자들에게 큰 도움이 됩니다. 모던 브라우저와 레거시 브라우저를 위한 번들을 각각 생성하고 스크립트를 통해 자동으로 분기 처리를 해주며, ES 문법에 대한 폴리필도 쉽게 설정할 수 있어 개발자의 부담을 크게 덜어줍니다. 덕분에 복잡한 브라우저 호환성 문제에 대해 크게 신경 쓰지 않고도 모던한 개발 환경을 유지할 수 있습니다.

하지만 레거시 플러그인이 만능은 아닙니다. ES 문법 외에도 ResizeObserver와 같이 추가적인 폴리필이 필요한 경우도 있습니다. 플러그인의 한계를 정확히 인지하고, 문제 발생 시 유연하게 대처할 수 있어야 합니다.

또한 모던 브라우저를 위한 번들과 레거시 브라우저를 위한 번들을 동시에 제공하는 만큼, 전체적인 번들 크기와 빌드 시간이 증가하는 단점에 대한 고민도 필요합니다. 상황에 따라 폴리필을 선별적으로 적용할 것인지, 아니면 다소 용량이 늘더라도 안정성을 확보할 것인지 판단해야 합니다.

Internet Explorer의 수명이 다했는데도 여전히 프론트엔드 개발자들은 브라우저 호환성으로 고통을 받고 있습니다. 그럼에도 불구하고, 이 글에서 소개한 Vite의 레거시 플러그인과 같은 도구가 여러분의 짐을 조금이나마 덜어줄 수 있기를 바랍니다.