모던 브라우저에도 꼭 es5를 써야 할까

프로덕션 레벨의 서비스를 개발할 때는, es6를 모든 브라우저에서 사용성을 보장하기 위해 es5로 바꿔주는 babel이라는 트랜스파일러를 씁니다.
babel에 browser target을 지정해서 지원하고자 하는 브라우저 스팩에 맞게 문법을 변환해주죠.

그런데 사실, 이렇게 바꾸지 않아도 이제는 대부분 모던 브라우저가 es6 문법을 거의 다 지원을 합니다. 오히려 지원을 안하는 브라우저는 모바일을 제외하곤 IE뿐이죠. 전체 유저의 약 84%정도가 es6를 지원하는 브라우저를 사용합니다. 모바일을 제외한다면 90% 이상입니다.

특히 두레이는, 모바일 웹을 지원하지 않아 IE만 문제가 되는 상황이죠.
나머지 10%를 위해서 es5로 번들을 만든다는 것은 상당히 큰 오버헤드입니다. 번들크기, parse/eval 시간면에서 많은 차이가 나죠.

그럼 IE는 es5로 배포를 하고 나머지 모던 브라우저는 es6 코드를 쓴다면 가장 베스트일 것 같은데, 그대로 쓸 방법이 없을까요.

이 고민을 당연히 많은 사람들이 했습니다.

module을 통한 Differential Serving

가장 먼저 논의된 글은 Deploying ES2015+ Code in Production Today라는 글입니다. 이 글을 바탕으로 여러 글과 플러그인들이 생기게 되었죠.

원리는 script의 module을 이용하는 방법인데요. 이것은 사실 import와 export(module)를 브라우저에서 번들러(webpack, rollup)없이 쓸 수 있도록 하는 스팩입니다. 참고

그러다보니 module을 지원하지 않는 브라우저에서는 module 코드를 실행시키지 않게 해야합니다.
이를 위해 nomodule이라는 스팩이 있는데요. module과 nomodule 둘을 이용해서 처리하는 아이디어 입니다.

먼저 번들을 es6용과 es5 두가지로 만들어서 아래와 같이 html에 파일에 넣으면 (만드는 법은 뒤에서 설명합니다)

<!-- 모던 브라우저 -->
<script type="module" src="main.modern.js"></script>
<!-- legacy 브라우저 -->
<script nomodule src="main.legacy.js"></script>

module을 지원하는 브라우저는 main.modern.js를 지원하지 않는 브라우저는 main.legacy.js를 받아서 실행시키죠.

잘 실행이 될까?

잘 됩니다. 약간의 문제를 제외하면요.

image.png
보다 싶이 대부분의 브라우저는 정상동작하지만 아주 예전버전의 크롬 ,edge, IE 등 일부는 modern과 legacy 둘 다 fetch를 하고 (module은 execute하진 않음) 사파리 10.1의 경우는 둘 다 가져오고 실행시킵니다. 물론 지금 safari는 module을 완벽하게 지원하기 때문에 큰 걱정이 없습니다. IE의 경우는 둘 다 fetch하지만 실행시키지는 않아서 실행시간에 영향이 없습니다.

설령 두번 실행한다 하더라도 90%의 유저를 위해서 10%의 불편함을 감내하는게 낫다고 생각하지만 17년 당시에 safari 이슈는 꽤 큰 이슈였습니다. 이럴 때는 비동기적으로 로딩을 해도 되고 아래와 같이 nomodule의 지원 여부를 보고 태그를 제거해주는 폴리필을 최상단에 넣어주면 됩니다.

<!-- polyfill `nomodule` in Safari 10.1: -->  
<script type=module>  
!function(e,t,n){!("noModule"in(t=e.createElement("script")))&&"onbeforeload"in t&&(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())}(document)
</script>

구현

테스트를 위해 간단히 webpack으로 module과 nomodule을 구현한 sample app을 만들었습니다.

entry를 두개로 둔다.

위의 글인 Deploying ES2015+ Code in Production Today에서 구현했던 방법은 멀티엔트리로 modern과 legacy를 만듭니다. babel의 taget을 다르게 지정해서 같은 소스를 다른 번들로 만드는 것이죠.

module.exports = [{
  entry: path.resolve(__dirname, './src/index.js'),
  output: {
    filename: 'main.legacy.js',
    path: path.resolve(__dirname, 'public'),
  },
  module: {
    rules: [{
      test: /\.m?js$/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: [
            ['env', {
              modules: false,
              useBuiltIns: true,
              targets: {
                browsers: [
                  '> 1%',
                  'last 2 versions',
                  'Firefox ESR',
                  "ie >= 11"
                ],
              },
            }],
          ],
        },
      },
    }],
  },
}, {
  entry: path.resolve(__dirname, './src/index.js'),
  output: {
    filename: 'main.modern.js',
    path: path.resolve(__dirname, 'public'),
  },
  module: {
    rules: [{
      test: /\.m?js$/,
      use: {
        loader: 'babel-loader',
        options: {
          presets: [
            ['env', {
              modules: false,
              useBuiltIns: true,
              targets: {
                browsers: [
                  'Chrome >= 60',
                  'Safari >= 10.1',
                  'iOS >= 10.3',
                  'Firefox >= 54',
                  'Edge >= 15',
                ],
              },
            }],
          ],
        },
      },
    }],
  },
}]

babel 개선 - env

위 방법은 조금 불편합니다.
중복된 코드도 많이 들어가고 babel 설정도 겹치고요. 겹치는 부분은 babel의 env옵션을 통해 관리가 가능합니다. env로 설정을 분리하고 웹팩의 로더에서 envName을 지정하면 되죠.

//.babelrc
{
    "env": {
        "modern": {
            "presets": ["@babel/preset-env", {
                //xxxx
               }]
        },
        "legacy": {
            "presets": [
               ["@babel/preset-env", {
                //xxxx
               }]
            ]
        }
    }
}
rules: [
    {
      test: /\.js$/,
      use: {
          loader: 'babel-loader',
          options: {
              envName: 'modern'
          }
      }
    }
  ]

babel 개선 - preset

모던 번들옵션에 그대로 preset-env로 브라우저의 타겟을 지정하는 것은 요즘 babel이랑은 안맞습니다. 거의 다 지원하는 상황에서 불필요한 코드도 많고요. babel에서도 이미 module에 관한 논의가 되었습니다.

먼저, module용으로 빌드해주는 preset-env의 esModules 옵션이 있습니다.

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "esmodules": true
        }
      }
    ]
  ]
}

이것을 개선한 preset인 preset-modules가 있습니다. 더 작은 번들과 성능을 보장해준다고 하네요. 일정기간 테스트 후에 babel/preset-env에 통합될 예정이라고 합니다.

{
  "presets": [
    "@babel/preset-modules"
  ]
}

기존 legacy 번들의 경우는 트랜드에 맞게 corejs3를 써줘서 폴리필을 자동화 시켜 줬습니다. https://velog.io/@vnthf/corejs3%EB%A1%9C-%EB%8C%80%EC%B2%B4%ED%95%98%EC%9E%90-zok3p9aouy를 참고해주세요.(core-js와 regenerator-runtime은 따로 추가시켜줘야 합니다.)

"targets": {
    "browsers" : ["last 2 versions", "ie >= 11"]
  },
"useBuiltIns": "usage",
"corejs": 3, 
"shippedProposals": true

multi-webpack-plugin

하나 더 문제는, 바벨옵션만 다른데 설정을 두 벌 가져가야 한다는 것입니다. 이것을 잘 감싼 플러그인이 하나 있습니다.

webpack-babel-multi-target-plugin란 플러그인입니다. 위에서 소개한 글을 보고 그것을 구현하기 위해 만들어졌는데요, 이 플러그인을 쓰면

  • 하나의 설정으로 legacy와 modern 두 개로 번들을 분리해주고
  • 또 내부적으로 html-webpack-plugin의 hook을 받아서 html에 nomodule과 module로 나눠서 두 번들을 넣어줍니다.
  • 또 safari 10.1 polyfill도 가지고 있습니다.

다만 바벨 최신화가 안되어 있어 @babel/preset-env만 쓰고 있습니다. 그래서 modern일 경우는 preset-modules를 쓰도록 소스를 살짝 수정을 했습니다.

//webpack-babel-multi-target-plugin의 babel-target.js#L166
 if (key === 'modern') {
    return {
        presets: [
            ['@babel/preset-modules'],
        ],
        cacheDirectory,
    }
}

webpack.config.js

최종으로 구현한 webpack.config.js입니다.

module.exports = {
  entry: {
    app: path.resolve(__dirname, './src/index.js')
  },
  resolve: {
    mainFields: [
        'es2015',
        'module',
        'main',
    ],
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].mjs',
    publicPath: '/'    
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Development',
      template: path.resolve(__dirname, './src/index.html')
    }),
    new BabelMultiTargetPlugin({
      normalizeModuleIds: true,
      babel: {
        "presetOptions" : {
          "targets": {
            "browsers" : ["last 2 versions", "ie >= 11"]
          },
          "useBuiltIns": "usage",
          "corejs": 3, 
          "shippedProposals": true
        }
      }
    })
  ],
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [BabelMultiTargetPlugin.loader()]
      }
    ]
  },
  devServer: {
    host: '0.0.0.0',
    contentBase: './dist',
    historyApiFallback: true,
    hot: true,
    inline: true,
  }
}

결과

테스트 코드에는 실행하면서 화면에 'append'라는 글자를 스크립트로 넣어주는 코드와 아래처럼 마구잡이의 es6코드가 들어있습니다.

var divg = document.createElement("div");
divg.appendChild(document.createTextNode("append"));
document.body.appendChild(divg);

image.png

실행 화면

modern과 legacy모두 html에 들어가 있습니다.
image.png

모든 브라우저에서 append라는 글자가 하나만 보입니다. 각각 modern과 legacy중 한 번만 실행이 되었다는 뜻이죠.

image.png

크롬

image.png

파폭

image.png

사파리

image.png

IE

ie의 경우 둘 다 fetch하지만 실행은 한번만 됩니다.
image.png

번들 크기

번들 크기에서는 엄청난 차이를 보이는데요. 무려 20배입니다. 샘플코드가 대부분 es6 코드라서 차이가 많이 나는 것도 있긴하지만 서비스 로직적인 코드가 들어가도 최소 2배이상은 차이가 날 것 같습니다.

image.png

참고

프로덕션환경에서의 영감
https://philipwalton.com/articles/deploying-es2015-code-in-production-today/
https://www.smashingmagazine.com/2018/10/smart-bundling-legacy-code-browsers/

이에 영감받은 플러그인들
https://github.com/DanielSchaffer/webpack-babel-multi-target-plugin#options-reference

https://github.com/swimmadude66/webpack-nomodule-plugin#readme
https://github.com/philipwalton/webpack-esnext-boilerplate/blob/master/tasks/bundles.js

자바스크립트 모듈에 관하여
https://v8.dev/features/modules#other-features
모듈동작
https://jakearchibald.com/2017/es-modules-in-browsers/#nomodule-for-backwards-compatibility
https://jasonformat.com/modern-script-loading/

바벨의 옵션
https://babeljs.io/docs/en/babel-preset-env#targetsesmodules
https://web.dev/serve-modern-code-to-modern-browsers/